なまえは まだ ない

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

フラーを卒業していった優秀な学生アルバイトに向けたポエム

この記事はフラー株式会社 Advent Calendar 2022の4日目の記事です。3日目は@masaya82さんで「StorybookのCSF 2.0とCSF3.0を見比べてみる」でした。

2年前、弊社の学生アルバイトだったキミへ

2年前、サーバーサイドの学生アルバイトとして手伝ってくれていたキミへ。 新卒で入社した某社では楽しく働けているでしょうか?

キミが在籍中にメンテナンスをしてくれていたあの社内アプリについて、最近また大きめのメンテナンスを入れました。 あの頃と変わったこと、またあの頃知らなかった(できなかった?)ことをいくつか紹介します。

Cloud Functionsを卒業しました

初っ端から大きな変更を書きますが、実行環境をCloud FunctionsからCloud Runに移行しました。

移行の理由は非常に単純で、Cloud Functionsのランタイム更新の遅さにうんざりしたからです。

Cloud Functionsは実行する関数だけを記述するサービスなので、それをアプリとしてビルドするためにある程度ランタイムが制限されています。 例のアプリはGoで書かれていましたが、2022年10月時点で使用できるGoのランタイムは1.16 or 1.13でした*1。Goは最新2世代しかサポートしない(2022年10月の時点で、1.19と1.18)方針なので、サポートが打ち切られたランタイムを使い続けるのに抵抗がありました。事実、依存する一部ライブラリがGo 1.17で追加された機能を利用しており、ライブラリのアップデートができないような事態も発生してました。

Cloud Runはコンテナー化したアプリケーションを簡単にデプロイ、実行できるというサービスなので、アプリケーション自体の言語やビルド環境の制限はありません。 Cloud Functionsと比較するとアプリケーション自体の記述量は増えますが、実装上の自由度も上がったのでトータルでポジティブな移行だったと思っています。

Firestore EmulatorがBetaを卒業しました

Firestoreを使った機能のテストを記述するために、GitHub Actionsワークフロー内でFirestore Emulatorをセットアップしてもらいましたね。当時は gcloud beta で実行する実験的機能でした。

いつの間にかBeta版ではない正式版(?)が gcloud コマンドに追加されていました。

cloud.google.com

使い方は全く一緒で単に beta が抜けただけですが、次のように実行することでエミュレーターを起動できるようになっています。

gcloud emulators firestore start

GCPと連携するためのGitHub Actionsが充実しました

キミにメンテナンスをしてもらっていた当時、GCP周りのGitHub Actionsはあまり充実してなかったような記憶があります。 Cloud FunctionsのデプロイにはこちらのActionsを利用していました。

github.com

現在はGCPと連携するためのActionsが充実し、認証や gcloud コマンドのセットアップ、Cloud FunctionsやCloud Runのデプロイ等が専用のActionsで簡単に実行できるようになりました。

github.com

ローカルでGCPの資格情報を簡単にわたす方法がありました

アプリをローカルでデバッグするときに一番頭を悩ませたのが、アプリに対するGCPの資格情報の渡し方だったと記憶しています。

Cloud Functionsで実行する場合は実行環境のデフォルトの資格情報をSDKがロードしてくれますが、ローカルで起動する場合は何らかの方法で資格情報を準備する必要がありました。当時有効な方法を見つけられず、結局SDKソースコードを追いかけたりしながら GOOGLE_APPLICATION_CREDENTIALS 環境変数にサービスアカウントの資格情報を設定するという強引な方法で解決してましたね。

これも最近たまたま知ったのですが、アプリケーション用の資格情報をセットアップする方法が gcloud コマンドに用意されていました。

cloud.google.com

次のように実行するとブラウザが開き、認証・認可のフローを通るとローカルに資格情報がセットされます。各種SDKはこの資格情報を勝手にロードするので、これでCloud Functions等の各種実行環境と同じような状態をセットアップできるようです。

gcloud auth application-default login

これ、当時からあったのかなぁ。。もうちょっと早く辿り着きたかったですね。

おわりに

このアプリに限らず、フラー社内ではキミが在籍していた頃からたくさんの変化がありました。

今のお仕事は充実しているでしょうか?もしまたフラーのエンジニアリングに興味が湧いてきたら、いつでも連絡をください。 フラーではいつでも一緒に働いてくれるサーバーサイドエンジニアを募集しています。

herp.careers


明日は@Nao1215さんで「【GolangCSV/TSV/LTSV/JSONSQLを実行するsqlyコマンドを作った話【開発背景/設計/使い方】」です。お楽しみに。

*1:この記事の執筆中に気づいたのですが、いつの間にかGo 1.18と1.19がプレビュー版として追加されていました。

ISUCON12に出場し予選敗退しました

2年ぶりに参加してきました

7月23日に開催されたISUCON12の予選会に参加し、最終スコア7831点(全体127位)で敗退してきました。

昨年(ISUCON11)はスケジュールの都合がつかず不参加となり、その無念を晴らすべく社内ISUCONを開催したりもしました。

www.wantedly.com

