blog.syfm

徒然なるままに考えていることなどを書いていくブログ

一年を振り返り… (2020)

f:id:ktr_0731:20201231032607p:plain

仕事

去年と変わらずメルペイのコード決済チームでバックエンドエンジニアをしていた。
今年関わったプロジェクトの中でもっとも大きかったのはメルペイと d 払いの QR コード (MPM) 統一だった。これによってメルペイの QR コードを d 払いアプリで読み込んで決済ができるようになった。個人的には今はなき MoPA を思い出す Yet Another MoPA だったので今度こそは、という気概があった。また、決済周りの開発のコアメンバーとして携われ、今まで得た技術・運用知識を元にベストな設計を模索できる時間が十分にもらえたのもありがたかった。実際にリリースされると、店頭で新しいアクセプタンスマークを見ることが多くなったので達成感も非常に大きかった。

jp.merpay.com

また、運用を二年もしているとあらゆるパターンの障害やデータ不整合を経験するので、これらによって発生する問題を解消するためにどうするかを考えることも多かった。単純にデータ不整合を解消するだけであれば比較的容易であっても、ビジネス要件からデータ不整合の状態が許容される時間が制限されるので、いかにしてデータ不整合の未解消時間を短くするかが一番中心の課題だった。

技術

仕事面では去年と変わらず同じチームに所属していたため、扱っていた技術はあまり大きな変化はなかった。Cloud Spanner や Cloud Pub/Sub、Kubernetes、Go、gRPC、Protocol Buffers あたりが最もよく使っていた技術だと思う。Cloud Spanner の気持ちも少しずつわかってきて、クエリを見てそのクエリがどんな感じで実行されるのかがわかるようになってきたし、シンプルに記述するだけではどうやっても効率が悪くなるクエリをどう書き換えれば良いかがだんだん掴めるようになってきた。*1

また、社内で Istio の導入が始まっているため、Istio を始めとした Service Mesh 関連技術の勉強を始めた。

ソフトウェアアーキテクチャパターンへの関心が薄れてきて、高凝集で疎結合かつ SOLID 原則に基づいたモジュール分割ができていればそれで良いというシンプルな考えかたでコードを書くことが多くなった。おそらくこのあたりの考え方は以前に読んだ A Philosophy of Software Design の影響が強い。

プライベートではもっぱら Go を書いていることが多い。もともとプログラミング言語自体にあんまり関心がないので、自分がやりたいことをシンプルに記述できる言語である Go ばかり使っているし、今後も使い続けると思う。
趣味のプログラミングではバックエンドだけでなくフロントエンドも書くことが多かったので、JavaScript や TypeScript もよく使っていた。最近書いていたアプリケーションでは TypeScript、Vue.js、Nuxt.js、Apollo GraphQL あたりを使っていた。このあたりの技術はかなり Developer Experience が良くて好き。

執筆

Software Design へメルカリ・メルペイエンジニアが寄稿している連載の一環でゴルーチンとチャネルについての記事を書いた。普段の業務でゴルーチンやチャネルをそのまま扱うようなコードを書くことがあまりないため、自分自身にとっても良い頭の整理となった。また、自分が Go を学習しているときに読んでいた、プログラミング言語 Go を読み返してその内容の深さに改めて驚いた。

個人ブログではあまり多く執筆できなかった。ちゃんと意識していないとどんどん書かないようになってしまうので些細なことでもしっかりまとめてアウトプットしていけるように心がけたい。

イベント登壇

Merpay Tech Fest 2020 で登壇する予定だったが、新型コロナの影響でイベント自体が中止になった。12 月に Software Design へ寄稿したメルペイメンバーによる座談会で話したりはしたものの、今年はイベントで登壇することがほとんどなかった。リモート開催のイベントでスピーカーになる意欲はあまりなく、今と同じ状況が続くのであればしばらくは登壇することはないだろうなと漠然と感じる。

OSS

引き続き Evans や go-fuzzyfinder のメンテナンスをしていた。ただ、Evans へ新しく機能追加するモチベーションがなくなってきているので、v1 を見据えてそろそろ Evans の今後をどうするか考えないといけないなと漠然と感じている。go-fuzzyfinder については、利用してくれるツールが増えてきていたり、コントリビュートしてくれる人が増えてきているのでとても嬉しい。インターフェースのシンプルさがかなり気に入っているので、このシンプルさを保ったまま継続的に改善していければ、と思う。

