SYM's Tech Knowledge Index & Creation Records

「INPUT:OUTPUT=1:1以上」を掲げ構築する Tech Knowledge Stack and Index. by SYM@孤軍奮闘する IT Engineer.

Knowledge Stack & Index (全記事一覧)

目指していた INPUT:OUTPUT = 1:1以上 の理想形は Link & TechTips Stack の形なのかもしれない(※仮挿入) 本ブログとの並列運用中


本ページは投稿記事一覧です。 (自動更新)

※ 旧ページ:SYM's Knowledge Index 徐々に移行予定

自作ツール(Githubリンク) により本ブログへの投稿/更新はほぼ自動化

【gRPC】Connect が作られた背景概要/これまでの gRPC-Web/Connect でできること

【gRPC】Connect が作られた背景概要/これまでの gRPC-Web/Connect でできること

https://qiita.com/SYM_simu/items/85d572e3520e98e09044 に公開している物と同じ。

はじめに

以下を読み、Connect-Web なるものの 1.0 がリリースされたことを知った(2022/8 半ばに)。

ref: gRPC がフロントエンド通信の第一の選択肢になる時代がやってきたかも?

gRPC については、Go 言語で学ぶ実践 gRPC 入門 (Udemy 講座) を通してド基礎を押さえただけの状態であったため、これを機に gRPC 関連を色々調べたため、それを記載する。

本記事では

  • (簡単な) Connect が作られた背景
  • gRPC 関連で調べたこと(gRPC-Web 中心)
  • Connect とは
  • Connect のチュートリアルを部分的&一部追加して試したこと

をまとめる。

gRPC とは? は省くが、以下が参考になる。

何故 Connect が作られたのか?

新しい物を作るということは、往々にして解決したい課題があるからと考える。以下の通り本家 gRPC には以下のような課題があったらしい。

あえて作ったのは既存の実装にいろいろ不満があるからということです。

  • コメントを除いて 100 以上のパッケージで合計 13 万行ででかすぎる
  • Go 標準ではなく独自実装の HTTP/2 実装を使っていて、Go の標準的なミドルウェアなどが使えない
  • ウェブから使うにはプロキシが必要
  • デバッグ大変
  • セマンティックバージョニングを使ってない

ref: gRPC の Go 実装の新星、Connect

gRPC サーバは HTTP/2 が前提となる。それに起因する課題が発生することは想像に容易いが、上記の中の「ウェブから使うにはプロキシが必要」に関して、現状に関して無知なため、ここを切り口に探ることとした。

※詳細を述べると、gRPC にはgRPC-Web という gRPC 通信を Web で使うことができるようにするためのものが既にある。Connect にも同様に Connect-Web があるが、Web から使用する部分も作り直す必要があった点に関して gRPC-Web に関して無知なため、想像が付かなかった。

そこで、ググって見つけた以下記事の内容をヒントに探り始めた。結論から言うと、以下の内容は概ね当っていると思っている。

※ このあたり理解がめちゃ浅いです RPC (Remote Procedure Call) を実現するためのプロトコルとして、gRPC があります。 このプロトコルは、ブラウザ側からは使えない(?)ため、gRPC-Web というブラウザ向けの gRPC というものを使うことになります。 その場合、ブラウザとサーバーとの間に、プロキシを建てる必要があるようです。(たぶん) そこで、Connect という gRPC 互換の HTTP API を構築するためのライブラリ群が開発されました。 これのおかげで、プロキシを建てる必要がなく、ブラウザ側から gRPC を使うことが可能になります。

ref : connect-web やってみた (Zenn)

その根拠を順を追って記載する。

gRPC-Web とは

そもそも gRPC-Web ができた背景は以下の通り。

gRPC は Google が公開している RPC 方式で、Protocol Buffers と HTTP/2 をベースにしたバイナリプロトコル

ブラウザは HTTP/2 に対応していないブラウザもまだまだ現役でたくさんいますし、バイナリを扱うのが苦手

ブラウザでも利用できる gRPC-Web という新しいプロトコルを作り、gRPC-Web を gRPC に変換する proxy 層を介して通信することで、gRPC の旨味をブラウザでも利用できるようにする、というのが gRPC-Web

ref: gRPC-Web を利用したクライアント・サーバー間の通信

Github リポジトリの README にも記載がある通り、gRPC-Web を gRPC に変換する proxy 層 が必要とだけ記載されている。

gRPC-web クライアントは、特別なプロキシ経由で gRPC サービスに接続します。デフォルトでは、gRPC-web は Envoy を使用します。

ref: gRPC Web (Github - README.md)

proxy 層が必要な理由

では、なぜプロキシを挟む必要があるのか?

理由は以下の通りと思われる。

  • ブラウザーで実行されている JavaScript では、HTTP2 を完全に制御することはできません。
  • gRPC プロトコルは、JavaScript では制御できない HTTP/2 の機能を使用します。
  • そして、制限が解除されることはないと思います。
    • ブラウザーは、HTTP 2 経由で web サーバーと通信できる場合は HTTP 2 を使用するが、それ以外の場合は(勝手に)HTTP/1.1 にフォールバックする。
    • 上位に位置する Web アプリケーション(恐らくフロントエンド)が下位(恐らくサーバサイド)で使われているのが HTTP/2 なのか、あるいは HTTP/1.1 なのかを認識できないし、意識するべきではない(HTTP/1.1 と HTTP2 を透過的に処理できる必要がある)
    • (故に) HTTP2 でのみ使用可能な機能を JavaScript で制御する方法がブラウザに提供されることはない

(翻訳機和訳含む)

refs:

要約すると、ブラウザは HTTP/1.1 を使うか HTTP/2 を使うかをブラウザを使用する人には意識させず、透過的に処理する。HTTP/2 を指定して使うといった制御は JavaScript ではできない、かつ(HTTP/1.1 と HTTP2 を透過的に処理できる必要があるため)今後も JavaScript で制御する方法は提供されないだろう。だが、gRPC サーバへの送信 は HTTP/2 で行う必要がある。(といった所でしょうか)

※こちらも参考になるかと思われる ⇒ ブラウザで HTTP/2 ストリーム接続を実装するには? (stackoverflow)

また、gRPC は HTTP トレーラー(trailer) を多用するが、Web ブラウザーを含む 多くの HTTP 実装は、まだトレーラーをサポートしていない。gRPC-Web では、応答本文の末尾にトレーラーをエンコードして付与することでその問題を解決しているとのこと。

refs:

gRPC-Web のソースを追う

以下個人的に 2 点気になった点を、実際のソースを追って、gRPC-Web の仕組みの一端を確認してみる。

  • 応答本文の末尾にトレーラーをエンコードして付与(※実際のコードを見てから信じる)

以下より、ストリーミング受信時は応答本文(responseText)から trailer を取り出していることが分かる(該当ソース一部抜粋。ここ以外にもあるかもしれないが)

class GrpcWebClientReadableStream {
  // : 省略
  const self = this;
  events.listen(this.xhr_, EventType.READY_STATE_CHANGE, function(e) {  // 138行目
    // : 省略
    let byteSource;
    if (googString.startsWith(contentType, 'application/grpc-web-text')) {
      // Ensure responseText is not null
      const responseText = self.xhr_.getResponseText() || '';
      const newPos = responseText.length - responseText.length % 4;
      const newData = responseText.substr(self.pos_, newPos - self.pos_);
      if (newData.length == 0) return;
      self.pos_ = newPos;
      byteSource = googCrypt.decodeStringToUint8Array(newData);
    } else if (googString.startsWith(contentType, 'application/grpc')) {
      byteSource = new Uint8Array(
      /** @type {!ArrayBuffer} */ (self.xhr_.getResponse()));
      } else {
      // : 省略
    }
    let messages = null;
    try {
      messages = self.parser_.parse(byteSource);
    } catch (err) {
      // : 省略
    }
    if (messages) {
      const FrameType = GrpcWebStreamParser.FrameType;
      for (let i = 0; i < messages.length; i++) {
        // : 省略
        if (FrameType.TRAILER in messages[i]) {             // 187行目
          if (messages[i][FrameType.TRAILER].length > 0) {
            let trailerString = '';
            for (let pos = 0; pos < messages[i][FrameType.TRAILER].length;
                  pos++) {
              trailerString +=
                  String.fromCharCode(messages[i][FrameType.TRAILER][pos]);
            }
            const trailers = self.parseHttp1Headers_(trailerString);
        // : 省略
          }
        }
      }
    }
  });
  // : 省略
}

ref: https://github.com/grpc/grpc-web/blob/35284bfe156fc41bbcdd554ac423a587d93ff8da/javascript/net/grpc/web/grpcwebclientreadablestream.js#L187

this.xhr の実態が何か(xhr = xmlHttpRequest なのか?)念のため更にソースを辿ると

// : 省略
const XhrIo = goog.require("goog.net.XhrIo");
// : 省略

