2021 年のふりかえり

Keep

英語の勉強

去年に引き続き英語の勉強は続けているのでそのまま続けたい。結局続いているのはDMM英会話とDuolingo。

f:id:Yoshiori:20211231215817p:plain

あと夏くらいから同僚に「俺の英語勉強のために週1で15分1on1してくれ!」ってお願いしている。これが思ったよりも良くてなんとなく15分くらいだとふんわり話せる。まだ英語ネイティブが複数人いるミーティングとかだと何言ってるか理解するの難しいけど、なんとなくコミュニケーションは取れるようになっている気はする。結構Duolingoで何回も同じような問題を解いているのが効いている気がしていて、よく聞くセンテンスの単語一個だけ変えて話すみたいなのをしている。

ギター

11月末に買ったんだけど少しづつ練習している。適当な音鳴らしたりおとぴぴアコースティックギターのレッスンをエレキでやってる。「Eから弾きな。」に書いてあったので最初から立って練習してるけどコードチェンジ難しいとか8ビートのジャンジャカカジャカってやりながら歌うの難しいとか思いながら楽しんでる。

 

Problem

体重があまり落ちていない

去年は全然体重増えなかったのでコロナ禍でも別に大丈夫なんだなと思って油断してたんだけど、ふと気がついたら5kg太ってた。ビックリして運動始めたりしている。あと朝ごはんはプロテインスムージーにするようにした。適当な冷凍フルーツと冷凍ほうれん草とプロテインと牛乳。それなりに腹持ちするし良い。少しづつ減ってたんだけど12月でまた少し上向いてきてしまっている。

f:id:Yoshiori:20211231221728j:plain

あまり本を読めていない

もともと本を読むのは好きなんだけど読むスピードは遅い人なので冊数をたくさん読めるわけではない。出社をしていた時は電車の中とかお昼に喫茶店でとか本を読む時間が作れたんだけど今はずっと家にいるしなかなかそういった時間が作れていない。あと子供が生まれる前とかは旅行先とかで本読んでたんだけどそういったのも無くなったので読めてない感がある。

Try

Spartan Race 参加

コロナ禍になってから参加出来てないので久しぶりに参加したい。レースに出たいというより目標あるとそのために運動するようになるのでそっちが目的かな。でもやっぱり出ると楽しいのでまた出たいと思ってる。

国内旅行に行く

コロナ禍になってから奥さんの実家くらいしか行っていないのでそろそろ国内旅行くらいはしたいなと思ってる。みんな沖縄行きすぎてて沖縄大変そうって思いながら沖縄行きたいなぁと漠然と思ってる。

もっとブログ書く

ここ数年ブログとかあまり書いていなかったんだけど仕事とか英語とか子育てとかインプットが増えた割にアウトプット増えてないのなんかフン詰まりみたいな感覚になってるなって気がついたのでもっと書いていきたいと思っている。

Linuxデスクトップ組む

15年以上前にラップトップでサスペンドがしっかりしているunix系マシンがほしくてMacにしたんだけどコロナ禍で全然移動しなくなったしもうラップトップにこだわる必要ないじゃんって思って来ている。結構前から「おんなじCPUでもガンガン電圧かけれてガンガンファン回せるデスクトップの方が性能出せるよなぁ」とか漠然と考えてた。転職もして在宅勤務になって最初は気晴らしにコワーキングスペースとか行くからラップトップにしておこうとか思ってたんだけど子供の送り迎えとか考えるとなかなかコワーキングスペース行くタイミングもない。あと家の環境をちゃんと整えたら快適すぎて他で仕事したくなくなった。ということでミドルタワーくらいで組もうかなと考え中。

雑感

