ヌルヌル動くReactコンポーネントの作り方【入門】

f:id:kaminashi-developer:20210126092859j:plain

こんにちは、カミナシの浦岡です。

最近、弊社のメンバーとしてUIデザイナーが新たに加わり、プロダクトのUI改善を進めています。

以前は、AntDesignなどUIライブラリーのコンポーネントをそのままプロダクトで使用する機会が多かったのですが、UI改善を行う上で、UIライブラリーそのままでは要件を満たすことが困難なケースも出てきました。

その結果、独自のReactコンポーネントを実装する機会が増えているのですが、 この記事では、その独自コンポーネントを「ヌルヌル動く」仕上がりにするために気をつけている点を架空の題材を使って書きます。

題材

今回、「空を舞うカレンダー」(ペルソナ5風!?)のUIがデザイナーから提示されたと仮定して進めます。

f:id:kaminashi-developer:20210124221452p:plain

極端な題材ですが、UIライブラリのカレンダーをベースには実現できそうにないので、独自コンポーネントとして作りましょう!

先に、ヌルヌル動かす上で気をつけている点をあげますが、下の2点だけです!

  • DOMを無駄に使わない
  • アニメーションはGPU使う

普段からレンダリンング速度を気にしながら開発しているエンジニアの方にとっては基本的な内容だと思いますが、レンダリング速度的なことはUIライブラリ任せにしているエンジニアの方を想定して書きます。

DOMを無駄に使わない

DOMに要素を大量に配置すると動作は重くなります。今回のカレンダーの場合、本日(2021年1月26日)〜9999年12月31日までの日数は約290万日。何も考えずに290万個のdiv要素をDOMに配置しようとすると、ブラウザは固まってしまいます。

仮想スクロール

上の問題を解決する方法として、仮想スクロールがあります。 簡単に説明すると、ブラウザ上に見えている範囲(+少しのバッファ)の要素だけDOM上に配置し、スクロールの移動に合わせてその要素を置き換えていく仕組みです。 スクロールがどれだけ進んでもDOM上の要素数を一定に保つことで、性能劣化を防ぎます。

仮想スクロールのライブラリは世にいくつも存在します。 その中から、今回はreact-windowを使ってみます。

github.com

import React from "react";
import { FixedSizeList as List } from "react-window";

export const Calendar = () => {
 // 今日を起点に未来しか見ない
 const startDate = new Date();

 return (
   <div className="calendar">
     <List
       layout="horizontal"
       direction="ltr"
       height={100}
       width={window.innerWidth}
       itemSize={100}
       // とりあえず、1000日分を指定(スクロールが進んだタイミングでこれを増やすことが可能
       itemCount={1000} 
     >
       {({ index, style }) => {
         return (
           <div key={index} style={style}>
           // 日付をレンダーする
           </div>
         );
       }}
     </List>
   </div>
 );
};

上の実装だけで今回作りたいカレンダーの大枠ができます 早速動かしてみましょう!

gyazo.com

スクロールでカレンダーの表示を進めても、DOM上の要素数が一定以内に抑えられているのがわかります。

アニメーションはGPU使う

今回のカレンダーの例では、画面中央付近の日付を両隅のものより大きく表示することにします。

カレンダーのスクロール中、react-windowのonScrollメソッドが発火するので ここを起点に、日付要素の画面位置を取得し、各日付要素の表示倍率を計算するようにします。

   <div className="calendar">
     <List
       onScroll={
            // スクロール中、ここが呼ばれる
       }
     >
            // ~略~
     </List>
   </div>

この時、注意しないといけないのが、このメソッドの発火頻度が約16msな点です。(実行ブラウザの仕様による) ここに遅い処理を実装してしまうとそれに引きずられて画面描画が遅くなります。 つまり、日付の拡大/縮小表示を高速に行う必要があります。

そもそも「遅い・早いの線引きは何?」となると思いますが、 ここでボトルネックになるのはJavaScript上の処理ではなく、大概がDOMのレンダリング処理です。 もっと言うとCPUでなくGPUレンダリングを使うようにすることで大概が解消します。

transform属性

対象要素のwidth/heightを変更するのではなく、transformのscaleで拡大するようにします。

<g transform={`scale(${props.zoom})`}>
    //~略~
</g>

これだけでGPUレンダリングされるようになり、CPUでのそれより高速に描画されます。

transform属性にはscale(拡大/縮小)以外にも移動(translate)、回転(rotate)などが指定できます。 https://developer.mozilla.org/ja/docs/Web/CSS/transform

結果

人間の目では識別できないくらいの速度になりました!

gyazo.com

ソースは以下

stackblitz.com

まとめ

以上、簡単な2点を注意するだけでヌルヌル動くReactコンポーネントができました!

実際問題、子コポンポーネント固有の性能問題に対処したり、イベント間引くなどのチューニングが必要になるケースもありますが、大枠としてはこの2点を注意することでヌルヌルが実現できると思います。ぜひ試してみてください!

TypeScriptで負荷シナリオテストを作りたくなった話

f:id:kaminashi-developer:20210124225852p:plain

はじめに

初めまして。株式会社カミナシPMの@gtongy1です。

負荷量の増加。どの企業にとっても悩みの種じゃないでしょうか?
そんな時にこそ、負荷シナリオテストツールを導入したい!となると、負荷シナリオテストだとLocustとかが有名所。
ただPythonを知らないメンバーに、一からチームへ布教するのってなかなか腰が重くなってしまう。
せっかくならフロント中心のメンバーでも、自分が投げるリクエストの負荷量ってどんな感じだろうとか、気になる状態にはしておきたい。
負荷問題はインフラでも、サーバーでも、フロントでもなく全体の横断的な問題です。
どこか分からないからこそ、問題を特定する人は多岐にわたるはず。
そこでフロントもサーバーも何かと馴染みの深いJS、強いてはTypeScriptで記述出来るいい感じの負荷テストツールがないかと探して導入してみました。
導入してみて、機能もリッチだしグラフの見え方も綺麗だったりとなかなかに体験よくおすすめです。