class GrpcWebClientBase {
  // : 省略
  startStream_(request, hostname) {
    // 183行目
    const methodDescriptor = request.getMethodDescriptor();
    let path = hostname + methodDescriptor.getName();

    const xhr = this.xhrIo_ ? this.xhrIo_ : new XhrIo(); // ★
    xhr.setWithCredentials(this.withCredentials_);

    const genericTransportInterface = {
      xhr: xhr, // ★
    };
    const stream = new GrpcWebClientReadableStream(genericTransportInterface);
    // : 省略
  }
  // : 省略
}

ref: https://github.com/grpc/grpc-web/blob/3956560ad01b4af0a2a5c29c081f5bbd1424e85d/javascript/net/grpc/web/grpcwebclientbase.js#L193

google/closure-library (Github)に含まれるXhrIo に行き着いたため、それも見に行った。

goog.net.XhrIo.prototype.createXhr = function () {
  "use strict";
  return this.xmlHttpFactory_
    ? this.xmlHttpFactory_.createInstance()
    : goog.net.XmlHttp();
};

ref: https://github.com/google/closure-library/blob/951a512d54578e5dbaff148c3bcb406957f78f46/closure/goog/net/xhrio.js#L721

// 27行目~
goog.net.XmlHttp = function () {
  "use strict";
  return goog.net.XmlHttp.factory_.createInstance();
};

// 146行目~
goog.net.XmlHttp.setGlobalFactory = function (factory) {
  "use strict";
  goog.net.XmlHttp.factory_ = factory;
};

// 159行目~
goog.net.DefaultXmlHttpFactory = function() {
  'use strict';
  goog.net.XmlHttpFactory.call(this);
};
goog.inherits(goog.net.DefaultXmlHttpFactory, goog.net.XmlHttpFactory);


