なまえは まだ ない

思いついたことをアウトプットします

サックスは金管楽器じゃないぞって話

たまには楽器ネタ。先日会社の同僚から「そういえば金管楽器木管楽器の違いが未だによくわかってないから解説して」と言われたので、ここに書き記しておきます。

TL;DR

管楽器って?

金管楽器木管楽器の違いを述べる前に、管楽器ってどんな楽器を指すのでしょう? 楽器の形状が管(≒筒状)の楽器?実はこれは誤りで、実際は気鳴楽器(空気の塊を振動させて発音する楽器)の一種という定義が正しいそうです(Wikipedia知識)。

なのでどちらかというと吹奏楽器(吹いて発音する楽器)と呼ぶべきであり、その解釈に従うと法螺貝やハーモニカ、オルガンも管(吹奏)楽器です。全然管じゃないのにね、不思議。

金管楽器木管楽器

さて、管楽器の認識が揺らいでしまったところで、金管楽器木管楽器の違いはなんでしょうか。

結論から言うと発音原理の違いです。管体の素材はまったく関係ありません!!

金管楽器は「唇を振動させて発音する楽器」と定義されています。金管楽器は自身の唇をマウスピースに密着させ、唇を震わせる(バズィングと言います)ことで発音します。


www.youtube.com

で、木管楽器はどう定義されているのかというと、金管楽器以外の管楽器と定義されています。雑すぎるだろ、ふざけるな!!って思いますよね。僕もそう思います。 一応書いておくと、サックスはリードと呼ばれる振動体を使って発音するので木管楽器に分類されます。


www.youtube.com

なぜこんな定義になった?

で、なんでこんな紛らわしい名前になってしまっているかというと、おそらく歴史的経緯とか翻訳の際のどうこうとか、そういうアレです。

金管楽器は英語ではBrass Instrumentsと表現されます。多くの楽器が真鍮製であり、英語での表現も元々は管体の素材が由来になったんでしょう。

では木管楽器はどうかというと…… Wind Instrumentsと表現されます。Windですよ。風、息、空気。木なんてどこにもないじゃないか!!! 多分なんですが、古典的な木管楽器たち(フルート、クラリネットオーボエファゴット)のほとんどが木製だったことに由来しています。 フルートも今でこそ金属製が主流ですが、昔はほとんどが木製だったと聞きます。

なまじ金管楽器の英語名が素材由来なものだから、和訳するとき対になるように「木管」と名付けてしまったんでしょう。気持ちはわかる。。

まとめ

というわけでサックスは管体が金属でできていますが金管楽器ではありません。木管楽器です。

サーバーサイドのオンボーディング課題と俺たちの果てなき戦いは続く その③

この記事はフラー株式会社 Advent Calendar 2023の13日目の記事です。12日目は私の「サーバーサイドのオンボーディング課題と俺たちの果てなき戦いは続く その②」でした。

furusax0621.hatenablog.com

Goaの学習コストが高いよという話

さて、前回は課題の構成をアップデートできたものの、Goaの学習コストの高さが目についてきたなぁ、というお話でした。 僕が入社した当時はオンボーディングなんてものはなく、Goaもまさに「現場で覚えろ」スタイルだったのですが、自分の若い頃(?)を引き合いに出して苦労を押し付けたらそれはもう立派な老害です。令和の世に適切な指導スタイルとは言えませんね。

でも毎度Goaを教えるのもコストかかるしなぁーどうしようかなぁーと悩んでいた頃に、あるイベントがやってきました。

新卒研修というチャンス

少し話が変わりますが、今年フラーでは多くの新卒社員を迎え入れ、それに伴って約3ヶ月間のエンジニア集合研修を実施しました。

大枠としては、各職種で言語や基礎的なスキルのトレーニングを行った後、新卒エンジニアで協力してひとつのスマートフォンアプリを作り上げてみようというものです。 サーバーサイドはアプリで利用するWeb APIの実装はもちろん、サーバーを稼働させる環境をAWSで1から構築したりもしました。

業務で利用している以上、この新卒研修でもGoaにふれる必要があり、とうとう重い腰を上げてごっついGoa入門資料を作りました。 とても頑張って作った資料なので、機会があればどこかで公開したいと思います。めっちゃ頑張ったんです。褒めてほしい

で、せっかく作った入門資料を新卒研修だけで終わらせるのは非常にもったいないので、これをオンボーディング課題の参考資料として取り込みました。自分で作ってアレですが、Goaの学習コストが本当にグッと下がったと思います。

未だ見えてくる課題

さてGoaに関しては一定の終息を見せたと思いますが、まだまだ課題は見えてきます。最近の課題は次のようなものです。

SQLまわりの実装・記述のお作法を伝えるのが大変