今年はこの社内ISUCONの効果もあってか、比較的最近入社した人が積極的に参加表明をしてくれました。嬉しい限りです。 社内でいい感じにチーム分けをし、私は初参加の2人と共に腐羅亜というチーム名で参加しました。

当日までの準備

私以外の2名が初参加で勝手がわからないということもあり、事前準備は基本的に私が主導して実施しました。 環境構築用のリポジトリによく使うツール類を揃えて、当日なるべく迅速に行動できるように。

例のISUCON本も当然一読し、当日役に立ちそうなTipsをチートシートとしてNotionにまとめたりもしました。 ISUCON本は(ISUCONに出なくても)勉強になるものがたくさんあり、とても良い本だなと思いました。

あと、alpによるアクセスログの集計結果をシュッとSlackに流せるように、簡単なプログラムを書いて公開したりしました。

github.com

類似サービスとしてSlackcatというものがありますが、こちらを会社のワークスペースにインストールすることが認められなかったので、自分で別手段を用意した感じです。

当日の感想

予選に使ったリポジトリを公開したので、気になる方は御覧ください。

github.com github.com

どんなことをやったかをつらつら書いていこうかと思ったのですが、思い返してみたら代表的な改善は全部他2人がやってくれていたので、私が取り上げるべきものはあまりありませんでした。 というわけで当日の感想ベースで書いていきます。

問題について

まさかのISUCONをSaaS提供するシステムが題材で笑いました。普通にほしいです。

競技が始まってからの動き

競技が始まったら他の2人にマニュアルの解読を任せ、私はせっせと環境構築に勤しみました。 とりあえず与えられたサーバーでSSH公開鍵の発行やレシピのクローン、ツールのインストールなど。 このあたりは事前に素振りをしたりチートシートを作っておいたのがとても役に立ちました。

SQLite との会敵

コードをざっと眺めていたら、なんかMySQL以外のドライバーがインストールされてる……と思い、よくよく追いかけてみたら各テナントのDBはSQLiteが使われてるじゃありませんか。 あ、ふーんそういうことね。と、今回の問題の概要を理解しました。恥ずかしながらこれまでSQLiteについて真面目に触れてこなかったので、このあたりで経験不足が重くのしかかる予感がしました。

なかなか上がらないスコア

最初はセオリー通りalpでアクセスログを集計して、重いエンドポイントから順次改善していくことに。

明らかにここN+1問題発生してるなという箇所やこの処理冗長だなという部分を見つけて改善していきましたが、なかなかスコアが伸びない。お、おかしい……。 ベンチを回している間topの出力からMySQLサーバーがCPU150%ぐらい使ってたのは観測していて、「なんでメインのDBじゃないのにこんな使ってるんだ?」と違和感は覚えていました。 この時点でMySQL側のインデックスやID採番クエリといった問題に向き合えれば早かったような気がします。そちらに気が向くのが遅すぎた。

足を引っ張る初期データ

SQLiteのDBは最初から初期データとして100テナント分のDBが存在していました。 SQLite側には一切インデックスが作成されておらず、今までのノリで考えればそこを改善すればスコア改善するだろうと踏んでました。 アプリはベンチマーカーのInitializeが走るたびに初期データをリセットするようになっていたので、Initializeのタイミングで100個のDBにインデックスを作成するクエリを投げる必要があります。 この辺りはこれまでのISUCONの傾向とは異なった部分であり、それなりに手惑いました。

また、途中どう考えてもplayer_scoreテーブルのデータ量が足を引っ張ってると思い、最新スコアだけを格納するテーブルだけを作ろうとかも考えました。 が、やっぱりここでネックになるのは初期データで、100個のDBを上手いことマイグレーションする手立てが思いつかず諦めました。

あとはローカルにDBがファイルとして存在している都合上、アプリとSQLiteがどうしても切り離せない&分散できないというのも悩みのタネでした。 結局アプリ+SQLiteの1号機とMySQLサーバーの2号機という2台構成が限界だったのですが、どうやれば3台使い切れたんだろう……謎です。

MySQLに載せ替えるか、SQLiteのまま突き進むか

SQLiteのデータベースはテナント毎に作成されるにも関わらず、各テーブルになぜかテナントIDを記録するカラムが用意されていました。 この設計は明らかに不自然であり、作問者の意図がプンプン臭ってくるものでした。

これは作問者がMySQLへの載せ替えを匂わせているんだろうと踏み、競技中3回ほどMySQLサーバーにすべてのデータをマイグレーションすることを検討しました。 が、私が試した拙い方法では1つのDB辺り5〜6分の時間を要してしまい、直列で実行したらマイグレーションが完了しないまま競技を終えてしまうことが目に見えていました。 結局私のチームではMySQLへのマイグレーションを断念しましたが、やはりというかそれをやってのけたチームはあったみたいですね。

後々会社の先輩にマイグレーション作業用に強いEC2とかRDSのインスタンスを立てて、その結果を競技用サーバーに持っていけば良かったのでは?」と言われました。

( ゚д゚)ハッ!なるほど!

たしかにレギュレーションをよく読めば、開発のために別のリソースを使うことは全然セーフのようです。