/** @override */
goog.net.DefaultXmlHttpFactory.prototype.createInstance = function() {
  'use strict';
  const progId = this.getProgId_();
  if (progId) {
    return new ActiveXObject(progId);  // ActiveX…だと…
  } else {
    return new XMLHttpRequest();  // ★
  }

// 250行目~
// Set the global factory to an instance of the default factory.
goog.net.XmlHttp.setGlobalFactory(new goog.net.DefaultXmlHttpFactory());

ref: https://github.com/google/closure-library/blob/a0248d22bd094840c2fc9e08d0f39c10bf4beacf/closure/goog/net/xmlhttp.js

gRPC-Web では概ね XMLHttpRequest を使用して送受信を行っていると思われる(※辿り方を間違えていなければ。実は fetch api を使用している箇所もあるが現時点では実験的機能であった。)

※ レスポンスをデコードする仕組みに関しては、以下が参考になると思われる。

ref: gRPC-web がどのようにリクエストをシリアライズしているのか

Envoy Proxy & gRPC-gateway

簡単に触れておく。

Enovy とは

Nginx と似た機能を持つ OSS で、マイクロサービスに対応するため、サービス間のネットワーク制御をライブラリとしてではなく、ネットワークプロキシとして提供することを目的に開発

ref: Envoy Proxy を始めてみよう

Envoy はクライアント →Envoy、Envoy→ バックエンドサーバ間の両方とも HTTP/2 と gRPC をサポートしている。

また gRPC-Web 用のフィルターがあり、これにより gRPC-Web(クライアント) から gRPC サーバへの通信が可能と思われる。

gRPC-Web は、 gRPC-Web クライアントが HTTP/1.1 経由で Envoy にリクエストを送信し、gRPC サーバーにプロキシされることを可能にするフィルターによってサポートされています。 (翻訳機和訳)

ref: enovy - gRPC

それ以外にも、マイクロサービスにおいて発生する様々な問題を取り扱うことができるとのこと。

refs:

Enovy 以外の選択肢として gRPC-gateway というものもある。gRPC-gateway とは HTTP JSON API リクエストを gRPC に変換して gRPC サーバーへプロキシできるものである。両者には以下の差異がある。

  • gRPC-gatewaygolang のみ対応
  • Enovy は 複数言語に対応

gRPC に関する Enovy や gRPC-gateway については以下が参考になるかと思われる。

ref:

補足

Github の README にある通り、gRPC-Web がサポートするのは以下 2 つのため、クライアントストリーミング RPC と双方向ストリーミング RPC は未対応。

gRPC-web currently supports 2 RPC modes:

  • Unary RPCs
  • Server-side Streaming RPCs

ref: gRPC-Web - Streaming Support

理由は、gRPC-Web に proxy 層が必要な理由と概ね同じである。HTTP/1.1 はストリーミング受信はできるが、ストリーミング送信を行うには HTTP/2 である必要があるため。

Connect とは

Connect についての詳細は以下の通り。

Connect は、ブラウザや gRPC 互換の HTTP API を構築するためのライブラリ群です。短い Protocol Buffer スキーマを記述し、アプリケーションロジックを実装すると、 Connect がマーシャリング、ルーティング、圧縮、コンテントタイプネゴシエーションを処理するコードを生成します。また、サポートされているあらゆる言語で、慣用的なタイプセーフなクライアントが生成されます。

ref: Connect Docs - Introduction

新しい Connect プロトコルは、HTTP/1.1 または HTTP/2 で動作する、シンプルな POST プロトコルです。ストリーミングを含む gRPC と gRPC-Web の最良の部分を取り込み、ブラウザ、モノリス、マイクロサービスにおいて同様に動作するプロトコルにパッケージ化しました。Connect プロトコルは、私たちが考える gRPC プロトコルのあるべき姿です。デフォルトでは、JSON とバイナリでエンコードされた Protobuf がサポートされています。(翻訳機和訳)

ref Connect Docs - Use the gRPC protocol instead of the Connect protocol

Connect の凄い所を一言で表すと、これまでフロント/サーバ間の gRPC 通信には、proxy 層が必須だったところを HTTP/1.1,HTTP/2 に捕らわることなく、フロント/サーバ間の gRPC 通信が可能になったことではないかと思われる。

特徴をまとめると、

  • Connect は、独自のプロトコル(HTTP/1.1 と HTTP/2 で動作する簡単で POST のみのプロトコル)をサポートすることで、HTTP/1.1 でも利用可能とした (※)
    • Connect 独自のプロトコルREST API になっており curl で簡単にテストも可能
    • 故に、Envoy のような変換プロキシに依存することなく、grpc/grpc-web によって使用される gRPC-Web プロトコルを直接サポート
  • Connect は、ストリーミングを含む gRPC および gRPC-Web プロトコルをサポート
    • Connect サーバは 3 つのプロトコル(Connect / gRPC / gRPC-Web)すべてからの入力をサポート
    • クライアントはデフォルトで Connect プロトコルを使用するが、gRPC または gRPC-Web に切り替えも可能

gRPc/gRPC-Web プロトコルをサポートしていることから、以下のような構成が可能と思われる(実際に試したわけではないため推測の域)。そのため、既に gRPc/gRPC-Web を使用しているシステムでクライアント/サーバ片側だけ Connect に入れ替えてひとまず gRPc/gRPC-Web を使うといったことも可能なように読み取れる。つまり、サーバ側だけ移行/フロント側だけ移行といった片側ずつ移行するといった段階移行も可能に思われる。

structures.png

※ 間に置いている Proxy を除けるかは、gRPC-Web ⇔ gRPC の変換以外の役割を持たせているかによる

また、ドキュメントには以下の通りの記載されているため、信頼性/安定性 に重きを置いていることが伺える。

Connect は、私たちの考えるプロダクショングレードの RPC です。なぜなら、誰も複雑なネットワークのデバッグや、100 もの難解なオプションを吟味している時間はないからです。

その下には、プロトコルバッファと net/http、fetch、URLSession、または HTTP のためのあなたの言語のゴールドスタンダードがあるだけです。

何よりも、Connect は安定しています。私たちは後方互換性を非常に重視しており、安定版リリースのタグを付けた後にあなたのビルドを壊すことは決してありません。

(翻訳機和訳)

Connect-Web

gRPC における gRPC-Web と同様の位置づけのもの。

Connect-Web は、Web ブラウザからリモートプロシージャ(RPC)を呼び出すための小さなライブラリです。REST とは異なり、タイプセーフなクライアントが得られ、シリアライゼーションについて考える必要はもうない。

ref: https://connect.build/docs/web/getting-started

gRPC-Web 同様、gRPC の通信方式でサポートしているのは以下2つ。理由ももちろん gRPC-Web と同じ。

  • Unary RPCs
  • Server-side Streaming RPCs

ref: Connect Docs - FAQ - Is streaming supported?

Connect-Web のソースを追う

Connect-Web についても、2 点気になった点+α を探った。

  • gRPC の通信方式のうちサポートしている対象

最初は公式ドキュメント上に gRPC の通信方式のうちサポートしている対象についての記載があることに気づかず、サポート範囲はどうなっているのか? Web ブラウザの制約がある以上、gRPC-Web と同じでは?それとも違うのか?と疑問を抱き、実際のコードを確認した。そしてチュートリアルでも登場する以下関数の内容から、その根拠を得た。

export function createPromiseClient<T extends ServiceType>(
  service: T,
  transport: Transport
) {
  return makeAnyClient(service, (method) => {
    switch (method.kind) {
      case MethodKind.Unary:
        return createUnaryFn(transport, service, method);
      case MethodKind.ServerStreaming:
        return createServerStreamingFn(transport, service, method);
      default:
        return null;
    }
  }) as PromiseClient<T>;
}

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/promise-client.ts#L45

  • リクエスト送受信方法

gRPC-Web では 主に XmlHttpRequest を使用して送受信を実現していたが、Connect-Web についてはどう実現しているかを探った。結論から言うと、Connect-Web は、fetch を使用して、リクエストの送受信を行っている。

fetch は、受け取ったレスポンスに対して、response.body とするだけで Stream として扱うことができる模様。

ref: mdn web docs - 読み取り可能なストリームの使用

fetch("./tortoise.png").then((response) => response.body);

// response.body の型: ReadableStream<Uint8Array>

Connect-Web でも、Server-Side Streming RPC にて、上記が使用されている。

async (unaryRequest: UnaryRequest<I>): Promise<StreamResponse<O>> => {
  const response = await fetch(unaryRequest.url, {
    ...unaryRequest.init,
    headers: unaryRequest.header,
    signal: unaryRequest.signal,
    body: createConnectRequestBody(
      unaryRequest.message,
      method.kind,
      useBinaryFormat,
      options.jsonOptions
    ),
  });
  // : 省略
  const reader = createEnvelopeReadableStream(
    response.body  // ★
  ).getReader();

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/connect-transport.ts#L264

export function createEnvelopeReadableStream(
  stream: ReadableStream<Uint8Array>
): ReadableStream<EnvelopedMessage> {
  let reader: ReadableStreamDefaultReader<Uint8Array>;
  let buffer = new Uint8Array(0);
  function append(chunk: Uint8Array): void {
    const n = new Uint8Array(buffer.length + chunk.length);
    n.set(buffer);
    n.set(chunk, buffer.length);
    buffer = n;
  }
  return new ReadableStream<EnvelopedMessage>({
    start() {
      reader = stream.getReader();  // ★
    },

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/envelope.ts#L53

  • trailer に関して

Server-Side Streaming RPC の場合、こちらも応答本文の末尾に付けているようである。

// 省略
const reader = createEnvelopeReadableStream(response.body).getReader();

let endStreamReceived = false;
return <StreamResponse<O>>{
  stream: true,
  service,
  method,
  header: response.headers,
  trailer: new Headers(),
  async read(): Promise<ReadableStreamReadResultLike<O>> {
    const result = await reader.read();
    if (result.done) {
      if (!endStreamReceived) {
        throw new ConnectError("missing EndStreamResponse");
      }
      return {
        done: true,
        value: undefined,
      };
    }
    if (
      (result.value.flags & endStreamResponseFlag) ===
      endStreamResponseFlag
    ) {
      endStreamReceived = true;
      const endStream = endStreamFromJson(result.value.data);
      endStream.metadata.forEach(
        (value, key) => this.trailer.append(key, value) // ★
      );
      if (endStream.error) {
        throw endStream.error;
      }
      return {
        done: true,
        value: undefined,
      };
    }
    // 省略
  },
};

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/connect-transport.ts#L264

export function createConnectTransport(
  options: ConnectTransportOptions
): Transport {
  assertFetchApi();
  const useBinaryFormat = options.useBinaryFormat ?? false;

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/connect-transport.ts#L100

1. 以下の通り、Connect-Web は fetch API が使えることが前提となっている。

export function assertFetchApi(): void {
  try {
    new Headers();
  } catch (_) {
    throw new Error(
      "connect-web requires the fetch API. Are you running on an old version of Node.js? Node.js is not supported in Connect for Web - please stay tuned for Connect for Node."
    );
  }
}

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/assert-fetch-api.ts#L18

2. 送信データは、json 形式と binary 形式を選択可能

const useBinaryFormat = options.useBinaryFormat ?? false;

上記の変数は以下の通り、リクエストボディ生成時などに使用される。

※ Web ブラウザでは、送信される内容を簡単に追跡できるため、JSON 形式の仕様が推奨するとのこと。

ref: https://connect.build/docs/web/choosing-a-protocol#connect

function createConnectRequestBody<T extends Message<T>>(
  message: T,
  methodKind: MethodKind,
  useBinaryFormat: boolean,
  jsonOptions: Partial<JsonWriteOptions> | undefined
): BodyInit {
  const encoded = useBinaryFormat
    ? message.toBinary()
    : message.toJsonString(jsonOptions);
  if (methodKind == MethodKind.Unary) {
    return encoded;
  }
  const data =
    typeof encoded == "string" ? new TextEncoder().encode(encoded) : encoded;
  return encodeEnvelopes(
    {
      data,
      flags: 0b00000000,
    },
    {
      data: new Uint8Array(0),
      flags: endStreamResponseFlag,
    }
  );
}

ref: https://github.com/bufbuild/connect-web/blob/7775d774829310b3c7ccc09608d4eb4a9c60a85e/packages/connect-web/src/connect-transport.ts#L332

fetch api についての補足

fetch api ができたのは 2015 年。それ以前からストリーミングは、XMLHttpRequest を使用すれば技術的には可能であった(故に gRPC-Web での Server-Side Streaming RPC のサポートが実現できている)。ただし綺麗ではないらしいため、シンプルに済むのは fetch の利点の一つと言えるかと思われる。

ref: Streams—The definitive guide

fetch api ではストリーミング送信も可能であるが、HTTP/2 である必要がある。

ref: Streaming requests with the fetch API

Connect チュートリアル + α

Connect のドキュメントに沿いつつ、気になった部分も多少追加して試した。その過程や Connect でできることなどを記載する。

先に作成したもの:https://github.com/Symthy/gRPC-practices/tree/main/connect-try

ドキュメント通りの部分は部分的に省略。

connect-go (サーバサイド)

初期構築

mkdir connect-go-example
cd connect-go-example
go mod init example
go install github.com/bufbuild/buf/cmd/buf@latest
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install github.com/bufbuild/connect-go/cmd/protoc-gen-connect-go@latest

コード生成

サービスを定義

mkdir -p greet/v1
touch greet/v1/greet.proto

buf.yaml 生成~コード自動生成

buf mod init
// buf.gen.yaml を生成してから以下実施
buf lint
buf generate

※ buf.gen.yaml に関しては Buf Docs を参照

※ buf.gen.yaml の path の項目は、使用 OS にパスセパレータを合わせないと buf generate が失敗するため注意が必要

ドキュメントのコードをそのまま実装すれば、curl でリクエストを送ってレスポンスが返ってくる(感動)

$ curl \
    --header "Content-Type: application/json" \
    --data '{"name": "Jane"}' \
    http://localhost:8080/greet.v1.GreetService/Greet

{"greeting":"Hello, Jane!"}

ルーティング

通常の API との併用も可能である。

※ Connect-Web を用いて実装したクライアントと連携するには CORS の対応が必要なため忘れずに

    api := http.NewServeMux()
    path, handler := greetv1connect.NewGreetServiceHandler(&server.GreetServer{})
    api.Handle(path, handler)

    mux := http.NewServeMux()
    // mux.Handle(path, handler)
    mux.Handle("/hello", helloHandler{})  // {"message":"hello world"} を返すだけのHandler
    mux.Handle("/connect/", http.StripPrefix("/connect", api))
    corsHandler := cors.AllowAll().Handler(h2c.NewHandler(mux, &http2.Server{}))
    http.ListenAndServe(
        "localhost:8080",
        corsHandler,
    )
$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/connect/greet.v1.GreetService/Greet
{"greeting":"Hello, Jane!"}

$ curl http://localhost:8080/hello
{"message":"hello world"}

ERROR

エラーコードとの対応については以下を参照

実際のエラーレスポンスの例

  • connect.CodeInvalidArgument
$ curl --header "Content-Type: application/json" --data '{"name": ""}' http://localhost:8080/connect/greet.v1.GreetService/Greet
{"code":"invalid_argument","message":"No name specified for greeting"}
  • connect.CodeUnknown
$ curl --header "Content-Type: application/json" --data '{"name": "error"}' http://localhost:8080/connect/greet.v1.GreetService/Greet
{"code":"unknown","message":"invalid name"}

実際のコードは以下のような形

func (s *GreetServer) Greet(
    ctx context.Context,
    req *connect.Request[greetv1.GreetRequest],
) (*connect.Response[greetv1.GreetResponse], error) {
    log.Println("Request headers: ", req.Header())

    if err := ctx.Err(); err != nil {
        return nil, err // automatically coded correctly
    }
    if err := validateGreetRequest(req.Msg); err != nil {
        return nil, connect.NewError(connect.CodeInvalidArgument, err)
    }

    greeting, err := doGreetWork(ctx, req.Msg)
    if err != nil {
        return nil, connect.NewError(connect.CodeUnknown, err)
    }
    res := connect.NewResponse(&greetv1.GreetResponse{
        Greeting: greeting.String(),
    })
    res.Header().Set("Greet-Version", "v1")
    return res, nil
}

Interceptors

Interceptor とは

  • ミドルウェアまたはデコレータに似たもの。Connect を拡張するための主要な方法
  • コンテキスト、要求、応答、およびエラーを変更可能。また、ロギング、メトリック、トレース、再試行などの機能を追加するためによく使用するもの

ドキュメント の NewAuthInterceptor を実装:https://connect.build/docs/go/interceptors  概要:トークンヘッダー Acme-Token があるリクエストのみを通す。それがない場合はエラー

動作:

$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/connect/greet.v1.GreetService/Greet
{"code":"unauthenticated","message":"no token provided"}
$ go run cmd/client/main.go
unauthenticated: no token provided
$ curl --header "Content-Type: application/json" -H "Acme-Token: test" --data '{"name": "Jane"}' http://localhost:8080/connect/greet.v1.GreetService/Greet
{"greeting":"Hello, Jane!"}
$ go run cmd/client/main.go
Hello, Jane!

Streaming

ストリーミングをサポートするには、完全な Interceptor インターフェースを実装する必要がある。

詳細はドキュメントを参照:https://connect.build/docs/go/streaming

greet.proto に 定義を追加し、buf generate

service GreetService {
  rpc Greet(GreetRequest) returns (GreetResponse) {}
  rpc GreetByClientStreaming(stream GreetRequest) returns (GreetResponse) {}
  rpc GreetByServerStreaming(GreetRequest) returns (stream GreetResponse) {}
}
Client-Side Streaming RPC
  • Client 側
    clientStream := client.Greet(
        context.Background(),
    )
    clientStream.Send(&greetv1.GreetRequest{Name: "Verstappen"})
    clientStream.Send(&greetv1.GreetRequest{Name: "Hamilton"})
    clientStream.Send(&greetv1.GreetRequest{Name: "Leclerc"})
    res2, err := clientStream.CloseAndReceive()
    if err != nil {
        log.Println(err)
        return
    }
    fmt.Println(res2.Msg.Greeting)
  • Server 側
func (s *GreetServer) GreetStream(
    ctx context.Context,
    stream *connect.ClientStream[greetv1.GreetRequest],
) (*connect.Response[greetv1.GreetResponse], error) {
    var greeting strings.Builder
    for stream.Receive() {
        g := fmt.Sprintf("Hello, %s!\n", stream.Msg().Name)
        if _, err := greeting.WriteString(g); err != nil {
            return nil, connect.NewError(connect.CodeInternal, err)
        }
    }
    if err := stream.Err(); err != nil {
        return nil, connect.NewError(connect.CodeUnknown, err)
    }
    res := connect.NewResponse(&greetv1.GreetResponse{
        Greeting: greeting.String(),
    })
    return res, nil
}
  • 出力
$ go run cmd/client/main.go
Hello, Verstappen!
Hello, Hamilton!
Hello, Leclerc!
Server-Side Streaming RPC
  • Client 側
  res, err := client.GreetByServerStreaming(
        context.Background(),
        connect.NewRequest(&greetv1.GreetRequest{Name: "SYM"}),
    )
    if err != nil {
        fmt.Println(err)
        return
    }

    for res.Receive() {
        fmt.Println(res.Msg().GetGreeting())
        // fmt.Printf("trailer: %v\n", res3.ResponseTrailer())
    }
    // fmt.Printf("trailer: %v\n", res3.ResponseTrailer())
  • Server 側
func (s *GreetServer) GreetByServerStreaming(
    ctx context.Context,
    req *connect.Request[greetv1.GreetRequest],
    streamRes *connect.ServerStream[greetv1.GreetResponse],
) error {
    // streamRes.ResponseTrailer().Set("Greet-Version", "v1")
    strs := strings.Split(req.Msg.Name, "")
    for i, str := range strs {
        greeting := "greeting " + strconv.Itoa(i+1) + " : " + str
        res := &greetv1.GreetResponse{
            Greeting: greeting,
        }
        streamRes.Send(res)
    }
    return nil
}

出力

$ go run cmd/client/main.go
greeting 1 : S
greeting 2 : Y
greeting 3 : M
補足 (HTTP Trailer について)

上記 Server-Side Streaming RPC のソースのコメントアウト部分を外し動作させると以下の通りの出力となる。

HTTP trailer は、終端子のようなもののため、期待通りメッセージ全てを受け取ってから送られてくることが確認できた。

greeting 1 : S
trailer: map[]
greeting 2 : Y
trailer: map[]
greeting 3 : M
trailer: map[]
trailer: map[Greet-Version:[v1]]

connect-web (フロントエンド)

初期構築

npm create vite@latest -- connect-web-example --template react-ts
cd connect-web-example
npm install

コード生成

remote generation

Buf Schema Registry (BSR) の機能であるリモート生成を使用することが可能である。

// 対象: https://buf.build/bufbuild/eliza
npm config set @buf:registry https://npm.buf.build
npm install @buf/bufbuild_connect-web_bufbuild_eliza

BSR 上に登録されている Buf スキーマをからファイルを生成し、必要なすべての依存関係を持つパッケージとして提供してくれるとのこと。

Buf Schema Registry (BSR) の特徴は以下の通り。

  • Github や DockerHub の Protcol buffer 版のようなイメージ
  • Protocol Buffers を使用するためには、使用する言語ごとにコードを生成する必要がある ⇒ この手間を解消する リモートコード生成機能がある
  • 標準のパッケージマネージャーとビルドツールを使用して Protobuf 定義から生成されたコードを直接インストール可能
  • JavaScript、TypeScript、Go のリモートコード生成をサポート(npm/yarn/go module 等でインストール可能)
  • ローカルでコード生成する必要がないため、ワークフローからのコード生成を排除したり、protoc プラグインのような実行時の依存関係を維持する必要がなくなる

ref: Buf Docs - Remote generation

Connect for Web - Getting started のコードを実装して

local generation

ローカル生成も可能:https://connect.build/docs/web/generating-code

npm install --save-dev @bufbuild/protoc-gen-connect-web @bufbuild/protoc-gen-es
npm install @bufbuild/connect-web @bufbuild/protobuf

docs にある内容で buf.gen.yaml 作成して buf generate

Using clients

以下クライアントが用意されている。

  • Promise ベース
  • Callback ベース
    • ※既存のコードを gRPC-web から Connect-Web に移行する場合に特に便利とのこと。

詳細はドキュメント参照:https://connect.build/docs/web/using-clients

(ドキュメントにも記載ある通り)React にてクライアントのインスタンス生成の繰り返しを避けたい場合は、以下のようにすれば良いとのこと。

import { useMemo } from "react";
import { ServiceType } from "@bufbuild/protobuf";
import {
  CallbackClient,
  createCallbackClient,
  createConnectTransport,
  createPromiseClient,
  PromiseClient,
} from "@bufbuild/connect-web";

const transport = createConnectTransport({
  baseUrl: "https://demo.connect.build",
});

export const usePromiseClient = <T extends ServiceType>(
  service: T
): PromiseClient<T> => {
  return useMemo(() => createPromiseClient(service, transport), [service]);
};

export const useCallbackClient = <T extends ServiceType>(
  service: T
): CallbackClient<T> => {
  return useMemo(() => createCallbackClient(service, transport), [service]);
};

どちらのクライアントも適さない場合は、独自のクライアントも作成可能とのこと。そのための便利なユーティリティもあり、詳細はpromise-client.tsを参照とのこと。

実装&起動

ドキュメントのコードの通りに実装して起動。

コード: https://connect.build/docs/web/getting-started

npm run dev
実行結果
  • Unary RPC

connect-web-ELIZAres.png

  • Server-Side Streaming RPC

connect-web-ELIZAstream.png

エラー出力

詳細はドキュメント参照:https://connect.build/docs/web/errors

以下のようなコンポーネントを作成してエラー表示を試した。

import { codeToString, ConnectError } from "@bufbuild/connect-web";

type ConnectErrorViewProps = {
  err?: ConnectError;
};

export const ConnectErrorView = ({ err }: ConnectErrorViewProps) => {
  console.log("lood error view");
  const isError = !!err;
  const errorMessage = isError
    ? `Code: ${err.code} - ${codeToString(err.code)} | Message: ${
        err.rawMessage
      }`
    : "";
  return (
    <>
      {isError && (
        <div>
          <span style={{ color: "red" }}>{`[Error]`} </span>
          <span>{errorMessage}</span>
        </div>
      )}
    </>
  );
};

チュートリアルで使用する ELIZA がエラーレスポンスを返す条件不明(空文字を送信しても正常応答する)のため、connect-go を使用して作成したサーバサイドに空文字送信した場合はエラーを返す実装を追加して試した。

connect-web-error.png

おわりに

connect-go は v1.0 になるらしいが、未だなっていない(2022/9 半ば:v0.4)ため、様子見が必要かもしれないが、connect-web は v1.0 であり、Connect 自体が信頼性/安定性に重きを置いている点から、選択肢足りえると考える。 Connect により、Web ブラウザで、gRPC を使用する敷居がぐんと下がることは確実に思われる。

Golang:API 実行 と httptest

GolangAPI 実行 と httptest

httptest

テスト用のモックサーバをたてることができる

  • API 実行コード
const apiurl = "https:/xxxxxx"

func buildGetRequest(name string) (*http.Request, error) {
    url := apiurl + name
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    return req, err
}

func getResource(req *http.Request) ([]byte, error) {
    client := &http.Client{Timeout: 10 * time.Second}

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("Error Response. Status:%s, Body:%s", resp.Status, body)
    }

    return body, err
}
  • テストコード
func TestFailureToGet(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusBadRequest)
        resp := make(map[string]string)
        resp["message"] = "Bad Request"
        jsonResp, err := json.Marshal(resp)
        if err != nil {
            log.Fatalf("Error happened in JSON marshal. Err: %s", err)
        }
        w.Write(jsonResp)
    }))
    defer ts.Close()

    req, err := http.NewRequest(http.MethodGet, ts.URL, strings.NewReader(""))
    if err != nil {
        assert.FailNow(t, "failed to build request")
    }
    actual, err := getResource(req)
    assert.EqualError(t, err, "Error Response. Status:400 Bad Request, Body:{\"message\":\"Bad Request\"}")
    assert.Nil(t, actual)
}

このように、正常系だけでなく、エラーレスポンスを返却させることもできる。(テスト捗る)

refs

Go の test を理解する - httptest サブパッケージ編

Go のテストに入門してみよう!

Github プロフィールのカスタマイズ

Github プロフィールのカスタマイズ

自身の Github アカウント名と同じ名前のリポジトリを作ることで、プロフィールの最初に自身の好きな内容を追加することができる

ref: プロフィールの README を追加する

以下の一部を導入

導入することで、以下のような Summary を作ることができる (2022/8/18 時点の内容)

refs

詳細や導入方法は各ブランチか以下参照。

golang AST & Jennifer によるコード自動生成

golang AST & Jennifer によるコード自動生成

モチベーション:以下と似ている

ref: entity からコード自動生成した話

ベースとなるソースファイルから AST 取得し必要情報抽出 ⇒ jennifer にてソース自動生成

template を使わない理由

  • template と埋め込むコードの2つを管理するのが手間なため(コードのみで済むのは1つの利点に思う)
  • 特殊ケース等あり、無理に共通化しようとすると煩雑化しやすいし、template 分けるにしても物がどんどん増えて、大変になりそうなイメージがあるため (物が増えてくるとこの辺のバランスとるのが大変になりそう。コードのみで済むなら1ケース1ソースにし共通化できる部分は外出しして管理しやすいと思われる)

AST(抽象構文木) 取得/解析コード

ソースファイルの情報を取得可能

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
)