自作 OSS の影響で自分が知っているツールを開発している企業からリファラル DM が飛んできたり *2、自作 OSS を紹介している記事が増えてきたりしていてとても嬉しい。自分がつくったものが実際に使われているのを目撃するとやっぱり嬉しいし、頑張ろうという励みになる。

自作 OSS 以外へのコントリビューションは、普段利用しているライブラリやツールのバグを見つけたときに行うことが多かった。GCP の Go ライブラリや Datadog APM の Go ライブラリ、protoreflect など。思ったより少なかったのでもう少し OSS のコードを読む量を増やしていきたい。

GitHub Sponsors では去年に引き続き 4 名もの方から支援をいただいていて、とても励みになっている。支援に見合うだけの動きができるように努力したい。

読んだ本

技術書

  • Database Internals
  • Istio: Up and Running
  • Microservices Patterns
  • OAuth徹底入門 セキュアな認可システムを適用するための原則と実践
  • Kubernetesで実践するクラウドネイティブDevOps
  • 人月の神話

かなり少ない…。月に最低一冊くらいは読んでいきたい。

ビジネス書

どちらもかなり読み応えのある本だった。Winny については金子勇自身が書いた本も読んでみたい。

ルポルタージュ

  • サカナとヤクザ: 暴力団の巨大資金源「密漁ビジネス」を追う

知らないことだらけで面白い。

哲学書

  • 愛するということ

哲学が好きなので引き込まれた。また読み返したい。

漫画

サマータイムレンダ、ランウェイで笑って、進撃の巨人が特に面白かった。

旅行

本来であれば草津や、毎年恒例の ISUCON メンバーでの旅行など、もっと多くの旅行をするつもりが新型コロナによって大きく予定が狂ってしまった。来年は少しは落ち着きますように 🙏

沖縄

閑散期である 1 月に行った。航空券は往復で 8000 円程度と非常に安くなっていた。また、大学の友人らと行ったため Airbnb で一軒家の宿を借りたところ、広々とした綺麗な家で大満足だった。アメリカンビレッジへも徒歩で行ける距離だったので夜ごはんや飲みのために繰り出したりできて便利だった。

京都

Go To トラベルが適用されていた 12 月の第一週に行った。東京 ⇔ 京都の新幹線往復券 + 一泊で 15000 円に加え、地域共通クーポン 3500 円分、京都タワーでもらえた謎の商品券 1000 円分が加わり実質 10500 円程度という異次元の安さだった。ちょうど Go To トラベルを停止するべきという声が大きくなっていた時期だったのもあり、人出は思った以上に少なくなっていた。特に清水寺二年坂あたりは普段であれば尋常じゃない人がいるのに、今回は周りを気にせず歩けるくらいに人が少なかった。また、はてなインターン同窓会ぶりにカマルへ行けたので良かった。

f:id:ktr_0731:20201229133301j:plainf:id:ktr_0731:20201229133305j:plainf:id:ktr_0731:20201229133453j:plainf:id:ktr_0731:20201229133306j:plain

観たアニメ

あんまり観た記憶がなかったけど、履歴を元に洗い出したら結構な数のアニメを観ていた…。テレビアニメだと思い出補正もあるだろうけどイエスタデイをうたってが特に良かった。劇場アニメだと千と千尋の神隠しを除くと SHIROBAKO が最も良かった。観ると仕事を頑張ろうという気持ちにさせてくれるようなアニメだし、制作陣がやりたいことをやっているのを感じられて良かった。円盤も買ったので年明けが楽しみ。千と千尋の神隠しは最も好きな・影響を受けたアニメーション作品なので、今の時代に劇場で観れたことが本当に嬉しい。

やったゲーム

十三機兵防衛圏はかなりストーリーが難解で、かなり後半にならないと全貌がわからなかったが、伏線が張り巡らされていてとても面白かった。Detroit: Become Human は今年やったゲームのなかで一番面白かった。*3 哲学的な面も多くあるのでこういった近未来 SF はとても惹きつけられたし、ゲームシステムも独特で常に映画を観ているような臨場感があった。コナーとハンクの関係が好きすぎる。

人生

父方の祖父が亡くなった。きわめて身近な人の死に触れたのは初めてだったのでとても哀しかったし、家族や自分自身の死を考えてしまって改めて死への恐怖を感じるようになってしまったのとともに、日常の変化からだんだんと身近な人の死が近づいてくるのが感じ取れるようになってしまって苦しい。人間いつかは死ぬものの、今は死ぬのに未練がありすぎるし早く電脳化したい。