ニコニコカレンダー的なノリで今年にマークをつけるならスマイルマークだなぁと思う。仕事は純粋に楽しい。西海岸のスタートアップでシードラウンドからシリーズAになる経験を出来るってなかなか無いことだと思うしエキサイティングで楽しい。去年は「早くお金を稼ぐためのものを作らねば」って焦る気持ちがだいぶ大きかったけど今年はとりあえず動くものは出来たので少し気持ちが楽になった。あとは優秀な人がどんどん入ってきていて環境もすごく楽しい。エンジニアもマジかと思うような人ばかり入って来ていて凄い刺激になっている。また正式にマネージャ職的な感じになった。僕はマネージャもエンジニアもどっちも楽しめる人で「俺はコレしかやらない」的な思いは別に無い。あ、「俺はマネジメントしない」と言っている人を責めてるとか否定しているわけじゃなくて個人的にはなんかとりあえず色々してみる方が飽きなくて面白いと思っている。じゃないと人事部長とかまでやらないしね。この先どんな道に進むのか自分でもわかってない方が楽しめるのでこのまま楽しんでいこうと思ってます。

プライベートの方は子供の成長が早すぎてなんか何も追いついていない感じ。まぁでもこれもすごく楽しい。

 

kubernetes-client/java で context を切り替える

Javaからk8s扱ってたんだけどcontextの切り替えが良くわからなくて結局コード読んで調べた。

正解は config 作ってそこでsetContextするだった。 kubectl config だとcontext切り替え時はset-contextは罠でuse-context使うんだけどプログラムからはsetContextでしたね〜 難しい

var kubeConfigPath = System.getenv("HOME") + "/.kube/config";
var config = KubeConfig.loadKubeConfig(new FileReader(kubeConfigPath));

// Staging 用のContext にして API 作成
config.setContext("staging");
var stagingApi = new CoreV1Api(ClientBuilder.kubeconfig(config).build());

// production 用のContext にして API 作成
config.setContext("production");
var productionApi = new CoreV1Api(ClientBuilder.kubeconfig(config).build());

あんまりお金かけない仕事机 2021

これは KOBA789 日記 Advent Calendar 2021 - Adventar 24日目の記事です。

 

みんなが仕事机の記事とか公開するのを見て「かっこいいなー」とか思いつつなんとなく自己顕示欲の塊っぽくも見えてちょっと気恥ずかしさを感じでたんだけど僕も自己顕示欲が出てきたので書きます!!!

f:id:Yoshiori:20211224102251j:plain

基本的にはあまりお金をかけない。けどちょっと奮発する時もあるくらいな感じでやってます。

机本体

IKEA で最初は安く揃えた。LINNMON っていうやつの 120x60 のやつと伸び縮みする足。なんかLINNMONの俺のサイズのはもう売ってなかったので別の似たやつ貼っておく。

www.ikea.com

www.ikea.com

サイズ感もちょうど良くて気に入ってたんだけどスタンディングでも仕事したくなったので定番の FLEXISPOT で足だけ買って付け替えた。ちなみに購入するならキャスター同時購入して一緒につけちゃうのおすすめ。マジで足がめっちゃ重いので動かすのが楽になる。僕は後からキャスターつけて苦労した。

flexispot.jp

flexispot.jp

ディスプレイとアーム

ここはお金かけたその1。USB-C 一本でMacと繋げたかったのでPD対応のやつってことでDELLの4Kのやつ。今見たら購入時よりも高くなってるんだけど何があったのか。

www.dell.com

アームは定番のエルゴトロンOEMのやつ、ただしAmazon BasicのやつじゃなくてHPのやつの方が安かったのでそれで。結果台座のロゴまで真っ黒で目立たないのでお気に入り。

ノートパソコンはアームへの取り付け部分だけのやつをヨドバシで発見したのでそれでディスプレイアームに一緒につけてます。机の上の空間が広くなるので便利!

https://image.yodobashi.com/product/100/000/001/001/870/689/100000001001870689_10204_002.jpg 

カメラとデスクトップライト

カメラは定番のロジクールのやつ。コスパ最強だと思ってる。手元の明かりがなくて夜とか辛かったのでBenQのScreenBar買って使ってるんだけどその上に置いて使ってる。

 

 

マイク

