『Production Ready GraphQL』の「Anemic GraphQL」

この記事は はてなエンジニア Advent Calendar 2022 の2023年1月3日の記事です。

昨日は id:onkストーリー性のあるプレゼン - id:onk のはてなブログ でした。


『Production Ready GraphQL』といえば Production Ready GraphQLはGraphQLを採用するなら必ず読んでおきたい良書 などでも紹介されていますが、GraphQL APIを設計・開発・利用するなら大変参考になる良書ですね。はてな社内でもGraphQL開発における前提本ですね、などと言われている模様です。

この中で好きな箇所の1つが「Specific or Generic」という節の「Anemic GraphQL」という項です。たくさん引用するわけにもいかないので冒頭だけ見ますと、

Anemic GraphQL is something I stole from the Anemic Domain Model, a pattern popularized by the great Martin Fowler. Anemic GraphQL means designing the schemas purely as dumb bags of data rather than designing them thinking of actions, use cases, or functionality. This is best expressed by an example:

type Discount {
  amount: Money!
}
type Product {
  price: Money!
  discounts: [Discount!]!
}

というスキーマ・型について、割引や税額を考慮した合計金額を表示することを考えるという例です。クライアントサイドで price から discounts の合計額を引いて totalPrice を算出することはできるが、Producttaxes が増えたりすると誤った計算になってしまうのでクライアントサイドの計算コードの修正が必要になる。クライアントサイドで合計金額を表示するというユースケースに応じて ProducttotalPrice フィールドを追加しようね、というような話。

type Product {
   price: Money!
   discounts: [Discount!]!
   taxes: Money!
   totalPrice: Money!
 }

こうすると Product のフィールドが増えたり合計金額の計算ロジックが変わっても、totalPrice フィールドを表示していれば良いままで変更に強くなる。

かのマーティン・ファウラー氏が命名したドメインモデル貧血症を引き合いに出していますが、GraphQLのスキーマ・型も単なるデータの入れ物に留まらず、ドメインユースケースに応じたフィールドを設計・公開するのが望ましいですねというお話でした。


メルペイのvvakameさんによる次の記事でも、同じような(と id:ikesyo が認識している)ことが言及されています。

engineering.mercari.com

Componentを構成するとき、1つのUI要素を表示するために複数のGraphQLのフィールドを組み合わせる必要がある場合、その処理をバックエンド側で肩代わりするcomputed fieldを導入することを検討してください。
たとえばメルカリの出品物に何個のコメントがついているかのバッジをUIに表示したいとします。しかしGraphQLスキーマにコメント数のフィールドが存在しない場合、クライアント側でコメントを全件取得し、その数をカウントするコードを書く必要があります。これは不便です。
クライアント側は不要なフィールドの取得をして、面倒なページングの処理も必要になります。バックエンド側もいくつかの不便を強いられます。個別のキャッシュが難しくなること、UIに対して過剰な本来必要な分以上のデータが取得されてしまうこと、これによりbackend主導の改善がより困難になることです。
バックエンド側はGraphQLのフィールドが要求されていることはわかっても、それがUI上でどのように使われているかは分析できません。どういったcomputed fieldが必要であるかは、基本的にはクライアント側のリクエストベースで検討されるべきです。
computed fieldを定義することをためらわないこと。どういうcomputed fieldを作るべきかは、バックエンドエンジニアが考えることは難しいでしょう。だから、クライアントエンジニアから相談があった場合、快く応じましょう。それが複雑な計算が要求されるとしても、必要ならばやるべきです。
なぜならば、バックエンド側が実装を拒んだ場合、単にクライアント側で同等の処理が実装されるだけだからです。しかも、それがどんなに重たい処理であろうとも、たった1つのUIを表示するために大量のリソースを消費するものだとしても、それを最適化する余地はあなたの手から離れてしまいます。computed fieldとして実装してあげれば、その処理が重かったらmemcachedに突っ込むなりなんなり、あなたが自由にチューニングすることができます。

こちらの記事もとても参考になる・自分達でも実践していることが多く、おすすめです。

あわせて読みたい

developer.hatenastaff.com

