見出し画像

静的解析によってGoのWeb APIにスキーマを後付けする REALITY Advent Calendar 2023

この記事はREALITY Advent Calendar 2023の2日目の記事です。
1日目の記事はtakashiさんの「今年も開発合宿やりました@土善旅館」とharunaさんの「REALITYに配信スケジュール機能をつけたい!」でした。

REALITYのサーバーチームのkunadoです。今年の7月頃から内定者アルバイトとして、主に内部向けの管理ツールへの機能追加やWebフロントエンドのコーディングガイドラインの策定などに携わっています。

今回は開発合宿にて取り組んだ、静的解析を用いてGo言語の既存のWeb APIに対してSwaggerのAPIスキーマを後付けする試みについて紹介します。


背景

REALITY におけるマイクロサービスアーキテクチャ

先月公開された落合さんのブログ記事で以下のように触れられている通り、REALITYではサーバサイドの実装方法としてマイクロサービスアーキテクチャを採用しています。

12個のAPIサーバ
4つのwebsocketサーバ
いくつかのwebサーバ
いくつかのfunctions
内部向けのツール
k8sやTerraformなどのインフラ定義を管理

Now in REALITY Tech #90 サーバサイドのリポジトリをmonorepoにした話|REALITY

そして以下の図で示されるように、iOSやAndroidのアプリ(及びアプリのライブラリとして組み込まれているUnity層)からサーバーサイドへの通信方式としてAPIゲートウェイパターンに倣った構成をとっています。

REALITYのサーバアーキテクチャ
(画像はGREE Tech Conference 2023での発表REALITYアプリのメンテナンスなしでの機能リリースを実現する、Istio導入とB/Gデプロイ実現の取り組みより引用)

ここで、REALITYアプリの動作を実現するための通信を型の観点から考えてみます。

型の観点でのREALITYを支える通信の整理

REALITYのアプリの動作を支える通信を型の観点から整理するにあたって、以下では便宜的にGatewayの外から来る通信をExternal、Gatewayの裏側での内部的なサービス間通信をInternalと呼ぶことにします。

REALITYを支える通信イメージイメージ図
REALITYを支える通信のイメージ図

まずはExternalの通信から見ていきます。