フラーでは主にメンテナンス性や可読性の観点から、SQLを記述するときは次のようなお作法を守っています。

他にも、database/sqlパッケージで提供されているメソッドは、基本的にcontext.Contextに対応した実装を採用するようにしています( QueryContex, ExecContext 等)。

これらはフラーとしての開発文化・方針であり、初学者が一発でこれらすべてをクリアした実装をするとは思えません(実際、未だ現れてない)。 なので毎回コードレビューの中で伝えていたのですが、これも毎度伝えるのがなかなかしんどくなってきました。

また、課題において「わざと間違えさせて正しい方向へと導く」という指導方法が個人的にものすごく嫌いです。 私は前職に新卒で入社したのですが、その会社の新卒研修で一番嫌いだった講師がまさにそういう指導をする人でした。 わざわざ失敗に導いておいて、その後ドヤ顔で「本当はね〜」と解説されるときのストレスたるや。 あぁ、思い出すだけで腹が立ってきた……。

閑話休題

課題を解く人がノーヒントでたどり着けるはずのないものを要求するのも良くないと思い、この辺りは実装上の制限事項として明記することにしました。

意外とみんなドキュメントを読んでくれないのでは説

課題の仕様は認識齟齬が発生しないよう、結構細かく要件だったり制限事項を記載しているつもりです。ですが、そもそもこのドキュメントをみんながあまり注意深く読んでいないのでは?と思うときがあります。 というのも、前回の記事で「インメモリデータストアの実装はハードルが高いので、 登録・更新したデータを保持する必要はありません と明記した」と書きましたが、それでもやっぱり初手で実装しちゃうんです

伝え方が悪いのかな……表現や構成はどんどんアップデートしていきたいです。とりあえず件の記述は「保持しないでください」に書き換えようと思います。

まとめ

全3回にわたってサーバーサイドのオンボーディング課題の進化をお伝えしてきました。

まだまだ改善すべき点はあると思いますし、実業務と比べるとカバーしきれていないスキル・知識がたくさん残っているのも事実です。 オンボーディング期間は限られているのですべてを学ぶ課題を作るのはさすがに無理だと思いますが、より充実したカリキュラムとなるよう今後もアップデートを続けようと思います。


明日は、、決まってない!誰か!

*1:データベースがMySQLであることを前提としています

サーバーサイドのオンボーディング課題と俺たちの果てなき戦いは続く その②

この記事はフラー株式会社 Advent Calendar 2023の12日目の記事です。11日目は私の「サーバーサイドのオンボーディング課題と俺たちの果てなき戦いは続く」でした。

furusax0621.hatenablog.com

前回のおさらい

フラーでは新入社員向けにフラーの利用技術に慣れてもらうためのオンボーディング課題というものを用意しています。 この課題の運用を始めて1年ほど経ち、以下のような課題が出てきた、というのが前回のお話でした。

  • インメモリデータストアの実装をみんな自主的にやってしまい、そこにつまづいて時間が溶ける
  • net/http パッケージに向き合う前にEchoやGinを利用されてしまう
  • MySQLで発生するデータ競合をUPSERTで強行突破されてしまう

課題の改善

これまでの課題・反省点を受け、オンボーディング課題を次のようにアップデートしました。

題材の統一化

前回課題の後半で利用していたNoPasteを廃止し、すべてユーザー情報をやりとりするAPIに題材を統一しました。 その際、ユーザー情報にアカウントIDという新しいメタ情報を付与し、アカウントIDは全ユーザーでユニークであるという制約を設けました。

{
  "id": 1,
  "account_id": "hatenakun",
  "first_name": "Taro",
  "last_name": "Hatena",
  "age": 18
}

NoPasteのときにUPSERTを許容してしまった原因のひとつとして、UPSERTで更新しては困るようなメタデータが存在しなかったというものがありました。今回は異なる氏名や年齢のユーザーが同じアカウントIDを取得しようとするシーンが考えられるため、UPSERTで強行突破することはできません。やったぜ。

また、元々ユーザー情報を扱うAPIからNoPasteに題材が切り替わるところで、課題のテーマに連続性がなくなってしまっているというのも個人的に気になっているポイントでした。 題材を統一することで、ひとつのテーマに対して「HTTPサーバーの基礎」「MySQLとの連携」「Goaによる再実装」というステップを踏めるようになりました。我ながら良いアップデートだったと思っています。

インメモリデータストアに関する明記の追加

ひとつめの課題の制限事項に「リクエストされたデータの登録・更新を保存する必要はありません」という項目を追加しました。課題を解くメンバーが自主的に実装してしまっていたことを受けて、それ今やらなくていいよという出題者の意思を課題に明記した形です。