要約

負荷テストにk6, グラフの可視化にGrafanaを使うと無料でTSの負荷テストツール作れます。

github.com

スタータキットを作成したので、是非試してみてください。

k6って?

k6.io

k6 はJSで書ける負荷テストツールです。

  • CLI Likeな設計の作り込み
  • ES2015/ES6のjsに対応、ローカル/リモートのモジュールを利用可能(importも使えるよ)
  • チェックとしきい値の記述も直感的なAPI

と開発者のコーディング体験もよく、痒いところによく手が届きます。
負荷テスト, パフォーマンス監視のどちらにも利用することが出来て、耐久性テスト, 高負荷テスト, ストレステストと幅広いシナリオを実現できるのも目を見張ります。
また、k6フリーでオープンソース です。
無料でOSSなのは、まず素晴らしいですね🎉
後webpackってJSへコンパイルすれば当然ですが、TypeScriptでもかける!
では肝心のコードの書き味はどうなのでしょう?気になりますね。
ではこのk6を使って、負荷テストの入り口から一緒に入っていきましょう。

k6でのsetupを実行してみる

まず、k6のsetupを書いていきます。src/tests/soak.test.tsをご覧ください。

import { Options } from 'k6/options';
import { randomUser } from '../lib/test-data.helpers';
import * as adminActions from '../actions/roles/admin.role';
import * as ownerActions from '../actions/roles/owner.role';

// {@link https://docs.k6.io/docs/options}
export let options: Partial<Options> = {
  stages: [{ target: 5, duration: '1s' }],
  // {@link https://docs.k6.io/docs/thresholds}
  thresholds: {
    http_req_duration: ['avg<1000', 'p(95)<1500'],
  },
};

const BASE_URL = 'https://test-api.loadimpact.com';

// {@link https://k6.io/docs/using-k6/test-life-cycle#setup-and-teardown-stages}
export function setup() {
  const user = randomUser();
  adminActions.signup(user, BASE_URL);
  const authToken = ownerActions.login(user, BASE_URL);
  return authToken;
}

export default (authToken: string) => {
  // ここに実際のテストケースを追加していきます
};

そもそもsetupとはなんでしょうか?
Unit Testをよく書いているみなさんは既にお気づきかもしれませんが、k6にも実際のシナリオを実行する前処理を記述することが出来ます。
ユーザーの作成/ログインなど、実際のAPIを実行する前の処理を記述することが出来ます。
ここで作成した値を後のシナリオへ渡すことが出来るので、ここで認証時のKeyを渡すとか、実際処理を実行する前に行いたい初期シナリオを記述できます。

export defaultから記述された関数に後ほどシナリオを追加していきます。
その前に一回処理を実行してみましょう!
yarn go:k6で処理を実行します。

gyazo.com

うまく実行出来ていますね!
ここで最後に表示されているオプションに関してですが、実際に処理を実行した結果を確認出来ます。
表示されたオプションのうちで特に重要なものを説明します。

  • vus
    • 同時実行したVUの数
  • iteration_duration
    • default/main 関数の完全な反復処理を 1 回完了するのに要した時間
  • checks
    • チェックの成功率
  • http_req_duration
    • リクエストの合計時間

負荷テストという観点だとこのあたりは必ず一読します。
k6の特殊な概念としてVU(Virtual User)という概念があります。

k6.io

k6同時に複数人がアクセスすることをコード上で表現でき、この中の1人をVUとして仮に立ててテストを実行することが出来ます。
設定された人数でx秒間アクセスし続ける、のようなケースを作れます。

その他オプションに関してはこちらをご覧ください。秒数やテスト成功条件あたりは実装が捗るので一読おすすめです。

実際のシナリオの実装

では、メイン所のシナリオを記述していきましょう!
上記でも実は使っていたのですが、k6が公式で提供しているモックサーバーがあるので、それを使って今回テストを作成していきます。

import { group, check, fail } from 'k6';
import http from 'k6/http';

import { randomCrocodile } from '../lib/test-data.helpers';
import { setSleep } from '../lib/sleep.helpers';
import { Crocodile } from '../lib/types/crocodile.api';
import { Counter } from 'k6/metrics';

export function createCrocodile(createRequestConfigWithTag: any, url: string, count: Counter): string {
  // 実行時のグループの指定
  group('Create Crocodiles', () => {
    const payload: Crocodile = randomCrocodile();
    // httpリクエストの実行
    const res = http.post(url, payload as {}, createRequestConfigWithTag({ name: 'Create' }));
    const crocodile: Crocodile = JSON.parse(res.body as string);
    // アサーション
    if (check(res, { 'Crocodile created correctly': (r) => r.status === 201 })) {
      url = `${url}${crocodile.id}/`;
      // 成功件数のカウント
      count.add(1);
    } else {
      fail(`Unable to create a Crocodile ${res.status} ${res.body}`);
    }
    setSleep(0.5, 1);
  });

  return url;
}
// ...

// 作成成功数のカウント
let numberOfCrocodilesCreated = new Counter('NumberOfCrocodilesCreated');

// ...
export default (authToken: string) => {
  // 階層的にグループを作成可能
  group('Create Crocodiles', () => {
    let URL = `${BASE_URL}/my/crocodiles/`;
    ownerActions.createCrocodile(createRequestConfigWithTag(authToken), URL, numberOfCrocodilesCreated);
  });
  // {@link https://docs.k6.io/docs/sleep-t-1}
  setSleep();
};

これで実行してみます。

gyazo.com