主催者の指示以外で利用が認められたサーバー以外の外部リソースを使用する行為(他のインスタンスに処理を委譲するなど) は禁止する。 ただしモニタリングやテスト、開発などにおいては、PCや外部のサーバーを利用しても構わない。

レギュレーションは把握してたつもりでしたが、そこまでの発想が無かった……。

総括

今年も結局悔しい結果に終わりましたが、8時間頭をフルに使って問題に取り組み、とても有意義な時間を過ごせたと思います。 ISUCONで得た知見・勘は業務でも活かせる部分が多く、今後も会社のメンバーを誘って参加してきたいと思います。 来年こそは本戦出場、そして優勝を目指して頑張りたいです。

最後になりますが、作問や準備、運営をした皆様、本当にお疲れさまでした。 とても楽しいISUCONでした。引き続き本選も応援しています。

左利き用のハサミは左利きでも使いづらい

たまには思いつきネタ。

私事ですが先日引越しをしまして、住居がアパートから戸建てにランクアップしました。

新居では各部屋に合ったカーテンを新しく購入したので、アパートで使っていたカーテンは処分することに。 私が住んでいる行政区ではカーテンや布団といった大きめの布類は50cm四方サイズに裁断をして燃やすゴミに出すか、そのまま粗大ゴミに出すかを選択することができます。 粗大ゴミは手続きが面倒なのでなんとか燃やすゴミに出したいなと家の中を探していたら、左利き用の裁ちバサミを見つけたんです。

この裁ちバサミは僕が小学校の頃、家庭科の授業のために購入したものでした。 小学校の裁縫道具といえば、健全な男子学生なら漏れなく憧れた格好いいドラゴンのイラストが描かれた裁縫箱。 僕も例に漏れず一目惚れし、購入してもらったような記憶があります。

ただ、当時それ以上に目を惹いたのが、裁ちバサミを右利き/左利き用で選択できるというものでした。 私は左利きなので、ペンや箸を始めほとんどのものを左手で扱います。残念ながらこの世の多くのものは右利きが扱いやすいように最適化されているので、 我々のような存在は常日頃から微量のストレスと戦い続けています。

当時既に左利きであることにいくらかのハンディキャップを感じていた私は、 左利き用のハサミ!なんて素晴らしいんだ!と感動して飛びついたんでしょう。あの頃は若かった。

そんな思い出深い裁ちバサミを発掘し、意気揚々とカーテンを切り始めたわけですが、

これがなんとまぁ使いづらい。

この裁ちバサミ、たしかに左手で扱うと快適に切れる。快適に切れるんですが……そもそも左手でハサミを扱うことに慣れない。 それもその筈で、世の中に出回ってるハサミの殆どはやっぱり右手で扱うように作られています。 左利きな私でもハサミは右手で扱うものだとすっかり飼い慣らされてしまっていて、全然左手で扱えない。 挙句の果てに、パッとその辺に置いたハサミを右手で持って切ろうとしてしまう始末。。

約20年前の僕には悪いが、もう左利き用のハサミは扱えないなと、カーテンと一緒に処分することを決意しました。

年の瀬に阿呆なプログラムを書いた

こんにちは。早いものでもう年末です。

皆さんは世界のナベアツという芸人をご存知でしょうか?

2007年頃に爆笑レッドカーペットという番組で「3の倍数と3が付く数字のときだけ阿呆になります」というネタを披露し、一斉を風靡した芸人さんです。現在は「桂三度」の名前で落語家として活動されています。

3の倍数と3が付く数字のときだけ阿呆になります

数字を1から順に数えていき、3の倍数と3が付く数字の時に「サァンwww」と奇声を発しながら変顔をするというネタです。 3の倍数だけでなく「3が付く数字」でも阿呆になるというところがミソで、13や23といった3の倍数ではない数字も含まれます。

最初にこのパターンが登場するのは13のときで、「ジュウニwwwジュウサァンwww」と連続で阿呆になるわけです。 ここでこのネタの意図に気付かせ、クライマックスとなる30〜39では10連チャンで阿呆になり続けます。 本当によく考えられたネタで、当時高専生だった私は初めて見た時涙を流しながら笑ったのを覚えています。

で、世界のナベアツがどうしたって?

さて、前置きが長くなってしまいましたが、事の発端はTwitterに流れてきたこのツイートになります。

氏がどうやってこのライブラリに辿り着いたのか全く以て不明ですが、なるほどたしかに、と思いまして。 ちょうど仕事で行き詰まって頭を抱えていたときだったので、気分転換にと思ってGoで作ってみることにしました。

go-nabeatsu の紹介

作成したものがこちらです。

github.com

コマンドとして使う

Goがインストールされている環境であれば、次のコマンドで簡単にインストールできます。

go install github.com/furusax0621/go-nabeatsu/cmd/nabeatsu@v1.0.0

nabeatsu コマンドの引数として数字を渡すだけです。3の倍数と3の付く数字であれば世界のナベアツよろしく阿呆になり、それ以外では渡された数字をそのまま返します。

$ nabeatsu 3 // 3の倍数
サァンwww

$ nabeatsu 4 // 3の倍数ではない
4