チャレンジ課題として実装するというのはそのままにしてあります。これにより、本来の課題とチャレンジ課題の切り分けがしっかりできたと思っています。

サードパーティライブラリ利用の禁止

これはEchoやGinのようなWebアプリケーションフレームワークを狙い撃ちして追加した制限事項です。 あと、フラーでは基本的にORMライブラリの類を採用していないので、それらを回避する意図も含まれています。

MySQLのドライバーやGoaも当然サードパーティライブラリになっちゃうんですが、各課題で「許可されたもの以外は使わないでください」という記述をしています。

またも見えてくる課題

この課題で回してみて、また課題が出てきました。出てきた、というか以前からあった課題がより浮き彫りになってきました。

それはGoaの学習コストの高さです。

Goaはとっても多機能で便利なフレームワークなのですが、多機能な分クセが強く、導入コストの高いフレームワークです。 課題の内容は「Goaで実装する」と言ってますが、その具体的な方法やステップは一切提供していませんでした。 「公式サイトにチュートリアルあるから、それ見つつあとはGoDoc読んで学んでくれ!じゃ!」と言って崖から突き落とすストロングスタイルです。 これで解いてたメンバー、なかなかにつらかったんじゃないかなと。ごめんよ。。

また、Goaは結構マイナーなフレームワークであり、インターネットで検索してもあまり情報が出てこないというのも学習コストの高さに拍車をかけています。 以前私のブログでも簡単な紹介記事を書きましたが、実装に困ったメンバーがGoogleで検索してこの記事を引っ掛けてくるぐらいには情報が少ないです。

furusax0621.hatenablog.com

まとめ

というわけで、最初に作成した課題から大幅なアップデートを実施したものの、未だ課題としてGoaの学習コストの高さが障壁となっています。

……と、いうのが今年の春先ぐらいまでの情報でした。実はここから更にアップデートをかけています。そのお話についてはまた次回。

サーバーサイドのオンボーディング課題と俺たちの果てなき戦いは続く

この記事はフラー株式会社 Advent Calendar 2023の11日目の記事です。10日目は @kanterburyさんで「AIに相談しながら正規表現を考えると捗るという話」でした。

オンボーディング課題とは

フラーのサーバーサイドでは、多くのプロダクトのサーバーサイド開発にGo言語、データベースにMySQL、そしてWebアプリケーションフレームワークにGoaを使っています。 入社してくるメンバーのレベル感や経験が様々であること、GoaがGoのフレームワークとしては少しマイナーであることなどを踏まえ、フラーでは主に中途社員向けにオンボーディング課題というものを用意しています。この辺りは過去の記事でも触れているので、ぜひそちらもご覧ください。

furusax0621.hatenablog.com

さてこの課題ですが、はじめに作成してから今日に至るまでいくつかのアップデートを重ねています。 作って終わりでなくアップデートもしてることを誰か褒めてほしい

ふと思い立ったので、これまでのアップデートの歴史を振り返ってみます。

課題の大まかな構成

課題はざっくりと三部構成になっていて、それぞれ次のようなスキル習得を目的としています

  • 第一部ではGoでHTTP(Web API)サーバーを構成する方法、 net/http パッケージや encoding/json パッケージの基本的な使い方を学ぶ
  • 第二部ではMySQLを使ったデータ管理、GoでMySQLと連携する方法を学ぶ
  • 第三部ではGoaを利用したWeb APIの記述方法を学ぶ

それぞれの課題ではWeb APIの仕様といくつかの制限事項を設けており、実装したらトレーナーにコードレビューをしてもらうという流れです。

また、進捗スピードやスキルレベルに合わせて追加で取り組めるチャレンジ課題を各課題に設けています。アプリケーションへの要件追加だったりテストの記述などが含まれます。

課題の誕生〜黎明期

以前の記事でも軽く触れていますが、この課題を作った元々のモチベーションは「Go言語未経験のメンバーが、スムーズに業務に入るために必要な最低限のスキルを培う」という点でした。私が入社して1年後(ちょうど世間で新型コロナウィルスが騒がれ始めた頃)にサーバーサイドエンジニアが立て続けに増えたこともあり、急拵えで課題を作りました。

当時は次のような四部構成でした。

課題①:簡単なWeb APIサーバーを作る

次のようなとてもシンプルなユーザー情報を扱うWeb APIサーバーを作れ、というものでした。

{
  "id": 1,
  "first_name": "Taro",
  "last_name": "Hatena",
  "age": 18
}

/users または /users/:id というエンドポイントに対して、いわゆるCRUDを実現するWeb APIを実装せよ、というものです。 なお、データストアは用意せず、固定のレスポンスを返すモックサーバーとして実装させます。メインはHTTPサーバーやJSONの扱い、隠し味にREST APIの要素も取り入れよう、という具合です。