func Parse(filename string) error {
    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, filename, nil, parser.Mode(0))
    if err != nil {
        return err
    }
    // for _, d := range f.Decls {  // 1ファイルの全情報表示
    //  ast.Print(fset, d)
    //  fmt.Println()
    // }

    fieldNames := []string{}
    ast.Inspect(f, func(node ast.Node) bool {
        t, ok := node.(*ast.TypeSpec)
        if !ok {
            return true
        }

        st, ok := t.Type.(*ast.StructType)
        if !ok {
            return true
        }
        for _, field := range st.Fields.List {
            ast.Print(fset, field)
            fmt.Println()

            for _, nameNode := range field.Names {  // フィールド名取得
                fieldNames = append(fieldNames, nameNode.Name)
            }
        }
        return true
    })
    fmt.Print(fieldNames)
    return nil
}

解析結果

以下サンプルを解析した結果

type Move struct {
    id                 identifier.MoveId
    name               string
    description        *string  // *stringとするのはBatだがサンプルとして試す
    effects            *battles.Effects
    usedMember         []*Member
    leanableCharacters []Character
    leanablePokemons   []*identifier.PokemonId
    mixin.UpdateTimes
}

AST 上では

  • 1 フィールドの情報は、*ast.Field に格納されている
    • *ast.Field は Names と Type を持つ
    • a, b string のように 1 フィールドに複数変数定義した場合は全て Names に格納される
  • 各フィールドの型(Type)情報はそれぞれ以下の構造体で表現されている
    • 外部パッケージの型(例:identifier.MoveId): *ast.SelectorExpr
    • ポインタ型 (例:*string): *ast.StarExpr
    • 配列/スライス (例:Character): *ast.ArrayType
    • 末端の要素 (string、identifier、MoveId 等): *ast.Ident
    • 複合パターンも上記の組み合わせで表現: *identifier.PokemonId の場合 *ast.ArrayType -> *ast.StarExpr -> *ast.SelectorExpr
    • 埋め込みの場合は、Names がないだけで Type は構造に変わりなし