実行成功時のこの行数をみると、実際にリクエスト成功した行数も合わせて確認することができます。
今回は15sの実行ですが、26件のリクエストが成功しているのが確認できます。
こういった形でCLI上から確認することも可能となります。非常にシンプルですね。
それでは、この結果を可視化するためにはどうしたら良いでしょうか?
そう、Grafanaの出番です。

実行した結果の可視化

ダッシュボードを起動して確認してみましょう!
スタータキットのディレクトリ以下でこのコマンドを実行してください。

$ docker network create k6-network && yarn monitors

グラフがdocker上で起動したはずです。中をみていきましょう。
http://localhost:3000/d/2jJz71_Wz/k6-dashboard こちらへアクセスしてみてください。

gyazo.com

グラフで結果を確認出来るようになりました!
このように、StepごとのCheck成功率/リクエスト作成成功数/リクエストの合計時間などを見れるようになります。
dashboards/k6-dashboard_rev1.json内に各グラフの設定が書き込まれています。
ここを変えて、実際の形にあった自分だけのグラフも作ってみてください!自分のプロダクトにグラフを当てはめた時、ワクワクしますよ。

終わりに

負荷テストツールk6 + Grafanaの紹介をしました。
ここまで実装を進めていく中で、概念の慣れに時間をかけつつも記述で難しいところなく、書き味よかったです。

データロスト = 事業的なダメージが大きい部分に対して、やり方分からず手作業で進めていくことって結構多いと思っていて。
単性能テスト(datadog syntheticsとかvegitaとか)は結構パッと出てくるけど、TypeScriptかつシナリオテストはなかなか日本の記事は見当たらなかったので記事にしました。
エラー発生確度を下げられる対応策として、負荷テストを導入出来る企業が増えることを願っています。
ユーザー増加した時のデータの安定性を話せるようになったりと、Biz側にもメリットも大きいシナリオ負荷テスト。
是非一度お試しあれ。

最後に宣伝です!

僕らは伝統産業に向けて、現場向けのちょっとニッチなB2B SaaSを提供しているスタートアップです。
エンタプライズの企業様にも導入され始め、徐々に負荷量が増えてきて課題感も出始めました。
今まさに面白い時です!課題って大きい方が楽しくないですか?

ブルーカラー面白そうとか、難しい課題好きという方は、是非!

ここまでお読みいただき、ありがとうございました。

参考文献

Goでの自動化が唸るカッコいいworkflow画面を作りたい

f:id:kaminashi-developer:20201218053700p:plain

これはGo 4 Advent Calendar 2020 18日目の記事です

こんにちは株式会社カミナシのエンジニアの浦岡です。

個人的に、Goが一番輝きを放つのはworkflowの自動化だと思っています。 k8sなど自動化分野でGoはなくてはならない存在ですよね!

ただ、そのworkflow、テキストで記述するか、画面があってもフローチャートのような物が多く、どこか味けなくないでしょうか?

f:id:kaminashi-developer:20201218061918p:plain

見た目大事!!

少しジャンルは違いますが、こちらIntegromatというiPaaSのworkflow画面です。なんとも個性的じゃないですか?幾何学的なデザインが自動化魂に火をつけてくれます🔥🔥

f:id:kaminashi-developer:20201218053945p:plain

「よし、Workflow作るぞ!」と思い立ったユーザーを盛り立ててくれる演出があってもいいのではないでしょうか? 今回モックレベル(見た目だけ)ですが、それっぽい物ができるかチャレンジします!

※GoのAdvent Calendarですが、Goのソースは一切出てきません🙏

チャレンジ

workflowはブラウザ上で扱えるように、webアプリとして作成します。 フレームワークには、Goと同じくgoogle製のAngularを使ってみます。

具体的には以下のworkflowの構成要素を作ります。

f:id:kaminashi-developer:20201218054419p:plain
言葉の定義

ノードの作成

まず、以下の構成でノードとソケットを作ります。

f:id:kaminashi-developer:20201218054724p:plain

ノードはドラッグ操作で移動できるように、Angular CDKのDrag and Dropを使用。

<div class="node" cdkDrag (cdkDragMoved)="handleDragMoved($event)" [cdkDragFreeDragPosition]="node.initialPosition"
 (cdkDragMoved)="handleDragMoved($event)">
   <input-socket *ngIf="node.inputSocket"></input-socket>
   <div class="circle">
     {{node.icon}}
   </div>
   <div class="title">{{node.title}}</div>
   <output-socket *ngIf="node.outputSocket"></output-socket>
</div>

下のようなjsonを基にノードが描画されます。

f:id:kaminashi-developer:20201218055249p:plain

パスの作成

次にノード間を連結するパスで、これはCSSではなくSVGでレイアウトするようにしました。 SVG要素として表現することで、後々アニメーションなども仕込みやすくなるはずです。

<div class="svg-area">
 <svg class="full-screen">
   <path *ngFor="let path of paths" class="svg-path" stroke-dasharray="7, 5" [attr.d]="getD(path)" />
 </svg>
</div>

<node *ngFor="let node of nodes" [node]="node" (changePosition)="handleChangeNodePosition($event)"></node>

SVGのpathタグでベジェ曲線も表現することができます。

public static getD(startPosition: Position, endPosition: Position): string {
   const hx1 =
     startPosition.x + Math.abs(endPosition.x - startPosition.x) * 0.8;
   const hx2 = endPosition.x - Math.abs(endPosition.x - startPosition.x) * 0.8;

   return `M ${startPosition.x} ${startPosition.y} C ${hx1} ${
     startPosition.y
   } ${hx2} ${endPosition.y} ${endPosition.x} ${endPosition.y}`;
 }

パスは下のようなfrom, toのノードのidだけ持たせたjsonです。

f:id:kaminashi-developer:20201218055726p:plain

と、かなり中途半端なところでタイムアップしてしまいました、今回作ったソースはこちら stackblitz.com