延長課題にはインメモリ(map や slice)によるデータ永続化を設定しました。 Goroutine Safeな実装が必須になるため、初学者には適度なハードルになったと思います。

課題②:NoPasteを作る

NoPasteとはテキストやスニペットを共有するサービスのことで、共有したいテキストを入力すると専用のURLが発行され、第三者がそのURLを介してテキストを参照できるというものです。GopherにはGo Playgroundの共有機能といえば伝わるアレですね。

仕様としては以下2つのエンドポイントを作ることになります

  • 任意のテキストを入力すると専用URLを返却するエンドポイント
  • URLに対応したテキストを返却するエンドポイント

ここではデータストアをローカルストレージとして、特定ディレクトリにファイルとして保存しましょうというものでした。 共有するテキストをそれぞれひとつのテキストファイルとして表現し、検索のキーとなるテキストのハッシュ値をファイル名にして保存することを想定しています。

このあとMySQLと連携させるための布石として作った課題でしたが、解く側の反応がイマイチで後に使われなくなっていきました。

課題③:NoPasteのバックエンドをMySQLにする

課題②で作ったNoPasteのデータ保存先をMySQLにする、というものです。

NoPasteは同じ内容のテキストを同時にリクエストすると競合が発生してしまうシステムです。 MySQLで競合の検出と、それを検出したときにどう対処するかという部分を学んでもらうことを想定していました。

課題④:NoPasteをGoaで作る

③から地続きに、今度はGoaを使ってエンドポイントを実装してみよう、というものです。

課題に穴が見え始めてきた

上記のようなメニューの課題を1年ほど回してみましたが、いろんなメンバーが課題に挑戦する中で、いくつか困ったポイントも出てきました。

インメモリのデータ保存実装のハードルが高い

課題①でチャレンジ課題とした「インメモリでのデータ保存」ですが、意外と多くのメンバーが初手で実装しようとしていました。

これは課題の仕様の書き方が悪かったのだと思いますが、「リクエストされたデータを保存しなくて良い」と明記してなかったんですよね。 僕だったら「保存しろって明記されてないんだから保存しなくていいでしょ」って考えちゃうんですが、他のメンバーは僕よりずっと素直な人間だったようです。

先にも書いたとおりGoroutine Safeな実装は初学者にはそれなりにハードルが高く、この部分の実装を詰めるのに結構な時間を要しました。 本来この課題で習得してほしい部分はここではなかったので、うーんそこに時間かけてほしくないんだよな……というところです。

Webアプリケーションフレームワークを使われる

EchoやGinを使って課題を解くメンバーが現れてきました。

元々net/httpパッケージの使い方を学んでもらい、そこからGoaにつなげることでフレームワークの役割・良し悪しを実感してもらうストーリーを想定していました。 なのでいきなりフレームワークを利用されるのは想定外というか、、都合が悪かった。

データ競合を力技で突破される

NoPasteはデータ登録時にテキスト(およびハッシュ)が衝突する可能性があります。 これを適切に対処してもらうのが課題の醍醐味だったのですが、、 INSERT ~ DUPLICATE KEY UPDATE 構文(いわゆるUPSERT)で乗り切るメンバーが現れました。

まぁたしかに、NoPasteのデータ構造的にハッシュと本文だけ保持していれば良く、ハッシュ衝突時に本文の値が一致していることは保証できているため、UPSERTをしても何ら問題はありません。 これは正直、学んでほしいテーマに対して教材が良くなかったと思っています。

ここまでのまとめ

長くなったので一旦区切ろうと思います。

そんなわけでサーバーサイドの新入社員に宛てた課題というものを作って運用していましたが、月日の経過と共に見直したいポイントも色々出てきました。次回はその改善点についてお話ししようと思います。


明日も再び私で、「サーバーサイドのオンボーディング課題と俺達の果てなき戦いは続く その②」です。お楽しみに。

ISUCON13に出場しました

ISUCONに出たよ

11/25(土) に開催されたISUCON13に参加してきました。

isucon.net

チーム

会社の同僚3人で参加しました。内1名は期待の新卒社員です。

チーム名は 69E0773F-B436-4CC4-B998-B6B143A30EFB です。 つけたあとに気づいたのですが、チーム名を誰も読めない・覚えられないという致命的な欠点を抱えてました。

最終スコアは 14,108点、全体順位でいうと120位ぐらいです。

リポジトリ

すでに公開済みです。気になる方は見てみてください

github.com github.com

やったこと

3人でなんとなく作業分担をしました。

