BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

私がGoのソースコードを読むときのTips

f:id:budougumi0617:20201223101620p:plain
私がGoのソースコードを読むときのTips

この記事はBASE Advent Calendar 2020の23日目の記事です。

devblog.thebase.in

BASE BANK 株式会社 Dev Division でSoftware Developer をしている清水(@budougumi0617)です。

freeeさんのAdvent Calendarでも同様の話題がありましたが1、私も今回はソースコードリーディング(Go)について書かせていただきます。

なぜ読むのか

まずなぜコードリーディングをするのでしょうか。

Goに限らず業務で多用される言語やフレームワークはさまざまなリッチな機能を提供しています。
それらを利用すればサンプルコードを少し編集してつなぎ合わせるだけでも動くコードを実装できます。
しかし、問題に直面したりあるいはバグなのか自分の使い方が悪いのかわからない場合、コードの中身を理解している必要があります。

ライブラリやツールのコードを読む

我々のGoのプロダクトは標準ライブラリの他にサードパーティのOSSをいくつか組み合わせることでwebサービスを実現しています。
車輪の再発明はしなくてもその車輪がどのような作りになっているのかは把握しておくと不具合発生時に安心です。

同様に普段利用しているツールの挙動を把握するにもコードリーディングは有用です。 弊社ではTerraformecspressoなどのツールを利用しています。
上記ツール以外にも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を検索することで仕様を確認できます。

pkg.go.dev

DNSリゾルバについて調べたところ、netパッケージにセクションが設けられ仕様が記載されていました。
普段はnet/httpパッケージばかりみているのでまったく読んだことがありませんでした。

pkg.go.dev

関連記事と一緒に読む

仕様の他にStack Overflowやブログ記事に情報がないか検索してみます。
Goは「Go」なのですが、検索のときは素直に「golang」で検索します。
今回は「golang dns resolver」「golang dns ttl」などでググりました。

(これは暗黙知ですが)信頼できるGopherがDNSリゾルバについて記事を書いていたので参考にしてみます。

qiita.com

http#Client構造体に自前のDNSリゾルバを設定する方法がわかりました。デバッグコードを仕込んで自前実装を差し込んで検証してもよいかもしれません。

shogo82148.github.io

Go1.7からnet/http/httptraceというパッケージが追加され、 名前解決やコネクション確立etcのタイミングにフックを仕込めるようになりました。 これを利用すれば各段階でどの程度時間がかかっているかが具体的に分かるはずです。

頑張って自前でフックを差し込んでもよいのですが、 deeeetさんのgo-httpstatという便利パッケージがあるので、 これをありがたく利用させていただきます。 go-httpstatを使うと時間計測を行うコードを簡単に差し込むことができます。

こちらを読むと、仕様でデバッグログのようなものを簡単に出力できることがわかりました。

pkg.go.dev

github.com

これを使って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が動いています。

github.com

詳細な操作方法は割愛しますが、デバッグモードで起動しておけば、Webサーバのプロセスや起動中のDocker上のプロセスのデバッグも可能です。

pleiades.io

前述のhttptraceの知識より、DNSリゾルバの処理が走るときはDNSStartメソッドが呼ばれることを知っていたので、その周辺にブレークポイントを設置して動かしてみます。
私はGoLandを使ってデバッグしていますが、gdbなどに慣れていればターミナルからステップ実行など可能です。
実際にブレークポイントを使ってデバッグしているときの状態が次のスクショ画像です。

f:id:budougumi0617:20201223102916p:plain
GoLandでデバッグ中

私はコードを読むだけでは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ください。

herp.careers

明日はBASEデザインチームの河越さんです!

参考リンク


  1. https://developers.freee.co.jp/entry/how-to-read-source-code-of-middleware

  2. むしろGoで書かれていることがツール選定理由のひとつになりえます。

  3. strings.TrimRight関数とstrings.TrimPrefix関数のような例もありますが。