この記事はBASE Advent Calendar 2020の23日目の記事です。
BASE BANK 株式会社 Dev Division でSoftware Developer をしている清水(@budougumi0617)です。
freeeさんのAdvent Calendarでも同様の話題がありましたが1、私も今回はソースコードリーディング(Go)について書かせていただきます。
なぜ読むのか
まずなぜコードリーディングをするのでしょうか。
Goに限らず業務で多用される言語やフレームワークはさまざまなリッチな機能を提供しています。
それらを利用すればサンプルコードを少し編集してつなぎ合わせるだけでも動くコードを実装できます。
しかし、問題に直面したりあるいはバグなのか自分の使い方が悪いのかわからない場合、コードの中身を理解している必要があります。
ライブラリやツールのコードを読む
我々のGoのプロダクトは標準ライブラリの他にサードパーティのOSSをいくつか組み合わせることでwebサービスを実現しています。
車輪の再発明はしなくてもその車輪がどのような作りになっているのかは把握しておくと不具合発生時に安心です。
同様に普段利用しているツールの挙動を把握するにもコードリーディングは有用です。
弊社ではTerraformやecspressoなどのツールを利用しています。
上記ツール以外にもGoで書かれているツールが多いため、すこし不思議な挙動があってもコードで確認できます2。
言語のフォーマルなコーディングを学ぶ
GoはGoで実装されているため、Goが読めればその内部実装を読めます。
標準パッケージのコードを読むことには次のようなメリットがあります。
- Goチームが実装・レビューした命名規則やテストの書き方がわかる
- その他のコードと比較してあらゆる場面を想定された実装がされている
- 実装者と見ず知らずの第三者が使っても呼び出し方を間違えないような設計がされている3
標準パッケージはGopher全員が利用するパッケージです。
多くの呼び出し状況に対応する内部実装とメソッドシグネチャは大いに設計の参考になるでしょう。
また、たとえば変数名などで迷ったときは標準パッケージをgrepすることで「Go way」を類推することもできます。
コードリーディングをするときのTips
「ではGitHubを開いてコードを読みましょう!!」…では難しいですね。コードリーディングを効率的に行なうために私が実践しているTipsをいくつか紹介します。
今回は私が直近で読んだDNSリゾルバを例にします。
具体的には「webサーバでリクエストを受け取るたびにhttp#Client
オブジェクトを作って外部APIを叩く実装を書いてるけど、これってDNS Lookupとかどうなるんだっけ?」というようなことを調べていました。
// 該当エンドポイントにリクエストを受け取るたびに外部APIにHTTP通信するハンドラ func indexHandler(w http.ResponseWriter, r *http.Request) { // Prepare request for another service cli := &http.Client{} res, err := cli.Do(req) // Parse response }
IDEを使って読む
元も子もないですが、GoLandやLSPを使って読むのが圧倒的に速いです。
特にGoの場合は明示的にインターフェイスを実装しないので、「このインターフェイスを満たす実装は?」ということを調べるときはIDEに頼ったほうがよいでしょう。
godocと一緒に読む
まずは何にせよ仕様を確認します。
最近のGoは標準パッケージも含めてpkg.go.devを検索することで仕様を確認できます。
DNSリゾルバについて調べたところ、net
パッケージにセクションが設けられ仕様が記載されていました。
普段はnet/http
パッケージばかりみているのでまったく読んだことがありませんでした。
関連記事と一緒に読む
仕様の他にStack Overflowやブログ記事に情報がないか検索してみます。
Goは「Go」なのですが、検索のときは素直に「golang」で検索します。
今回は「golang dns resolver
」「golang dns ttl
」などでググりました。
(これは暗黙知ですが)信頼できるGopherがDNSリゾルバについて記事を書いていたので参考にしてみます。
http#Client
構造体に自前のDNSリゾルバを設定する方法がわかりました。デバッグコードを仕込んで自前実装を差し込んで検証してもよいかもしれません。
Go1.7からnet/http/httptraceというパッケージが追加され、 名前解決やコネクション確立etcのタイミングにフックを仕込めるようになりました。 これを利用すれば各段階でどの程度時間がかかっているかが具体的に分かるはずです。
頑張って自前でフックを差し込んでもよいのですが、 deeeetさんのgo-httpstatという便利パッケージがあるので、 これをありがたく利用させていただきます。 go-httpstatを使うと時間計測を行うコードを簡単に差し込むことができます。
こちらを読むと、仕様でデバッグログのようなものを簡単に出力できることがわかりました。
これを使ってDNSリゾルバの挙動を検証するコードを書いてみます。
動かしながら読む
コードを読むだけより動かしたほうが圧倒的に理解度が上がるので、ミニマムな検証コードを書きます。
今回は「リクエストを受けるたびにhttp#Client
オブジェクトしてリクエストを飛ばすwebサーバ」を実装しました。
// 表示スペースの都合上エラーハンドリングは省略 package main import ( "fmt" "io" "io/ioutil" "log" "net" "net/http" "time" "github.com/tcnksm/go-httpstat" ) func indexHandler(w http.ResponseWriter, r *http.Request) { var result httpstat.Result req, err := http.NewRequestWithContext( r.Context(), http.MethodGet, "https://budougumi0617.github.io", nil, ) ctx := httpstat.WithHTTPStat(req.Context(), &result) cli := &http.Client{} req = req.WithContext(ctx) res, _ := cli.Do(req) _, _ := io.Copy(ioutil.Discard, res.Body) res.Body.Close() result.End(time.Now()) w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintf(w, "response code: %+v", result) } func main() { srv := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(indexHandler), } srv.ListenAndServe() }
普通の動作確認だったら実行がしやすいテストコードの形式でも良いと思います。
今回は「リクエストを受けるたびに(毎回別のgroutine上で)」という状況の挙動が知りたかったのでwebサーバの検証コードを書きました。
また、ネットワークという比較的OS層に近いロジックの動作を確認したかったため運用同様Linux上で動作させたくDockerfileも用意しました。
FROM golang:1.15.6-alpine3.12 as build-env ENV CGO_ENABLED 0 RUN apk add --no-cache git WORKDIR /debuggingTutorial/ ADD . /debuggingTutorial/ RUN go build -o /debuggingTutorial/srv . WORKDIR /go/src/ RUN go get github.com/go-delve/delve/cmd/dlv # 後述するデバッグ用の設定 FROM alpine:3.12 as debugger WORKDIR / COPY --from=build-env /debuggingTutorial/srv / COPY --from=build-env /go/bin/dlv / EXPOSE 8080 40000 CMD ["/dlv", "--listen=:40000", "--headless=true", "--api-version=2", "exec", "/srv"] # デバッガのアタッチなしで起動させる設定 FROM alpine:3.12 as server COPY --from=build-env /debuggingTutorial/srv / EXPOSE 8080 CMD ["/srv"]
Dockerで立ち上げたwebサーバへ2回リクエストを送ったときのログです。
$ docker build -t til/debug --target server . && docker run -d --rm -p 18080:8080 --name resolver til/debug $ curl localhost:18080/ response code: DNS lookup: 4 ms TCP connection: 21 ms TLS handshake: 89 ms Server processing: 10 ms Content transfer: 1 ms Name Lookup: 4 ms Connect: 25 ms Pre Transfer: 115 ms Start Transfer: 127 ms Total: 128 ms $ curl localhost:18080/ response code: DNS lookup: 0 ms TCP connection: 0 ms TLS handshake: 0 ms Server processing: 8 ms Content transfer: 1 ms Name Lookup: 0 ms Connect: 0 ms Pre Transfer: 0 ms Start Transfer: 8 ms Total: 9 ms
実際に動作させてみると、リクエストを受け取るたびにhttp.Client
構造体を新規に生成しているのに2回目のhttptraceではTCPコネクションとDNS Lookupが省略されています。
これはhttp.DefaultTransport
経由でコネクションが使い回されているからなのですが、正直実際に検証するまで自覚せずにつかっていました。
デバッグしながら読む
次のような処理が多いとただコードを読むだけではあまり理解が進みません。
- クロージャが多く実動作でどんな関数(ロジック)が呼ばれているのかわからない
inteface{}
型の変数やスライスに何が入るのかわからない
こんなときはデバッガを利用して実際に動かしたときのメモリの状態を確認します。
Goの場合はdelve
というOSSを使うことでデバッグできます。
操作感覚はgdbに近いです。VS CodeやJetBrains IDEでデバッグをするときも裏でdelveが動いています。
詳細な操作方法は割愛しますが、デバッグモードで起動しておけば、Webサーバのプロセスや起動中のDocker上のプロセスのデバッグも可能です。
前述のhttptrace
の知識より、DNSリゾルバの処理が走るときはDNSStart
メソッドが呼ばれることを知っていたので、その周辺にブレークポイントを設置して動かしてみます。
私はGoLandを使ってデバッグしていますが、gdbなどに慣れていればターミナルからステップ実行など可能です。
実際にブレークポイントを使ってデバッグしているときの状態が次のスクショ画像です。
私はコードを読むだけではhttp#Client.Do
メソッドからうまくnet#Resolver.lookupIPAddr
メソッドまでまでたどり着けていませんでした。
しかし、デバッガで止めてスタックトレースを確認することどの関数やメソッドを経由してnet#Resolver.lookupIPAddr
メソッドが呼ばれているのかわかりました。
上記のTipsをオブジェクトのフィールド値を少し変更してから繰り返すことでコードの挙動を読み解いていきます。
みんなで一緒に読む
コツや勘所がわかってくるとコードリーディングのスピードもどんどん速くなっていきます。
しかし、「コツや勘所がわかるためにはコードをたくさん読まないといけない」という鶏卵問題もあります。
それに対して、弊社ではBASEメンバーと合同で定期的にGoコードリーディングパーティを実施しています。
ひとりでは詰まってしまうようなこともみんなで見ていると解決の糸口がみつかったり、他のメンバーのエディタ捌きを盗むことができます。
お題にするコードはその時の参加者の気分で特に決まっていません。
- 業務でツールを使う前にどんな動きをするのか読みたい
- この前
terraform apply
で失敗したときのエラーメッセージがどう出ているか確認したい - Go1.15で追加された機能がどのように実装されているのか読んでみたい
- terraform-provider-awsにPRを作るのでテストケースを考えたい
その時々の課題感でコードを読むので、自分では読もうと思っていなかったコードを読んだり、問題解決のきっかけになったりと毎回勉強になっています。
終わりに
今回の記事ではコードリーディングの重要さと私がコードを読むときによくやる方法をいくつか紹介させていただきました。
何かひとつでも参考になると幸いです。
最後に、今回はコードリーディングにフォーカスしましたが、BASE BANKの開発チームでは自分たちで開発したサービス・機能をグロース・サポートまで担当します。
2021年はコードだけでなくシステム開発ライフサイクル全般に積極的に関わっていきたいぞ!という方は@budougumi0617までDMください。
明日はBASEデザインチームの河越さんです!
参考リンク
- https://pkg.go.dev
- https://github.com/go-delve/delve
- https://pleiades.io/help/go/attach-to-running-go-processes-with-debugger.html
- https://developers.freee.co.jp/entry/how-to-read-source-code-of-middleware↩
- むしろGoで書かれていることがツール選定理由のひとつになりえます。↩
- strings.TrimRight関数とstrings.TrimPrefix関数のような例もありますが。↩