🙄

Go の静的解析による影響分析

に公開

はじめに

この記事は、「KNOWLEDGE WORK Blog Sprint」第6日目の記事になります。

Middleware グループのバックエンドエンジニアの aita です。

今回は Go のコードの静的解析からコードの変更による影響範囲の分析を行う事例の紹介をします。

背景

ナレッジワークのバックエンドのシステムは複数のAPIサーバーによって構成されています。これらのAPIサーバーはモノレポで管理され、共通して connect-go を用いて proto ファイルで定義された gRPC と互換性のある Web API を提供しています。

私が所属している Middleware グループでは、複数のプロダクトのAPIサーバーから利用される共通機能を提供するAPIサーバーの開発を行っています。ナレッジワークではこれらの共通機能を提供するシステムを Middleware と呼称しています。

Middleware API

Middleware の共通機能は複数のプロダクトから利用されているため、どのAPIが、どのプロダクトのどの機能から利用されているのか把握しきるのが難しく、変更を行った際に影響範囲の正確な特定が困難でした。そのためコードの変更による予期せぬ影響が、リリースの後に発覚するなどの課題がありました。

課題解決のため、コードの変更からシステム全体への影響を可視化する仕組みを作ることにしました。

静的解析による影響分析

コードの変更からシステム全体への影響を可視化するために、差分から変更された関数を見つけ、その関数に依存するAPIをリストアップすることにしました。

幸いなことに、Goでは静的解析のためのパッケージがとても充実しています。標準パッケージでは構文解析や型検査など、準標準パッケージとしてSSA(静的単一代入)やコールグラフが提供されています。

コールグラフはプログラム内の関数同士の呼び出し関係を表現した有向グラフです。コールグラフを用いれば、変更された関数からコールグラフのエッジを辿っていきAPIのハンドラー関数の実装を見つけ、変更がどのAPIへ影響があるか判定することができます。

しかし、プロダクトからのAPIの呼び出しはHTTP通信を経由しているため、そのままコールグラフから発見するのは困難です。各プロダクトで呼び出されているAPIクライアントのメソッドのリストを作成して、各プロダクトのAPIが依存している Middleware の API を見つけることができます。

そこで、複数の静的分析を組み合わせることで、システム全体への影響を可視化する仕組みを実現しました

gitの差分から変更の関数を特定する

最初のステップは 変更箇所の抽出です。
このステップでは git diff の出力をパースし、変更されたファイル・関数シンボルを抽出します。

ここでは AST を利用することで、単なるテキスト比較では検出しづらい「関数の追加・シグネチャ変更・本体の変更」などを正確に追跡します。

git diff のパースには go-gitdiff を利用して、次のような差分の変更が startLine~endLine に含まれるかをチェックする関数を実装しました。

func isCoveredByChanges(diffFile *gitdiff.File, startLine, endLine int) bool {
	for _, fragment := range diffFile.TextFragments {
		// Check if the range overlaps with any changed lines in this fragment
		oldLineNum := int(fragment.OldPosition)
		newLineNum := int(fragment.NewPosition)

		for _, line := range fragment.Lines {
			if line.Op == gitdiff.OpAdd || line.Op == gitdiff.OpDelete {
				var lineNum int
				if line.Op == gitdiff.OpDelete {
					lineNum = oldLineNum
				} else {
					lineNum = newLineNum
				}
				if startLine <= lineNum && endLine >= lineNum {
					return true
				}
			}

			// Update line numbers based on the operation
			if line.Op == gitdiff.OpDelete || line.Op == gitdiff.OpContext {
				oldLineNum++
			}
			if line.Op == gitdiff.OpAdd || line.Op == gitdiff.OpContext {
				newLineNum++
			}
		}
	}
	return false
}

以下は簡略化したコードですが、ast.Inspectを用いてASTを探索して、関数定義の範囲に差分が含まれている関数の *ast.Ident のリストを作成します。

func findFuncIdents(diffFile *gitdiff.File, file *ast.File) []*ast.Ident {
    results := make([]*ast.Ident)
	ast.Inspect(file, func(n ast.Node) bool {
        pos := fset.Position(n.Pos())
    	end := fset.Position(n.End())
    	switch decl := n.(type) {
    	case *ast.FuncDecl:
            if isCoveredByChanges(diffFile, pos.Line, end.Line) {
                results = append(results, decl.Name)
                return true
            }
        }
	})
    return results
}