今後について

  • データ構造は見直す必要ありそうです(AWS Step Functionsあたりが参考になるかもしれません)
  • monacoエディター組み込んでソース記述できるようになると便利そう

などなど

完成までの道のりは遠そうですが、まずはworkflowの定義情報をjsonファイルなりでインポート/エクスポートできるところを目指したいところです。(需要ありそうであれば、続き書きます)

【Golang + echo】S3のファイルをバイナリデータでレスポンスする

はじめに

この記事はGo 4 Advent Calendar 2020 17日目の記事です。

こんにちは、株式会社カミナシのエンジニア @ImuKnskです。

早速ですが、S3にあるファイルをバイナリデータで取得してレスポンスに含めたいと思ったことはありませんか?

『カミナシ』ではバッチ処理でファイルを作成してS3にファイルをアップロードした後、Client側でファイルをダウンロードする機能があります。

よくある処理はClient側からAWSにアクセスをしてファイルをダウンロードする方法ですが、今回はAPI側を経由してファイルをダウンロードする方法について書きたいと思います!

ファイルをバイナリデータとしてレスポンスに含めたいがうまくいかず、困っている方の参考になれば幸いです!

この記事で分かること

  • API側でS3ファイルをダウンロードせずに取得する方法
  • 取得したファイルをレスポンスに含める方法(Golang + echo)
  • Client側で自動ダウンロードさせる方法(React Native)

開発環境

  • go (1.14.2)
  • echo (v3.3.10+incompatible)
  • aws-sdk-go (v1.34.4)
API側でS3ファイルをダウンロードせずに取得する方法
package main

import (
    "fmt"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/gommon/log"
)

func main() {
    key := "xxx_key"
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                log.Error(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                log.Error(err)
            default:
                log.Error(aerr)
            }
        }
    }
    defer obj.Body.Close()
}

S3のセッションを作成した後に、GetObjectを使ってファイルをダウンロードせずにオブジェクトとして扱いましょう。 GetObjectに関しては公式サイトを参照ください。

docs.aws.amazon.com

取得したファイルをレスポンスに含める方法(Golang + echo)
package main

import (
    "io"

    "github.com/labstack/echo"
)

func setResponse(c echo.Context, body io.Reader) {
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+"ファイル名+拡張子")
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}

echo.ContextのResponseに対して必要なヘッダ情報、ステータスコードを設定、最後にWriterに対してデータをコピーする流れになります。

ここでハマったのが、Responseの組み立てるところです。 ステータスコード、ヘッダ、データコピーの順番が違うと意図した結果にならず困っていました。

レスポンスの中身をAPI側とClient側でデバッグしたりしてみましたが、何が原因なのか分からず こちらを 参考にして解決できました。どうやらResponseを組み立てる順番があり、順番通りにしないと正常に動作しないようでした。

API側のまとめ
package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/echo"
    echomw "github.com/labstack/echo/middleware"
    "gopkg.in/go-playground/validator.v9"
)

type S3KeyRequest struct {
    Key string `json:"key"`
}

func main() {
    router := initRouter()
    router.Logger.Fatal(router.Start(":8080"))
}

func initRouter() *echo.Echo {
    e := echo.New()
    e.Use(echomw.CORSWithConfig(echomw.CORSConfig{
        AllowOrigins: []string{"*"},
        AllowHeaders: []string{echo.HeaderContentType},
        AllowMethods: []string{echo.POST},
    }))
    e.POST("/sample", getS3FileResponseBinary)
    return e
}

func getS3FileResponseBinary(c echo.Context) error {
    var req S3KeyRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    v := validator.New()
    if err := v.Struct(req); err != nil {
        return err
    }

    obj, err := getS3ObjectOutput(req.Key)
    if err != nil {
        return err
    }
    defer obj.Body.Close()

    setResponse(c, obj.Body)

    return c.NoContent(http.StatusOK)
}

func getS3ObjectOutput(key string) (*s3.GetObjectOutput, error) {
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                return nil, errors.New(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                return nil, errors.New(err)
            default:
                return nil, aerr
            }
        }
    }
    return obj, nil
}

func setResponse(c echo.Context, body io.Reader) {
    // ファイル名:この名前でファイルダウンロードされます
    encodeName := url.QueryEscape("ファイル名.xlsx")
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+encodeName)
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}
Client側から自動ダウンロードしてみましょう!
import React from 'react'
import { View, Button } from 'react-native'

const downloadFile = async (body: string) => {
  const options = {
    method: 'POST',
    body: body,
    headers: {
      'Content-Type': 'application/json'
    }
  }

  let fileName = ''

  return fetch(`http://localhost:8080/sample`, options)
    .then(res => {
      // content-dispositionからファイル名を取得します
      // ここはAPI側で設定したファイル名をそのまま利用するイメージです
      fileName = getFileNameFromContentDisposition(res.headers.get('content-disposition') ?? '') ?? ''
      if (fileName !== '') return res.blob()
    })
    .then(blob => {
      const anchor = document.createElement('a')
      anchor.download = fileName
      anchor.href = window.URL.createObjectURL(blob)
      anchor.click()
    })
    .catch(e => {
      throw Error(e)
    })
}