コロナ禍でオンラインイベント登壇とか増えてきたので買った。コンデンサマイクダイナミックマイクか悩んだ。なんかオシャレでかっこいいYentiのコンデンサマイクにしようかと思ったんだけど周囲の音が入らないようが良いと思ったのでダイナミックマイクにした。

この記事でみやーんがオススメしてたオーテクのマイクにした。USB-Cで繋げられるしマイクにヘッドフォンジャックついてるし便利で気に入ってる。

ポップガードは最初はアームにつけるニョロニョロしてたやつ使ってたんだけど、アーム動かすと場所変わったししてめんどくさくなったのでマイク自体につけられるこれにした。

で、マイクアームがここはお金かけたその2になります。なんかマイクアームって縦に折り畳む形式のやつが一般的でまぁ、そんなもんかなぁと思って諦めて使ってたんだけどTakaの記事を見て一目惚れしたのでElgato の Low Profile Mic Armにした。Low Profile というだけあって圧迫感なくて最高!

www.elgato.com

充電まわり

ワイヤレス充電に慣れるとマジでケーブル刺したりがめんどくさくなるので出来るものは基本的にワイヤレス充電に寄せてる。とりあえず仕事机に置きたいのがすべて充電できるこれを使ってる。場所も取らないし便利。

その他のケーブルは結局Ankerのやつを使っている。前は別のを使ってたんだけど磁力が弱くて落ちてきちゃう。残骸だけテーブルに残ってるけどデザインは好きだった。

f:id:Yoshiori:20211224132323j:plain

キーボードとマウス

キーボードはHHKBを長いこと使ってたのでHHKB配列以外使う気があまりしなかったんだけど、完全にHHKB配列を再現しつつ分割しているChoco60にした。

keys.recompile.net

キーキャップは最初は無刻印だったんだけどパスワード入力とかの大事な時に打ち間違えるので変更した。なんか普通にコード書いてる時とかはなんとなくで打てるのにちょっと慎重にキーを意識して打とうとすると間違えるのなんでなんだろう?

NP PBT Crayon KEYCAPS SET – 遊舎工房

マウスは前も書いたけどロジクールトラックボール使ってる。

ロジクールのワイヤレストラックボールが快適 - 宇宙行きたい

紙とペン

f:id:Yoshiori:20211125141443j:plain

ちょっと雑にメモ取りたいとかなんだかんだ便利なので紙とペンは常に置いてある。分割したキーボードの真ん中に置いてる。ペンは draftcode が万年筆使っているのが羨ましくなって自分でも買おうと思ったんだけど値段見てドン引きしたので最初は安い万年筆使ってみようと思ってカクノにした。千円以下で買えるのに書き心地にすごく満足してる。インクはなんとなく黒だと寂しかったのでブルーを使ってる。

紙の方はなんとなくでリーガルパッド使ってる。なんとなく結局リーガルパッドに帰ってきちゃう感じ。なんとなくで20年以上使ってるのでリーガルパッドの色とか紙質とかが好きなのかもしれない。言葉で説明できないけどずっと使ってます。

デバッガ

ラバーダック・デバッグ - Wikipedia

おしまい

f:id:Yoshiori:20211224102855j:plain

 

GitHub Action で PR に何かして push する

先に結論

      - uses: actions/checkout@v2
        with:
          ref: ${{ github.event.pull_request.head.ref }}

やったこと

PR に対して特定の実行してそのPRにコミットしたい。 例えば code format とか

まぁ、最初 main とかでやるように雑にやってみた

      - name: Commit updated files
        run: |
          if ! git diff --exit-code --quiet
          then
            git add .
            git config --local user.email "nobody@example.com"
            git config --local user.name "File Update GitHub Workflow"
            git commit -m "Update Files"
            git push
          fi

で、まぁエラー

fatal: You are not currently on a branch.
To push the history leading to the current (detached HEAD)
state now, use

    git push origin HEAD:<name-of-remote-branch>