終わりに

今年は新型コロナによって生活が大きく変わり、常識だったものがそうではなくなってしまった一年だった。その影響で苦しいことが増えた一年だったものの、嬉しいことや楽しいこともまた同じように感じられた一年でもあった。WFH をしていると変化のない日常が繰り返されているかのように感じてしまうので、来年はいろいろな変化をつけられるような年にしていきたい。


去年

syfm.hatenablog.com

*1:とはいえ必ず実行計画を見るべきだけど…。

*2:今の所転職するつもりはないので断った…。

*3:そもそも本数が少ないけど

curl から Go の API クライアントを自動生成する

これは Aizu Advent Calendar 2020 の 10 日目の記事です。9 日目は虚無さん、11 日目は虚無さんです。


curl を使って API クライアントやリクエスト・レスポンス型を自動生成したいと思ったことはありませんか?わたしはあります。ということでつくりました。

github.com

使い方

apigenCLI ツールではなく、apigen をインポートしてジェネレータを書き、実行することでコード生成を行います。CLI ツールにしていない理由は、API 定義を与える方法が難しく、逆に複雑さが増してしまうからです。Go のコードを自動生成したいユーザであれば間違いなく Go の実行環境があるのでジェネレータを Go で実装させるような方式としています。

まず、API サーバが持つサービス (複数のメソッドを包括したものの名称) やそのサービスが持つメソッドを表す *apigen.Definition を定義する必要があります。以下の例では Dummy サービスに CreatePostListPostsGetPost メソッドを定義しています。Request フィールドは実行環境を示しており、コード生成時にこの環境で実際に各メソッドを叩いていきます。この例であればいずれのメソッドも curl を使ってリクエストを行うことになります。Request フィールドの型 RequestFunc を満たすものであればどのような実装でも良いため、curl が好きではない人は Wget を使ったり、Go の *http.Client を使うということもできます。

ParamHint はパスパラメータ (/posts/11 のようなリソースによって変化する部分) を解釈するためのものです。これはパスパラメータを利用しているメソッドでは必須となります。

def := &apigen.Definition{
    Services: map[string][]*apigen.Method{
        "Dummy": {
            {
                Name:    "CreatePost",
                Request: curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts' 
                      --data-binary '{"title":"foo","body":"bar","userId":1}'`,
                ),
            },
            {
                Name:    "ListPosts",
                Request: curl.ParseCommand(`curl https://jsonplaceholder.typicode.com/posts`),
            },
            {
                Name:    "GetPost",
                Request: curl.ParseCommand(`curl https://jsonplaceholder.typicode.com/posts?id=1`),
            },
            {
                Name:      "UpdatePost",
                Request:   curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts/1' 
                      -X 'PUT' --data-binary '{"title":"foo","body":"bar","userId":1}'`,
                ),
                ParamHint: "/posts/{postID}",
            },
            {
                Name:      "DeletePost",
                Request:   curl.ParseCommand(`
                  curl 'https://jsonplaceholder.typicode.com/posts/1' -X 'DELETE'`,
                ),
                ParamHint: "/posts/{postID}",
            },
        },
    },
}

この定義を使って実際にコード生成を行います。コード生成を行うには apigen.Generate を呼び出します。apigen.WithWriter オプションを使うと出力先を変更することができます。デフォルトは標準出力です。

f, err := os.Create("client_gen.go")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

if err := apigen.Generate(context.Background(), def, apigen.WithWriter(f)); err != nil {
    log.Fatal(err)
}

生成した API クライアントを使って実際にリクエストを投げてみます。例では client.ConvertStatusCodeToErrorInterceptor() を使って 2XX 系以外のステータスコードをエラーに変換していますが、より現実のアプリケーションでは独自にエラー変換用の interceptor を定義するのが良いでしょう。

client := NewDummyClient(client.WithInterceptors(client.ConvertStatusCodeToErrorInterceptor()))

res, err := client.GetPost(context.Background(), &GetPostRequest{ID: "10"})
if err != nil {
    log.Fatal(err)
}

b, err := json.MarshalIndent(&res, "", "  ")
if err != nil {
        log.Fatal(err)
}

fmt.Println(string(b))

以下のように正しくレスポンスが返り、デコードすることができました。

[
  {
    "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error",
    "id": 10,
    "title": "optio molestias id quia eum",
    "userId": 1
  }
]

もうちょっと複雑な例を試してみましょう。GitHub APIリポジトリ取得 API を使ってみます。

var def = &apigen.Definition{
    Services: map[string][]*apigen.Method{
        "GitHub": {
            {
                Name:      "GetRepository",
                Request:   curl.ParseCommand(`curl https://api.github.com/repos/ktr0731/go-fuzzyfinder`),
                ParamHint: "/repos/{owner}/{repo}",
            },
        },
    },
}