UIButton.Configuration.Sizeの各サイズごとのデフォルト状態での高さ

iOS 16.1シミュレーターで確認してみた。

UIButton.Configuration.Size

@2x @3x
UIButton.Configuration.Size.large 50.5 50.33…
UIButton.Configuration.Size.medium 34.5 34.33…
UIButton.Configuration.Size.small 28 28
UIButton.Configuration.Size.mini 28 28

特別にHuman Interface Guidelinesの44x44ポイントのヒットターゲットが意識されている・遵守するようになっているわけではないのか。

Buttons - Menus and actions - Components - Human Interface Guidelines - Design - Apple Developer

Make buttons easy for people to choose. On a touchscreen, buttons need a hit target of at least 44x44 points to accommodate a fingertip.

UINavigationController配下でUIHostingControllerによって表示しているSwiftUIのViewからNavigationLinkは使えるのか

結論としては使えます。端的なPlaygroundのコードとしては次のようになります。

import UIKit
import SwiftUI
import PlaygroundSupport

struct RootView: View {
    var body: some View {
        NavigationLink("This is NavigationLink") {
            Text("Hello World!")
        }
    }
}

let hosting = UIHostingController(rootView: RootView())
let navigation = UINavigationController(rootViewController: hosting)
navigation.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)

PlaygroundPage.current.liveView = navigation

NavigationLinkはNavigationStackがなくても(UINavigationControllerがあれば)動く!

iOSシミュレーター・Androidエミュレーターにアプリをインストールだけする(起動までしない)方法

なぜ

アプリの初回起動時専用の特別な処理や画面遷移を行うケースで、そのデバッグ実行・動作確認をするため。

iOS

まずインストールするためのアプリは Build For Running でビルドしておく。次にシミュレーターを起動しておいて

xcrun simctl install booted <~~.appのパス>

のコマンドを実行する。

もしくは、シミュレーターのウィンドウに ~~.app をFinderからドラッグ&ドロップする。

Xcodeのスキーム設定の Wait for the executable to be launched が使えるのでは、と一瞬考える。しかしこれはシミュレーターにインストール済みのアプリには使えるのだが、まだインストールされていないアプリの場合は、アプリのインストールまではやってくれないので、ただ待つだけ(何も起きない)状態になる。ので、別途自分でインストールしてあげる必要がある。

UserDefaultsやKeychainも含めてまっさらな初回起動状態の再現のために、シミュレーターのメニューの Device -> Erase All Content and Settings… も便利。

Android

エミュレーターを起動しておいて

gradlew installDevelopDebug

のコマンドを実行する。DevelopDebug の部分は使いたいbuild variantを指定する。こちらはエミュレーターではなく実機接続でも動作する。

iOSシミュレーター同様に、エミュレーターのウィンドウにapkファイルをドラッグ&ドロップでもインストールできる。

RenovateのGemfileやPodfileの更新でリリースノートを取得する

Renovateは様々なデータソース・パッケージマネージャーに対応していて、もちろんRubyBundlerのGemfileにも対応しているのだが、APIリクエストのレートリミットを回避する関係で、https://rubygems.org をデータソースとする場合はリリースノートの取得ができなくなっている。

これが不便で、自分達のチームではGemfileの更新にはDependabotを併用するという面倒な運用をしていた。最近になってこれを部分的に解消できる設定がRenovateに誕生したのでご紹介。

customChangelogUrlという設定を使うと、あるパッケージのリリースノートを探しにいくURLを指定することができる。これを使うとGitHubやGitLabの該当リポジトリのReleasesや CHANGELOG.md などからリリースノートをリストアップしてくれる。

例えばfastlaneを指定する場合はこうなる:

{
  "packageRules": [
    {
      "matchManagers": ["bundler"],
      "matchPackageNames": ["fastlane"],
      "customChangelogUrl": "https://github.com/fastlane/fastlane",
    },
  ]
}

iOS開発文脈ではCocoaPodsのPodfileでも同様の状態にあり、そちらにも活用することができる。

{
  "packageRules": [
    {
      "matchManagers": ["cocoapods"],
      "matchPackageNames": ["SwiftLint"],
      "customChangelogUrl": "https://github.com/realm/SwiftLint",
    },
  ]
}