はいはい detached ね。みたいな気分で提案通り git push origin HEAD:<name-of-remote-branch> してみる

 git push origin HEAD:${GITHUB_REF#refs/heads/}

ダメだった

 ! [rejected]        HEAD -> xxxxxx (fetch first)
error: failed to push some refs to 'https://github.com/xxxxx/xxxxx'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

で、ちょっと調べてみる

Release v2.0.0 · actions/checkout · GitHub

  • Creates a local branch
    • No longer detached HEAD when checking out a branch
    • A local branch is created with the corresponding upstream branch set

checkout の action は detached しなくなってる。けど、まぁこれは main とかの話かなぁ

じゃぁなんで動かないんだと思ってさらに調べたら checkout 時に ref 指定できることを発見した。 ということで最初に書いた with ref 使えば動きました。

なんかネット調べてたら別途 checkout しろとかいろいろ情報錯綜してたのでちょっとハマった。

デプロイメントに求める速さ

これは KOBA789 日記 Advent Calendar 2021 - Adventar 4日目の記事です。

社内向けにデプロイについてエッセイを書くために先に日本語で書いたら想いが強すぎて思ったより長くなっちゃったので勿体ないし公開します。

あと、今回は k8s とか ArgoCD とかそういう特定ツールの名前は出さずに実現したい環境だけ書いてます。


デプロイメントに求める速さ

マーチンファウラーも「モノリスをマイクロサービスにする前にお前らやることちゃんとやってんのか?」の一つに Rapid application deployment をあげているようにデプロイの速さは大事です。速さは正義です。

ではデプロイの速度とはどこのことを言っているのでしょうか?

デプロイ速度

デプロイの速度を速くするとなった時にどの時間を参考にすればいいのでしょうか?例えばチャットボットにデプロイコマンドを話しかけた時から実際にサーバに反映されるまででしょうか? それとも kubectl rollout 実行してから反映されるまででしょうか? そういったシステムの話なのでしょうか? 僕はそうではないと思っています。わかりやすく説明するために極端な例を挙げてみます。例えば

チャットボットにデプロイコマンドを話しかけるのに上長含め10個のハンコを集めて承認しないとだめ

これは果たしてデプロイ速度が早いといえるでしょうか? アプリケーションのデプロイ速度を上げるというのは別にシステムに閉じたことだけを差しているわけではないのです。プロセスも含めてデプロイを速くすることを考えなくてはいけません。

ではどこから測るべきか。それはエンジニアが「この機能が完成した」と思ったタイミングだと思っています。これはちょっとプラットフォームエンジニア的な思想になってしまうかもしれませんが、アプリケーションエンジニアは常にユーザーにとって素晴らしい価値を開発してくれています。その価値をなるべく早く届けるべきなのです。完成した価値がデプロイされないままではユーザーに価値が届いていないので開発していないのと変わりません。

なので開発が完了したタイミングからユーザーが実際に経験できるまでの時間をデプロイ速度としたいと思います。

理想のデプロイ

開発が完了したタイミングからユーザーが実際に体験できるまでの時間というのをもう少し具体的にいうとプルリクのレビューが通ってmain相当のブランチにマージされたタイミングから、実際にサーバにデプロイ完了するところまでになります。なのでmainにマージした瞬間にユーザーがそれを経験できるようになっているのが理想です。

例えば週に一回デプロイとかある程度まとめてデプロイというのは現実解としては十分ありえる話です。iOS アプリなどは審査などがあるためどうしてもまとめてデプロイするものもあるでしょう。しかしあくまでも理想という話で言えばやはり開発が完了したものから順次ユーザーに届くのが理想だと思います。

また、個人的な今までの経験からも production で動いているコードと main ブランチのコードが離れていて良いことは何もないのでなるべく近づけるべきだと思っています。

理想実現のために

マージした瞬間にユーザーが経験できるようになるためには色々なやり方があると思います。今回は Webアプリケーションに限った話をしていこうと思います。

  1. main にマージされる
  2. container image をビルドする
  3. productionに Rollout する

今のところ僕が考えられる一番速いデプロイの実現はこの手段になります。とはいえこれだけだとただただ速いだけです。デプロイするためにはなるべく安全にデプロイしたいと思っています。そのためには次の三つが大事だと思っています。

  1. マージした後のコードでテストの実行
  2. staging で自分で気になったところの動作確認
  3. productionで障害が発生した時に即座に戻せる仕組み

これらを速度を犠牲にせずに実現するための方法を考えてみます

マージした後のコードでテストの実行

これは container image を作る前段階に CI でのテスト実行を持ってくれば良いのですがそれだと速度が犠牲になります。なので container image をビルドするのと並列でテストを走らせます。 image 作成とテスト実行がほぼ同じタイミングで終わっていることが理想です。テストに時間がかかる時は並列度を上げたり launchable を導入したり :) してテストの高速化を行います。*1 そしてテストが通った時だけデプロイするようにします。