const getFileNameFromContentDisposition = (disposition: string) => {
  const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
  const matches = filenameRegex.exec(disposition)
  if (matches != null && matches[1]) {
    const fileName = matches[1].replace(/['"]/g, '')
    return decodeURI(fileName)
  } else {
    return undefined
  }
}

const App: React.FC = () => {
  return (
    <View>
      <Button
        title="ファイルダウンロード"
        onPress={() => {
          const key = JSON.stringify({ key: 'xxx_key' })
          downloadFile(key)
        }}
      ></Button>
    </View>
  )
}

export default App

Client、APIを繋いでファイルダウンロードした結果はこちらです。 gyazo.com

おわりに

いかがだったでしょうか。今回紹介した機能自体はよく記事にされていると思いますが、一部だけ紹介されているケースがほとんどかなと思います。私も一部だけ紹介されている記事を参考にしながら実装をしていましたが、いざClientとAPIを繋いだときに意図しない結果になり、困ることが多かったです。 そのため全体を通してまとめてみましたので、困っていた方に少しでも参考になれば幸いです!

最後に…

弊社ではエンジニアを募集しており、興味がある、話を聞いてみたい、応募したいという方はWantedlyTwitterのDMからお待ちしております!

【Go言語】自作コンテナ沼。スクラッチでミニDockerを作ろう

f:id:kaminashi-developer:20201214013719p:plain

初めまして。株式会社カミナシPMの@gtongy1です。

Dockerというツール。SRE, Backend, Frontendどの領域のエンジニアも馴染みのあるツールではないでしょうか。
コンテナを利用することにより、インフラの環境を一つの空間に梱包し、その内部で柔軟に様々な環境を作ることが出来ます。
コンテナの実体とはなんなのでしょう? 叡智が詰め込まれたそんな一つの宝箱のように見えます。

コンテナ作ってみたくなりませんか?

僕と同じように知的好奇心をくすぐられたそこのあなた!コンテナ沼の一歩目を一緒に踏み出してみましょう!

検証環境

Dockerの機能おさらい

docs.docker.com

まず、ドキュメント内を読み進めてDockerに対する知識を整理します。

f:id:kaminashi-developer:20201212165419p:plain

DockerはDocker daemonを基幹とし、その呼び出しを行うREST API, Docker Cliと機能が覆い被さっています。

f:id:kaminashi-developer:20201212170642p:plain

普段Cli経由で処理を行う、network, container, image, volumesはCli経由からこのDocker daemonの機能の呼び出しを行います。
RegistryからImageを取得し、ローカル内のImageをベースにコンテナを作成する部分は上記の図のようなイメージで呼び出されています。

そうなると気になるのは、Docker daemon。この内部の処理に全てのエッセンスが詰まっているように思われます。
Docker daemon内で動作しているのは、Linuxカーネルの基本となる機能群。 そこでLinuxのコンテナ周りに必要な機能の詳細、そして最小のコンテナを作って説明していきます。

Linux上にコンテナを呼び出し

大前提として、コンテナとは何者なのでしょうか。
コンテナを一言で言うと「 システムから分離されたプロセス 」です。
Linux上でunshareコマンドを打つことにより、最速でコンテナを作成することが出来ます。

※ 以降のコマンドはLinux上で実行しています。

$ unshare -u /bin/bash
$ hostname newhost && hostname
newhost

上記を実行することで、以下のようにプロセスが別に立ち上がっていることがわかります。
別のセッションでhostnameを実行してみましょう。

gyazo.com

コンテナの誕生です🎉!これであなたも自作でコンテナを作ったと言っても良いでしょう!おめでとう!
この立ち上がったプロセスこそがコンテナであり、この内部で動く技術こそがDockerの基幹の技術なのです。

Linux、コンテナ3種の神器

上記のコンテナの内部ではどのようにして、プロセスの立ち上がったのでしょうか。
これはLinux内に備わったコンテナに必要な3種の神器である、

  • Namespace
  • Control Group
  • File System

が関係しています。一つずつ説明していきましょう。

Namespace

Namespaceはコンテナ上に隔離された層を作成することが出来ます。
同一のカーネル上に別々の環境を作成するために、一意な名前を付けることによって別の環境であることを示すための機能こそがNamespaceです。
ネットワーク, 通信, ファイルマウント等処理を行う様々なプロセスはそれぞれでNamespaceを持っており、上記はunshareはプロセスを分離させNamespaceを作成しました。
Docker上でも同じようにNamespaceを活用しており、以下の種類があります。

  • pid: プロセスID。システム内での一意なことを表すID
  • net: ネットワークの管理
  • ipc: プロセス間通信の管理
  • mnt: ファイルシステムマウント管理
  • uts: バージョン識別子, カーネルの管理

立ち上げたコンテナに上記のようなNamespaceを利用することによって、プロセスを分離して各機能を利用することが出来ます。

Control Group(cgroup)

Control Groupはアプリケーションを特定のリソースセットに制限します。
以下コマンドを実行してみて、内部にファイルとして置かれているものがControl Groupです。

$ cat /sys/fs/cgroup/cpuset/cpuset.cpus
0-3

Control Groupを使用すると、オプションで制限や制約を強制することができます。
メモリの最大利用数や、プロセス最大実行数などそれぞれです。
以下のようなファイルによって、制限を付与することが出来ます。

File System

子プロセスが親からマウントされたFile Systemに関するデータのコピーを取得し、親と同じデータ構造へのポインタを取得して変更できるように出来ます。

$ cat /proc/mounts

システム内でマウントされている情報をファイルから情報を読み解くことが出来ます。

proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
tmpfs /dev tmpfs rw,nosuid,size=65536k,mode=755 0 0
devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0
sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0
tmpfs /sys/fs/cgroup tmpfs ro,nosuid,nodev,noexec,relatime,mode=755 0 0
cpuset /sys/fs/cgroup/cpuset cgroup ro,nosuid,nodev,noexec,relatime,cpuset 0 0
cpu /sys/fs/cgroup/cpu cgroup ro,nosuid,nodev,noexec,relatime,cpu 0 0

このように、Namespaceを利用し、File System, Control Groupを活用することで稼働するコンテナに対して制約を設け、必要なファイル群をマウントし、コンテナの型を作ることが出来るようになります。

Go言語コンテナ始めの一歩

それでは、Go言語を使って実装していきましょう!

// +build linux
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("help")
    }
}