$ nabeatsu 13 // 3の付く数字
ジュウサァンwww

ライブラリとして組み込む

コマンドで遊ぶだけじゃつまらない(?)と思い、世界のナベアツらしい要素をAPIとして提供しています。

pkg.go.dev

渡された文字列が阿呆になるべきか判定する IsFool と、渡された文字列を世界のナベアツみのある文言に変換する GetFoolExpression です。 なお、 GetFoolExpression 内部で IsFool を呼び出しているので、次のようなシンプルな記述をするだけで当時のネタを再現できます。

package main

import (
    "fmt"
    "strconv"

    "github.com/furusax0621/go-nabeatsu"
)

func main() {
    for i := 1; i <= 40; i++ {
        fmt.Println(nabeatsu.GetFoolExpression(strconv.Itoa(i)))
    }
}

Go Playground - The Go Programming Language

go-nabeatsu の実装について

阿呆なライブラリですが、ちょっとだけ考えた点を紹介します。

数字の扱いについて

Goのプリミティブ型である uint64 で扱える最大値はせいぜい20桁程度になります。 20桁(1019)というと千京になりますが、日本の数字の接頭辞はそれより上のものがいくつもあります。

数字でマトモに扱おうとすると簡単に桁あふれが発生してしまうので、あえて string 型で扱うことにしました。 string で扱うと当然数字以外の文字列を考慮しなくてはいけません。ここは単純に正規表現で数字の羅列かどうかを判定するようにしています。

var mustNumber = regexp.MustCompile(`^\d+$`)

よくGoの正規表現エンジンは遅いと言われますが、私はナベアツにそこまでの速度を求めていないので、これで十分だと思っています。

3の倍数の判定

先のツイートにもあったとおり、3の倍数かどうかを判定するには各桁の数字を足していき、その結果が3の倍数かどうかを判定すればよいです。 後述しますがこのライブラリでは千無量大数の桁まで扱えるようにしているので、最大で72桁になります。

理論上の最大値は各桁が9で埋まった時なので、3の倍数の判定は9 * 72 = 648までの値を扱えれば十分であるとわかります。 あとは加算していった結果を3で割り切れるかを判定すれば良いです。

また、先に書いたとおりこのライブラリ内で数字を文字列として扱っています。 strconv パッケージ等を使って stringint 変換をしても良かったのですが、APIの定義上エラーハンドリングを考えなくてはいけません。 こんな阿呆なライブラリのためにそこまで作り込みたくなかったので、Unicodeコードポイントの差分をとることで数字に変換するようにしています。

var sum uint64
for _, r := range s {
    sum += uint64(r - '0')
}

阿呆な読み方への変換

困ったことに日本語はかなり複雑な言語なので、仮数部によって接頭辞の読みが変わったり、逆に接頭辞によって仮数部の読みが変わったりします。 いくつか例を挙げると

  • 100は いち-ひゃく と読まず ひゃく と読む
  • 300は さん-ひゃく と読まず さん-びゃく と読む
  • 10,000,000は せん-まん または いち-せん-まん と読まず いっ-せん-まん と読む

スマートに解決する方法が見当たらなかったので、大分泥臭い組み立て方をしています。

一の位〜千の位までの読み上げ方は、万や億といった接頭辞がつくかどうかでも微妙に変化します。ここの共通化は諦め、まず1〜1,000までの数字の読み方を構築し、その後接頭辞(万、億、兆……)毎に区間を切って変換していく、という実装にしました。

ナベアツの性能限界

日本語として扱われている最大の接頭辞は(私が知る限り)無量大数になるので、千無量大数より桁が上がると読み上げ方がわからなくなってしまいます。ここらを性能限界と決め、これより大きい桁の数字が来たら無限大(ムゲンダァイwww)と判定してもらうことにしました。

まとめ

Go言語で世界のナベアツを再現するライブラリを開発しました。

実装に関して物申したい方がいらっしゃいましたら、Pull Requestをお待ちしております。

参考

フラーで採用しているWebアプリケーションフレームワーク Goa の紹介

はじめに

この記事は、フラー株式会社 Advent Calendar 2021の13日目の記事です。 12日目は@seto_inugamiで「技術選定を疎かにしたツケを払ったお話し 」でした。

さて、フラーではデジタルパートナー事業の一環としてこれまで何本かのスマートフォンアプリケーションをリリース、運用しています。当然ながらアプリが利用するデータはサーバーで管理されており、ほとんどのアプリでそのバックエンド(Web APIやインフラ)もフラーが開発、運用しています。

今回はフラーがGoのWebアプリケーションフレームワークとして採用しているGoaの紹介と、フラー流の使い方について紹介します。

Goaとは

GoaはAPIデザインをDSLで記述すると、Web APIホスティングするためのベースとなる部分(ルーティングやリクエストボディの構築など)や、APIクライアントを開発するためのベースとなる部分を自動生成してくれるフレームワークです。

goa.design

Goaの特徴

Goaは独自定義のDSLをベースにAPIサーバー、APIクライアント、Swagger、果てはテストコードまで生成することができます。とても機能が多く、GoのWebフレームワークとしてメジャーなGinEchoと比べたらとても重いフレームワークだと思います。