実際の結果

  • id identifier.MoveId
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:12:2
4  .  .  .  Name: "id"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "id"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.SelectorExpr {
13  .  .  X: *ast.Ident {
14  .  .  .  NamePos: <full path>\move.go:12:16
15  .  .  .  Name: "identifier"
16  .  .  }
17  .  .  Sel: *ast.Ident {
18  .  .  .  NamePos: <full path>\move.go:12:27
19  .  .  .  Name: "HeldItemId"
20  .  .  }
21  .  }
22  }
  • name string
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:13:2
4  .  .  .  Name: "name"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "name"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.Ident {
13  .  .  NamePos: <full path>\move.go:13:16
14  .  .  Name: "string"
15  .  }
16  }
  • description *string
     0  *ast.Field {
     1  .  Names: []*ast.Ident (len = 1) {
     2  .  .  0: *ast.Ident {
     3  .  .  .  NamePos: <full path>/move.go:14:2
     4  .  .  .  Name: "description"
     5  .  .  .  Obj: *ast.Object {
     6  .  .  .  .  Kind: var
     7  .  .  .  .  Name: "description"
     8  .  .  .  .  Decl: *(obj @ 0)
     9  .  .  .  }
    10  .  .  }
    11  .  }
    12  .  Type: *ast.StarExpr {
    13  .  .  Star: <full path>/move.go:14:14
    14  .  .  X: *ast.Ident {
    15  .  .  .  NamePos: <full path>/move.go:14:15
    16  .  .  .  Name: "string"
    17  .  .  }
    18  .  }
    19  }
  • effects *battles.Effects
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:15:2
4  .  .  .  Name: "battleEffects"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "battleEffects"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.StarExpr {
13  .  .  Star: <full path>\move.go:15:16
14  .  .  X: *ast.SelectorExpr {
15  .  .  .  X: *ast.Ident {
16  .  .  .  .  NamePos: <full path>\move.go:15:17
17  .  .  .  .  Name: "battles"
18  .  .  .  }
19  .  .  .  Sel: *ast.Ident {
20  .  .  .  .  NamePos: <full paht>\move.go:15:25
21  .  .  .  .  Name: "BattleEffects"
22  .  .  .  }
23  .  .  }
24  .  }
25  }
  • ポインタ配列 usedMember []*Member
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:4:2
4  .  .  .  Name: "items"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "items"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.ArrayType {
13  .  .  Lbrack: <full path>\move.go:4:8
14  .  .  Elt: *ast.StarExpr {
15  .  .  .  Star: <full path>\move.go:4:10
16  .  .  .  X: *ast.Ident {
17  .  .  .  .  NamePos: <full path>\move.go:4:11
18  .  .  .  .  Name: "User"
19  .  .  .  }
20  .  .  }
21  .  }
22  }
  • 配列 leanableCharacters []Character
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:14:2
4  .  .  .  Name: "leanablePokemons"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "leanablePokemons"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.ArrayType {
13  .  .  Lbrack: <full path>\move.go:14:20
14  .  .  Elt: *ast.Ident {
15  .  .  .  NamePos: <full path>\move.go:14:22
16  .  .  .  Name: "Pokemon"
17  .  .  }
18  .  }
19  }
  • ポインタ配列: leanablePokemons []*indetifier.PokemonId
0  *ast.Field {
1  .  Names: []*ast.Ident (len = 1) {
2  .  .  0: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:16:2
4  .  .  .  Name: "leanablePokemons"
5  .  .  .  Obj: *ast.Object {
6  .  .  .  .  Kind: var
7  .  .  .  .  Name: "leanablePokemons"
8  .  .  .  .  Decl: *(obj @ 0)
9  .  .  .  }
10  .  .  }
11  .  }
12  .  Type: *ast.ArrayType {
13  .  .  Lbrack: <full path>\move.go:16:19
14  .  .  Elt: *ast.StarExpr {
15  .  .  .  Star: <full path>\move.go:16:21
16  .  .  .  X: *ast.SelectorExpr {
17  .  .  .  .  X: *ast.Ident {
18  .  .  .  .  .  NamePos: <full path>\move.go:16:22
19  .  .  .  .  .  Name: "identifier"
20  .  .  .  .  }
21  .  .  .  .  Sel: *ast.Ident {
22  .  .  .  .  .  NamePos: <full path>\move.go:16:33
23  .  .  .  .  .  Name: "PokemonId"
24  .  .  .  .  }
25  .  .  .  }
26  .  .  }
27  .  }
28  }
  • 埋め込み mixin.UpdateTimes