func run() {
    fmt.Printf("Running %v \n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3]...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }
    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

まず始めに環境内で引数から渡されたコマンドを実行するまで作成します。
syscall.SysProcAttrとそのフラグのうちでCloneflagsを追加しています。
これは新規のプロセスに対して新しいNamespaceを追加することが出来ます。 これによって、今回のUTS(UNIX Time Sharing), PID, NS(mount周り)を新しいnamespaceで作成しています。

$ go run exec.go run echo hello
Running [echo hello]
hello

上記ファイルを引数を渡し実行すると、内部でコマンドが実行していることがわかると思います。
次はコンテナを子プロセスとして立ち上げてみてみましょう。

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("help")
    }
}

func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    // ...
}

func child() {
    fmt.Printf("Running %v \n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    must(syscall.Sethostname([]byte("container")))
    must(syscall.Chroot("/"))
    must(os.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))
    must(cmd.Run())
    must(syscall.Unmount("proc", 0))
}

ここで変更を加えているポイントとして

  • childの関数を用意し、これまでと似たような関数を用意している。今までと比較して変更している点は以下
  • /proc/self/exe

のあたりが主に変わっているところでしょう。
今回の実装内で新規でchild関数を用意し、その内部でこれまでと同じようにプロセスを立ち上げ、syscall.Sethostnameで新しく立ち上げたプロセスにホスト名を付与しています。
また新しく立ち上げたプロセスに対して、syscall.Mount,Chrootを利用し子プロセスの設定を追加しています。
chrootは、子プロセスのルートの指定、syscall.Mountはファイルのマウントを行う処理です。
上記実装により、新しく立ち上げたプロセスに親プロセスの情報を追記することや、ディレクトリの構成を指定することが出来ています。
また、親プロセス内で実行している、/proc/self/exeは今実行しているプロセスを再実行します。この時の第一引数としてchildを渡しています。
よって、親プロセス内部で子プロセスを呼び出し、処理を実行することが出来ます。
それでは実行してみましょう。

root@24b10018b09a:/go# go run main.go run /bin/bash
Running [/bin/bash]
Running [/bin/bash]
root@container:/#

上記のようにプロセスが2つ実行され、hostnameがcontainerに変換されているのがわかります。
子プロセスの立ち上げと、その内部でプロンプト上で操作することができました!

Control Groupの作成

最後にControl Groupを追加していきます。

func child() {
    // ...
    setControlGroup()
}
func setControlGroup() {
    cgroups := "/sys/fs/cgroup/"
    pids := filepath.Join(cgroups, "pids")
    must(os.Mkdir(filepath.Join(pids, "gtongy"), 0755))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/pids.max"), []byte("20"), 0700))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/notify_on_release"), []byte("1"), 0700))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

Control Groupはファイル群です。
立ち上げた子プロセスのpidを設定に登録、このグループ内にその他の制約を設けています。
ここで上げているのはpids.maxで、設定したプロセスに対して最大数を設けています。
ここに追加でmemoryの制限や、CPUのリソースの制約を追加することも出来ます。

コードサンプル

最後にコードサンプルです。一連の流れを追うためにご活用ください。

github.com

終わりに

コンテナ作成の一連の流れを説明、またGo言語を利用してコンテナ化を行いミニDockerを作成してみました。
一連の流れを追い、小さい単位でスクラッチで機能を作成することが深い理解に繋がると思います。
こうして、自作で今まで難しいと思っていた機能を紐解いてみるとワクワクしませんか!
自分の知らなかった世界の広がりと、今動いているシステムの良さや改善できそうなところが滲み出てくる感覚がします。

そんな自作コンテナ沼。一緒に今年の年末年始のあいた時間に遊んでみてはいかがでしょうか。

最後に宣伝です。

カミナシでは上記のようなコンテナをAWS Fargateを利用し、本番環境で元気に動かしています。
そんな環境で、柔軟なインフラ作ったりアプリを成長させたりチャレンジしてみたい熱量持ったエンジニアの方を募集しています。
エントリーお待ちしております!!

参考資料

フロントエンドエンジニアがGoの書き方を理解する

本記事は Go3 Advent Calendar 2020 15日目の記事になります。

こんにちは、株式会社カミナシのエンジニア @tomiです。

JavaScript, Node.jsをメインに扱ってきたエンジニアがGoに触れるときにどう解釈したかを、JavaScriptGolangを比較しながら、理解を深める記事となっています。

はじめに

まず私自身のJavaScriptへの理解としては、VueやReact,Redux.Next.jsなどのフロントエンド周りに加え、ExpressやAdonisJSなどのバックエンドもかじっており、Node.jsでフルスタックに開発できるレベルです。
また、TypeScriptもここ1年ほどは個人開発でも積極的に取り入れております。

そんなJavaScriptにしがみついてきた私が、カミナシへのジョインをきっかけにGo言語を本格的に触れることになり、コードをどう噛み砕いったかを改めてまとめました。

ひとつひとつの機能を紹介するというよりは、Goではこう書くのかという視点となります。

さっそく、コードを見ながら比較してきます!
(Goが静的型付け言語なので、JavaScriptではなくTypeScriptとの比較がほとんどとなります。)

import/export

まずはじめに、コードを開くとpackageimport がほとんどのファイルに書かれています。

// hello/world.go
package hello

import (
  "fmt"
)

func World() {
  fmt.Println("Hello, World!")
}

上のソースを見たらだいたい察しは付くと思いますが、

importは外部パッケージを読み込んだり、作成したpackage を読み込むことができます。

JavaScriptでいうとこの部分。

import fmt from "fmt"

exportに関しては、packageに書いたパッケージ名から関数を読み出せるようにしており、先程のHello, World!を出力するには以下のように書きます。

package main

import (
  "./hello"
)

func main() {
  hello.World()
}
// Hello, World!