なぜGoaなのか?

Goaの特徴のひとつであるSwaggerを生成できるという点に魅力を感じているからです。

フラーではサーバーとクライアントの間のWeb API仕様はSwaggerによって共有しています。 開発をスムーズに進めるためには、Web APIの仕様と実装の同期を保てるか(=Swaggerを最新の情報に保ち続けられるか)がとても重要なポイントとなります。

GoaはひとつのDSLからアプリケーションコードとSwaggerファイルが同時に生成されるので、仕様と実装が一致した状態を簡単に保つことができます。また、Swaggerも自動生成なので、ニンゲンがあのYAMLJSONを記述するより遥かに正確・確実に更新することができます。

よくGoのWebアプリケーション界隈では「ドキュメントと実装の整合性をどう保つか」みたいな問題が発生して、go-swaggerをはじめとするSwaggerと連携できるライブラリと、GinやEchoなどのWeb APIフレームワークをどう組み合わせるか、みたいな苦労をしている印象があります。 GoaはWeb API開発とドキュメンテーションをセットで扱えるため、そういったライブラリ/フレームワークの相性問題を考える必要がありません。

Goaの開発事情

現在Goaはv3とv1の2ラインがあり(なぜかv2は無かったことにされている)、メインの開発はv3で行われています。フラーでは昔からv1を使っていたのですが、v3リリース後も(2021年12月現在)移行に踏み切れていません。完全に下火になってしまった本家にコントリビュートし続けることに疲れた我々は、ついに弊社テックリード独自カスタマイズを加えたfork版を使うことにしました。

github.com

完全に本家と袂を分かったことで、完全にv3にマイグレーションするきっかけを見失ってしまいました。。どうしましょうかね。要検討です。

実際にGoaで開発してみる

文章でつらつら書いててもアレなので、実際にGoaでWeb APIを開発するときのサンプルコードを用意してみました。

github.com

GET /v1/users/{user_id} という単一のエンドポイントを持つシンプルなコードです。アプリケーションを起動して次のようにリクエストをすると、予め登録された情報が返ってきます。

$ curl http://127.0.0.1:8080/v1/users/1

{
  "created_at": "2021-09-25T11:22:33Z",
  "family_name": "田中",
  "given_name": "太郎",
  "user_id": 1
}

自動生成されたSwaggerファイルをSwagger UIで表示すると、このエンドポイントの仕様を確認することができます。

f:id:furusax0621:20211212230730p:plain
Goaから生成されたSwagger

ディレクトリ構成

Goaに関係する部分だけ抜き出すと大体こんな感じになってます。

webapi/
├── app     # APIを実装するための自動生成コード
├── design  # DSL
├── gen     # DSLから各種コードを生成するコマンド
├── swagger # Swaggerファイル
├── user.go
├── user_test.go
└── webapi.go

注目すべきは gen ディレクトリです。

gen にはDSLからコードを生成するためのプログラムが入っています。次のようなコードです。

package main

import (
    _ "github.com/furusax0621/goa-sample/webapi/design"

    "github.com/shogo82148/goa-v1/design"
    "github.com/shogo82148/goa-v1/goagen/codegen"
    genapp "github.com/shogo82148/goa-v1/goagen/gen_app"
    genswagger "github.com/shogo82148/goa-v1/goagen/gen_swagger"
)

func main() {
    codegen.ParseDSL()
    codegen.Run(
        genswagger.NewGenerator(
            genswagger.API(design.Design),
        ),
        genapp.NewGenerator(
            genapp.API(design.Design),
            genapp.OutDir("app"),
            genapp.Target("app"),
        ),
    )
}

このコードを go generate で実行されるよう設定しておき、DSLを更新したら go generate ./... と実行することで自動生成コードを更新する、といった手法で開発しています。

GoaはDSLから各種コードを生成するときに通常 goagen というコマンドをインストールして使います。 goagen を使わないようにしているのは、弊社が複数のアプリを開発・運用しているという事情が関係しています。

通常、1台の開発環境にインストールできる goagen コマンドはひとつだけです。 goagen をビルドしたGoaのバージョンとDSLが依存しているGoaのバーションは一致している必要があるので、複数のプロジェクトが同時稼働しているとバージョン管理がとても困難になってしまいます。

プロジェクト間やエンジニア間での混乱を防ぐため、Goの標準コマンドである generate で管理できるような構成にしているというわけです。

APIを追加してみる

では、新しいエンドポイントとしてユーザーを登録するための POST /v1/users というエンドポイントを追加してみましょう。

まずはDSLを記述します。実装したいアクション、ルーティングパス、パラメータ、レスポンスの種類などを記述していきます。

Action("post", func() {
    Routing(POST("")) // /v1/users というパスに POST メソッドのエンドポイントを作る
    Payload(func() { // given_name, family_name というパラメータを必要とするペイロードを受け付ける
        Member("given_name", String, func() {
            Description("名前")
            Example("太郎")
        })
        Member("family_name", String, func() {
            Description("姓")
            Example("田中")
        })

        Required("given_name", "family_name")
    })
    Response(Created, UserMedia) // Created(201)レスポンスを返し、ボディにユーザー情報が入る
})

