SYM's Tech Knowledge Index & Creation Records

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

GraphQL 設計に関する知識メモ 【執筆中】

GraphQL 設計に関する知識メモ 【執筆中】

GraphQL 前提知識色々

GraphQL が向いてるケース

  • 変化・進化し続けるアプリケーション。複雑性の高いアプリケーション(変化しない/作り切りアプリの場合は REST で十分な可能性)

  • フロントエンド視点

    • クライアント/サーバ間での状態同期が必要
      • 特にクライアントからの状態更新(Mutation)が多い
      • 逆にサーバーから一方的にデータが送られる SSR/SSG/Read ヘビーなサイトは REST と SWR で十分な可能性あり
    • モバイルクライアント(低速、劣悪環境)で利用される、複数種類のクライアントがある or る予定がある
    • エンドポイントが多すぎて開発体験が悪い
  • サーバーサイド視点

GraphQL が向いてない事

  • Web API であること、HTTP の制約を受ける
  • 巨大なデータ転送
  • 非構造型データ、テーブル型のデータを扱う

勉強会まとめ:Hatena Engineer Seminar #21 GraphQL 活用編

参加したかった&楽しみにしていたが、仕事に阻まれとても苦い思いをしたため、後で見てまとめた。

refs:

GraphQL を使い続けて気づいたこと

  • GraphQL のスキーマ設計によって、フロントエンド/バックエンド共に無理のない実装が可能
    • フロントエンド/バックエンドの実装上の都合を一方に押し付け合わずに済む

フロントエンドの都合

  • 画面に表示するデータのみが欲しい
  • 1API から様々なデータが欲しい
  • 上記はバックエンドに不都合
    • 1API の処理で様々なデータ集める/返す。そのために多くのパラメータを考慮する
    • = バックエンドの実装複雑化

バックエンドの都合

  • REST API のように作りたい (リソースや操作に対してエンドポイントを作成するため秩序立った API にできる)
  • 上記はフロントエンドには不都合
    • 表示するために必要なデータを取得するために何度も API リクエストを行う必要が生じる
    • 表示に必要ない余分なデータまでも一緒に取得してしまう
    • = フロントエンドの実装複雑化、パフォーマンス劣化

GraphQL はこれ(実装上の都合)を解消する

  • フロントエンドの都合を満たし、バックエンドに不都合な実装をさせずに済む

タグ機能の実現

都合

  • フロント:ユーザが元々入力したものを表示したい
  • バック:英大文字/小文字を同じタグと識別したい -> テキストを正規化して保持

機能要件

  • (英大文字/小文字を同じタグと識別した上で)
    • 作品に紐づけられたタグを一覧表示可能
    • タグが付いた作品を一覧表示可能

スキーマやクエリは

type Tag {
  title: String!    // ★
  works: [Work!]!
}
type Work {
  tags: [Tag!]!
}
query {
    work($id: ID!) {
        tags {
            title   // ★
        }
    }
}

上記だと、「英大文字/小文字を同じタグと識別」できない

  • フロントエンドは返ってきたデータを表示するだけ
  • バックエンドは、返すデータを作品によって出しわける必要が生じるため、煩雑に(仕様の都合を背負う)

これを解決するためにスキーマを改善

  • DB の中間テーブルのような Type を追加
type Tag {
  title: String!    // 正規化して保持しているタイトル
}
type WorkTag {
    title: String!  // ユーザが元々入力したタイトル = 表示するタイトル
    tag: Tag!
}
type Work {
  workTags: [WorkTag!]!
}
query {
    work($id: ID!) {
        workTags {
            title
        }
    }
}

まとめ(ポイント):

  • GraphQL のスキーマ設計にて、フロントエンドが欲しいデータ/バックエンド実装上の都合も考慮することで、 フロントエンド/バックエンド両者で無理のない実装が可能になる
  • フロントエンド/バックエンド両者の実装の詳細から一歩引いた立場で設計すること(詳細をそのままスキーマにしない)

スキーマ設計をうまく回すための Tips:

  • フロントエンド/バックエンド両者に理解があり(画面で欲しいリソースを理解し、バックエンドの実装/都合に通じている)スキーマ設計時に自然と両者の都合を考慮できている
  • ワイヤーフレーム => リソース検討 => スキーマ設計 の流れで行うのが良い(かもしれない)
    • (GraphQL に限った話でなく)ユーザが見る物から検討を始める

マルチテナントで GraphQL を使う際の工夫

マルチテナントとは、同一のシステムやサービスを、無関係な複数のユーザー(企業や個人)で共有するモデル

ref: マルチテナントとは ~シングルテナントとの違いからメリットまで徹底解説~

利用ユーザ(複数サイト)によって、使用できる機能や表示する物を切り替える(ON/OFF)

→ Feature Toggle を使う