僕は最初に計測系の設定を仕込み、あとは他のメンバーと修正箇所を分けて取り組む、というスタイルでした。 以下、特徴的だったトピックをいくつか振り返ります。

条件付きリクエストの実装

とりあえずアイコン取得のAPIがめちゃくちゃ叩かれていて、そいつがレスポンスタイムの大部分を占めていることに気づきました。 アプリケーション仕様に条件付きリクエストに関する記述があったため、よーしやったるかということで修正を担当しました。

さっとチーム内で話し合い、以下の方針としました

  • 毎回画像のSHA256ハッシュを計算するのは無駄なので、別途 icon_hashes テーブルを用意する
  • 画像の登録時にハッシュ値を計算してしまい、 icon_hashes テーブルに格納しておく
  • アイコン取得APIで条件付きリクエストがきたら icon_hashes テーブルから情報取得し、レコードがあれば 304 を返す

ETag の実装は業務でも経験があったので、値がダブルクォート " で囲われているという罠(?)に対してもそこまで混乱せず対応できました。 最終的にアイコン取得APIは約 9,000 リクエスト中 7,000 リクエスト以上を 304 で返していたようなので、期待通りの効果を出せたと思います。

あとハッシュテーブルはユーザー情報を取得するような場面でも使い回せたため、ちょっとは効果あったかな?どうなんでしょ

NGワード登録時のSQLを軽くする

差分としてはこちら

スパム報告体験を改善 by furusax0621 · Pull Request #9 · furusax0621/isucon13-app · GitHub

NGワード登録のエンドポイントが上位に上がってきたので頑張って読み込んだところ、過去投稿を削除する部分でなんかすごい無駄なことやってんな?ということに気づきました。

  1. 該当ライブ配信NGワードをすべて取得
  2. NGワード単位のループ
    1. すべてのライブ配信のコメントを取得
    2. コメント単位のループ
      1. なんか難しいサブクエリで該当コメントがNGワードか判定し、削除

ざっくり書くとこんな感じ。

まずライブ配信のIDはすでにわかっていたので、すべての配信のコメントを取得するのはどう考えても無駄でした。なのでまずはコメント一覧取得をライブ配信IDで絞り、次にコメントもループの度に取る必要がないので外に出し、よーしこのサブクエリを理解するか〜としばらくにらめっこした結果

……これ、単なる文字列比較では?

と気付くことができました。 しかもDELETE文のWHERE句条件として書いてましたが、必要なNGワードもコメントもすでにそれ以前のSQLで取得済みです。 であればわざわざSQLで書かなくても、アプリ側で判定して削除対象IDの一覧を抽出し、それを DELETE ~ WHERE id IN ~ というSQLで一括削除すればOKです。比較も内容的にGoの strings.Contains() で置き換えることができました。

この実装によって該当APIの平均レスポンスタイムが1/10ぐらいになり、スコアも目に見える形で上がりました。やったぜ

できなかったこと

複数台による負荷分散

top の推移からして終始MySQLサーバーがボトルネックになっているのはわかっていたので、ここを早く分離すれば良かったなと思います。 特に今回はPowerDNSによるMySQLへの負荷がそれなりに大きかったっぽいので、アプリ用とPowerDNS用とでMySQLサーバーを分けられればもっとスコアが伸びた気がします。

PowerDNSは完全にわからんと思って目を逸らしていたので、全然検討できませんでした。ここは非常にもったいない

MySQLのさらなるチューニング

競技の途中でBinログを無効にするなどのいわゆる「鉄板」チューニングはしていました。が、メモリに全然余裕があったのでMax Connectionsを引き上げるなどのチューニングもできたなぁーと反省しています。

あと講評には「インデックスをうまく貼っていけばそれだけでスコア10,000点ほどになる」と書いてあってぶっ飛びました。まじかよ。。全然インデックス対策足りなかったんだな

感想

結果は及びませんでしたが、過去に参加したISUCONの中でも一番戦ってる実感を得られたなぁーと思います。本当に結果は及びませんでしたが。

一緒に参加してくれた新卒メンバーは、業務とはまた違った脳の使い方ができて楽しかったんではないでしょうか。今後の成長に期待。

というわけで来年もまた出るぞ!運営の皆様お疲れ様でした

カバレッジ計測ツール octocov に関するTips

この記事はフラー株式会社 Advent Calendar 2023の1日目の記事です。


カバレッジ計測ツール octocov について

ここ2〜3ヶ月ほど前から、弊社サーバーサイドで k1LoW/octocov の導入が少しブームになっています。 octocov はGoプロジェクトで出力したテストのカバレッジを集計・レポートしてくれるツールで、GitHub Actionsに組み込むと簡単にコードカバレッジを可視化することができます。

github.com github.com