staging で自分で気になったところの動作確認

これはいわゆる PR staging を作ることで解決します。pull request 毎にステージングを作成し、そこで動作確認できるようにします。また staging の動作確認をもっと確実にするためにproductionに近いデータを持つようにしたり、productionのシャドーリクエストを投げるようにしたりする仕組みが必要です。

productionで障害が発生した時に即座に戻せる仕組み

実はこれは本来二つの項目なのを一つにまとめてしまっています。監視とロールバックですね。そしてその二つを使って実現できるカナリアリリースが欲しいです。監視項目や閾値の話はまた深いのであまり触れませんが、少なくとも監視するのは500エラーなどのアプロケーションの実行エラーだけでなく、そのサービスとしての状態を監視できるようにします。そして異常な場合はロールアウトを止めロールバックし通知します。

理想への道

全ての環境が整ったら main にマージしたタイミングで上記デプロイプロセスが自動で動くのが理想です。しかし環境が整っていないのに自動でデプロイプロセスが動くは危険なので手動でchatbotや何かしらのデプロイ作業をトリガーにしてデプロイプロセスが動くようにします。いきなり理想に辿り着くのは大変なので中間地点を置いてまずはそこを目指すようにします。

まず大変なので PR staging とカナリアリリースは後回しにします。中間地点として目指すのは staging が一つありそれは常にmainの最新コードが動いている。何かしらのデプロイトリガーでstagingにある image がproductionにデプロイされるという流れです。

  1. main にマージされる
  2. CI が回る
  3. container image をビルドする
  4. staging に Rollout する
  5. 手動で production へのデプロイトリガーを実行する(Chatbot など)
  6. Staging に上がっている image をproductionへデプロイする

とういう手順になります。

ここでのキモはアプリケーション開発時に The Twelve-Factor App の提唱しているように設定を全て外出しすることです。 そうすることによってmainにマージされるたびに image を作り、それを staging とproduction両方にデプロイ出来るようになります。production用のimageを再ビルドするのは避けましょう。それは理想の環境から少し離れてしまいます。 main のコードがimage化されproductionにデプロイされるという状態はデプロイ後の結果は理想の状態と同じになります。これは理想への中間地点としてデプロイのプロセスが違うだけで結果は理想と同じになるというところを目指しています。

この状態でも安心のために必要としていた下記は実現できています。

  1. マージした後のコードでテストの実行
  2. staging で自分で気になったところの動作確認
  3. productionで障害が発生した時に即座に戻せる仕組み

また、production へのデプロイもすでにテストが通って作成されている image があるのでそれを rollout するだけなのでスグに行えます。production用のimageを再ビルドするのを避けるのはここの速度のためでもあります。

まずはこの状態を作り、カナリアリリースに向け監視を充実させ、 pr staging を作り staging のデータなどを充実させて行って理想へ辿り着きたいと思います。

デプロイトリガーをどうするか