DSLを記述したら go generate ./... を実行して、自動生成コードやSwaggerを更新します。

$ go generate ./...

swagger
swagger/swagger.json
swagger/swagger.yaml
app
app/contexts.go
app/controllers.go
app/hrefs.go
app/media_types.go
app/user_types.go
app/test
app/test/user_testing.go

この時点でSwaggerファイルも自動的に更新されます。便利ですね。

f:id:furusax0621:20211212235304p:plain
更新後のSwagger

自動生成したコードを利用してControllerにメソッドを追加します。 app パッケージには、実装すべきControllerのインターフェースが宣言されています。

// https://github.com/furusax0621/goa-sample/blob/fac893d35c4db6384c4a7f088d2bbbe3cf6d64eb/webapi/app/controllers.go#L27-L32
// UserController is the controller interface for the User actions.
type UserController interface {
    goa.Muxer
    Get(*GetUserContext) error
    Post(*PostUserContext) error
}

リクエストのパラメータやペイロードで宣言した変数や、宣言したレスポンスを返すためのメソッドは app パッケージの (Action名)(Resource名)Context に内蔵されています。

// https://github.com/furusax0621/goa-sample/blob/fac893d35c4db6384c4a7f088d2bbbe3cf6d64eb/webapi/app/contexts.go#L60-L66
// PostUserContext provides the user post action context.
type PostUserContext struct {
    context.Context
    *goa.ResponseData
    *goa.RequestData
    Payload *PostUserPayload
}

Controllerはこれを引数として受けつつ、ここから情報を受け取りながらビジネスロジックを書いていきます。

実際に実装した例がこちらになります。

github.com

Goaのここがつらい

初期導入コストが高い

これはこのぐらい大きなフレームワークならあるあるだと思うのですが、そもそもGoaの学習コストがとても高いです。アプリケーションの記述方法が決まってるだけでなく、独自定義のDSLも覚える必要があります。

フラーのサーバーサイドエンジニアとして入社すると、まずGoaとお友達にならなくてはなりません。最近入社した方は、オンボーディング期間中に一回Goaを使えるようにするためのチュートリアルをやってもらうようにしています。

また、前準備として記述しなくてはいけないコード量が多いので、最低限のAPIサーバーを立ち上げるだけでもそれなりの時間を要します。「ちょっと試しにAPI作ってみよ」という軽いノリで導入できるものではありません。

コードの記述量も多くなりがち

今回紹介したシンプルなコードですらそれなりのコーディングを要求されますし、何より自動生成されるコード量がとても多いです。 ほとんどはレビュー対象から外れるとは言え、うっかり変更が10,000行超えるPull Requestなんかが簡単に発生してしまうので、 レビュワーの精神力をごっそり持っていきがちです。

swagger.json が確定コンフリクトする

Goaで生成されるSwaggerファイルにはYAMLJSONフォーマットがあるのですが、この内JSONはインデントはおろか改行すらされず、一行にすべての内容がガッと記述されてしまいます。

これの何が困るって、複数のAPIを同時並行で開発していると、確定でコンフリクトを起こしてしまうんですね。複数人で開発を進めていると毎度一回mergeするなりrebaseするなり対応しなくてはいけないので、非常に面倒です。YAMLファイルさえ生きていればJSONファイルは不要なので、フラーでは大抵 swagegr.json をGit管理から外すことで対応しています。

github.com

おわりに

というわけで、フラーで採用しているGoaフレームワークに簡単に触れてみました。

Goaにはまだまだたくさんの機能があり、慣れれば色々なユースケースに対応したAPIを開発することができます。 Goでバリバリ活躍したい方、Goaフレームワークに対する理解をもっと深めたい方、ぜひ私達と一緒に働きましょう!!フラーは通年でサーバーサイドエンジニアを募集しています!!!

明日は@SaturnR7さんで「何かを」です。お楽しみに!


GoでHTTPサーバーを立ち上げる際のいくつかの注意点

前置き

フラーではサーバーサイドエンジニアの共通言語としてGoを採用しており、その最も代表的な開発対象はWeb API、つまりHTTPサーバーになります。 弊社に新しくジョインしてくれたメンバーには、入社時にチュートリアル的な課題をいくつかやってもらっているのですが、その課題も基本的にはWeb APIの開発となります。

Goのnet/httpパッケージはちょっと癖があると言いますか、いくつか気をつけて実装しないといけない部分があります。 どれも公式パッケージのドキュメントを読めば書いてある話ですが、せっかくなのでいくつか実例を混じえて整理してみました。

気をつけるポイント

ResponseWriterのメソッドには呼び出せる順番がある

http.ResponseWriter にはレスポンスを構成するための以下3つのメソッドが宣言されています。

  1. WriteHeader()ステータスコードを設定し、レスポンスヘッダーを送信する
  2. Header() … レスポンスヘッダーを取得、操作する
  3. Write() … レスポンスボディを書き込む