func main() {
    if err := apigen.Generate(context.Background(), def, apigen.WithPackage("main")); err != nil {
        log.Fatal(err)
    }
}

実行結果 (レスポンスフィールドが多いので折りたたみ)

// Code generated by apigen; DO NOT EDIT.
// github.com/ktr0731/apigen

package main

import (
    "context"
    "fmt"
    "net/url"

    "github.com/ktr0731/apigen/client"
)

type GitHubClient interface {
    GetRepository(ctx context.Context, req *GetRepositoryRequest) (*GetRepositoryResponse, error)
}

type gitHubClient struct {
    *client.Client
}

func NewGitHubClient(opts ...client.Option) GitHubClient {
    return &gitHubClient{Client: client.New(opts...)}
}

func (c *gitHubClient) GetRepository(ctx context.Context, req *GetRepositoryRequest) (*GetRepositoryResponse, error) {
    u, err := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s", req.Owner, req.Repo))
    if err != nil {
        return nil, err
    }

    var res GetRepositoryResponse
    err = c.Do(ctx, "GET", u, nil, &res)
    return &res, err
}

type GetRepositoryRequest struct {
    Owner string
    Repo  string
}

type GetRepositoryResponse struct {
    ArchiveURL       string  `json:"archive_url,omitempty"`
    Archived         bool    `json:"archived,omitempty"`
    AssigneesURL     string  `json:"assignees_url,omitempty"`
    BlobsURL         string  `json:"blobs_url,omitempty"`
    BranchesURL      string  `json:"branches_url,omitempty"`
    CloneURL         string  `json:"clone_url,omitempty"`
    CollaboratorsURL string  `json:"collaborators_url,omitempty"`
    CommentsURL      string  `json:"comments_url,omitempty"`
    CommitsURL       string  `json:"commits_url,omitempty"`
    CompareURL       string  `json:"compare_url,omitempty"`
    ContentsURL      string  `json:"contents_url,omitempty"`
    ContributorsURL  string  `json:"contributors_url,omitempty"`
    CreatedAt        string  `json:"created_at,omitempty"`
    DefaultBranch    string  `json:"default_branch,omitempty"`
    DeploymentsURL   string  `json:"deployments_url,omitempty"`
    Description      string  `json:"description,omitempty"`
    Disabled         bool    `json:"disabled,omitempty"`
    DownloadsURL     string  `json:"downloads_url,omitempty"`
    EventsURL        string  `json:"events_url,omitempty"`
    Fork             bool    `json:"fork,omitempty"`
    Forks            float64 `json:"forks,omitempty"`
    ForksCount       float64 `json:"forks_count,omitempty"`
    ForksURL         string  `json:"forks_url,omitempty"`
    FullName         string  `json:"full_name,omitempty"`
    GitCommitsURL    string  `json:"git_commits_url,omitempty"`
    GitRefsURL       string  `json:"git_refs_url,omitempty"`
    GitTagsURL       string  `json:"git_tags_url,omitempty"`
    GitURL           string  `json:"git_url,omitempty"`
    HTMLURL          string  `json:"html_url,omitempty"`
    HasDownloads     bool    `json:"has_downloads,omitempty"`
    HasIssues        bool    `json:"has_issues,omitempty"`
    HasPages         bool    `json:"has_pages,omitempty"`
    HasProjects      bool    `json:"has_projects,omitempty"`
    HasWiki          bool    `json:"has_wiki,omitempty"`
    Homepage         string  `json:"homepage,omitempty"`
    HooksURL         string  `json:"hooks_url,omitempty"`
    ID               float64 `json:"id,omitempty"`
    IssueCommentURL  string  `json:"issue_comment_url,omitempty"`
    IssueEventsURL   string  `json:"issue_events_url,omitempty"`
    IssuesURL        string  `json:"issues_url,omitempty"`
    KeysURL          string  `json:"keys_url,omitempty"`
    LabelsURL        string  `json:"labels_url,omitempty"`
    Language         string  `json:"language,omitempty"`
    LanguagesURL     string  `json:"languages_url,omitempty"`
    License          struct {
        Key    string `json:"key,omitempty"`
        Name   string `json:"name,omitempty"`
        NodeID string `json:"node_id,omitempty"`
        SpdxID string `json:"spdx_id,omitempty"`
        URL    string `json:"url,omitempty"`
    } `json:"license,omitempty"`
    MergesURL        string      `json:"merges_url,omitempty"`
    MilestonesURL    string      `json:"milestones_url,omitempty"`
    MirrorURL        interface{} `json:"mirror_url,omitempty"`
    Name             string      `json:"name,omitempty"`
    NetworkCount     float64     `json:"network_count,omitempty"`
    NodeID           string      `json:"node_id,omitempty"`
    NotificationsURL string      `json:"notifications_url,omitempty"`
    OpenIssues       float64     `json:"open_issues,omitempty"`
    OpenIssuesCount  float64     `json:"open_issues_count,omitempty"`
    Owner            struct {
        AvatarURL         string  `json:"avatar_url,omitempty"`
        EventsURL         string  `json:"events_url,omitempty"`
        FollowersURL      string  `json:"followers_url,omitempty"`
        FollowingURL      string  `json:"following_url,omitempty"`
        GistsURL          string  `json:"gists_url,omitempty"`
        GravatarID        string  `json:"gravatar_id,omitempty"`
        HTMLURL           string  `json:"html_url,omitempty"`
        ID                float64 `json:"id,omitempty"`
        Login             string  `json:"login,omitempty"`
        NodeID            string  `json:"node_id,omitempty"`
        OrganizationsURL  string  `json:"organizations_url,omitempty"`
        ReceivedEventsURL string  `json:"received_events_url,omitempty"`
        ReposURL          string  `json:"repos_url,omitempty"`
        SiteAdmin         bool    `json:"site_admin,omitempty"`
        StarredURL        string  `json:"starred_url,omitempty"`
        SubscriptionsURL  string  `json:"subscriptions_url,omitempty"`
        Type              string  `json:"type,omitempty"`
        URL               string  `json:"url,omitempty"`
    } `json:"owner,omitempty"`
    Private          bool        `json:"private,omitempty"`
    PullsURL         string      `json:"pulls_url,omitempty"`
    PushedAt         string      `json:"pushed_at,omitempty"`
    ReleasesURL      string      `json:"releases_url,omitempty"`
    SSHURL           string      `json:"ssh_url,omitempty"`
    Size             float64     `json:"size,omitempty"`
    StargazersCount  float64     `json:"stargazers_count,omitempty"`
    StargazersURL    string      `json:"stargazers_url,omitempty"`
    StatusesURL      string      `json:"statuses_url,omitempty"`
    SubscribersCount float64     `json:"subscribers_count,omitempty"`
    SubscribersURL   string      `json:"subscribers_url,omitempty"`
    SubscriptionURL  string      `json:"subscription_url,omitempty"`
    SvnURL           string      `json:"svn_url,omitempty"`
    TagsURL          string      `json:"tags_url,omitempty"`
    TeamsURL         string      `json:"teams_url,omitempty"`
    TempCloneToken   interface{} `json:"temp_clone_token,omitempty"`
    TreesURL         string      `json:"trees_url,omitempty"`
    URL              string      `json:"url,omitempty"`
    UpdatedAt        string      `json:"updated_at,omitempty"`
    Watchers         float64     `json:"watchers,omitempty"`
    WatchersCount    float64     `json:"watchers_count,omitempty"`
}