Feature Toggle とは、コードを変更することなくシステムの振る舞いを変えることができる

refs:

クライアント側

  • 狙ったものだけを出し分ける
function UserInfoPage() {
  const { user, error } = useUserQuery(query);
  if (error) throw error;
  return (
    <div>
      <div>User: {user?.name}</div>
      {user?.point ?? <div>Point: {user?.point}</div>}
    </div>
  );
}

サーバ側

  • エラーは返さず NULL を返す

はてなが作るマンガアプリの GraphQL 導入から活用 ~コミック DAYS から GigaViewer for Apps へ~

GraphQL 導入後は、フロントエンドは、以下のようなアーキテクチャが可能

ref: はてなが作るマンガアプリの GraphQL 導入から活用 ~コミック DAYS から GigaViewer for Apps へ~

※ 補足

  • GraphQL 導入でそれまでのレイヤー構造を捨てた(Apollo の自動生成オブジェクトをそのまま利用する形態に)
  • GraphQL では Repository 層は大体アンチパターン

ref: https://twitter.com/adwd118/status/1569675348232773632

その他

  • !uery は Usecase
  • Schemas/Types は Resource

GraphQL を利用したアーキテクチャの勘所

ref: GraphQL を利用したアーキテクチャの勘所 / Architecture practices with GraphQL

GraphQL の特徴

  • Web API として提供する Schema と 欲しいデータを取得する Query。これらが分離されている
  • 概念の対応
    • Query <=> Usecase
    • Schemas, Types <=> Resource

イメージ:

GraphQL Query GraphQL Schemas and Types
Usecase Resource
Presetation Domain
System of Engagement System of Resource
Frontend (が欲する物) Backend (が提供するもの)

Resource ベースの API と Usecase ベースの API の良いところを両立できる

GraphQL Web API 設計の勘所

  • 基本的に Resources-based API として作る(GraphQL の良さを活かすため)
  • Schemas and Types を Resource、 query を Usecase で表す
  • 注意深い Resource (≒ Schemas/Types) 設計を行う
    • Usecase をきちんと理解した上で、同時にデータ設計をイメージする
    • 以下を満たすスキーマを考える
      • ユースケースを自然に満たせる(複雑な query や client 実装にはならない)
      • データ設計から自然に resolvers の実装ができる
      • ビジネス的な共通理解や共通言語を正しく反映している

注意点:

  • N+1 問題
    • クエリの自由度が高すぎるが故、パフォーマンス低下を引き起こす可能性あり
      • 配列でも中身の詳細まで取得するクエリも書けてしまう(サーバ側で適切に対処しない場合、N+1 問題が起きる可能性あり)
    • 対処法
      • Dataloader Pattern
        • 典型的にはこちらで対応
        • resolver を遅延評価し、バッチで実行する仕組み
      • ユースケース管理と妥協
        • Dataloader が適用できないようなケースはこちらで対応
        • 実際に投げられることのないクエリは対処不要に倒せる
        • Presistend Queries:クライアントが投げうるクエリを事前に GraphQL サーバに登録する手法、登録されていないものは弾く

Hasura

  • 初期は Hasura の API をそのまま使うことで、実装コストを下げられる。
  • 実装が複雑化してきたら、Remote Schema を活用し、ロジックをバックエンドに寄せる。

ref: GraphQL を利用したアーキテクチャの勘所 / Architecture practices with GraphQL

Hasura が提供するスキーマは、GraphQL の仕様に沿わず、DB(というより SQL)に寄せているものもある

https://hasura.io/docs/latest/queries/postgres/pagination/

その他:

Stock

某社フロントエンドコーディング試験を題材とした React 学習記(作成時の考慮事項まとめ)

某社フロントエンドコーディング試験を題材とした React 学習記(作成時の考慮事項まとめ)

はじめに

何番煎じになっているか分からないフロントエンドはチョットダケワカル者が React 学習のために、ゆめみ社様のフロントエンドコーディング試験を一から作ってみた

仕様にはない機能として以下を追加

  • API Key 入力画面
  • 都道府県別人口グラフでの統計項目選択

最初は先駆者のゆめみのフロントエンドコーディング試験の題材で React の勉強をしました のコードをほぼ模倣して、一部書き換えるか機能追加するか程度に考えていたが、(批判等の意図は全くないのですが…) 個人的にはコードが読みにくいと感じた部分もあり、そう感じるなら自分ならどう作るか?を突き詰めようと考え、(多少リスペクトさせて頂いた部分はあるものの) 一から自身で方針等を考えて作り上げた。

2022/11/8 現在、予定しているテスト実装がやりきれていないなど完全ではないが一区切り付く所までは進めた。できていない部分や改善項目は、残課題として挙げる。