iOSとAndroidアプリ、そしてそれらにバンドルされるUnityからの通信は、前述の通りGatewayを通じて行われています。通信をやり取りする言語の組み合わせに着目すると、Externalの通信は以下の3通りで、いずれも他言語間での通信となります。

  • Gateway(Go) <-> iOS App(Swift)

  • Gateway(Go) <-> Android App(Kotlin)

  • Gateway(Go) <-> Unity as a Library(C#)

以下のうすぎぬさんの記事でも紹介されているように、REALITYではGatewayとの通信部分のAPIスキーマを定義するのにProtobufを用いています。これにより、以上の3通りの通信経路では双方の言語向けの型定義をProtobufのスキーマ定義から自動生成することができ、型のある通信を実現することができています。

では次にInternalの通信を見ていきます。

サーバーサイドの各サービスは、基本的にGo言語を用いて実装されています。Go言語で実装されたサーバー間の通信では、各サーバで共有されているライブラリ内にリクエストとレスポンスの型定義が存在し、これを用いることで型のついた通信をすることができています。

しかしInternalのサーバーの中でも、例えばWebフロントエンドを持つ内部向けツールのサーバーなどはNode.jsで実装されています。このようなGo言語とNode.jsの異言語間での通信では共通ライブラリを通じて型定義を共有することはできず、External向けの通信のようなAPIスキーマも用意されていません。そのため、ここでの通信には型がつきません。

これまで見てきた通信と型の関係を表にまとめると以下のようになります。

REALITYを支える通信において、型のついた通信をどう実現しているかについての整理

上の表のように、Internalかつ他言語間での通信の場合は、現状では型のついた通信を実現することはできていません。

そこで今回はこの部分の通信にも型をつけるべく、Go言語で実装された既存のWeb APIに対して静的解析を用いてSwaggerのAPIスキーマを生成することを試みました。

方法

ここからは静的解析を用いて既存のWeb APIに沿ったSwaggerのAPIスキーマを生成する方法を紹介します。

まず初めにどのように静的解析を用いてSwaggerスキーマを生成するのかについて概観し、次にコード例を交えながら実際にハンドラ関数に対応するSwaggerのAPIスキーマを生成する方法を解説します。

swaggo/swagによるSwaggerスキーマの作成

今回、Swaggerスキーマの生成にはswaggo/swagというパッケージを利用します。swagはハンドラ関数に付与されたコメントを元にそのハンドラ関数のAPI定義を含むswagger.yamlを生成することができるツールです。

以下のコードはswagリポジトリのREADMEからの引用です。フレームワークとしてGinを使っている例になりますが、以下のようなコメントが付与されたハンドラを含むソースコードを入力としてswagを実行することで、コメントの内容に対応したAPI定義を含むswagger.yamlを生成できます。

// ShowAccount godoc
// @Summary      Show an account
// @Description  get string by ID
// @Tags         accounts
// @Accept       json
// @Produce      json
// @Param        id   path      int  true  "Account ID"
// @Success      200  {object}  model.Account
// @Failure      400  {object}  httputil.HTTPError
// @Failure      404  {object}  httputil.HTTPError
// @Failure      500  {object}  httputil.HTTPError
// @Router       /accounts/{id} [get]
func (c *Controller) ShowAccount(ctx *gin.Context) {
  id := ctx.Param("id")
  aid, err := strconv.Atoi(id)
  if err != nil {
    httputil.NewError(ctx, http.StatusBadRequest, err)
    return
  }
  account, err := model.AccountOne(aid)
  if err != nil {
    httputil.NewError(ctx, http.StatusNotFound, err)
    return
  }
  ctx.JSON(http.StatusOK, account)
}

つまり、既存のハンドラ関数に対してこのようなコメントを付与すれば、あとはswagによりSwaggerスキーマを生成することができます。

各APIにこのようなコメントの付与を手作業で行うことを考えると、

  • InternalのAPIの数が多いため、膨大な作業量が必要である

  • コメントが実際の仕様と一致するようにメンテナンスするためのコストが増えてしまう

という理由から、コメントの付与は自動化できることが望ましいと考えました。今回はハンドラ関数を静的解析することによってAPI定義生成のためのコメントを付与することを考えます。

以上をまとめると、swagger.yamlを生成するまでの大まかな流れは以下の図のようになります。

静的解析を用いてSwaggerスキーマを生成するまでの大まかな流れ

静的解析を行なってコメントを付与する方法のあらまし

ここからは、コード例を交えながら実際にコメントの付与をどのように実現するのかを見ていきます。今回は説明のためにnet/httpベースのJSON形式でデータをやり取りするハンドラ関数 sampleHandler を含む以下のようなサンプルコードを用意しました。

type sampleRequest struct {
	userID int
}

type samplePayload struct {
	user User
}

type response struct {
	payload interface{} `json:"payload"`
}

func parseJSON(data io.Reader, obj interface{}) error {
	return json.NewDecoder(data).Decode(obj)
}

SampleRouter.HandleFunc("/", sampleHandler)

func sampleHandler(w http.ResponseWriter, r *http.Request) {
	var req sampleRequest
	if err := parseJSON(r.Body, &req); err != nil {
		// error handling
	}

	user, err := getUserById(req.userID)
	if err != nil {
		// error handling
	}

	payload := samplePayload{
		user: user,
	}
	resp := response{
		Payload: payload,
	}

	b, err := json.Marshal(resp)
	if err != nil {
		// error handling
	}

	_, err = w.Write(b)
	if err != nil {
		// error handling
	}
}

上記のコードでは、 sampleHandler 関数は sampleRequest 型のリクエストを受け付けます。

したがってこの sampleHandler 関数に付与すべきコメントのうち、paramアノテーションについてはたとえば以下のようなものが付与されていれば良いことになります(アノテーションの構文と詳細についてはswagのREADMEをご参照ください)。

// @Param body body sampleRequest true "request parameter"

上記のようなparamアノテーションを含むコメントを生成するためには、paramアノテーションの3つ目の引数にリクエストの型の名前 sampleRequest が必要となります。

よってここでは静的解析を通じて sampleHandler 関数からリクエストの型名 sampleRequest を取得することを考えます。

ハンドラ関数に与えられたリクエストは、その情報を後続の処理に用いるためにJSONとしてパースされる必要があります。つまり、ハンドラ関数内でリクエストをJSONとしてパースする処理が必要で、ここでは parseJSON 関数がそれにあたります。よって、ハンドラ関数内での parseJSON 関数の呼び出しに着目して、このとき第2引数に与えられた変数 req の型名として sampleRequst を抽出することにします。

(注: 実際のコード上では各ハンドラ関数によってリクエストの型を指定する方法には様々な書き方がありえますが、ここでは簡単のためにリクエストの型の変数が parseJSON 関数の引数として与えられているという強い仮定を置いています。)

Go言語の静的解析を行う手段の一つとして、go/astパッケージを用いる方法があります。astパッケージを用いると、Goのソースコードを入力としてソースコードの抽象構文木を表現する値を得ることができます。

astパッケージを用いて以上のような処理を行うためのコードは、例えば以下のように書くことができます。

// 引数fで与えられたast.Nodeインタフェースの値から型引数Tで与えられた型に一致する子孫を配列で返す関数
func nodeSelector[T ast.Node](f ast.Node) []T {
	var selectedNodes []T
	ast.Inspect(f, func(n ast.Node) bool {
		if targetNode, ok := n.(T); ok {
			selectedNodes = append(selectedNodes, targetNode)
		}
		return true
	})

	return selectedNodes
}

// getRequestTypeName関数は与えられた*ast.FuncDeclに対応する関数内のparseJSONの第2引数の型名を返す
func getRequestTypeName(f *ast.FuncDecl) string {
    // 関数名がparseJSONであるような関数呼び出し表現に相当する*ast.Nodeを取得する
	callExprs := nodeSelector[*ast.CallExpr](f)
    var parseJsonCallExpr *ast.CallExpr
	for _, callExpr := range callExprs {
		if fun, ok := callExpr.Fun.(*ast.Ident); ok {
			if fun.Name == "parseJSON" {
				parseJsonCallExpr = callExpr
			}
		}
	}

    // parseJSONの第2引数に相当する*ast.Nodeを取得する
	secondArg := parseJsonCallExpr.Args[1]
	var requestVariable *ast.Object
	if unaryExpr, ok := secondArg.(*ast.UnaryExpr); ok {
		if ident, ok := unaryExpr.X.(*ast.Ident); ok {
			requestVariable = ident.Obj
		}
	}

    // 第2引数の型名を取得する
	var typeName string
	if valueSpec, ok := requestVariable.Decl.(*ast.ValueSpec); ok {
		if ident, ok := valueSpec.Type.(*ast.Ident); ok {
			typeName = ident.Name
		}
	}

	return typeName
}

上記のようなコードによって、ソースコードから sampleHandler 関数のリクエストの型名を抽出することができます。このようにして取得した型名をテンプレートに埋め込むなどして、前述のparamアノテーションのコメントを生成することができます。

以上、非常に大雑把にではありますが静的解析によってリクエストの型名を特定することでparamアノテーションを生成する方法を紹介しました。

param以外のsuccessやfailure、routerなどの他のアノテーションについても、同様のアプローチで静的解析により必要な情報を取得できるでしょう。開発合宿では実際に既存のWeb APIに対してparam、success、failure、routerといったアノテーションのコメントを付与し、それを元にswagを用いてswagger.yamlを生成するところまで行いました。

swagger.yamlさえ作ることができれば、そのAPI定義に則った型付きのクライアントのコードを他言語向けにも生成することが可能です。当初の目的であったInternalの他言語間の通信の全てに対して直ちに型をつけることはできませんが、この方針なら確かに実現することは可能そうであることを確認できました。

今後の課題と展望

最後に、今回開発合宿で取り組んだ内容の今後の課題と展望について触れます。

課題1: 対応できるハンドラ関数を増やす

今回記事内で取り上げたWeb APIのコードと静的解析のためのコードは実際に開発合宿中に作成していた静的解析のコードとは異なりますが、合宿中に作成した静的解析のコードでも概ね上記と同じような手順でコメントを付与しています。

開発合宿は2日間という時間制限つきだったこともあり、サーバーチームで管理する各APIサーバーに実装されている数多あるハンドラ関数のうち、静的解析で解析しやすい、いわば"お行儀の良い"書き方をされたハンドラ関数のみに対応する形で静的解析のコードを作成しました。そのため、今回のコードで全てのハンドラ関数にアノテーションのコメントを付与することはできません。

コメントを付与することのできるハンドラ関数の幅を拡げるため、以下の2通りの方法があると考えています。

  1. お行儀の良くないコードに対しても静的解析できるようにする

  2. お行儀の良い書き方がされたハンドラ関数とそうでないものとを区別できるようにし、ハンドラ関数の書き方をお行儀の良い書き方に寄せていく

1の方法で典型的な実装パターンのいくつかに対応できるようにしていきつつ、2の方法によって典型的なパターンから逸脱するような書き方のコードについては少しずつ書き方の統一化を進めていけると良いかと思っています。

課題2: Analyzer対応

以下の記事で詳しく説明されていますが、golang.org/x/tools/go/analysisパッケージが提供する analysis.Analyzer という単位に対応した形で静的解析の処理を書くと、複数の静的解析の処理の重複する部分をまとめて、1度だけ行うようにすることができるようです。

今回は初めてのGo言語での静的解析だったこともあり、素朴にgo/astパッケージを使って静的解析の処理を実装しましたが、実運用に乗せることを見据えると、今回実装した静的解析の処理を上記の仕組みに則った形にしていければと思います。

展望1: 手動テストのためのCLIの作成

Web APIの実際の動作をサクッと確認したい時、手動でテストをしたい場合があります。サーバーチームにはブラウザベースのAPIテストツールの設定を整備している方もいらっしゃいますが、普段ターミナルに篭って作業をしている身からするとCLIでテストをしたいものです。

以前お世話になった別の現場では、開発しているAPIサーバー専用のCLIクライアントをメンテナンスしていました。そのCLIツールは認証情報をキャッシュしてヘッダに埋め込んでリクエストを送ってくれるような仕組みを備えており、curlコマンドよりも快適にWeb APIの動作確認を行うことができていました。

すでに触れた通り、swagger.yamlを自動生成することができればそれを元にクライアントのコードも自動生成することができるため、このようなツールを自作したい場合にも大分楽をすることができるでしょう。

展望2: 静的解析をカジュアルに開発に組み込む

現在のサーバーチームは各マイクロサービスに対して専任のエンジニアを置くというような形態はとっていません。

以下の記事で触れられているように、現在のREALITYはマトリクス型組織という形態をとっています。

各サーバーエンジニアは、プロジェクトごとに必要に応じてサービスを問わずコードに触るという形で業務にあたっています。コードベースを複数のエンジニアで分割統治するような体制ではないため、エンジニア一人ひとりがコードベース全体と向き合う必要があります。

このように巨大なコードベースを日々維持・管理していくにあたり、技術を用いて各エンジニアの負荷を減らすためにできることとして、自動化できる部分は仕組みを作って機械に任せるというのが一つの手段だと思っています。そのような一例として、自動テストの仕組みの整備や静的解析によるlint、コード生成などによる業務の効率化が考えられます。

このような問題意識を持っていたこともあって、今回はGo言語でのソースコードの静的解析に挑戦してみました。実際にやってみると、ASTを操作するコードはとっつきにくいところもありますが、仕組みが分かってくればコードの書き方にも徐々に慣れていきました。

これを機に、サーバーチーム内でGo言語でのAST操作の知見やユーティリティツールを蓄積していき、普段の業務を効率化するための道具の一つとして日々管理しているコード向けにカスタマイズした静的解析ツールやコード生成ツールをカジュアルに用いることができるようになればいいなと思っています。

参考

アドベントカレンダーの予告

本日はこの記事以外にiOSエンジニアのあおやまさん、Unityチームのようてんさん、サーバチームののぶつねさん、Androidチームの応為さんの「スクショを投稿できる機能を作ってみた」も公開されます。

明日はUnityエンジニアのIKEPさんの「『カスタムモーション』記録 / 再生機能を作ってみた」が公開されます。お楽しみに!