octocov の良いところは、CodecovやCoverallsといった既存のカバレッジ計測サービスとは異なり、アカウント作成やGitHubリポジトリの外部連携が不要で導入ハードルが低い点です。 弊社はクライアントワークをやっている都合上、利用する外部サービスの選定やアカウント管理、ソースコード・情報の取り扱いに特に配慮しなくてはいけません。 octocov はすべてGitHubリポジトリ内で完結するため、そういった難しいことを考えずにサッと導入できるのが強みですね。

octocov に関するTips

octocov の基本的な使い方については、GitHubリポジトリのREADMEや世のエンジニアのブログにたくさん情報が載っているので、ここでは触れません。 今回はcodecovを少し特殊な要件で利用したいときのTipsを紹介します。

go.mod がリポジトリルートに配置されていない場合

多くのGoプロジェクトでは、特別な理由でもない限り go.mod をリポジトリのルートに配置すると思います。しかしのっぴきならない事情により、 go.mod をルートに置かない(置けない)ケースもあるかと思います。

具体例を挙げると、ISUCONのリポジトリとか。isucon/isucon12-qualifyリポジトリでは、色んな言語の実装を並列で載せている都合上、 webapp/go/ 以下にGoプロジェクトが存在します。

github.com

GitHub Actionsでこのようなリポジトリに対してテストを実施する場合、ワークフローのステップ中でディレクトリを移動しつつテストを実行するような書き方をすると思います。

- name: test
  run: |
    cd webapp/go/
    go test ./... -coverprofile=coverage.out

素直にこのように実装した場合、カバレッジのプロファイルは webapp/go/coverage.out として出力されます。 octocov-action には実行ディレクトリを指定するようなオプションがなく、常にルートディレクトリで octocov を実行するようです。この場合、どのようにプロファイルを読み込ませれば良いでしょう?

A. 設定ファイルの中でプロファイルのパスを指定する

octocov の設定ファイルである .octocov.yml に、集計対象のプロファイルのパスを渡してあげます。例えば次のような形です。

coverage:
  paths:
    - webapp/go/coverage.out

B. Goプロジェクトに設定ファイルを作成し、そのパスを指定する

octocov の設定ファイルをGoプロジェクトのパスに作成( webapp/go/.octocov.yml )します。 octocov には設定ファイルを指定する --config オプションがあるため、これで設定ファイルの場所を指定してあげれば良いです。

octocov --config webapp/go/.octocov.yml

どうやらデフォルトで設定ファイルと同じディレクトリにあるカバレッジプロファイルを読むような実装になっているようです。

GitHub Actionsで同様のことを実現したい場合は、以下のように config パラメータを指定してあげればOKです。

- name: test
  run: |
    cd webapp/go/
    go test ./... -coverprofile=coverage.out

- uses: k1LoW/octocov-action@v0
   with:
     config: webapp/go/.octocov.yml

マルチモジュールで動かす場合

octocov の GitHub Actionsはとても便利で、レポート作成時にmainブランチなど特定時点のカバレッジとのDiffをとり、その差分も含めてレポートしてくれます。 なので、例えばテストをサボってゴリゴリと新機能を追加したりすると、mainブランチからカバレッジが低下していく様子が一発で見えてしまうわけです。便利ですね。

さて、この便利な機能ですが、マルチモジュールで使う分には少しだけ注意が必要です。

例えば以下のように、 module1 module2 の2モジュールを抱えるプロジェクトがあり、各モジュール毎にテストカバレッジを収集しているとします。

├── module1
│   ├── .octocov.yml
│   ├── go.mod
│   └── go.sum
└── module2
    ├── .octocov.yml
    ├── go.mod
    └── go.sum

一般論はわかりませんが、このような構成の場合、 module1 のテストと module2 のテストを別々のGitHub Actionsワークフローとして定義し、 変更が入ったモジュールだけテストが実行されるような設定をするんじゃないでしょうか(少なくとも、弊社ではそうしています)。

ある時点において、 module1とmodule2のカバレッジがそれぞれ 50%、70% だったとします。 そこから module1 のテストを頑張って書いてカバレッジを 60% にするPull Requestを作成したとき、その直前の状態によって「50%から増えた!」とレポートされる場合と「70%から減った!」とレポートされる場合があります。 つまり module1 のカバレッジレポートのDiffととる際、module2 のカバレッジレポートが参照されてしまう可能性があるのです。

カバレッジのDiffがうまくいかない原因

octocovがレポートのDiffをとる設定は、 octocov init で設定ファイルを作成した場合、次のようになっているはずです。

diff:
  datastores:
    - artifact://${GITHUB_REPOSITORY}
report:
  if: is_default_branch
  datastores:
    - artifact://${GITHUB_REPOSITORY}