0  *ast.Field {
1  .  Type: *ast.SelectorExpr {
2  .  .  X: *ast.Ident {
3  .  .  .  NamePos: <full path>\move.go:16:2
4  .  .  .  Name: "mixin"
5  .  .  }
6  .  .  Sel: *ast.Ident {
7  .  .  .  NamePos: <full path>\move.go:16:8
8  .  .  .  Name: "UpdateTimes"
9  .  .  }
10  .  }
11  }

jennifer によるコード生成

以下に example はあるものの読み取るのが大変なためケースごとに一部だけコードを記載

refs:

パッケージ

f := jen.NewFile("pkg")
// package pkg

インポート

// f.ImportName("github.com/Symthy/Product/internal/xxx", "xxx")  // どちらでも変わらない模様
f.ImportName("github.com/Symthy/Product/internal/xxx", "")
// import xxx "github.com/Symthy/internal/xxx"

f.ImportAlias("github.com/Symthy/Product/internal/yyy", "ailias")
// import ailias "github.com/Symthy/internal/yyy"

importNames := map[string]string{
    "github.com/Symthy/Product/internal/xxx": "xxx",
    "github.com/Symthy/Product/internal/yyy": "yyy",
    "github.com/Symthy/Product/internal/zzz": "zzz",
}
f.ImportNames(importNames)
// import (
//     xxx "github.com/Symthy/Product/internal/xxx"
//     yyy "github.com/Symthy/Product/internal/yyy"
//     zzz "github.com/Symthy/Product/internal/zzz"
// )

構造体

f.Type().Id("SampleBuilder").Struct(
  jen.Id("id").Qual("github.com/Symthy/Product/internal/domain/value", "Indetifier"),
  jen.Id("name").String(),
  jen.Id("description").String(),
  jen.Id("effects").Index().Op("*").Qual("github.com/Symthy/Product/internal/domain/battles", "Effects"),
)
// type SampleBuilder struct {
//     id          value.Identifier
//     name        string
//     description string
//     effects     []*battles.Effects
//}

// 以下のように組み立てることも可能
fieldStatements := []*jen.Statement
fieldStatements = append(fieldStatements, jen.Id("id").Qual("github.com/Symthy/Product/internal/domain/value", "Indetifier"))
//  : 略
f.Type().Id("SampleBuilder").StructFunc(func(g *jen.Group) {
    for _, fieldStatement := range fieldStatements {
        g.Add(fieldStatement)
    }
})

コンストラクタ or 関数

typeName := "SampleBuilder"
f.Func().Id("New"+typeName).
        Params().
        Op("*").Qual("", typeName).
        Block(
            jen.Return(jen.Op("&").Qual("", typeName).Block()),
        )
// func NewSampleBuilder() *SampleBuilder {
//     return &SampleBuilder{}
// }

メソッド (セッター)

receiverName = "s"
typeName := "SampleBuilder"
argVarName := "name"
f.Func().
        Params(jen.Id(receiverName).Op("*").Id(typeName)).   // pointer receiver
        Id("Name").                                          // func
        Params(jen.Id(argVarName).String()).                 // arguments
        Op("*").Qual("", typeName).                          // return type
        Block(
            jen.Id(receiverName).Op(".").Id("name").Op("=").Id(argVarName),
            jen.Return(jen.Id(receiverName)),
        )
// func (s *SampleBuilder) Name(name string) *SampleBuilder {
//     s.name = name
//     return s
// }

部分的にに作って、Add() で任意の要素に付け足すことが可能なため柔軟。

go での コード生成

  • go generate で完結するようにした方が良い
  • コード生成を行うための go ファイルに以下を追加すれば、 go generate ./... で実行できる
//go:generate go run .

ref: go generate のベストプラクティス

React 実践のために調べまくったこと書き綴り まとめメモ

React 実践のために調べまくったこと書き綴り まとめメモ

React のド基礎を押さえただけで React ができるとは言えないので、実践のために以下を行ってみる

そのために調べまくったことを書きなぐった結果故に、偏りがあるのはご容赦を。

フォルダ構成

