Slide 1

Slide 1 text

SNKRDUNKでGo+gRPCで すすめるモジュラモノリス 2022/10/15 sh0e1 Go Conference mini 2022 Autumn IN SENDAI

Slide 2

Slide 2 text

概要 1. 自己紹介 2. 開発チームの課題と改善案 3. Go+gRPCでのモジュラモノリスでの実装方法 4. モジュールからgRPCに切り替える 5. モジュラモノリスをすすめていく上での課題 6. まとめ

Slide 3

Slide 3 text

1. 自己紹介 渡邊 翔永(Shoei Watanabe / sh0e1) @sh0e12uatanal3e Go/GCPで開発をしてきたバックエンドエンジニア。 2021/03に株式会社SODAに入社し、SNKRDUNKの自社倉庫システムの開発、 海外向けサービスの新規開発に携わり、現在はSREチームに所属。

Slide 4

Slide 4 text

国内 No.1 スニーカー・トレカフリマ メディア コミュニティ マーケットプレイス

Slide 5

Slide 5 text

技術スタック Pub/Sub 決済代行業 者 Worker Batch Server 決済Proxy JP Store Admin Batch Server GB Store Internal AWS Local CI/CD Monitoring (HTTPS endpoints)

Slide 6

Slide 6 text

2. 開発チームの課題と改善案

Slide 7

Slide 7 text

課題: モノリスでコードが大きくなりすぎている ● Backendエンジニアが全員で同じリポジトリで開発してる ○ 特に境界はなく、必要な機能に応じて必要な部分を実装する ○ コンフリクトが多発 ● 認知負荷が高い ○ 新規メンバーが全体を把握するのは難しい ● リリーススピードの低下 ○ CI/CDが遅い ● プロダクト品質の低下 ○ 思いも寄らない部分に影響がある

Slide 8

Slide 8 text

4,422 Goのファイル数 808,781 Goの行数 2022/10/11時点でのコード規模

Slide 9

Slide 9 text

改善案: モジュラモノリスに移行していく ● モジュール単位に分割し、モジュールごとに担当チームを決める ○ チームの自立性を高める ○ 認知負荷をさげる ○ プロダクト品質の改善 ● 将来的にマイクロサービスへの移行も視野にいれながら、ドメインの境界 をさぐる ● デプロイラインはひとつなのでCI/CDが遅い問題は解決できない

Slide 10

Slide 10 text

何故マイクロサービスではなくモジュラモノリスなのか ● マイクロサービスは難易度が高い ○ 社内に知見がほぼない ○ ドメイン分割に失敗したときの影響が大きい ● モジュラモノリスでは解決できない課題は別軸で改善を進める ○ CI/CDが遅い ■ テストケースを整理してなるべく並列で実行するなど

Slide 11

Slide 11 text

モジュラモノリスにしていく上での方針 ● Protocol Buffersでモジュールのインターフェースを定義 ○ モジュールのインターフェースをProtoファイルで定義できる ○ コード、ドキュメントの自動生成が可能 ○ 破壊的変更の検知が可能 ○ 少ない変更でネットワーク間の通信に切り替えられるようにする ● DBの分割はしない ○ ドメインの境界が明確になってからDB分割をする

Slide 12

Slide 12 text

3. Go+gRPCでのモジュラモノリスの実装方法

Slide 13

Slide 13 text

ディレクトリ構成 . ├── cmd # 各アプリケーションのmain.go ├── pkg # Goのパッケージ郡 ├── protobuf │ ├── gen # protoファイルから生成したファイル郡 │ │ ├── doc │ │ │ └── snkrdunk │ │ │ └── search │ │ │ └── v1 │ │ │ └── index.md │ │ └── go │ │ └── snkrdunk │ │ └── search │ │ └── v1 │ │ ├── service.pb.go │ │ └── service_grpc.pb.go │ ├── proto # protoファイル │ │ └── snkrdunk │ │ ├── search │ │ │ └── v1 │ │ │ └── service.proto │ │ └── buf.yaml │ ├── buf.gen.yaml │ └── buf.work.yaml ├── services # 各モジュールを実装するディレクトリ │ └── search │ └── service.go ├── go.mod └── go.sum

Slide 14

Slide 14 text

Protocol Buffersでインターフェースを定義 syntax = "proto3"; package snkrdunk.search.v1; // `SearchService` provides a way to search for products by keywords. // - Each RPC may return Internal but it is not listed in the [ERRORS] section // for brevity. service SearchService { // Returns a list of suggestions for search keyword. rpc ListSuggestions(ListSuggestionsRequest) returns (ListSuggestionsResponse); } // `ListSuggestions` returns a list of suggestions and the count of results for // a search keyword. // - If the keyword is empty, an empty list is returned. // - If there are no suggestions for the search keywords, an empty list is // returned. // - The list of suggestions is returned in descending order of search count. message ListSuggestionsRequest { // Search keyword. string keyword = 1; // Limit of suggestions list. int32 limit = 15; }