上記説明でデプロイトリガーをどうするかをあまり詳しく書きませんでした。これは僕の中でも決定打というほどのものがなく、なんとなくこうした方が良いのではないかなというのがあるので chat bot と書いたのですが、なぜ Chat bot が今のところ有効だと思っているかを軽く書こうと思います。 ちなみに理想の世界が出来ても手動でデプロイやロールバックを行うことはあると思うのでここにコストをかけるのは良い判断だと思います。 デプロイトリガーを考えるときに僕が大事だと思っているのは下記になります。

  1. デプロイするということをみんなに知らせられる事
  2. 誰がいつデプロイしたか後で探せること
  3. 手順が他の人にもわかりやすい事

例えば何も通知などがない場合、チャットなどで「今からデプロイします」と一言言ってから行いましょうという運用に最初はなると思います。チャットで一言言ってから行うくらいなら一言言ったらデプロイされてば手順をひとつ省けます。また、すごい雑ですがチャットの検索でもある程度誰がデプロイしたかスグに見つけることができます(もっと詳細に必要であれば chatbot 側で保存しておくべきです) そして 3 が実は大事だと思っています。例えばシェルでコマンド実行したらデプロイされ、その時にチャットにも「xxx さんがデプロイしました」と通知されるという仕組みにしたとしましょう。これも良いのですが、新しく入社したエンジニアなどはその通知を見ただけではどうやってその通知を出しているのかわかりません。僕が今のとこと一番良いと思っているのはチャットボットを使ったデプロイです。slack の slash コマンドで実現する場合はコマンドが見えるように “response_type”: “in_channel” にするのがみんなにも見えて良いと感じます。 これなら新しく入社したエンジニアもスグにデプロイの仕方を理解できますしね。

まとめ

まずは一番大事な速度の定義を行いました。今回僕は開発者が完成させた価値をユーザーに届けるまでの時間をデプロイ速度としてみるようにしました。そしてそれを最速にするための理想の環境を考えました。そして理想は理想なんだけどちょっと遠いのでそこに至るまでの中間地点を定義しました。まずは中間地点を目指したいと思っています。

理想は「完成した価値をすぐにユーザーに届けること」です。それを違う言葉で表すと 「main にマージされた機能がスグにデプロイされること」になります。

本題ですが最近のおすすめの漫画は宙に参るです。

www.amazon.co.jp

*1:テストの高速化については別の話題なのでここでは触れません。

Slack チャットボット的なものを作るときは / command よりも @chatbot で話しかけるのを俺は好む

追記

教えていただきました! ありがとうございます!

Enabling interactivity with Slash Commands | Slack

When the response_type is in_channel, both the response message and the initial Slash Command typed by the user will be shared in the channel:


チャットボットというか最近だと Slack アプリを作ると大体 /chatbot do something 的な実装されることが多いし実際推奨されている気がする。ちなみに別に裏はとってないけどw で、@chatbot do something 的な話しかけるやり方はモダンじゃない古いやり方な気もする。

だけど俺は @chatbot do something 的なやつの方が好きだ。というか /command 的なのはチャットボットの良さをひとつ消している気がする。俺の勝手定義でチャットボットの二大利点は

  1. スグにアクセスできる場所でコマンド実行できる
  2. 何を実行したか他の人にも見えている

だと思っている。で、2の利点が俺は結構大事だと思ってて、これは通知とは違って他の人に誰が何をやったかが見えるし、新しく入った人も自然に覚える。

例えばデプロイを例に出してみよう

/command の場合

  1. デプロイする人は Slack に /chatbot deploy と打ち込む。これは打ち込んだ本人にしか見えない。
  2. chatbot が応答で「デプロイします」とかいう。これは全員に見える

@chatbot で話しかける場合

  1. デプロイする人は Slack に @chatbot deploy と打ち込む。これは全員に見える
  2. chatbot が応答で「デプロイします」とかいう。これは全員に見える