大事なのは、

  • 1機能を構成する要素(hooks/provider/context 等)は1パッケージにまとめて格納すること(hooks/provider 毎での分散配置はアンチパターン
  • 横断的関心事(共通コンポ/関数や定数等)と機能(features)で閉じる物を明確に分ける
  • 機能(features)で閉じて、依存関係を「機能(features) -> 横断的関心事」とする。このように全体で依存関係が入り乱れないようにする

サンプル

大規模向け。components の下に色々置くでは見通しが悪くなるので、機能ごとに分け、さらに構成要素毎にフォルダを切る。

src
|
+-- assets      # イメージ、フォントなどのすべての静的ファイルを格納
|
+-- components  # 共有コンポーネント
|
+-- config      # グローバル構成、env変数など
|
+-- features    # 機能ベースモジュール ★
|    +--<featureName>
|        +-- api         # エクスポートされたAPIリクエスト宣言と特定機能に関連するAPIフック
|        |
|        +-- components  # 特定機能にスコープした構成部品
|        |
|        +-- hooks       # 特定機能にスコープしたhooks
|        |
|        +-- routes      # 特定機能にスコープしたrouteコンポーネント # イメージ的にはpage兼routingのイメージ
|        |
|        +-- stores      # 特定機能にスコープした状態ストア
|        |
|        +-- types       # 特定機能のドメインの型
|        |
|        +-- utils       # 特定機能のUtility関数
|        |
|        +-- index.ts    # 機能のエントリポイントの場合は、特定の機能のパブリックAPIとして機能し、機能の外部で使用する必要があるすべてのものをエクスポートする。
|
+-- hooks       # 共有フック
|
+-- lib         # 外部ライブラリの再エクスポート
|
+-- providers   # all of the application providers
|
+-- routes      # routes configuration
|
+-- stores      # global state stores
|
+-- test        # test utilities and mock server
|
+-- types       # アプリケーション全体で使用する基本型
|
+-- utils       # 共有Utility関数

小規模~中規模なら、components の下にフォルダを切って格納していく形でもよさそう

小規模で済むなら、公式のような構成でも良さそうだし

多少大きくなるようなら以下のように components を分けるのが良さそう

src
+-- components
|    +-- page  # 1ページを表すコンポーネント (Next.js 使用時)
|    |    +-- top
|    +-- model
|    |    +-- user  # ステート&ロジック?機能?
|    +-- ui
|    ~    +-- button  # ステートを持たないような表示のみのコンポーネント
|    +-- functional
+-- pages  # Next.js使用時 こちらはルーティングのみにする
src/
  ├ components/ ... 汎用的なコンポーネント
  ├ consts/     ... 中央管理したい定数群
  ├ hooks/      ... 汎用的なカスタムフック
  ├ features/
  │   ├ users/  ... ユーザー関連の機能
  │   └ posts/  ... 投稿関連の機能
  └ concerns/       ... 全体を横断して使われる、技術者視点で抜き出した個々の機能
      ├ auth/   ... ページや操作の許可・不許可
      └ toast/  ... トースト関連
          ├ Toast.tsx
          ├ toastContext.tsx
          ├ ToastProvider.tsx
          └ useToast.ts
  • ※ Toast コンポーネントの Props と、状態管理の型を別々に切り離し、Toast.tsx だけ components/ 以下に移動するのもありかもらしい

規模に合わせて、段階的に?フォルダ構成を変えるのが良い?。以下も大規模になった際は、features フォルダを導入している。

refs:

その他

  • フォルダはパスカルケース

    コンポーネントベースの UI ライブラリを使用するフロントエンドでは、コンポーネントを含むフォルダー/ファイルの場合、この名前付け規則が PascalCase に変更されました。

  • pages フォルダは 複数ページ構成なら設けた方が良さそう(pages は Next.js での慣習)

まとめ

上記の総括。src 直下は以下のように分けていくのが良い?(あくまで個人の見解)

この分け方がベストというわけではない。大事なのは、適切に責務/関心等の分離/分類がしやすい形に小さく分ける。管理と把握と拡張をしやすく保つこと。

以下の図のイメージが素晴らしい。

features と components/models の明確な境界がイメージしきれてない(実践してみないと恐らく見えてこない)。悩む&小規模なら統合しても良いかも

src/
  ├ components/          # 共通コンポーネント(特定のfeatureに関係なくまたいで利用されるようなコンポーネント)
  |  ├ pages/              # ページコンポーネント ※features内に入れるべきか不明。個人的にはひとまとめにする場所が欲しい
  |  ├ ui/ or view/        # Viewの部品。見た目に関するコンポーネント
  |  └ models/             # その他。モデルに関するコンポーネント(domain/entityに近いイメージ)
  ├ consts/              # (中央管理したい)共通定数群
  ├ configs/             # グローバル構成、env変数など (constsと一緒でもいいかも)
  ├ hooks/               # 共通カスタムフック
  ├ features/            # 機能ベースコンポーネント群(機能毎でフォルダを割った方がいいなら割る)
  |  └ <FeatureName>/    # ※以下は大規模でなければ分けずにファイル名管理でも十分ならそれで良いと考える
  |    ├ api/              # APIフック
  |    ├ components/
  |    ├ hooks/
  |    ├ routes/ or pages/
  |    ├ stores/ or contexts/
  |    ├ types/
  |    ├ test/
  |    ├ utils/
  |    └ index.js or index.ts
  ├ concerns/            # 横断的に使用する機能コンポーネント群
  ├ providers/           # アプリケーション(全体にかかわる)プロバイダー
  ├ routes/              # ルーティング設定
  ├ stores/ or contexts/ # グローバルストア (Reactのcontextのみならcontextの方が良いかも?)
  ├ libs/ or external/   # 外部ライブラリをラップしたもの
  ├ utils/ or services/  # 共通Utility関数群(functinalでも良いかも? ここは人/組織で命名がブレる)
  ├ types/               # 全体で共有する型定義
  └ test                 # test utilities and mock server

ベストプラクティス(コーディング)

ref: React ベストプラクティスの宝庫!「bulletproof-react」が勉強になりすぎる件

  • アプリケーション内に複数のレンダリング関数を追加しない。(これはすぐに制御できなくなる)
  • ユニットと見なすことができる UI の一部とみなせるなら、それを別のコンポーネントに抽出。

Bad

// this is very difficult to maintain as soon as the component starts growing
function Component() {
  function renderItems() {
    return <ul>...</ul>;
  }
  return <div>{renderItems()}</div>;
}

Good

function Items() {
  return <ul>...</ul>;
}

function Component() {
  return (
    <div>
      <Items />
    </div>
  );
}
  • コンポーネントが受け入れているプロップが多すぎる場合は、複数のコンポーネントに分割するか、子またはスロットを介して合成手法を使用することを検討する。

example code

import { Dialog, DialogTitle } from '@/components/Elements/Dialog';
// :

export type ConfirmationDialogProps = {
  triggerButton: React.ReactElement;
  confirmButton: React.ReactElement;
  // :
}
export const ConfirmationDialog = ({
  triggerButton,
  confirmButton,
  // :
}: (ConfirmationDialogProps) => {
  // :
  const trigger = React.cloneElement(triggerButton, {
    onClick: open,
  });
  return (
    <>
       {trigger}
       // :
       <div>
         {confirmButton}
       </div>
    </>
  );
}

大規模なプロジェクトでは、すべての共有コンポーネントを抽象化することを推奨。これにより、アプリケーションの一貫性が向上し、保守が容易になる。

  • その他

refs:

CSS (Style)

命名は BEM で書くより rscss の方が良い(コンポーネントでクローズドにできるため BEM は冗長)

refs:

styled-components vs emotion

  • 可読性に関して、emotion > styled-components は同意。
  • 速度も emotion 優位らしい。
  • emotion は特定のフレームワークにロックインされない。
  • emotion なら Object Style で書ける。
  • emotion の欠点は SSR 環境への導入はややこしいらしい。

refs:

CSS Modules & CSS-in-JS 特徴

  • パフォーマンスが重要なアプリケーションの場合は CSS Modules の方が適している。
  • CSS-in-JS は、コードの可読性を最大化し、開発者の生産性と開発エクスペリエンスを向上させる。
  • ※パフォーマンス面で CSS-in-JS が最適でないのを解消しようとしているのが ゼロランタイム CSS-in-JS。

ref:

個人の見解

  • 動的に style 変える必要ないのなら、CSS in JS の使用はオーバースペックに思う
  • webpack の css-loader が deprecated にしたため CSS Modules は下火になりつつある?
  • 2022 の結果が出ないと分からないが、満足度:CSS Modules > CSS in JS (Vanilla-Extract 除く) にも関わらず早々消えるとは思えない
  • コンポーネントのソースファイルに CSS の Style の定義が入ってると可読性が良くないように感じる(色々な物が1ファイルに書かれていると何が何かパッと見見づらい)
    • 故に分けたいため、CSS Modules と Vanilla-Extract が個人的には好み(ただ Vanilla-Extract はまだマイナーなのが気になる)
    • だが、ビューとアプリケーションロジックを分ける container/presentation パターン で実装するなら CSS-in-JS でも気にならない
      • ビュー(HTML/CSS メイン)と ロジック(JS の実装ゴリゴリ) で分けれるなら、そちらの方が関心の分離になるため(可読性や責務的にも)良い
  • webpack で CSS Modules が非推奨というだけで、Vite では非推奨になっていない(デフォルトで使用可能)なため、Vite で CSS Modules 使う分には直近問題にはならない

結論(Vite を使用する前提で)

  • 小規模で動的に style 変える必要もないなら CSS Modules で十分
  • 中~大規模で、ビューとアプリケーションロジックを分けてコンポーネントを作ることを徹底するなら CSS-in-JS の方が良いように思う(CSS-in-JS の方が対応の幅が効くというのはありそうなので、先を見据えて CSS-in-JS 採用はありと思う)
  • CSS-is-JS で個人的に使用するなら emotion (ただし、求められるパフォーマンスがシビアでなければ。)

emotion

参考:

<div
  css={[
    css`color: white;`,
    // 1) テーマを引数に取る指定も含められる
    (theme) => css`background-color: ${theme.colors.primary};`,
    // 2) && etc. による条件付き指定も可
    selected && css`background-color: red;`,
  ]}
>

theme の モードチェンジ

refs:

※ sass で実現する方法もある。ちょっと面倒そう

React で CSS variables でダークモードとライトモードを手軽に切り替える

storybook

ref: Vite + React + TypeScript に Vite 用 Storybook を導入する。Storybook は必要だぞ

CSF v3.0

refs:

Template.bind ではなく Object で定義できるようになった

CSF2.0

import { Story, Meta } from "@storybook/react/types-6-0";
import SimpleFrom, { Props } from "./SimpleForm"; // 対象コンポーネント
export default {
  title: "Atoms/SimpleFrom", // CSF3.0では省略可能
  component: SimpleFrom,
} as Meta<Props>;

const Template: Story<Props> = (args) => <SimpleFrom {...args} />;

export const Index = Template.bind({});
Index.args = {
  title: "お名前フォーム",
};

CSF3.0

import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import SimpleForm from "./SimpleForm"; // 対象コンポーネント
export default { component: SimpleForm } as ComponentMeta<typeof SimpleForm>;

export const Index: ComponentStoryObj<typeof SimpleForm> = {
  args: {
    title: "お名前フォーム",
  },
};
import userEvent from '@testing-library/user-event';

export default { component: AccountForm }

export const Empty = {};

export const EmptyError = {
  ...Empty,
  play: () => userEvent.click(screen.getByText('Submit'));
}

export const Filled = {
  ...Empty,
  play: () => {
    userEvent.type(screen.getById('user'), 'shilman@example.com');
    userEvent.type(screen.getById('password'), 'blahblahblah');
  }
}

export const FilledSuccess = {
  ...Filled,  // 再利用も可能
  play: () => {
    Filled.play();
    EmptyError.play();
  }
}

Interaction test

refs:

以下を導入すれば、再生と巻き戻しも可能

npm i -D @storybook/addon-interactions

.storybook/main.js

  addons: [
    // 他のアドオン,
    '@storybook/addon-interactions'
  ],
  features: {
    interactionsDebugger: true
  },
  • play で実行
  • useEvent で コンポーネントとの相互作用をシミュレート
  • Jest で Dom Structure を検証
// テスト実行
npm run test-storybook

React 諸々

※公式ドキュメントを見ろ。目に入ったものだけを書き留めておく。

ref: React 18 に備えるにはどうすればいいの? 5 分で理解する

Suspense

コンポーネントを「ローディング中なのでまだレンダリングできない」という状態にすることができる

カスタム hooks

  • カスタムフック=ロジックの外だし
  • カスタムフックによるある種のカプセル化が可能な点(現代ではコンポーネントのロジックがほとんどカスタムフックに書かれる)
  • 関数のスコープやモジュールレベルのスコープを活用した多様なカプセル化ができる点が優れている

代表例は、API の実行&レスポンスんの解析 => カスタムフック

refs:

Lazy

ref: React のベストプラクティスとコード削減パターン - パート 1: 4.コードの分割

const Home = React.lazy(() => import("./Home"));
const About = React.lazy(() => import("./About"));
function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Route path="/home" component={Home} />
      <Route path="/about" component={About} />
    </Suspense>
  );
}