これをJavaScriptで書くと、以下のようになります。

// hello/World.js
export default function World () {
  console.log('Hello, World!')
}

// hello/index.js
import World from './World.js'

export default {
  World,
}

// index.js
import hello from './hello'

hello.World()
// => Hello, World!

Goでは同じpackage名のファイルを複数用意できるので、関数を複数のファイルに分割することがとても簡単になっています。

// hello/local.go
package hello

import (
  "fmt"
)

func Local() {
  fmt.Println("Hello, Local!")
}

// main.go
package main

import (
  "./hello"
)

func main() {
  hello.World()
  hello.Local()
}

// Hello, World!
// Hello, Local!

Var(変数)

変数でよく使う書き方が2パターンあります。

// ひとつめ
var hello string
hello = "Hello, World"

// ふたつめ
hello := "Hello, World"

:=とすることで、宣言と代入をまとめることができます。

Typescriptで書くと、以下のようになります。

// ひとつめ
let hello: string
hello = "Hello, World"

// ふたつめ
let hello = "Hello, World"

Function(関数)

上でもさらっと書いていますが、funcが関数となっています。

Goでは、Typescriptのように引数や返り値には型が必須になります。

func GetHello(name string) string {
  message := "Hello, " + name
  return message
}

(name string)で引数nameはString型であることを示し、関数getHelloの返り値の型を引数( )の後ろ書きます。

これをTypescriptで書くと、以下のようになります。

function GetHello(name: string): string {
  const message = `Hello, ${name}`
  return message
}

for ... range(繰り返し)

fruits := []string{"apple", "banana", "orange"}
for index, fruit := range fruits {
  fmt.Println(index, fruit)
}
// 0 apple
// 1 banana
// 2 orange

for i := 0; i < 10; i++ { ... }という書き方もありますが、for ... rangeを使ったやり方の方が良く見かけます。

indexが不要な場合は、ブランク修飾子_を使って、for _, fruit := range fruitsと書きます。

これをTypescriptで書くと、以下のようになります。

const fruits = ["apple", "banana", "orange"]
fruits.forEach(fruit, index) {
  console.log(index, fruit)
}

Struct(構造体)

type User struct {
  name string
  age int
}
tom := User{"Thomas", 20}
fmt.Println(tom.name, tom.age)
// Thomas 20

これをTypescriptで書くと、以下のようになります。

type User = {
  name: string
  age: number
}
const tom = new User("Thomas", 20)
console.log(tom.name, tom.age)

classを使った書き方もできます。

class User {
  name: string
  age: number
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }
}
const tom = new User("Thomas", 20)
console.log(tom.name, tom.age)

レシーバ(構造体と関数の紐付け)

先程作成したtomが、tom.intro()という関数で自己紹介を出力したいとする。

type User = {
  name: string
  age: number
}

func (u *User) intro () {
  fmt.Println("I am " + u.name)
}
const tom = new User("Thomas", 20)
tom.intro()
// I am Thomas

関数名の前に構造体を記述することで、構造体用の関数であることを示し、関数内で構造体を扱うことができる。

これをTypescriptで書くと、以下のようになります。

class User {
  name: string
  age: number
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }

  intro() {
    console.log(`I am ${this.name}`)
  }
}
const tom = new User("Thomas", 20)
tom.intro()

はじめてこの関数を見たときは、関数名の前後に( )がある・・・なんだ?🤔となりました。

実際には↓のように引数も返り値もあるので、さらに複雑に見えたのが初見の印象。

func (r *Report) GetReports(limit, offset int64, query *entity.GetReportsQuery) (*entity.Reports, int64, error) { ... }

まとめ

JavaScript脳がGolangを理解するために、JavaScriptGolangを比較してみました。

この書き方で戸惑っていたのかと思うと自分のレベルの低さに落ち込みますが、Goを触ってみたいという人も同じところで悩むはず!
そんな人のお役に立てる記事となれば幸いです。

GoでGraphQL Subscriptionsを実装する

はじめに

この記事はGo3 Advent Calendar 2020の11日目の記事です。gqlgenを使ってGraphQL Subscriptionsを実装する方法とハマったポイントを紹介したいと思います。

利用技術

  • gqlgen
    • GraphQL SchemaからGoのコードを出力するコードファーストなライブラリ
  • Redis Pub/Sub
    • Redisでクライアント間通信を行う
  • echo
    • Webフレームワーク

今回はドメイン3層とgqlgenの生成物を合わせた構成にしています。 詳しくは先日の記事にまとめています。

実装した内容はGitHubに公開しました。
※記事の中のコードはわかりやすさを重視するため今回の内容に関わらない部分は省略しています

github.com

Docker環境の構築

docker-compose.yaml にRedisコンテナを追加します。

version: '3.7'
services:
  redis:
    image: redis:latest
    ports:
      - "6379:6379"

Redis Clientの選定

Goのパッケージに公開されているRedis Clientは公式ドキュメントにまとめられていて、星マークがついてるライブラリがRedis公式に推奨されてます。最初はその中のRadixを使用していましたが、Redisのコマンドを文字列で入力する必要があったため、Redisコマンドをメソッドとして抽象化しているgo-redis/redisを使用しました。

Redis Clientセットアップ

まずライブラリをインストールします。

$ go get -u github.com/go-redis/redis/v8

Redis Clientはインフラ層に定義し、環境変数からURLを取得して初期化しています。

type Store struct {
    Client redis.UniversalClient
    TTL    time.Duration
}

func NewRedisClient(ctx context.Context) (*Store, error) {
    client := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_URL"),
    })
    err := client.Ping(ctx).Err()
    if err != nil {
        return nil, err
    }
    defaultTTL := 24 * time.Hour
    return &Store{Client: client, TTL: defaultTTL}, nil
}