事前に色々調べるのと開発環境下準備に 1 ヶ月、開発に 7 末~ 8 頭+ 10 ~ 11 頭の計 2 ケ月程かかった。

(技術要素は調べればいくらでも出てくるので割愛して) 本記事では、主に作成する上で考慮したことを記載する。

React 学習経緯

フロントエンドはここ数年水面下で個人で学習をしてきた(最初は業務で活きる場面は来ないと思っていたが奇跡的に個人での学習が実を結び少しだけ実務経験を得ることができた)

この度、React を学習しようとした理由は大きく3つ。

  • 業務で React を使用する機会がありそうだったこと。(残念ながらその機会は訪れず半分無駄となったが)
  • フロントエンドの実務経験が、大規模レガシー SPA(jQuery + HTML テンプレート)の改修/バグ修正 + Graphana のカスタムプラグイン改修での HTML/CSS/JS 修正少々のみであること。フロントエンドが多少なりともできると言えるためには Vue/React/Angular/Svelte 等のライブラリ/フレームワークが使える必要があると思っており、そのレベルには到達しておきたかったため。
  • 以下の点から、いずれは通らなければならない道であったこと。
    • 自身が個人で作ろうとしているプロダクトを開発する上で、かなりユーザビリティが良い GUI が必要であること
    • (CLI ツールはいくつか作ってきたが) GUI ツールを作れないと作れる物の幅が広がらないこと

元々 React の学習の優先順位は下げていたため1年位先の予定であったが、業務で必要になりそうだったために今回その優先順位を入れ替え前倒しで学習に取り組んだ。

※ React を選んだ理由は、過去個人で Vue を勉強したが肌に合わず挫折し、その後、某書で読んだ「HTML/CSS/JS (構造/見た目/動き) の分離は技術の分離であり関心の分離でない」といったような言葉(この通りであったかはうろ覚え)に衝撃を受けつつ激しく納得し、試しに React&JSX に触れたらとても肌にあったこと。

フォルダ構成/コンポーネント構成

構成を考えるにあたり、以下などを参考にはした。

refs:

最近は features (機能) でまとめていくのがベストプラクティスなようである。ただ、始めは小規模ということもあり features フォルダを設けなくても良いだろうとの判断から以下のような構成にしていた。(参考元も記憶もロスト。atomic design ベース? atomic design ベースの構成はアンチパターンらしいが小規模のため悪い面はそうでないだろうと判断して、やってみてダメだったら変えればいいとしたような気がする)

src/components
    |--elements
    |--models
    |   |--prefectures-selector         // 都道府県選択コンポーネント
    |   |   |--prefectures-selector.tsx
    |   |   |--usePrefectureQuery.ts
    |   |   |--useXxx.ts
    |   |   |--types.ts
    |   |--prefecture-population-graph  // 都道府県別人口グラフコンポーネント
    |       |--prefecture-population-graph.tsx
    |       |--usePopulationQuery.ts
    |       |--useXxx.tsx
    |       |--types.ts
    |--templates
    |--pages
        |--prefecture-statistical-graph-page  // 都道府県別人口グラフ表示ページ
            |--prefecture-statistical-graph-page.tsx
               // prefectures-selector と prefecture-population-graph を使用

が、上記の構成ではとある事に悩み、考えた結果 features を導入し、以下の通りの構成に変更した

src
|--components
|   |--elements
|   |--layouts
|   |--templates
|
|--features
    |--prefecture-statistical-graph
        |--api
        |   |--usePrefectureQuery.tsx
        |   |--usePopulationQuery.tsx
        |--components
        |   |--prefectures-selector.tsx  // 都道府県選択コンポーネント
        |   |--prefecture-population-graph.tsx  // 都道府県別人口グラフコンポーネント
        |--hooks
        |   |--usePrefectureState.tsx
        |   |--usePopulationState.tsx
        |--prefecture-statistical-graph.tsx 都道府県別人口グラフ表示ページ
        |--types.ts

フォルダ構成の再検討

構成変更前は、以下のような違和感ありありの実装にしてしまっていた。

  • 構成
src/components
    |--models
    |   |--prefectures-selector         // 都道府県選択コンポーネント
    |   |--prefecture-population-graph  // 都道府県別人口グラフコンポーネント
    |--pages
        |--prefecture-statistical-graph-page  // 都道府県別人口グラフ表示ページ
  • prefecture-statistical-graph-page.tsx
export const PrefectureStatisticalGraphPage = () => {
  const [prefectures, setPrefectures] = useState<Prefecture[]>([]);
  return (
    <>
      <PrefecturesSelector
        prefectures={prefectures}
        setPrefectures={setPrefectures}
      />
      <PrefecturePopulationGraph
        prefectures={prefectures.filter((pref) => pref.isSelected)}
      />
    </>
  );
};
  • prefectures-selector.tsx