Connect の Handler と Client の型を見つける

次に、変更された関数がどのWeb APIに影響するかを特定します。

このステップではコールグラフを作成して、前段のステップで発見した変更された関数に依存しているAPIのHandlerメソッドを探索します。

そのため、まずは connect-go の Handler や後のステップで利用する Client の型を探します。型を見つけるために proto から生成されるコードの構造に着目します。

ナレッジワークのWeb APIは次のような gPRC の proto として定義され、protoc-gen-connect-go を利用して gRPC と互換性のある Connect の HTTP API のコードが生成されます。

syntax = "proto3";
package middleware.content.v1;

option go_package = "contentv1";

service PingService {
  rpc Ping(PingRequest) returns (PingResponse) {}
}

message PingRequest {}
message PingResponse {}

proto から生成されたGoのコードは次のようなコードになります。

// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: middleware/content/v1/ping_service.proto

package contentv1connect

import (
	connect "connectrpc.com/connect"
	context "context"
	errors "errors"
	v1 "github.com/knowledge-work/knowledgework/middleware/content/contentpb/middleware/content/v1"
	http "net/http"
	strings "strings"
)

// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0

const (
	// PingServiceName is the fully-qualified name of the PingService service.
	PingServiceName = "middleware.content.v1.PingService"
)

// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
	// PingServicePingProcedure is the fully-qualified name of the PingService's Ping RPC.
	PingServicePingProcedure = "/middleware.content.v1.PingService/Ping"
)

// PingServiceClient is a client for the middleware.content.v1.PingService service.
type PingServiceClient interface {
	Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error)
}

// NewPingServiceClient constructs a client for the middleware.content.v1.PingService service. By
// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
// connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPingServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PingServiceClient {
	baseURL = strings.TrimRight(baseURL, "/")
	pingServiceMethods := v1.File_middleware_content_v1_ping_service_proto.Services().ByName("PingService").Methods()
	return &pingServiceClient{
		ping: connect.NewClient[v1.PingRequest, v1.PingResponse](
			httpClient,
			baseURL+PingServicePingProcedure,
			connect.WithSchema(pingServiceMethods.ByName("Ping")),
			connect.WithClientOptions(opts...),
		),
	}
}

// pingServiceClient implements PingServiceClient.
type pingServiceClient struct {
	ping *connect.Client[v1.PingRequest, v1.PingResponse]
}

// Ping calls middleware.content.v1.PingService.Ping.
func (c *pingServiceClient) Ping(ctx context.Context, req *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) {
	return c.ping.CallUnary(ctx, req)
}

// PingServiceHandler is an implementation of the middleware.content.v1.PingService service.
type PingServiceHandler interface {
	Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error)
}

// NewPingServiceHandler builds an HTTP handler from the service implementation. It returns the path
// on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPingServiceHandler(svc PingServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
	pingServiceMethods := v1.File_middleware_content_v1_ping_service_proto.Services().ByName("PingService").Methods()
	pingServicePingHandler := connect.NewUnaryHandler(
		PingServicePingProcedure,
		svc.Ping,
		connect.WithSchema(pingServiceMethods.ByName("Ping")),
		connect.WithHandlerOptions(opts...),
	)
	return "/middleware.content.v1.PingService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case PingServicePingProcedure:
			pingServicePingHandler.ServeHTTP(w, r)
		default:
			http.NotFound(w, r)
		}
	})
}

// UnimplementedPingServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedPingServiceHandler struct{}

func (UnimplementedPingServiceHandler) Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) {
	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("middleware.content.v1.PingService.Ping is not implemented"))
}

生成されたコードを見ると、PingServiceName というサービス名を表す定数や、PingHandlerPingClient という interface が生成されることがわかります。

XxxService の Handler の interface は XxxHandler であることがわかるので、 生成された connect のコードから Handler という suffix を持つ interface を探すと Handler の interface の型がわかります。 Client の interface の型についても同様です。