@ で話しかけた方は誰が話しかけたのかも、なんて話しかけたのかも全員に見える。新しく入社したエンジニアもそれをみたら「あ、俺もああやって話しかければいいんだな」とスグに理解できるだろう。でも /command の時はボットの反応しか見えない。ボットの反応テキストを工夫すれば誰が行ったかとかは見える。けれどもどういったコマンドを打ったかは見えない。なんならコマンドラインか何かでデプロイ実行した時の通知が飛んできているのとの判断も難しい。それを見ただけでは /command で実行したのか、他の手段で実行して通知が来ただけなのか判断するのは難しいだろう。

もちろん /command にも保管とか色々なUIを入力者に出せたり利点があるのは理解してる。でも俺はそういったのが必要でなければなるべく @ で話しかける方で実装する方が好きかなー

本題ですが最近のおすすめの漫画はカナカナです。

graphql-java で雑に pagination 実装できるようにした

yoshiori.hatenablog.com

の続きです。 GraphQL の pagination っていうか Connections の仕様はGraphQL Cursor Connections Specification で、まぁ、実装した。基本的にはDBへクエリ投げやすくするように first/afterlast/beforeoffset/limit に変換して渡すようにしてます。

コメントにも書いたけど変換はこんな感じになる。一応エッジケース全部テストしたはず。

   * for Example
   * [0,1,2,3,4]
   *
   * ## first/after
   * first:2, after:1 -> [2,3] -> offset:2, limit:2,
   * first:3, after:0 -> [1,2,3] -> offset:1, limit:3
   * first:null, after:2 -> [3,4] -> offset:3, limit:DEFAULT_SIZE(20)
   *
   * ## last/before
   * last:2, before:3 -> [1,2] -> offset: 1, limit:2
   * last: 2, before: null -> [3,4] -> offset: 3, limit: 2
   * last: null, before: 3 -> [0,1,2] -> offset: 0, limit: 3
   * last 10, before: 2 -> [0,1] -> offset: 0, limit: 2

DataFetcher Interface を実装してるので多分各種WebFrameworkでも使えるはず(俺は DGS 使ってるのでそれだけ動作確認済み)

使い方はトータル件数と offset/limit 引数に処理する関数を渡すだけ

new OffsetLimitConnection<>(fooService.getTotal(), fooService::getFoos).get(env));

標準でこのくらい用意してあるんじゃないの?とか誰か参考実装とか公開してるんじゃないの??とか思ったけど見つけられなかったので同じような人がいたら助かるように貼っておきます。

import static graphql.Assert.assertNotNull;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Objects;
import com.google.common.io.BaseEncoding;
import graphql.relay.Connection;
import graphql.relay.ConnectionCursor;
import graphql.relay.DefaultConnection;
import graphql.relay.DefaultEdge;
import graphql.relay.DefaultPageInfo;
import graphql.relay.Edge;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.BiFunction;

public class OffsetLimitConnection<T> implements DataFetcher<Connection<T>> {

  static final int DEFAULT_SIZE = 20;
  final int total;
  final BiFunction<Integer, Integer, List<T>> func;

  public OffsetLimitConnection(Integer total, BiFunction<Integer, Integer, List<T>> func) {
    this.total = assertNotNull(total, () -> "total cannot be null");
    this.func = assertNotNull(func, () -> "function cannot be null");
  }