Pub/SubのクライアントはデータストアであるRedis本体とは目的が異なるため、Storeとは別の構造体で定義しました。

model.Matchドメイン層の構造体で、ユーザー間のマッチングをやり取りするためのデータです。model.Match が生成されたタイミングで通知するchannelを定義します。今回は一つのみにしていますが、GraphQL Subscriptionsが複数ある場合はchannelも複数定義していきます。

type PubSubClient struct {
    Store         Store
    MatchChannels map[string]chan *model.Match
    Mutex         sync.Mutex
}

func NewPubSubClient(ctx context.Context, store Store) *PubSubClient {
    subscriber := &PubSubClient{
        Store:         store,
        MatchChannels: map[string]chan *model.Match{},
        Mutex:         sync.Mutex{},
    }
    return subscriber
}

定義した2つのクライアントをResolverに注入します。

type Resolver struct {
    DB           *gorm.DB
    StorePool    *store.Store
    PubSubClient *store.PubSubClient
}

各ResolverでRedisを利用できるようになりました。

スキーマの追加

GraphQLスキーマにSubscriptionsを追加します。 今回はユーザーとユーザー間を Match で関連付けるデータ構造にしているため、 Match オブジェクトが生成されたタイミングでユーザーに通知する仕組みを作ります。

type Mutation {
    createMatch(input: NewMatch!): Match!
}

type Subscription {
    createMatch(userUID: String!): Match
}

スキーマを編集した後、 gqlgen コマンドを実行してSubscriptionsのResolverを生成します。 resolver パッケージに以下のコードが追加されたら完了です。

func (r *subscriptionResolver) CreateMatch(ctx context.Context, userUID string) (<-chan *model.Match, error) {
   panic(fmt.Errorf("not implemented"))
}

他のResolverと異なり、関数の返り値がchannelになっています。

Subscriptionsの実装

GraphQLでSubscriptionsを実装する場合、以下の内容を追加する必要があります。

  1. APIがRedis PubSubから配信されるメッセージを受信
  2. ユーザーがSubscriptionsを実行したときびWebSocket通信
  3. ユーザーがMutationを実行したときにRedis PubSubに配信

実装は PubSub Client のメソッドに追加していき、Resolverでは定義されたメソッドを呼び出すようにしました。

それぞれ実装をみていきます。

1. APIがRedis PubSubから配信されるメッセージを受信

API起動時にRedis PubSubからのメッセージを受信し、パースしたデータをChannelに流し込みます。 msg.Payload がRedisからのメッセージになっています。

func (s *PubSubClient) StartMatchChannel(ctx context.Context) {
    go func() {
        pubsub := s.Store.Client.Subscribe(ctx, channel.MatchChannel)
        defer pubsub.Close()

        for {
            msgInterface, _ := pubsub.Receive(ctx)
            switch msg := msgInterface.(type) {
            case *redis.Message:
                m := model.Match{}
                if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
                    continue
                }
                for _, ch := range s.MatchChannels {
                    ch <- &m
                }
            default:
            }
        }
    }()
}

2. ユーザーがSubscriptionsを実行したときのWebSocket通信

ユーザーがSubscriptionsを実行するときにユーザーごとのchannelを生成します。

構造体に定義しているchannelのキーをユーザーIDにしてRedisに保存し、受信状態を作ります。

func (s *PubSubClient) Receive(ctx context.Context, userUID string) <-chan *model.Match {
    _, err := s.Store.Client.SetXX(ctx, userUID, userUID, 60*time.Minute).Result()
    if err != nil {
        return nil
    }

    match := make(chan *model.Match, 1)
    s.MatchChannels[userUID] = match

    go func() {
        <-ctx.Done()
        delete(s.MatchChannels, userUID)
        s.Store.Client.Del(ctx, userUID)
    }()

    return match
}

3. ユーザーがMutationを実行したときにRedis PubSubに配信

最後にSubscriptionsが発火するMutation内でRedis PubSubにデータを配信します。

func (s *PubSubClient) Publish(ctx context.Context, match *model.Match) error {
    matchJSON, err := json.Marshal(match)
    if err != nil {
        return err
    }
    s.Store.Client.Publish(ctx, channel.MatchChannel, matchJSON)
    return nil
}

これでMutationが実行されるたびにユーザーにデータを配信できます。

ハマったポイント

gqlgen generateできない

最初にリポジトリを生成してから2ヶ月ほど経過してから修正したため、gqlgenのバージョンがCLIは0.12.2に対してgo.modは0.13.0と差分が出てました。 この状態で gqlgen generate を実行すると generated.go のなかで not declared by package エラーが発生してしまいます。

今回はCLIのgqlgenを再インストールすることで解決しました。

WebSocket通信できない

gqlgenの handler には NewDefaultServer メソッドが存在していて、返り値のserverには大枠の設定が自動で行われています。しかしWebSocketの設定も追加されているためRedis PubSubと接続するときにoriginチェックの処理で落ちてしまいます。

そのため handlerNew メソッドを使って必要な部分だけ Transport するようにしました。

WebSocket通信が認証エラーになる

実装をまとめてるリポジトリでは元々Firebase Authを組み込んでいいて、Go上ではクライアントから渡されたトークンをFirebase Authに確認しユーザーIDを取得する処理を行っていました。 Firebase Authの認証処理をechoの middleware で定義していたため、WebSocket通信でも認証チェックが行われてしまい、ヘッダーのトークンを参照できないためエラーになってしまいました。

今回は echo.Context に含まれている IsWebSocket メソッドを利用してWebSocker通信では認証処理をしないようにしました。

まとめ

gqlgenとRedis PubSubを使ってGraphQL Subscriptionsを実装しました。 Goのchannelに流し込むとクライアントに配信してくれるので比較的簡単に機能を作れました。 今後はchannelが増えたときに設計をどう拡張・修正していくか考えてみたいです。

参考