次に Handler や Client を実装している型を探します。Client については実装は proto から生成されるコードに含まれているので、Clinet の interface を実装している型を探せば良いです。一方で Handler は生成されたコードでは interface のみが提供されて、利用者側が実装を行うので注意しなければならないことがあります。Go では「型が Handler interface を実装している」ことと「実際に Handler として使われている」ことは別であることです。つまり、単に型シグネチャが一致しているだけでは Handler の実装であることが確かめられません。

そこで、Go の SSAを用いて、実際に Handler の interface に変換されている型を探すことにしました。

func findConnectServiceHandlerImplementations(services map[string]*ConnectService, pkgs []*ssa.Package) map[types.Type]*ConnectService {
	handlers := make(map[types.Type]*ConnectService, 0)
	for _, pkg := range pkgs {
		for _, member := range pkg.Members {
			if fun, ok := member.(*ssa.Function); ok {
				for _, block := range fun.Blocks {
					for _, instr := range block.Instrs {
						if mi, ok := instr.(*ssa.MakeInterface); ok {
							if _, ok := mi.X.Type().Underlying().(*types.Interface); ok {
								continue
							}

							interfaceType := mi.Type().Underlying().(*types.Interface)
							for _, service := range services {
								handlerInterface := service.Handler.Type().Underlying().(*types.Interface)
								if handlerInterface == nil {
									continue
								}
								if handlerInterface == interfaceType {
									handlers[mi.X.Type()] = service
								}
							}
						}
					}
				}
			}
		}
	}
	return handlers
}

これで Handler と Client の実装の型を見つけることができました。

コールグラフの探索

次のステップはコールグラフの探索です。HTTP通信を経由したAPIの呼び出しはコールグラフでは難しいので、コールグラフの探索を2つのステップに分けました。

  • 変更された関数から探索して、依存するHandlerのメソッドを見つける
  • クライアントのメソッド呼び出しを探索してAPI間の依存グラフを作成

コールグラフの探索はDFSで行い、関数がメソッドでそのレシーバーがハンドラーを実装している型であるかをチェックします。

func findDirectlyEffectedRPC(
    node *callgraph.Node,
    visited map[*callgraph.Node]bool,
    handlerFuncs map[*ssa.Function]bool,
    cg *callgraph.Graph,
    handlerTypes []types.Type,
) {
	if visited[node] {
		return
	}
	visited[node] = true

	recv := node.Func.Signature.Recv()
	if recv != nil && node.Func.Object().Exported() {
		for handlerType, service := range handlerTypes {
			if types.Identical(recv.Type(), handlerType) {
				handlerFuncs[node.Func] = true
				return
			}
		}
	}

	for _, edge := range node.In {
		findDirectlyEffectedRPC(edge.Caller, visited, handlerFuncs, cg, handlerTypes)
	}
}

クライアントについても同様に、クライアントのメソッドに依存するハンドラーのメソッドをDFSで探索して依存グラフを作成します。

最後に二つの結果を組み合わせて、変更に依存するAPIのリストを作成します。

まとめ

上記の実装を整理して、最終的にGitHubのPull Requestで次のようなレポートが出るようにしました。

# Diff Impact Analysis Report

## Modified Code Elements

| Name | Location |
|------|----------|
| GetMulti | service.go:33 |

## Direct Local Impact

| Bamboo Name | RPC |
|-------------|-----|
| middleware API | MiddlewareService.GetMulti |

## Dependent Services

| Dependent Name | Dependent RPC | Dependency Name | Dependency RPC |
|-------------|-----|------------------------|----------------|
| product A | ServiceA.MethodA | middleware API | MiddlewareService.GetMulti |
| product B | ServiceB.MethodB | middleware API | MiddlewareService.GetMulti |

現状はバックエンド間の影響解析が中心でバックエンドエンジニアへの展開もこれからです。今後はフロントエンドとの連携も予定しています。具体的には「どの RPC がどの画面に影響するか」を可視化し、開発者だけでなく PdM や QA にも役立つプラットフォームに進化させたいと考えています。

今回は、Goの静的解析による影響分析の一部を紹介しました。

明日のKNOWLEDGE WORK Blog Sprintの執筆者はQAエンジニアのtommyです!!

株式会社ナレッジワーク

Discussion