Slide 15

Slide 15 text

Protocol Buffersの運用 ● bufを利用 ○ https://github.com/bufbuild/buf ● Goのコードとドキュメントを生成 ● CIでformatエラー / lintエラー / breaking changeを検出する ● mainにマージされたらProtocol Buffersの破壊的変更は不可 ○ 破壊的変更があるとCIでおちる buf generate / buf format / buf lint / buf breaking

Slide 16

Slide 16 text

モジュールの呼び出しをどうやって実現するか ● 前提 ○ 生成されたServer/Clientのコードをそのまま使いたい ■ 後々gRPCへ切替えやすくしたい ● ClientからServerの関数をどうやって呼び出すかを検討

Slide 17

Slide 17 text

生成されたServerのコード // SearchServiceServer is the server API for SearchService service. // All implementations must embed UnimplementedSearchServiceServer // for forward compatibility type SearchServiceServer interface { // Returns a list of suggestions for search keyword. ListSuggestions(context.Context, *ListSuggestionsRequest) (*ListSuggestionsResponse, error) mustEmbedUnimplementedSearchServiceServer() } // UnimplementedSearchServiceServer must be embedded to have forward compatible implementations. type UnimplementedSearchServiceServer struct { } func (UnimplementedSearchServiceServer) ListSuggestions(context.Context, *ListSuggestionsRequest) (*ListSuggestionsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListSuggestions not implemented") } func (UnimplementedSearchServiceServer) mustEmbedUnimplementedSearchServiceServer() {} // UnsafeSearchServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to SearchServiceServer will // result in compilation errors. type UnsafeSearchServiceServer interface { mustEmbedUnimplementedSearchServiceServer() } func RegisterSearchServiceServer(s grpc.ServiceRegistrar, srv SearchServiceServer) { s.RegisterService(&SearchService_ServiceDesc, srv) }

Slide 18

Slide 18 text

gRPC Serverのexample // https://github.com/grpc/grpc-go/blob/master/examples/helloworld/greeter_server/main.go // server is used to implement helloworld.GreeterServer. type server struct { pb.UnimplementedGreeterServer } // SayHello implements helloworld.GreeterServer func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) { log.Printf("Received: %v", in.GetName()) return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil } func main() { flag.Parse() lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() pb.RegisterGreeterServer(s, &server{}) log.Printf("server listening at %v", lis.Addr()) if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }

Slide 19

Slide 19 text

RegisterSearchServiceServerの処理 // protobuf/gen/go/snkrdunk/search/v1/service_grpc.pb.go func RegisterSearchServiceServer(s grpc.ServiceRegistrar, srv SearchServiceServer) { s.RegisterService(&SearchService_ServiceDesc, srv) } // https://github.com/grpc/grpc-go/blob/master/server.go // ServiceRegistrar wraps a single method that supports service registration. It // enables users to pass concrete types other than grpc.Server to the service // registration methods exported by the IDL generated code. type ServiceRegistrar interface { // RegisterService registers a service and its implementation to the // concrete type implementing this interface. It may not be called // once the server has started serving. // desc describes the service and its methods and handlers. impl is the // service implementation which is passed to the method handlers. RegisterService(desc *ServiceDesc, impl interface{}) }

Slide 20

Slide 20 text

生成されたServerのコードをそのまま利用するには ● grpc.ServiceRegistrar を実装した構造体を定義 ● Protocol Buffersで定義したServiceの構造体をフィードに保持 ● Clientから呼び出されたら、フィールドで保持しているServiceの関数を実 行する func NewModule() *Module { return &Module{} } type Module struct { Service interface{} } func (m *Module) RegisterService(_ *grpc.ServiceDesc, impl interface{}) { m.Service = impl }

Slide 21

Slide 21 text

生成されたClientのコード // SearchServiceClient is the client API for SearchService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type SearchServiceClient interface { // Returns a list of suggestions for search keyword. ListSuggestions(ctx context.Context, in *ListSuggestionsRequest, opts ...grpc.CallOption) (*ListSuggestionsResponse, error) } type searchServiceClient struct { cc grpc.ClientConnInterface } func NewSearchServiceClient(cc grpc.ClientConnInterface) SearchServiceClient { return &searchServiceClient{cc} } func (c *searchServiceClient) ListSuggestions(ctx context.Context, in *ListSuggestionsRequest, opts ...grpc.CallOption) (*ListSuggestionsResponse, error) { out := new(ListSuggestionsResponse) err := c.cc.Invoke(ctx, "/snkrdunk.search.v1.SearchService/ListSuggestions", in, out, opts...) if err != nil { return nil, err } return out, nil }