実はこのメソッドの呼出順には決まりがあって、順番を守らずに呼び出されたメソッドは無視されてしまいます。 具体的には次の順番です。

  1. Header() … レスポンスヘッダーを取得、操作する
  2. WriteHeader()ステータスコードを設定し、レスポンスヘッダーを送信する
  3. Write() … レスポンスボディを書き込む

まずはレスポンスヘッダーの設定です。net/httpパッケージのドキュメントには、レスポンスヘッダーは WriteHeader() が呼ばれたタイミングでクライアントに送信されると記載されています。

Header returns the header map that will be sent by WriteHeader. The Header map also is the mechanism with which Handlers can set HTTP trailers. Changing the header map after a call to WriteHeader (or Write) has no effect unless the modified headers are trailers.

なので、例えば次のように WriteHeader() 呼び出しより後にヘッダーを追加しても無視されてしまいます。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.WriteHeader(http.StatusBadRequest) // ここでもうヘッダーは送信されてしまう
    rw.Header().Add("Cache-Control", "no-cache") // このヘッダーはクライアントに送信されない
    ...
}

次にレスポンスヘッダーの送信です。 WriteHeader() を呼び出すことで、サーバーはクライアントへのレスポンスを開始します。 なお、このメソッドを呼び出さずに Write() を呼び出した場合は、暗黙的に WriteHeader(http.StatusOK) が呼び出され、正常レスポンスと見なされます。 ドキュメントにも次のような記載があります。

If WriteHeader is not called explicitly, the first call to Write will trigger an implicit WriteHeader(http.StatusOK). Thus explicit calls to WriteHeader are mainly used to send error codes.

最後にレスポンスボディの書き込みです。先にも述べたとおり、 WriteHeader() 呼び出し前に Write() を呼び出した場合、暗黙的に WriteHeader(http.StatusOK) を呼び出します。 なので、例えば以下のようなコードを書いてもレスポンスは OK (200) となります。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.Write([]byte("oh no..."))
    rw.WriteHeader(http.StatusNotFound)
}

ステータスコードは一度しか指定できない

私も長いこと勘違いしていたのですが、 http.ResponseWriter.WriteHeader()ステータスコードを設定するメソッドではなく、レスポンスヘッダーを送信するメソッドです。 ですので、一回のリクエストにつき呼び出して良い WriteHeader() は一回だけという制約があります。例えば以下のように複数回呼び出したとしても、二回目以降は無視されてしまいます。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.WriteHeader(http.StatusBadRequest)
    rw.WriteHeader(http.StatusNotFound) // この呼び出しは無効となり、クライアントには BadRequest を返す
    rw.Write(...)
}

net/httpパッケージのドキュメントにおいても、この制約はきちんと明記されています。

Only one header may be written.

レスポンスボディを書き込んでる間のエラーはハンドリングしても手遅れ

CodeReviewComments - golang/go Wikiでも言及されているとおり、 受け取ったエラーは基本的にハンドリングして何かしらの対処をすべきです。これを正直に受け取ると、次のようなコードを書きたくなります。

func handle(rw http.ResponseWriter, r *http.Request) {
    if _, err := rw.Write([]byte("hello world")); err != nil {
        http.Error(rw, err.Error(), http.StatusInternalServerError)
    }
}

ここまでの話を咀嚼できた人ならわかると思いますが、この例のエラーハンドリングで http.Error() を呼び出してもあまり意味がありません。 なぜならこのエラーの原因となった rw.Write() によって(暗黙的な)ステータスコードの書き込みが行われしまっているからです。 コードの例では http.StatusInternalServerError を指定していますが、これは無視されてしまいます。

次のようなコード例を見てみましょう。

package main

import (
    "errors"
    "log"
    "net/http"
)

type FailWriter struct {
    rw http.ResponseWriter
}

func (w *FailWriter) Write(p []byte) (n int, err error) {
    w.rw.Write(p[:0]) // エラーハンドングしてないけど、検証のためなので許して
    return 0, errors.New("unexpected error")
}

func main() {
    http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
        fw := &FailWriter{
            rw: rw,
        }

        if _, err := fw.Write([]byte("hello world")); err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
        }
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

検証のために、 FailWriter という http.ResponseWriter をラップした構造体を用意しています。 FailWriter.Write() メソッドは http.ResponseWriter.Write() を呼び出しますが1バイトも書き込むことなく失敗します。 その結果として、目的のエラーハンドリング箇所に到達することになります。

このサーバーを起動してリクエストをかけてみると次のような結果になります。

$ curl -v "http://127.0.0.1:8080"
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sun, 29 Aug 2021 06:21:59 GMT
< Content-Length: 28
< Content-Type: text/plain; charset=utf-8
< 
unexpected error
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0

ステータスコードとして200が返ってきました。これは先にも述べたとおり、 rw.WriteHeader() 呼び出し前に rw.Write() を呼び出すと、暗黙的に rw.WriteHeader(http.StatusOK) が呼ばれてしまうからです。 一方でレスポンスボディにはエラーメッセージが書き込まれてしまいました。