これでDependabotに別れを告げてRenovateに統一することができた。めでたしめでたし。

GraphQLスキーマをサーバーサイドからクライアントサイドに自動同期し、変更履歴も確認したい

似たような話が先日ありましたね。

こちらはGraphQLではなくてOpenAPI、かつサーバーサイド側のリポジトリからクライアントサイド側のリポジトリにプッシュする方式でした。

今関わっているプロダクトでは、1つのGraphQLスキーマが複数のリポジトリから必要とされていたり、それぞれで同期タイミングを制御できたらいいかなということもあったりで、クライアントサイドからGitHub Actionsの定期実行でプルする方式で更新をしています。

イメージは次のとおり:

name: Update GraphQL Schema

on:
  schedule:
    - cron: "0 0 * * 1-5" # JSTで月曜〜金曜の9時、これは適当です
  workflow_dispatch:

jobs:
  UpdateGraphQLSchema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: make update-graphql-schema
      - uses: peter-evans/create-pull-request@v4
        with:
          commit-message: Update schema.graphqls
          title: Update schema.graphqls
          body: ""
          branch: update-graphql-schema
          token: ${{ secrets.GITHUB_PAT }}

トリガーとしては、scheduleでの定期実行 OR workflow_dispatchでの手動実行になっています。

実際の更新部分は例として make update-graphql-schema としていますが、中身としてはApollo Clientのコマンドを実行してスキーマをダウンロードする(実態としてはIntrospectionを実行してスキーマを得る)ようにしています。ここについては、サーバーサイドのリポジトリをチェックアウトしてきて、元のスキーマファイルをコピーするようにしてきてもよいでしょう。

更新したスキーマをゲットできたら、あとは peter-evans/create-pull-request actionを使ってプルリクエスト(以下、PR)を作成しています。

ここで扱っているのはApollo Clientを使ったネイティブアプリで、Apollo iOSApollo Kotlinもアプリのビルド時にオンデマンドでコード生成を行うので、スキーマ更新と同時にコード生成をしてPRに含める、ということはありません。

さて、これはこれで単純でいいのですが、スキーマの変更内容がPRで確認できたとしても、その詳細(変更の意図や経緯)がこれだけでは分かりません。ということで、サーバーサイドのリポジトリの関連するPRを拾い上げて、スキーマ更新のPRに変更履歴という体で貼り付けていました。

しかしさすがに人力で関連PRを探しに行くのは面倒なので自動化したい。ということで、チームメンバーであるところの id:mangano-ito さんがやってくれました。

name: Update GraphQL Schema

on:
  schedule:
    - cron: "0 0 * * 1-5" # JSTで月曜〜金曜の9時、これは適当です
  workflow_dispatch:

jobs:
  UpdateGraphQLSchema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - id: generate-associated-pr-list
        uses: ./.github/actions/generate-associated-pr-list
        with:
          our-path: path/to/client/schema.graphqls
          their-path: path/to/server/schema.graphql
          their-owner: ...
          their-repo: ...
          github-token: ${{ secrets.GITHUB_PAT }}
      - run: make update-graphql-schema
      - uses: peter-evans/create-pull-request@v4
        with:
          commit-message: Update schema.graphqls
          title: Update schema.graphqls
          body:  |
            ## Associated Pull Requests
            ${{ steps.generate-associated-pr-list.outputs.result }}
          branch: update-graphql-schema
          token: ${{ secrets.GITHUB_PAT }}

generate-associated-pr-list というのがお手製のComposite Actionです。自分方のリポジトリのファイルと、相手方のリポジトリのファイルの変更日時から、相手方のファイルのその間のコミット・それに紐付くPRを拾い上げて、Markdownのリストとして出力してくれるものです。パラメーター名も、gitの衝突解消時のours, theirsという用語を基にしていてオシャレですね。

その実装の大事な部分だけ抜粋してみます:

runs:
  using: composite
  steps:
    - id: get-last-updated-date
      shell: bash
      env:
        TZ: UTC0
        PATH_TO_FILE: ${{ inputs.our-path }}
      run: |
        value=$(\
          git log -1 \
            --date="format-local:%Y-%m-%dT%H:%M:%SZ" \
            --pretty="format:%cd" \
            "${PATH_TO_FILE}" \
        )
        echo "::set-output name=value::$value"
    - id: find-prs
      uses: octokit/graphql-action@v2.x
      env:
        GITHUB_TOKEN: ${{ inputs.github-token }}
      with:
        query: |
          query FindAssociatedPullRequests(
            $since: GitTimestamp!,
            $path: String!,
            $owner: String!,
            $repo: String!,
          ) {
            repository(owner: $owner, name: $repo) {
              defaultBranchRef {
                target {
                  ... on Commit {
                    history(since: $since, path: $path) {
                      nodes {
                        associatedPullRequests(first: 1) {
                          nodes {
                            number
                            url
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        since: ${{ steps.get-last-updated-date.outputs.value }}
        path: ${{ inputs.their-path }}
        owner: ${{ inputs.their-owner }}
        repo: ${{ inputs.their-repo }}

GitHubのGraphQL API (v4) を上手く使って、特定のファイルの特定日時以降のコミット・それに紐付くPRをリストアップしています。これで人力で関連PRを探しに行かなくてよくなりました。めでたしめでたし。

このようなワークフロー・改善により、自分達のGraphQL APIとこれからも仲良く付き合っていきたい。

期間の扱い方とその名前

とあるAPIスキーマの叩き台をクライアントサイドとして検討している際に、コンテンツの公開期間やイベントの開催期間のような期間について議論が少し盛り上がった。

要件としては、期間の開始と終了の日時をそれぞれ取得できたい。

期間を考える時、開始と終了がそれぞれinclusiveなのかexclusiveなのかをまず考慮すべきであるが、開始日時の重複や終了日時に隙間を発生させないためには、開始はinclusive、終了はexclusive、つまり半開区間(左閉右開)にするのが望ましいだろう。

終了をexclusiveにすると、例えば8月の1ヶ月間、つまり8月1日0時0分〜9月1日0時0分という期間の場合、ユーザー向けの表示としては終了日時は「8月31日23時59分まで」と表示したくはなるが、これはプレゼンテーションロジックとしてクライアントサイドの責務としてやる。基本的には-1秒してからフォーマットすればよい。

次にその名前を簡単に考えてみるとsince / until のような名前が思い付く。ここであるメンバーから「untilにはinclusiveな意味合いを感じてしまうがどうだろうか」という意見が出た。

untilはinclusiveかexclusiveなのかを少し調べてみると、どちらの意味にも取り得て、文脈依存であるようだ。

ここではuntilはexclusiveとして扱いますよ、ということでもいいのだが、『リーダブルコード』にもそういう話がありましたね、ということをまた別のメンバーが提示してくれた。該当箇所は「3.5 包含/排他的範囲にはbeginとendを使う」で、

 ここに使う仮引数の名前は何がいいだろうか? プログラミングの命名規約では、包含/排他的範囲に begin と end を使うことが多い。

 でも、end は少しあいまいだ。例えば、「本の終盤(the end of the book)を読んでいる」の「end」は包含的だ。残念ながら英語には「ちょうど最後の値を超えたところ」を意味する簡潔な言葉がない。

 begin と end の対はイディオムになっている(少なくとも C++ の標準ライブラリではこれが使われている。また、配列がこのように「スライス」されることも多い)ので、これが最善の選択と言える。

begin / end というイディオムが示されていた。我々の扱うAPIでは開始日時としてstartAtという命名例もあったので、これを考慮すると startAt / endAt ということになるだろうか(DBの日時系のカラムで created_atupdated_at とするように、日時系のデータに At サフィックスを付けている)。

また別の例として、半開区間の話にも絡むが、java.timePeriodDurationAPIはこのようになっていた。

APIの仕様として左閉右開の半開区間であることを明にして、引数名にもInclusiveとExclusiveを入れていて冗長だが分かりやすい。今回のケースではそこまで冗長にはせず startAt / endAt に落ち着きそうになったのであった。

学びある議論で楽しかった。