export const PrefecturesSelector = ({ prefectures, setPrefectures }: PrefecturesSelectorProps) => {
  const updateSelectedPrefecture = useUpdateSelectedPrefecture(prefectures, setPrefectures);

  const { isLoading, prefectureResponseResult } = usePrefecturesQuery();  // api実行
  useSavePrefectures(prefectureResponseResult, setPrefectures); // 中にuseEffectを使用

  return (
    // 省略
  );
};

何故こうしたのか?

  • prefectures (都道府県一覧) の state は、prefectures-selector と prefecture-population-graph の両方で必要になるため、その上位コンポーネントで管理する必要がある
  • だが、prefectures-selector と prefecture-population-graph が必要とするロジックはそれぞれのコンポーネントと一緒に配置して知識の集約を図りたい

この 2 点満たそうとして気づけば上記のような実装にしてしまったが、setState (上記コードの setPrefectures) が剥き出しになるのは明らかに良くない(剥き出しになっている隙間で値の変更を許容してしまう)

何がいけなかったのか? 恐らく機能の定義の仕方と変に分けてしまっていることが問題と考えた。

アプリが必要とする機能は「都道府県別人口グラフを表示する」機能であるため、この機能を構成するコンポーネントや hooks は部品と考えると、この機能を構成する3つのコンポーネント&hooks をある意味バラけさせていたのが良くない。故に features フォルダを導入し、1 機能を構成する部品として 1 箇所に集約した。

そうすることで、当初の違和感を解消し、まとまりのある構成にすることができた。(ついてでに hooks も以下の通り、state と state を更新するロジックがひとまとまりになり、自然な形となった)