デフォルトブランチでワークフローが回ったときにレポートを artifact://${GITHUB_REPOSITORY} に保存し、そのレポートを元にDiffをとる、という設定です。

artifact:// スキームを設定すると、GitHub Actions の Workflow artifacts を使用します。レポートファイルがどのように保存されるかは、octocovのREADMEを参照してみましょう。

https://github.com/k1LoW/octocov#github-actions-artifacts

Use artifact:// or artifacts:// scheme.

artifact://[owner]/[repo]/[artifactName]
  • artifact://[owner]/[repo]/[artifactName]
  • artifact://[owner]/[repo] ( default artifactName: octocov-report )

……はい、バッチリ書いてありますね。 そうです、octocov はデフォルトで octocov-report という名前でレポートを保存します。 つまり複数モジュールのレポートが全部この名前で保存されてしまうため、最後にどのモジュールのテストが走ったかによって、その後のレポート表示が変わってしまうという状態になります。

これは思いっきり業務の中でハマりまして、マルチモジュールのプロダクトにoctocovを導入したら、タイミングによってカバレッジが急上昇したり急低下する現象に見舞われました。

保存されるレポートをモジュール毎に分ける

各モジュールの設定ファイルにおいて、次のように設定を変更しましょう。

diff:
  datastores:
+    - artifact://${GITHUB_REPOSITORY}/octocov-report-module1
-    - artifact://${GITHUB_REPOSITORY}
report:
  if: is_default_branch
  datastores:
+    - artifact://${GITHUB_REPOSITORY}/octocov-report-module1
-    - artifact://${GITHUB_REPOSITORY}

module1 の部分はモジュール毎に衝突しない(つまり、設定ファイル間で重複しない)一意の名称をつけます。まぁ普通にモジュール名で良いでしょう。

まとめ

octocov のちょっとニッチな設定方法を紹介しました。 とても手軽に使えて便利なツールなので、これからもどんどん広がっていくことを願います。


次回は @chooblarin さんで「なにか書く」です。お楽しみに。

Sign In with Apple REST APIをGoで扱うためのライブラリを作っている話

この記事はフラー株式会社 Advent Calendar 2022の17日目の記事です。16日目は@Taip00nさんで「ターミナルでスターウォーズを見るべ」でした。

12日目と思いっきりネタ被りしてしまいました。氏が扱ってる言語が違うので許してください。

nnsnodnb.hatenablog.jp

Appleが定めるアカウント削除に関する要件について

さて、ご存知の方も多いと思いますが、2022年6月30日より新しいApp Store Reviewガイドラインが適用されました。新しいガイドラインでは、ユーザーからの申し出によってアカウントを削除する際の要件が追加されています。

developer.apple.com

色々気になる部分はありますが、今回特に気にしたい点は次の項目です。

Appleでサインインに対応したAppでは、AppleでサインインのREST APIを使用して、アカウントの削除時にユーザーのトークンを無効化する必要があること。

つまりSign In with Appleで連携しているユーザーがアカウント削除を申し出たとき、ユーザーデータを削除するだけでなく、Appleが提供するREST APIをリクエストして連携情報の無効化をしなくてはいけないようです。具体的に実装すべき機構の詳細は一旦置いておきますが、まずはこのREST APIをどうにか相手にする方法が必要です。弊社サーバーサイドはそのほとんどをGoで記述しているので、今回はGoでSign In with Apple REST APIをリクエストするためのクライアントを開発してみました。

Sign In with Apple REST API Client for Go の紹介

開発しているライブラリはGitHubにて公開しています。後述する事情によりまだ安定版とは言えないのでバージョンタグは切ってません。

github.com

使い方

※ この情報は執筆時点のものであり、今後のアップデートにより変更される可能性があります。

クライアントの初期化

まずはREST APIをリクエストするクライアントを初期化します。 クライアントを初期化するにはApple Developer Programからダウンロードできる、アプリや開発者に関するいくつかの情報が必要です。 初期化のためのパラメータが多くて辟易してしまいますが、こういうもんなんで諦めてください。 具体的な値についてはお近くのiOSアプリエンジニアにお問い合わせください。

client, err := siwarest.New(&siwarest.ClientConfig{
    Client:        http.DefaultClient,
    ClientID:      "client-id",
    KeyID:         "key-id",
    TeamID:        "team-id",
    PrivateKeyPEM: "private-key",
})
if err != nil {
    // TODO: error handling
}

トークンを取得する

Generate and validate tokens APIをリクエストしてリフレッシュトークンを入手するには、GenerateAndValidateTokens関数を実行します。