厄介なことに、クライアント側としてはステータスコードとして200が返ってきてるので、リクエストが正常に処理されたものとして解釈してしまいます。 これに関してはもはやどうしようもないので、クライアント側で頑張ってもらいましょう。

サーバーサイドでは、せめて次のようにロギングをしてあげるのが無難なところかなと思います。

func handle(rw http.ResponseWriter, r *http.Request) {
    if _, err := rw.Write([]byte("hello world")); err != nil {
        log.Printf("failed to write to response body: %s", err.Error())
    }
}

(オマケ) Request.Body は閉じなくて良い

初学者で頑張って調査をしてくれた人の中には、 *http.Request にも Body があり、それが io.ReadCloser インターフェースを実装していることに気付いてくれる人がいます。 これを受けて、ファイル操作をするときのように次のようなコードを書く場合があります。

func handle(rw http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    ...
}

この一文、とても丁寧なのですが実は不要だったりします。 答えはまたしてもnet/httpパッケージのドキュメントにあります。

For server requests, the Request Body is always non-nil but will return EOF immediately when no body is present. The Server will close the request body. The ServeHTTP Handler does not need to.

リクエストボディはサーバーが自動的に閉じてくれるため、ハンドラー内で閉じる処理を入れなくて良いそうです。

まとめ

Goの net/http パッケージを(特にサーバー利用で)利用する上で気をつけるべきポイントをいくつか挙げました。

net/http パッケージは、 *http.Request をサーバーを構築するときとクライアントを構築するときとで共通化してしまっているなど、いくつか初学者を混乱させる要素を持っています。 今絶賛開発が進められているGo 2では net/http パッケージの再デザインも提案の一部に入ってた……とどこかで聞いた記憶があるのですが、あの話どうなったんだろう。

……( ゚д゚)ハッ!

冒頭にも触れましたが、弊社サーバーサイドはGoで開発をしてます! Goエンジニアの皆様、ご応募お待ちしております!

herp.careers

参考文献

Pull RequestがマージされたときだけSlackに通知するGitHub Actionsワークフローの設定

はじめに

この記事は以前書いたGitHub ActionsからSlackに通知を飛ばす方法の続きです。久しぶりにこのワークフローをいじる機会があったので備忘録です。 前の記事も併せて御覧ください。

furusax0621.hatenablog.com

furusax0621.hatenablog.com

furusax0621.hatenablog.com

前回までのおさらい

API定義の変更や新規APIの追加によりSwaggerファイルが更新された際に、Slackに通知するワークフローを書いてました。ありがたいお便りを受けて、最終的に次のように実装していました。

name: notify
on:
  pull_request:
    types: ['closed']
    paths:
      - 'webapi/swagger/**'
    branches:
      - main

jobs:
  notify:
    name: Slack Notification
    runs-on: ubuntu-latest
    steps:
      - name: 'Send Notification'
        run: |
          cat "$GITHUB_EVENT_PATH" | jq '{
            attachments: [{
              pretext: "Swagger が更新されたよ!",
              color: "good",
              title: .pull_request.title,
              title_link: .pull_request.html_url
            }]
          }' | curl -H 'Content-Type: application/json' -d @- ${{ secrets.SLACK_WEBHOOK }}

Pull Request イベントのアクションが closed となったときに走るワークフローとして登録しています。 $GITHUB_EVENT_PATH で読み取っているファイルには、このGitHub Actionが走ったイベントのペイロードが格納されています。

ここでひとつ問題なのが、Pull Requestイベントの closed アクションはPull Requestが閉じたときであり、マージされたかどうかは関係ないということです。 とあるPull RequestでSwaggerファイルに変更を入れたけど、何らかの理由でPull Requestをマージせずに閉じちゃった……なんてケースでもSlackにお知らせが飛んでしまいます。

じゃあどうするか?

答えは簡単で、同じPull Requestイベントのペイロードmerged というマージされたかどうかを表すフィールドがあります。GitHubの公式ドキュメントにも

If the action is closed and the merged key is false, the pull request was closed with unmerged commits. If the action is closed and the merged key is true, the pull request was merged.

とバッチリ明記されてます。というわけでワークフローを次のように書き換えればOKです。

jobs:
  notify:
    name: Slack Notification
    runs-on: ubuntu-latest
    steps:
      - name: 'Send Notification'
        if: github.event.Pull_request.merged
        run: |
          cat "$GITHUB_EVENT_PATH" | jq '{
            attachments: [{
              pretext: "Swagger が更新されたよ!",
              color: "good",
              title: .pull_request.title,
              title_link: .pull_request.html_url
            }]
          }' | curl -H 'Content-Type: application/json' -d @- ${{ secrets.SLACK_WEBHOOK }}

merged は true or false をとるので if 句にそのまま渡すことができます。これでPull RequestがマージされたときだけSlack通知が飛ぶようになりました。

……しかし、ちょっと調べればすぐ行き着きそうな答えに当時なぜ気付かなかったんでしょうね。。

まとめ

以前実装したワークフローを改良し、Pull Requestがマージされたときだけ実行されるようにしました。

みんな、公式ドキュメントはちゃんと読みましょう。お兄さんとの約束だ

参考