実際に叩いてみます。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"

    "github.com/ktr0731/apigen/client"
)

func main() {
    client := NewGitHubClient(client.WithInterceptors(client.ConvertStatusCodeToErrorInterceptor()))

    res, err := client.GetRepository(context.Background(), &GetRepositoryRequest{
        Owner: "ktr0731",
        Repo:  "go-fuzzyfinder",
    })
    if err != nil {
        log.Fatal(err)
    }

    b, err := json.MarshalIndent(&res, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(b))
}

実行結果

{
  "archive_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/{archive_format}{/ref}",
  "assignees_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/assignees{/user}",
  "blobs_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/blobs{/sha}",
  "branches_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/branches{/branch}",
  "clone_url": "https://github.com/ktr0731/go-fuzzyfinder.git",
  "collaborators_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/collaborators{/collaborator}",
  "comments_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/comments{/number}",
  "commits_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/commits{/sha}",
  "compare_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/compare/{base}...{head}",
  "contents_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/contents/{+path}",
  "contributors_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/contributors",
  "created_at": "2019-01-24T08:04:06Z",
  "default_branch": "master",
  "deployments_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/deployments",
  "description": "fzf-like fuzzy-finder as a Go library",
  "downloads_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/downloads",
  "events_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/events",
  "forks": 18,
  "forks_count": 18,
  "forks_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/forks",
  "full_name": "ktr0731/go-fuzzyfinder",
  "git_commits_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/commits{/sha}",
  "git_refs_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/refs{/sha}",
  "git_tags_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/tags{/sha}",
  "git_url": "git://github.com/ktr0731/go-fuzzyfinder.git",
  "html_url": "https://github.com/ktr0731/go-fuzzyfinder",
  "has_downloads": true,
  "has_issues": true,
  "has_projects": true,
  "has_wiki": true,
  "hooks_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/hooks",
  "id": 167327805,
  "issue_comment_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues/comments{/number}",
  "issue_events_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues/events{/number}",
  "issues_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/issues{/number}",
  "keys_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/keys{/key_id}",
  "labels_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/labels{/name}",
  "language": "Go",
  "languages_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/languages",
  "license": {
    "key": "mit",
    "name": "MIT License",
    "node_id": "MDc6TGljZW5zZTEz",
    "spdx_id": "MIT",
    "url": "https://api.github.com/licenses/mit"
  },
  "merges_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/merges",
  "milestones_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/milestones{/number}",
  "name": "go-fuzzyfinder",
  "network_count": 18,
  "node_id": "MDEwOlJlcG9zaXRvcnkxNjczMjc4MDU=",
  "notifications_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/notifications{?since,all,participating}",
  "open_issues": 3,
  "open_issues_count": 3,
  "owner": {
    "avatar_url": "https://avatars1.githubusercontent.com/u/12953836?v=4",
    "events_url": "https://api.github.com/users/ktr0731/events{/privacy}",
    "followers_url": "https://api.github.com/users/ktr0731/followers",
    "following_url": "https://api.github.com/users/ktr0731/following{/other_user}",
    "gists_url": "https://api.github.com/users/ktr0731/gists{/gist_id}",
    "html_url": "https://github.com/ktr0731",
    "id": 12953836,
    "login": "ktr0731",
    "node_id": "MDQ6VXNlcjEyOTUzODM2",
    "organizations_url": "https://api.github.com/users/ktr0731/orgs",
    "received_events_url": "https://api.github.com/users/ktr0731/received_events",
    "repos_url": "https://api.github.com/users/ktr0731/repos",
    "starred_url": "https://api.github.com/users/ktr0731/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/ktr0731/subscriptions",
    "type": "User",
    "url": "https://api.github.com/users/ktr0731"
  },
  "pulls_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/pulls{/number}",
  "pushed_at": "2020-12-03T18:19:49Z",
  "releases_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/releases{/id}",
  "ssh_url": "git@github.com:ktr0731/go-fuzzyfinder.git",
  "size": 135,
  "stargazers_count": 199,
  "stargazers_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/stargazers",
  "statuses_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/statuses/{sha}",
  "subscribers_count": 5,
  "subscribers_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/subscribers",
  "subscription_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/subscription",
  "svn_url": "https://github.com/ktr0731/go-fuzzyfinder",
  "tags_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/tags",
  "teams_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/teams",
  "trees_url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder/git/trees{/sha}",
  "url": "https://api.github.com/repos/ktr0731/go-fuzzyfinder",
  "updated_at": "2020-12-09T13:01:20Z",
  "watchers": 199,
  "watchers_count": 199
}