token, err := client.GenerateAndValidateTokens(
    ctx,
    siwarest.GenerateAndValidateTokensWithAuthorizationCode("auth-code"),
)
if err != nil {
    // TODO: error handling
}

引数に渡している認可コードはiOSアプリがSign In with Appleを実施した際に入手できる、比較的有効期限の短い情報です。 アプリ側でサインインが発生した直後、この認可コードをサーバーにPOSTし、サーバーサイドでこのAPIをリクエストしてリフレッシュトークンを入手するような実装を想定しています。

引数にはリフレッシュトークンも渡すことができます。サーバーで保存しているリフレッシュトークンが無効になっていないかチェックするために利用できます。

token, err := client.GenerateAndValidateTokens(
    ctx,
    siwarest.GenerateAndValidateTokensWithRefreshToken("refresh-token"),
)
if err != nil {
    // TODO: error handling
}

トークンを無効化する

この記事の本題です。 RevokeTokens 関数に対してリフレッシュトークンを渡すことでRevoke tokens APIをリクエストしてくれます。

err := client.RevokeTokens(
    ctx,
    siwarest.RevokeTokensWithRefreshToken("refresh-token"),
)
if err != nil {
    // TODO: error handling
}

引数にはGenerate and validate tokens APIをリクエストすることで入手したリフレッシュトークンを渡します。

工夫した点

Client Secretのリフレッシュ

このREST APIを利用する上で一番めんどくさいなと感じたのがClient Secretの取り扱いです。

ドキュメントによるとAPIリクエストに必要なClient Secretは利用者が自分でJWTを構築しなくてはいけません。 また、構築するJWTには有効期限が含まれているので、おそらく利用者自身で定期的なJWTのリフレッシュをかけなくてはなりません。

developer.apple.com

今回開発したライブラリでは、Client Secretの構築から有効期限の監視、リフレッシュまでを内部的に処理するようにしました。 ユーザーが適切な初期パラメータを渡してClientを初期化すれば、それ以降の利用でClient Secretの存在を意識しなくても良いような実装になってます。

http.Client の外部注入

APIリクエストの際に利用する *http.Client をClient初期化時のパラメータとして含められるようにしました。 通常利用の範囲であれば http.DefaultClient を使えば良いですが、特定環境下においてHTTPクライアントをカスタマイズしたい*1という要望はそれなりにあると考え、このような実装にしています。

Functional option patternの採用

対象にしているREST APIは、例えばGenerate and validate tokens APIでは認可コードまたはリフレッシュトークンを、Revoke tokens APIではアクセストークンまたはリフレッシュトークンをリクエストパラメータとして受け付けます。こういうパターンのパラメータを引数として受け付ける場合、手っ取り早い方法としては次のようにそれぞれのパラメータをポインターにした構造体を用意し

type GenerateAndValidateTokensInput struct {
    AuthorizationCode *string
    RefreshToken      *string
}

関数の中でnilチェックをして条件分岐するというのがあると思います。

if input.AuthorizationCode != nil {
    // 認可コードを利用したリクエストと見なす
}
if input.RefreshToken != nil {
    // リフレッシュトークンを利用したリクエストと見なす
}

しかしこの場合、認可コードとリフレッシュトークンが両方nilだった場合認可コードとリフレッシュトークンが両方渡された場合の挙動を考えなくてはいけません。前者は明らかなエラーとして扱えますが、後者の場合はどうでしょう?どちらかを優先すべきか、エラーとして落としてしまうか、実装者や利用者の意図・利用シーンによって使い勝手が変わってきそうですね。

こういった事を考えずにいずれか一方だけ指定できるインターフェースを実現したかったので、今回はFunctional option patternを採用しました。

イマイチな点

クライアントを初期化するときに必須パラメータとして *http.Client を要求するのはちょっとイマイチだったかなと思っています。 ここをカスタムするかはオプションとして実装者の裁量に委ねても良かったかもしれません。 また、Client Secretに設定する有効期限が現在ライブラリ内で固定値になっています。これも要件によってはカスタマイズしたい場合があるかもしれないので、今後ここもオプションとして渡せるようにするかもしれません。

あとは何より、まだ一回も動作確認できてないという点ですね!僕の周りでなんだかんだこのガイドラインに対応する機会がないのです。。

というわけで人柱絶賛募集中です!!!

まとめ

Sign In with Apple REST APIをGoで扱うためのライブラリを作成しました。

人柱、Pull Requestを絶賛募集しております。

関連リンク


次回は@ujikawa1026で「エンジニアリングマネージャーとしての今年の学びをまとめます」です。お楽しみに。

*1:例えば、弊社ではAWS X-Rayでトレースをするためにshogo82148/aws-xray-yasdk-goを利用しており、このライブラリがHTTPクライアントをカスタマイズします