export const PrefectureGraphPage = () => {
  const { prefectures, savePrefectures, updateSelectedPrefecture } =
    usePrefecturesState();

  return (
    <>
      <PrefecturesSelector
        prefectures={prefectures}
        savePrefectures={savePrefectures}
        updateSelectedPrefecture={updateSelectedPrefecture}
      />
      <PrefecturePopulationGraph
        prefectures={prefectures.filter((pref) => pref.isSelected)}
      />
    </>
  );
};
export const PrefecturesSelector = ({
  prefectures,
  updateSelectedPrefecture,
  savePrefectures
}: PrefecturesSelectorProps) => {
  const { isLoading, prefectureResponseResult } = usePrefecturesQuery();

  const perfNamesKey = prefectureResponseResult?.map((result) => result.prefName).join('');
  useEffect(() => {
    savePrefectures(prefectureResponseResult);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [perfNamesKey]);

  return (
    // 省略
  );
};

※ useEffect をカスタムフックから外出ししたのは、1 コンポーネントに useEffect は 1 つ

ページコンポーネント(prefecture-statistical-graph-page)に presentation/container パターンを導入し、の presentation を pages 下に container と 部品を models に配置といったような手も選択肢として挙げることはできるかもしれないが、presentation/container パターンを導入する程の規模でもなく導入しても冗長かつ不要に複雑化を招く元になるため、今回は機能に関する物(知識)を 1 箇所に集約することを重視した。

ステート管理

前提として、ステートには主に 3 種ある。

  • サーバーデータのキャッシュ
    • SWR
    • React Query (Tanstack-Query)
  • Global State
    • ページをまたいで保持し続ける必要のある State
      • Context
      • Redux
      • Recoil
      • Zustand
      • Jotai 等
  • Local State
    • 各 Component 内で useState を使って管理
    • setState は直接露出させない

ref: 「3 種類」で管理する React の State 戦略

サーバーデータのキャッシュ

API の仕様が変わることは(ほぼ)無い、レスポンスデータの更新も(恐らく)低頻度であることから、API を都度実行するよりキャッシュを利用するのが良いと判断

SWR よりは React-Query の方が 細かいところに手が届く感があり、先々機能追加をすること考えてもそう困らないと考えて採用

refs:

※ただし、いつレスポンスデータに更新が入っても早い段階で最新データが取れるように キャッシュの期限は 1 日に設定した

Global State

Global State は大きく 2 種に分類できる

  • React 外のストア
    • Redux (シングルステート)
    • Zustand (シングルステート、複数も可)
  • React コンポーネントツリー内のストア
    • Recoil ※推測のため要確認 (複数可)
    • Jotai (複数可)
    • Context

(以下は推測) この2つの大きな違いは、以下ではないかと思っている

  • React コンポーネントツリー内のストア: ステートの更新がコンポーネントの再レンダリングに直結する
  • React 外のストア:React コンポーネントツリーから切り離されているため、裏側でステートの更新のみを行うことが可能なのではないか。例えば、(ユーザ操作や定期実行プロセスなどをトリガーに)画面表示とは非同期で行う処理(処理が終わればステートは更新するが画面表示は更新不要な処理等。まさに Redux のベースである Flux の仕組みが活きるような場面)

今回は、 React 外のストアは不要かつ、軽量なものでよいため、Jotai を選択した。

が、グローバルで管理するものが1つだけだったため、これも過剰で Context で十分だったように思う(改善項目)

※ Context を使用する上での工夫方法は以下が参考になる

refs:

エラーハンドリング

React-Query でのエラーハンドリングに関しては以下が参考となった。

ref: React Query Error Handling

今回は、エラーケースを以下の通りに分類して実装をした。

  • Client Error (400 番系)
    • 継続可能エラー として、Toast でエラーを表示するのみ(操作は引き続き可能)
  • Server Error (500 番系) + その他予期せぬエラー(バグ)
    • 継続不能エラー として、エラー画面に遷移

API エラーレスポンスの振り分けは React-Query (Tanstack-Query) のオプションで実現した。

export const errorBoundaryOption = {
  useErrorBoundary: (err: Error) => !(err instanceof ApiClientError),
  onError: (err: Error) => {
    if (err instanceof ApiClientError) {
      onCustomToaster(err); // Toast を表示
    }
  },
};

上記オプション利用側

import { useQuery } from "@tanstack/react-query";

const queryResult = useQuery<
  PrefectureResponeseResult[],
  Error | ApiClientError
>(
  [PREFECTURES_QUERY_KEY],
  async () => apiClient.getPrefectures(),
  errorBoundaryOption
);

エラー画面の遷移には ErrorBoundary を利用して実現している。

※エラー画面には「トップに戻る」ボタンを用意しており、ボタン押下でトップ画面に戻った後は、ブラウザの戻るボタンを押しても、エラー画面には戻らないようにしている。不必要に(継続不能エラーが起きた時以外)エラー画面を開くことがないようにするためと考えて

CSS

選択肢として以下を挙げた

  • CSS modules
  • CSS in JS
    • styled-component
    • emotion
  • ※ゼロランタイム CSS in JS はまだ発展途上感がありそうだったため一旦見送り

CSS in JS は、移植性の観点から emotion > styled-component と速攻で判断。

CSS modules でも十分と思ったが以下の点から先を見据えて、かつパフォーマンスが CSS Modules > CSS in JS といえど今回の作成物では問題にならない程度と判断し emotion を使用することとした。

  • (今後今回の作成物を実験台にしようと思っていることもあり) theme(ノーマルモード/ダークモード)の導入を行いたいと考えている (CSS Modules でもできなくはないが手間が大きいらしい)
  • (実際にやってみて特に感じたことだが) 1 ファイルに HTML と CSS 両方ある方が開発しやすく、CSS Modules の場合命名規則をどうするかを悩んだがそこに悩むのも嫌であった (開発者体験を優先)
  • CSS Modules を使用する上でフォーマット等に使用したい Stylelint がまだ ESM 未対応(2022/11/6 時点) ref: stylelint: Move to ESM ※結局使用している別の何かが ESM 未対応のため、ESM に移行しきれていない

styled components を使用する上で考慮したこと

多少参考にさせて頂いた物のコードを見ていると、styled components と そうでないコンポーネント(自前で作った React Component) の見分けがし辛く、可読性が悪いと感じた。

故に、styled components のメリット/デメリットを調べた。以下の通り。

ref: The Pros and Cons of Using Styled Components in React

  • メリット
    • CSS の特異性の問題を解決(クラス名の衝突)
    • コンポーネント内に CSS を記述できる
    • すぐに使えるテーマ設定をサポート
      • ダーク テーマやその他のテーマをアプリケーションに追加するのは難しく時間がかかるが、容易に実現できる
  • デメリット
    • JS で CSS を書くと、将来的に 2 つを分離することが難しくなり、保守性が大幅に低下する(例:JavaScript フレームワークを切り替える場合、ほとんどのコードベースを書き直す必要がでてくる)※CSS モジュールや emotion のようなライブラリを使用すれば将来性は高まる
    • ★ 読みにくい場合がある
      • Styled Component と React Component を区別することは、特にアトミックデザインシステムの外では難しい場合がある
      • Styled Component のみをラッパーとして使用し、その中の要素にセマンティック HTML タグを使用することで、この問題を解決はできる。以下のように別ファイルに分けるとより明確にできる
import * as styled from "./styled";
// use styled.components
<styled.Main>{code}</styled.Main>;

更に、デメリットとして、Styled Components は簡単すぎて、初歩の構造を隠蔽する事が挙げられるとのこと。

ref: Styled Components を無闇に使わないで

export const Button = styled.button<Props>`
  height: 40px;
  padding: 0 16px;
  border-radius: 4px;
  ${colorStyle}
`;

Button.displayName = "Button";

実態は以下

// React.forwardRefでラップする構文を使い、refを受け取れるようにする
export const Button = React.forwardRef<Ref, Props>((props, ref) => {
  // classNameを追加でマージできるようにする (1)
  const { color, className, ...rest } = props;

  return (
    <button
      ref={ref}
      // classNameを追加でマージできるようにする (2)
      className={cx(styles.root, styles[`root__color_${color}`], className)}
      // button要素が持つpropsでclassName以外のものは、そのまま受け流す
      {...rest}
    />
  );
});

上記の通り、Styled Components の使用は無意識的に ref の多用に繋がるため実はあまりよくないのではないか? カプセル化されているため実害はそうないように感じるかもしれないが、ref には DOM 情報が保持されるため、多用すればする程メモリを喰うのではないか? = 塵も積もればパフォーマンスに影響が出る可能性があるかもしれない(推測)。

emotion を実際に使う上での方針

  • (emotion の) styled components を使用する際

    • 以下のようにして、使用シーンを限定する

      Styled Component のみをラッパーとして使用し、その中の要素にセマンティック HTML タグを使用することで、この問題を解決はできる

    • (とは言っても 1 HTML 要素 に 多くの CSS を設定する/それを再利用する場合には、利用したくなるように思うため)、コンポーネント名に 接頭辞 (例:Styled) を付けるなど、見分けやすくするための規約を定める
    • (見た目だけ加工したコンポーネントと、ロジックを伴うコンポーネントをパッと見で見分けにくいのが一番の問題と感じたため)presentation/container パターンを適用して、presentation コンポーネント側に隔離する

など、使用範囲の限定や見分けやすくすための規約が必要に思う。※今回は試したいがためにわざと一部に styled components を使用し、2 点目の方針を採用している。今回作成したものに関しては基本的に styled components を使用して旨味のある箇所がないためわざと使用した部分以外には使用していない。

また、わざと使用した部分に関しては margin や padding 等の余白調整をするためのスタイル(ユーティリティ層相当の物)は外側から注入できるようにしている。コンポーネントを使用したい場所の枠に応じて幅/余白等を調整できるようにするとコンポーネントの再利用性が増す。

外からスタイルを注入できるようにしようとすると、以下のようになる訳だが、毎度以下のように書くのは面倒なため

export const StyledTitle = styled.h2(
  (props: StyledProps) =>
    css`
      margin: 0;
      ${props.css}
    `
);

export type StyledProps = {
  css?: SerializedStyles;
};

以下のようなちょっとした共通化の工夫は入れた。

export const StyledTitle = styled.h2(
  cssMerger(css`
    margin: 0;
  `)
);

export const cssMerger = (styles: SerializedStyles) => (props: StyledProps) =>
  css`
    ${styles}
    ${props.css}
  `;

将来利用する可能性があるとはいえ、必要になってから入れるとしても何ら問題はないため、利用する時になってから導入することとした。

  • コンポーネントでの CSS の定義
    • 基本的に styled components は使わず、各コンポーネントに以下のように固めて定義している
    • CSS は、子要素のスタイルを設定するときに親要素のスタイルの内容も考慮する必要があるため、個人的には以下のように(コンポーネント内の全要素の)CSS が一か所まとまっている方が調整がしやすいと思っている
const styles = {
  foundation: css`
    box-sizing: content-box;
  `,
  container: css`
    box-sizing: content-box;
    position: relative;
  `,
  border: css`
    border: 1px solid ${commonStyles.themeColor};
  `,
  title: css`
    background-color: white;
    padding: 1px 3px;
    margin: 0;
    position: absolute;
    top: 0;
    left: 0;
    transform: translateY(-50%) translateX(${10 / 16}rem);
  `,
  body: css`
    padding: 1rem;
  `,
};

export const TitleBodyLayout = ({
  title,
  children,
  existsBorder = false,
  additionalStyles,
}: TitleBodyLayoutProps) => {
  const containerStyles = existsBorder
    ? [styles.container, styles.border]
    : [styles.container];
  return (
    <div css={[styles.foundation, additionalStyles]}>
      <div css={containerStyles}>
        <p css={styles.title}>{title}</p>
        <div css={styles.body}>{children}</div>
      </div>
    </div>
  );
};

テスト

用意するテストは以下(一部残課題行き)

  • ユニットテスト → カスタム hooks や 関数 のロジックのテスト (Jest & testing-library)
  • コンポーネントテスト → コンポーネント単位の動作テスト (testing-library or cypress) ※予定:未実装
  • E2E テスト (API はモック) → インテグレーションテスト (Cypress)
  • シナリオテスト → Github Pages にて可能
  • Visual Regression Test → 画面差分確認 (Chromatic)
  • リグレッションテスト → 未実装(保留)

補足事項

  • ユニットテスト+ E2E テストで主要な機能のテストは概ねカバーできているためコンポーネントテストの優先度を下げている
  • E2E テストにて、使用 API の仕様はそう変わらない点とエラーケースのテストが網羅できない点から、API モックを使用。故に実物を使用するリグレッションテストは効果が薄いため保留。

E2E テストに関しては詰まりどころがあったため記載する。

  • 実際の画面を操作してコードを自動生成できる Playwright を最初は使用(API モックは MSW)
  • Playwright にてテスト時の MSW のレスポンス上書き設定(デフォルトは正常系、異常系テストのため rest.once()で一度だけエラーを返すよう上書き)がうまくいかず
  • MSW のドキュメントに Cypress での例があるため、Cypress なら確実と思い乗り換え ref: https://mswjs.io/docs/api/setup-worker/use
  • 乗り換えだけでは足りず右記の対応が必要であった ref: Cypress issues when using window.msw ※Playwright も同じかもしれない(未試行)。戻すのも手間なため Cypress で続行
  • ベストプラクティスに従い、HTML 要素の取得のために data-test 要素を追加。 ref: Cypress - Best Practice
  • data-test 要素はあくまで e2e テスト用のため、以下のようなコードを用意して本番環境に含めないようにした
export const makeAttrForTest = (label: string) => {
  if (import.meta.env.VITE_E2E_MODE) {
    return { "data-test": label };
  }
  return {};
};
  • 上記コードでは、(アプリを Native ESM に対応させてないのもあり) Jest でのテスト実行がうまくいかず vite-plugin-env-compatible を使用してみるも GithubPages や Chromatic でうまくいかず
  • 最終的に環境変数の参照は ReactDOM.createRoot を行う main.ts に隔離し、(e2e 実行かをアプリ全体の状態と捉えて…) useContext を用いて値を渡すようにした
export const EnabledE2EContext = createContext(false);
export const useEnabledE2EMode = (): boolean => useContext(EnabledE2EContext);
// provider への import.meta.env.VITE_E2E_MODE === 'true' の結果を渡す
export const useMakeAttrForTest = () => {
  const isEnabledE2E = useEnabledE2EMode();

  return (label: string) => {
    if (isEnabledE2E) {
      return { "data-test": label };
    }
    return {};
  };
};

Jest がまだ Native ESM に対応しきっていないが、Vitest はまだ v1 でもないため、Native ESM に対応するのは保留とし、最終的に Jest を使うこととしている。Vite で babel-plugin-remove-attributes のような実用レベルの方法が見つからなかったため、上記のような対応とした。

※テストに関しては以下等が参考になりそう

ref: メルペイフロントエンドのテスト自動化方針

Github Pages

アプリは Github Pages にデプロイしたが、

react-router-dom の BrowserRouter を使用している場合は、以下の通り直接 URL を踏むを Github の 404 ページに遷移してしまうため、対応が必要である。

ref: React で gh-pages にデプロイしたとき、直接 URL を踏むと 404 が返る問題への対応

  • 原因:ブラウザーはその URL のサーバー (この場合は GitHub ページ サーバー) を要求するが、この時点でクライアント側のルーター (react-router) は、そのページをまだ読み込めていないため、アクションを実行できず、404 が出る。

  • 対処:以下の 404.html と index.html の script をコピペする

rafgraph/spa-github-pages

Vite を使用している場合 そのままではリダイレクト先の URL が https://username.github.io/?/repo-name/ になってしまうため 404.html 内の var pathSegmentsToKeep を 1 にする( そうすれば URL が https://username.github.io/repo-name/?/ になり正常に動く)

Github Pages 時のみ専用スクリプトを取り込む

上記の対処で追加する 404.html と index.html の script は Github Pages デプロイ時以外は不要な物のため、Github Pages 用のビルド時のみ取り込むようにした。

404.html の取り込みは vite.config.ts の設定を以下のようにして実現した。

export default defineConfig({
  build: {
    target: 'esnext',
    rollupOptions: {
      input: process.env.GITHUB_PAGES
        ? {
            index: `${__dirname}/index.html`,
            notfound: `${__dirname}/404.html`
          }
        : {
            index: `${__dirname}/index.html`
          }
    }
  },

また、index.html 内の script は 以下をヒントに、置換にて Github Pages デプロイ時のみスクリプトを取り込むようにした。

Vite 環境で index.html から環境変数を参照する

// ref: https://dev.classmethod.jp/articles/vite-index-html-read-env-variables/
// ref: https://vitejs.dev/guide/api-plugin.html#transformindexhtml
const htmlPlugin = () => {
  return {
    name: "html-transform",
    enforce: "pre" as const,
    transformIndexHtml(html: string) {
      return html.replace(
        /    %SCRIPT_FOR_GITHUB_PAGES%/g,
        process.env.GITHUB_PAGES
          ? fs.readFileSync("./ghpages/script.txt", "utf8")
          : ""
      );
    },
  };
};

(もっと良いやり方はありそう…)

その他参考になりそうなもの

※ コードを置いてるリポジトリhttps://github.com/Symthy/react-clone-yumemi-exam)に作成するにあたって色々調べたことはほぼ memo_xxx.md に記載している。参考までに

残課題

改善タスク(残課題):

※ Suspsense について

  • useQueries の対応が完全ではない?ようなので導入見送り ref: TanStack/query#1523
  • そもそも Suspsense 導入できるコンポーネントが 1/3 のため導入するメリットも今はあまりない(必要になったらで良い)

使用したライブラリに関しては色々断片ではあるものの基本的な所は押さえることができた上、完全独力にて概ね仕上げることができたため、今後開発するものの礎となるだろう。

以上

フロントエンド E2E テスト + MSW (Playwright/Cypress 試行) メモ

フロントエンド E2E テスト + MSW (Playwright/Cypress 試行) メモ

E2E テストフレームワーク

色々あるが、本記事で触れるのは Playwright と Cypress

以下4つの比較がある。 ref:E2E テストツール Autify を使うまでの話

  • TestCafe
  • WebDriverIO
  • Cypress.io
  • Autify (有料)

以下3つの比較がある。 ref: E2E テストフレームワークはどれを選べばいいんじゃい!

  • Cypress
  • Playwright
  • CodeceptJS

Playwright

導入:

npm i -D @playwright/test msw
npx playwright install

コード自動生成(画面操作によってコードが自動的に作られる)※playwright-cli が統合された

npx playwright codegen https://yahoo.co.jp

参考:

Cypress

テスト時に特定の要素を取得する際は、data-*属性を追加し、アプリ側の変更でテストへ影響が及びにくくする。

ref: Best Practice

e2e テスト時にしか使用しない属性であれば、本番環境で余分になるものを含めないように、e2e テスト時のみ追加するような工夫をするのが良い(はず)。

export const makeAttrForTest = (label: string) => {
  if (process.env.VITE_E2E_MODE) {
    return { "data-test": label };
  }
  return {};
};

※Vite を使用していてコードに import.meta.env.VITE_XXXX での分岐を入れているとうまくいかないので、以下で置き換えを行う必要があった(置換ライブラリはいくつかあるが何が最適化分からないため archive されていなかった以下をひとまず使用)

// vite.config.ts
import env from "vite-plugin-env-compatible";
// ref: https://github.com/IndexXuan/vite-plugin-env-compatible

export default defineConfig({
  plugins: [env({ prefix: "VITE", mountedPath: "process.env" })],
});

その他参考

MSW のレスポンス上書き設定

window オブジェクトに msw を挿入することでテストコードから操作できるようになる

refs:

上記だけではうまくいかなかった。アプリ側での MSW の起動を e2e テスト側で待つ必要がある

ref: Cypress issues when using window.msw

beforeEach(() => {
  cy.visit("/");
  // Wait for MSW server to start
  cy.window().should("have.property", "appReady", true);
});

※ Playwright でも、window オブジェクトに msw を挿入するだけではうまくいかなかった。上記と同じように待ち合わせが必要かもしれない。Playwright の場合は以下も参考になるかも

playwright-msw

mswjs/data

  • 仮想 DB をブラウザ(インメモリ)に展開する、データモデリング・リレーションライブラリ
  • ORM 風な API を提供
  • UI のみをテストしたいのであれば MSW 単体で十分。mswjs/data が活きるのは、画面を横断する E2E テストケース(別画面操作による更新内容/状態を引き継げる)

採用モチベーション:

  • 実際の DB・バックエンドが揃う前に、アプリケーション E2E テスト実施可能
  • 従来のテストインフラ整備と比較し、軽量・手軽に E2E テストが実施可能

ref: mswjs/data で広がるテスト戦略

Github Actions (Cypress)

以下がとても参考になった。

実際にやってみた。

https://github.com/Symthy/react-clone-yumemi-exam

公式ドキュメント:

さいごに

簡単な E2E テスト(MSW を利用した正常系と異常系)を実装してみた

https://github.com/Symthy/react-clone-yumemi-exam

個人的には Playwright の方が開発者体験が良く(テストコードが実際に画面を操作して自動で生成される)、こちらを使用したかったが

MSW の設定上書きがうまくいかず(window オブジェクトに msw の注入がうまくいかず)

MSW の公式ドキュメントに Cypress の例があるため、Cypress なら確実にできるだろうと考え、Cypress を使用して事なきを得た。

今回のように小規模のテストであれば、Cypress でも苦はないが、テストの内容が大きくかつ複雑化してくると、多くのコードを書く必要が出てくるため、やはり苦しくなりそうである。

【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 のベストプラクティス