ちゃんとすべてのフィールドに値が入っています。このように、apigen を使うと簡単に API クライアントを生成することができます。

実装

apigen は以下のようなプロセスでコード生成を行っています。

  1. API 定義を読み込む
  2. API 定義からリクエストを作成
  3. メソッド呼び出し
  4. API 定義とリクエストからリクエスト型を作成
  5. メソッドのレスポンスからレスポンス型を作成
  6. API 定義、リクエスト型、レスポンス型からコード生成

このうち、特徴的な部分をいくつか紹介します。

API 定義からリクエストを作成

curl を使ったコマンドからリクエストを作成しているこの処理では、実際に curl を叩いているわけではなく、curl が備えているフラグをパースしたあとに *http.Request を作成しています。渡されたコマンド文字列を *pflag.FlagSet に食わせることで HTTP メソッドやリクエストボディ、ヘッダなどを取り出しています。

args, err := shellwords.Parse(cmd)
if err != nil {
    return nil, fmt.Errorf("failed to parse command '%s', err = '%s': %w", cmd, err, apigen.ErrInvalidDefinition)
}

for i := range args {
    args[i] = strings.TrimSpace(args[i])
}

var flags flags
fs := pflag.NewFlagSet("curl", pflag.ContinueOnError)
fs.StringArrayVarP(&flags.headers, "header", "H", nil, "")
fs.StringVarP(&flags.request, "request", "X", http.MethodGet, "")
fs.StringVar(&flags.data, "data-binary", "", "")
fs.StringVarP(&flags.data, "data", "d", "", "")
fs.BoolVar(&flags.compressed, "compressed", false, "")