ref: named imports for React.lazy

function lazyImport<
  T extends React.ComponentType<any>,
  I extends { [K2 in K]: T },
  K extends keyof I
>(factory: () => Promise<I>, name: K): I {
  return Object.create({
    [name]: React.lazy(() =>
      factory().then((module) => ({ default: module[name] }))
    ),
  });
}

// Usage
const { Home } = lazyImport(() => import("./Home.component.tsx"), "Home");

以下 router と組み合わせて使う

React Router

npm i react-router-dom
npm i -D @types/react-router-dom

React Router Doc: Quick Start

Router の種類(一部のみ。ドキュメント見るべし)

ref: ルーティングライブラリ、React Router(v5)入門

以下のように、コンポーネント化も可能(v6 ~?)

ref: https://github.com/alan2207/bulletproof-react/tree/master/src/routes

export const publicRoutes = [
  {
    path: "/auth/*",
    element: <AuthRoutes />,
  },
];

export const protectedRoutes = [
  {
    path: "/app",
    element: <App />,
    children: [
      { path: "/discussions/*", element: <DiscussionsRoutes /> },
      { path: "/users", element: <Users /> },
      { path: "/profile", element: <Profile /> },
      { path: "/", element: <Dashboard /> },
      { path: "*", element: <Navigate to="." /> },
    ],
  },
];

export const AppRoutes = () => {
  const auth = useAuth();
  const commonRoutes = [{ path: "/", element: <Landing /> }];
  const routes = auth.user ? protectedRoutes : publicRoutes;
  const element = useRoutes([...routes, ...commonRoutes]);
  return <>{element}</>;
};

State 戦略

ref:

3 種

  • サーバーデータのキャッシュ
  • Global State
    • ページをまたいで保持し続ける必要のある State ⇒ Redux や Recoil で管理
  • Local State
    • 各 Component 内で useState を使って管理

デザインパターン

フロントエンドのデザインパターン

その他やって得そうなこと

ref:

以下

  • hooks 抜き出し
  • useMemo/useCallback の適用
    • 「React は Immutability を前提に考えられている世界なので、意味合い的にはメモ化して内容が変化したときにだけ参照も更新されるようにしておいたほうが正しい」
    • 「いざ重くなったときにひとつひとつメモ化からやっていくのは大変だしデグレが怖い」とのこと ⇒ たしかに…。最初からそれなりにやっていった方が良さそう
  • container/presenter の分離

ref:

ビューをアプリケーションロジックから分離(関心の分離を促進)

  • プレゼンテーションコンポーネント
    • 役割:props を通じてデータを受け取り、受け取ったデータを変更することなく、スタイルも含めて意図通りに表示すること
    • プレゼンテーションコンポーネントは(UI のためにステートが必要な場合を除き)ステートを持たない
  • コンテナコンポーネント

Pros:

  • プレゼンテーションコンポーネント
    • UI を担当する純粋関数(=テストが容易)
    • 再利用が容易(アプリケーション全体で異なる目的のために再利用可能=全体での一貫性を保てる)
    • (デザイナーとの) 分業化容易 Cons:
  • 小規模なアプリケーションでは過剰=複雑化

多くの場合、コンテナ・プレゼンテーションパターンは React のフックに置き換えることができる(コンテナ=カスタムフック)

テスト

React Doc: テストのレシピ集

Redux/Recoil

共通点:パフォーマンス上の課題を解決するもの

refs:

React 単体での共通ステート管理の限界

  • コンテキストで、Redux っぽく「全てのステートを詰め込んだオブジェクトでステートを管理し、一つのコンテキストにそのオブジェクトを流す」ように、一つのコンテキストに全ての情報を入れてしまう場合はそれに依存する全てのコンポーネントが再レンダリングされ、パフォーマンス劣化に繋がるため、パフォーマンスを重視する場合に問題となる。

Redux

  • ステートの宣言が中央集権的
  • アプリケーションの状態とロジックを一元化したストアを提供する。
  • コンポーネントが必要なデータに直接アクセスできるようにし、クライアントサーバー環境とネイティブ環境で一貫して動作する JavaScript アプリの作成を支援するように設計された予測可能な状態コンテナー
  • アプリケーション全体でデータの一貫性を提供することにより、複雑なアプリケーションの構築を容易にすることができる
  • 開発者のベストプラクティスを実施するのに優れており、従うべき単純なパターンを提供するため、機能を構築するときにあまり多くの実装決定を行う必要がない
  • API リクエストをアクションに抽象化して、すべてを 1 か所にまとめることもできる
  • Redux Devtools を使用して、アプリケーションの状態がいつ、どこで、なぜ、どのように変化するかを追跡可能
  • React アプリケーションの状態を処理するために提案された 「アーキテクチャ」 である Flux も実装している。(Flux:https://facebook.github.io/flux/

Recoil

  • Recoil はステートの宣言が局所的
  • 大きなメリットは、 React の Suspense との統合
  • フックとの相性がよい(Recoil が提供する各種のフックは、カスタムフックに組み込みやすいように作られている)
  • セレクターで非同期リクエストを作成して、API リクエストから状態を開始することができる
  • (Recoil は React の最新モードと統合されているため)、Suspense を使用してロード状態を処理し、ErrorBoundary を使用してエラーを処理することで、より宣言的な方法でアプリケーションを構築することができる。
  • 開発者エクスペリエンスとユーザーエクスペリエンスの両方に関しては Redux より適している

その他

PLOP

PLOP:テンプレートからファイル生成に使える

以下でも利用している。参考になるはず

github: bulletproof-react

eslint での 依存関係チェック

依存関係のチェック(strict-dependencies)

Redux 基本

Redux 基本

ひとまず、storybook のチュートリアルに載っていた範囲+ α のみ

Redux とは

Flux ベースの state(状態)を容易に管理をするためのフレームワーク

refs:

Redux ストア

import { configureStore } from "@reduxjs/toolkit";

export const store = configureStore({
  reducer: {},
});
import { store } from "./app/store";
import { Provider } from "react-redux";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

configureStore を使用する場合、追加の入力は必要ないが、必要に応じて、RootState 型と Dispatch 型を抽出する必要がある。

ストア自体からこれらの型を推測することは、state slices を追加したりミドルウェア設定を変更したりすると、正しく更新されることを意味する。

アプリケーションで使用する際は、以下フックの作成推奨

  • useDispatch フック

useSelector の場合、毎回(state:RootState)と入力する必要はない。

  • useSelector フック

useDispatch の場合、既定の Dispatch 型は thunks を認識しません。正しく thunks を dispatch するには、thunk middleware types を含むストアから特定のカスタマイズされた AppDispatch 型を使用し、それを useDispatch と共に使用する必要がある。事前に型指定された useDispatch フックを追加すると、必要な場所に AppDispatch をインポートすることを忘れずに済む。

  • createSlice

初期状態、reducer 関数のオブジェクト、および 「スライス名」 を受け取り、reducer と状態に対応するアクションクリエーターとアクションタイプを自動的に生成する関数。

// app/sotre.ts
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import type { TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./store";

const TasksSlice = createSlice({
  name: "taskbox",
  initialState: TaskBoxData,
  reducers: {
    updateTaskState: (state, action) => {
      const { id, newTaskState } = action.payload;
      const task = state.tasks.findIndex((task) => task.id === id);
      if (task >= 0) {
        state.tasks[task].state = newTaskState;
      }
    },
  },
});

// Redux ストア
export const store = configureStore({
  reducer: {
    taskbox: TasksSlice.reducer,
  },
});

// ストア自体から`RootState`型と`AppDispatch`型を推測する
export type RootState = ReturnType<typeof store.getState>;
// 推定型: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

// 単純な`useDispatch`と`useSelector`の代わりにアプリ全体で使用する
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// 発行
const pinTask = (value) => {
  dispatch(updateTaskState({ id: value, newTaskState: "TASK_PINNED" }));
};
  • createAsyncThunk

Redux アクションタイプ文字列と promise を返すコールバック関数を受け取る関数。渡されたアクションタイププレフィックスに基づいて promise ライフサイクルアクションタイプを生成し、promise コールバックを実行し、返された promise に基づいてライフサイクルアクションをディスパッチする thunk アクションクリエーターを返す。

これにより、非同期要求ライフサイクルを処理するための標準的な推奨アプローチが抽象化されます。

import { userAPI } from "./userAPI";

const fetchUserById = createAsyncThunk(
  "users/fetchByIdStatus",
  async (userId: number, thunkAPI) => {
    const response = await userAPI.fetchById(userId);
    return response.data;
  }
);

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    // ry
  },
  extraReducers: (builder) => {
    // ここに追加のアクションタイプのレジューサを追加し、必要に応じてロード状態を処理します
    builder.addCase(fetchUserById.fulfilled, (state, action) => {
      state.entities.push(action.payload);
    });
  },
});

// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123));