Slide 22

Slide 22 text

gRPC Clientのexample // https://github.com/grpc/grpc-go/blob/master/examples/helloworld/greeter_client/main.go func main() { flag.Parse() // Set up a connection to the server. conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() c := pb.NewGreeterClient(conn) // Contact the server and print out its response. ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name}) if err != nil { log.Fatalf("could not greet: %v", err) } log.Printf("Greeting: %s", r.GetMessage()) }

Slide 23

Slide 23 text

生成されたClientのコード // protobuf/gen/go/snkrdunk/search/v1/service_grpc.pb.go func NewSearchServiceClient(cc grpc.ClientConnInterface) SearchServiceClient { return &searchServiceClient{cc} } type searchServiceClient struct { cc grpc.ClientConnInterface } // https://github.com/grpc/grpc-go/blob/master/clientconn.go // ClientConnInterface defines the functions clients need to perform unary and // streaming RPCs. It is implemented by *ClientConn, and is only intended to // be referenced by generated code. type ClientConnInterface interface { // Invoke performs a unary RPC and returns after the response is received // into reply. Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...CallOption) error // NewStream begins a streaming RPC. NewStream(ctx context.Context, desc *StreamDesc, method string, opts ...CallOption) (ClientStream, error) }

Slide 24

Slide 24 text

生成されたClientのコード // protobuf/gen/go/snkrdunk/search/v1/service_grpc.pb.go func (c *searchServiceClient) ListSuggestions(ctx context.Context, in *ListSuggestionsRequest, opts ...grpc.CallOption) (*ListSuggestionsResponse, error) { out := new(ListSuggestionsResponse) err := c.cc.Invoke(ctx, "/snkrdunk.search.v1.SearchService/ListSuggestions", in, out, opts...) if err != nil { return nil, err } return out, nil }

Slide 25

Slide 25 text

生成されたClientのコードをそのまま利用するには ● grpc.ClientConnInterface を実装した構造体を定義 ● Invoke関数の中でServerの関数をコールする ● 定義した構造体を NewSearchServiceClient() の引数に渡す

Slide 26

Slide 26 text