if err := fs.Parse(args); err != nil {
    return nil, fmt.Errorf("failed to parse curl flags, err = '%s': %w", err, apigen.ErrInvalidDefinition)
}

すべてのフラグを網羅しているわけではないので登録されていないフラグを渡すとエラーになりますが、必要に応じて追加していけば良いと思い、今の段階では最低限のフラグのみ定義されています。

メソッドのレスポンスからレスポンス型を作成

レスポンスボディのスキーマをあらかじめ知ることはできないため、map[string]interface{} もしくは []interface{} へレスポンスボディをデコードしています。今の所コーデックは JSON のみをサポートしています。 encoding/json において、map[string]interface{}JSON オブジェクト、[]interface{} は配列のための型です。まずこの動的な型へデコードしてからオブジェクトのフィールドや配列の要素の型を特定していっています。

API 定義、リクエスト型、レスポンス型からコード生成

コード生成は text/template を一切使わずに 筋肉によってコード生成 しています。この方法は protoc-gen-go などでも採用されています。
text/template を使わない理由は牧さんのスライドで触れられている内容とほとんど同じです。

生成したコードは goimports が使っているパッケージである imports パッケージでフォーマットしています。

課題

レスポンスボディの JSON をソースにしてコード生成を行うという性質上、以下のようなことが課題として挙げられます。

  • null が入っているフィールドの型情報が取得できない
  • 動的に値が変わるレスポンスで適切なレスポンス型を生成できない

null フィールドの問題は JSON が静的型付けのスキーマを持っていないためどうしようもありません。null フィールドを少しでも減らすために同じメソッドに対する複数のリクエストのレスポンスを合成できるような機能があると良いかもしれませんが要検討です。
動的に値が変わるレスポンスへの対応も同じような方針で生成できないケースを減らすことができますが完璧ではありません。とはいえ、そういったレスポンスはアンチパターンである場合がほとんどなのでパブリックな Web API でそういったスキーマに出会うことは稀かと思います。

まとめ

apigen を使うと curl などの結果を元に Go の API クライアントのコードを自動生成することができます。一部うまく動作しない箇所もありますが、ほとんどのケースでうまく動作します。
自分は API クライアントの作成自体は結構好きなのですが、何度も同じことをやっていると虚無になるのでそういったときは自動化するのが良いですね。

gRPC のトランスポートを任意の実装に差し替えられる grpchan を試す

GitHub を眺めていたら protoreflect や gRPCurl で有名な jhump 氏が fullstorydev org 配下に面白いリポジトリを公開していることに気づいた。

github.com

grpchan は gRPC Channel の抽象を定義・提供し、HTTP/2 ではなくインメモリや HTTP/1.1 などのトランスポートを使うことを可能にしている。 grpchan のドキュメント に具体的な使い方も含めて詳細が書かれている。

Hello, world

実際に grpchan を試してみる。grpchan はライブラリと Protocol Buffers プラグイン (protoc-gen-grpchan) に分かれており、gRPC プラグイン (protoc-gen-go-grpc) で自動生成されたコードではなく protoc-gen-grpchan によって自動生成されたコードを利用することで Channel を差し替えることができる。

$ protoc --grpchan_out helloworld --proto_path helloworld --go_out=plugins=grpc:helloworld helloworld/helloworld.proto

生成されたコードは量もなく理解しやすい。
protoc-gen-go-grpc で生成されたコードでは *grpc.ClientConnInvoke メソッドを内部的に叩いているが、protoc-gen-grpchan では grpchan.Channel というインターフェースに差し替わっているところが大きな違いで、これを NewGreeterChannelClient から DI できるようになっている。

// Code generated by protoc-gen-grpchan. DO NOT EDIT.
// source: helloworld.proto

package helloworld