  /**
   * Convert to offset/limit from last/before or last/before.
   *
   * <pre>
   * for Example
   * [0,1,2,3,4]
   *
   * ## first/after
   * first:2, after:1 -> [2,3] -> offset:2, limit:2,
   * first:3, after:0 -> [1,2,3] -> offset:1, limit:3
   * first:null, after:2 -> [3,4] -> offset:3, limit:DEFAULT_SIZE(20)
   *
   * ## last/before
   * last:2, before:3 -> [1,2] -> offset: 1, limit:2
   * last: 2, before: null -> [3,4] -> offset: 3, limit: 2
   * last: null, before: 3 -> [0,1,2] -> offset: 0, limit: 3
   * last 10, before: 2 -> [0,1] -> offset: 0, limit: 2
   * </pre>
   *
   * @param total
   * @param first
   * @param after
   * @param last
   * @param before
   * @return offsetAndLimit
   */
  static OffsetAndLimit getOffsetAndLimit(
      int total, Integer first, Integer after, Integer last, Integer before) {
    if ((first == null && after == null && last == null && before == null)
        || ((first != null || after != null) && (last != null || before != null))) {
      throw new IllegalArgumentException(
          "Only the combination of first/after or last/before can be given.");
    }
    int offset;
    int limit;

    // first/after
    if (first != null || after != null) {
      offset = after == null ? 0 : after + 1;
      limit = first == null ? DEFAULT_SIZE : first;

      // last/before
    } else {
      if (before == null) {
        before = total;
      }

      if (last == null) {
        last = DEFAULT_SIZE;
      }

      offset = Math.max(0, before - last);
      limit = Math.min(before, last);
    }
    return new OffsetAndLimit(offset, limit);
  }

  @Override
  public Connection<T> get(DataFetchingEnvironment environment) {
    Integer first = environment.getArgument("first");
    Integer after = NumberCursor.convertToOffset(environment.getArgument("after"));
    Integer last = environment.getArgument("last");
    Integer before = NumberCursor.convertToOffset(environment.getArgument("before"));

    var offsetAndLimit = getOffsetAndLimit(total, first, after, last, before);
    var offsetCursor = offsetAndLimit.offset;
    var limitCursor = offsetAndLimit.limit;

    List<T> list = func.apply(offsetCursor.toInt(), limitCursor.toInt());
    if (list.isEmpty()) {
      return emptyConnection();
    }

    List<Edge<T>> edges = new ArrayList<>();
    int index = offsetCursor.toInt();
    for (T o : list) {
      edges.add(new DefaultEdge<>(o, new NumberCursor(index++)));
    }

    var firstEdge = edges.get(0);
    var lastEdge = edges.get(edges.size() - 1);
    var pageInfo =
        new DefaultPageInfo(
            firstEdge.getCursor(),
            lastEdge.getCursor(),
            0 < ((NumberCursor) firstEdge.getCursor()).toInt(),
            ((NumberCursor) lastEdge.getCursor()).toInt() < total - 1);
    return new DefaultConnection<>(edges, pageInfo);
  }

  Connection<T> emptyConnection() {
    return new DefaultConnection<>(
        Collections.emptyList(), new DefaultPageInfo(null, null, false, false));
  }

  static class OffsetAndLimit {
    final NumberCursor offset;
    final NumberCursor limit;

    OffsetAndLimit(int offset, int limit) {
      this.offset = new NumberCursor(offset);
      this.limit = new NumberCursor(limit);
    }
  }

  static class NumberCursor implements ConnectionCursor {
    static final String PREFIX = "DUMMY";
    final int num;

    NumberCursor(int num) {
      this.num = num;
    }

    static Integer convertToOffset(String cursorString) {
      if (cursorString == null) {
        return null;
      }
      try {
        var string = new String(BaseEncoding.base64().decode(cursorString), UTF_8);
        return Integer.parseInt(string.substring(PREFIX.length()));
      } catch (IllegalArgumentException ignored) {
      }
      return null;
    }

    /** @return an opaque string that represents this cursor. */
    @Override
    public String getValue() {
      return convertToCursorString(num);
    }

    private String convertToCursorString(int num) {
      return BaseEncoding.base64().encode((PREFIX + num).getBytes(UTF_8));
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      NumberCursor that = (NumberCursor) o;
      return num == that.num;
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(num);
    }

    public int toInt() {
      return num;
    }
    /**
     * In fact, the getValue method is not called, because toString has been called.
     *
     * @see <a href="https://github.com/graphql-java/graphql-java/issues/1944">GraphQL-java uses
     *     toString instead of ConnectionCursor getValue</a>
     * @return getValue() string
     */
    @Override
    public String toString() {
      return getValue();
    }
  }
}