生成されたClientのコードをそのまま利用するには type Module struct { Service interface{} } func (m *Module) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error { split := strings.Split(method, "/") // method -> "/snkrdunk.search.v1.SearchService/ListSuggestions" // split -> []string{"", "snkrdunk.search.v1.SearchService", "ListSuggestions"} inputs := []reflect.Value{ reflect.ValueOf(ctx), reflect.ValueOf(args), } outs := reflect.ValueOf(m.Service).MethodByName(split[2]).Call(inputs) // outs[0]がZero ValueだとpanicになるのでIsZero()でチェック, reflect: call of reflect.Value.Set on zero Value if !outs[0].IsZero() { reflect.ValueOf(reply).Elem().Set(outs[0].Elem()) } err, _ := outs[1].Interface().(error) return err } func (m *Module) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { // Streamは使用しないのでエラーを返す return nil, status.Error(codes.Unknown, "stream not supported") }

Slide 27

Slide 27 text

モジュラモノリスのため実装 ● Module構造体を定義 ● Module構造体に RegisterSearchServiceServer() を実装 ○ フィールドにServiceをセット ● Module構造体に Invoke() を実装 ○ フィールドのServiceの関数をから呼び出す

Slide 28

Slide 28 text

モジュラモノリスのための実装 func NewModule() *Module { return &Module{} } type Module struct { Service interface{} } func (m *Module) RegisterService(_ *grpc.ServiceDesc, impl interface{}) { m.Service = impl } func (m *Module) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error { split := strings.Split(method, "/") inputs := []reflect.Value{ reflect.ValueOf(ctx), reflect.ValueOf(args), } outs := reflect.ValueOf(m.Service).MethodByName(split[2]).Call(inputs) if !outs[0].IsZero() { reflect.ValueOf(reply).Elem().Set(outs[0].Elem()) } err, _ := outs[1].Interface().(error) return err } func (m *Module) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { return nil, status.Error(codes.Unknown, "stream not supported") }

Slide 29

Slide 29 text

モジュール(Server)の実装 package search import ( "context" "github.com/org/repository/pkg/grpc" searchv1 "github.com/org/repository/protobuf/gen/go/snkrdunk/search/v1" ) func NewModule() *grpc.Module { m := grpc.NewModule() searchv1.RegisterSearchServiceServer(m, &SearchService{}) return m } var _ searchv1.SearchServiceServer = (*SearchService)(nil) type SearchService struct { searchv1.UnimplementedSearchServiceServer } func (s *SearchService) ListSuggestions(ctx context.Context, in *searchv1.ListSuggestionsRequest) (*searchv1.ListSuggestionsResponse, error) { // Suggestionの取得処理 out := &searchv1.ListSuggestionsResponse{} return out, nil }

Slide 30

Slide 30 text

モジュールの呼び出し元(Client)の実装 package main import ( "net/http" "github.com/gin-gonic/gin" searchv1 "github.com/org/repository/protobuf/gen/go/snkrdunk/search/v1" "github.com/org/repository/services/search" ) func main() { searchService := search.NewModule() searchClient := searchv1.NewSearchServiceClient(searchService) r := gin.Default() r.GET("/search/suggestions", func(c *gin.Context) { in := &searchv1.ListSuggestionsRequest{ Keyword: c.Query("keyword"), } out, err := searchClient.ListSuggestions(c, in) if err != nil { c.JSON(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, out) }) r.Run() }

Slide 31

Slide 31 text

4. モジュールからgRPCに切り替える

Slide 32

Slide 32 text

モジュールからgRPCに切り替える (Server) func NewModule() *grpcpkg.Module { m := grpcpkg.NewModule() searchv1.RegisterSearchServiceServer(m, &SearchService{}) return m } // 追加 func NewServer() *grpc.Server { s := grpc.NewServer() searchv1.RegisterSearchServiceServer(s, &SearchService{}) return s } var _ searchv1.SearchServiceServer = (*SearchService)(nil) type SearchService struct { searchv1.UnimplementedSearchServiceServer } func (s *SearchService) ListSuggestions(ctx context.Context, in *searchv1.ListSuggestionsRequest) (*searchv1.ListSuggestionsResponse, error) { // Suggestionの取得処理 out := &searchv1.ListSuggestionsResponse{} return out, nil }

Slide 33

Slide 33 text

モジュールからgRPCに切り替える (Server) package main import ( "log" "net" "github.com/org/repository/services/search" ) func main() { s := search.NewServer() lis, err := net.Listen("tcp", ":8000") if err != nil { log.Fatal(err) } if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }

Slide 34

Slide 34 text

モジュールからgRPCに切り替える (Client) func main() { // 追加 conn, err := grpc.Dial("localhost:8000", grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() searchClient := searchv1.NewSearchServiceClient(conn) r := gin.Default() r.GET("/search/suggestions", func(c *gin.Context) { in := &searchv1.ListSuggestionsRequest{ Keyword: c.Query("keyword"), } out, err := searchClient.ListSuggestions(c, in) if err != nil { c.JSON(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, out) }) r.Run() }

Slide 35

Slide 35 text

5. モジュラモノリスをすすめていく上での課題

Slide 36

Slide 36 text

開発時にモジュールの境界をどう守ってもらうか ● Protocol Buffersで定義したインターフェース以外でのモジュールの呼び出 しを防ぎたい ● DBなど全体で共有するパッケージのimportは許容する ● レビューだと限界がある 解決案 ● 静的解析ツールを自作してCIに組み込み、機械的にチェックする ● 現在実装を進めている

Slide 37

Slide 37 text

別リポジトリからgo getするとエラーになる ● 別リポジトリからProtocol Buffersで生成したファイルをimportするためにgo get すると、リポジトリのサイズが大きすぎてエラーになる ● create zip: module source tree too large (max size is 524288000 bytes) 解決案 ● Protocol Buffersを管理するリポジトリを作成する ● リポジトリのルートディレクトリのgo.modとは別に、Protocol Buffersで生成した ファイルを置いているディレクトリにgo.modをおく ○ Protocol Buffersで生成したファイルのディレクトリのみgo getする ○ リポジトリのルートディレクトリのgo.modではreplaceを使う ○ 現在はこの方法で対応

Slide 38

Slide 38 text

5. まとめ

Slide 39

Slide 39 text

まとめ ● モノリスからモジュラモノリスに移行中 ● モジュールのインターフェースはProtocol Buffersで定義 ○ 採用して良かった ○ コードとドキュメント生成される ○ 破壊的変更が防げる ● gRPCのinterfaceを満たしながらモジュラモノリスを実装 ○ 今のところ少ない変更でモジュール -> gRPCの移行もできそう ● 実装ルールはLinterを自作して機械的にチェックしていきたい