import "github.com/fullstorydev/grpchan"
import "golang.org/x/net/context"
import "google.golang.org/grpc"

func RegisterHandlerGreeter(reg grpchan.ServiceRegistry, srv GreeterServer) {
    reg.RegisterService(&_Greeter_serviceDesc, srv)
}

type greeterChannelClient struct {
    ch grpchan.Channel
}

func NewGreeterChannelClient(ch grpchan.Channel) GreeterClient {
    return &greeterChannelClient{ch: ch}
}

func (c *greeterChannelClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)
    err := c.ch.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

クライアントは先程の NewGreeterChannelClient を使って初期化する。ここでは grpchan.Channel の HTTP/1.1 実装の httpgrpc.Channel を利用する。

package main

import (
    "context"
    "fmt"
    "net/http"
    "net/url"

    "github.com/fullstorydev/grpchan/httpgrpc"
    "github.com/ktr0731/grpchan-playground/helloworld"
)

func main() {
    u, err := url.Parse("http://127.0.0.1:50051")
    if err != nil {
        panic(err)
    }

    client := helloworld.NewGreeterChannelClient(
        &httpgrpc.Channel{
            Transport: http.DefaultTransport,
            BaseURL:   u,
        },
    )
    res, err := client.SayHello(context.Background(), &helloworld.HelloRequest{
        Name: "ktr",
    })
    if err != nil {
        panic(err)
    }
    fmt.Println(res.Message)
}

サーバは RegisterHandlerGreeter を使ってサービスを登録し、通常の HTTP サーバのように http.ListenAndService を使って起動する。

package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/fullstorydev/grpchan"
    "github.com/fullstorydev/grpchan/httpgrpc"
    "github.com/ktr0731/grpchan-playground/helloworld"
    "google.golang.org/grpc"
)

func main() {
    reg := grpchan.HandlerMap{}
    helloworld.RegisterHandlerGreeter(&reg, &server{})

    srv := grpc.NewServer()
    reg.ForEach(srv.RegisterService)

    httpgrpc.HandleServices(http.HandleFunc, "/", reg, nil, nil)

    http.ListenAndServe(":50051", nil)
}

type server struct{}

func (s *server) SayHello(ctx context.Context, req *helloworld.HelloRequest) (*helloworld.HelloReply, error) {
    return &helloworld.HelloReply{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
}

サーバを起動し、クライアントを実行するとレスポンスがちゃんと返ってくる。

$ go run client/main.go
hello, ktr

curl で叩いてみてもちゃんと動作した。拙作の pb を使って入出力のエンコード・デコードを行った。

$ in="$(echo '{"name": "ktr"}' | pb -F helloworld/helloworld.proto encode helloworld.HelloRequest)"
$ curl -s -d "$in" -X POST -H 'Content-Type: application/x-protobuf' http://127.0.0.1:50051/helloworld.Greeter/SayHello | pb -F helloworld/helloworld.proto decode helloworld.HelloReply
{
  "message": "hello, ktr"
}

vs gRPC

HTTP/2 を利用した gRPC と比較すると、トランスポートに依存する点を中心として以下のような違いがある。ちゃんと考えてないのでもっとあるはず。

  • grpc.DialOption が使えない
  • grpc.StatsHandler が使えない (未実装)
  • gRPC リフレクションが使えない (未実装)
  • metadata や grpc-statusgrpc-status-details-bingrpc-timeout のような header/trailer に依存する部分が独自実装

また、HTTP/1.1 を実装として利用する場合、以下のような制約がある。これは gRPC-Web の制約と同様。

  • Bidirectional Streaming が限定的なサポート (レスポンスが返ったあとに再度リクエストを投げることができない)

vs improbable-eng/grpc-web

improbable-eng/grpc-web (以下 gRPC-Web) と比較すると以下のような違いがある。

  • gRPC-Web は gRPC サーバを HTTP サーバでラップし、HTTP → gRPC への変換を行うので gRPC サーバへ独自のメカニズムを導入しないのに対し、grpchan はトランスポートを入れ替えている
  • grpchan は特定のトランスポートのみを使用するので、gRPC-Web のように HTTP/1.1 と HTTP/2 を同時にサポートすることができない
  • gRPC-Web は gRPC Status をレスポンスボディに格納するのに対し、grpchan は X-GRPC-Status ヘッダに格納する
    • 同様に Status Details や Trailer もレスポンスボディではなく独自のヘッダに格納する

より詳しい違いはドキュメントに記述されているので読んでみると良さそう。

pkg.go.dev