見出し画像

gRPC×Go×ReactのWebアプリテンプレート作ってみた

こんにちは、エンジニアのhideです!

今回は、最近個人開発で試験的に使用しているgRPCについて簡単なテンプレートコードとともにまとめてみました。

特に、gRPC、Go、Reactを組み合わせた開発で苦労した点や、それをどう乗り越えたかを中心にお話しします。


アウトプット

簡易的なECサイトの構造をイメージして作成しました。
※client-webの方は動作まで細かくチェックしていないため参考程度のものになります。

gRPCを使用するメリット

RESTではなくgRPCを採用するメリットとして、主に以下の点が挙げられると思います。

  1. Protocol BuffersとHTTP/2により、高速かつ双方向な通信ができる

  2.  定義ファイルによるコード自動生成により、言語間のエンティティ定義を統一できる

それぞれの要素について説明するためには、HTTP/2とProtocol Buffersについて理解する必要がありそうです。

HTTP/2とProtocol Buffersとは

HTTP/2とProtocol Buffersとは簡単にまとめると以下のようになります。

  1. HTTP/2: 高速で効率的な通信プロトコル

  2. Protocol Buffers: 効率的なデータシリアライズ方式

HTTP/2のメリット

HTTP/2は、複数リクエストを同時に捌くことができるなど、HTTP/1.1と比較して、昨今のウェブサイトが抱える大量のコンテンツを効率的に送受信できる機能が複数追加されています。

Protocol Buffersの役割

Protocol Buffersは、関数の引数と戻り値の情報を効率的にシリアライズするために使用されます。これにより、データの転送サイズが小さくなり、通信効率が向上します。

gRPCの4つの通信方式

gRPCは、StreamingとUnaryの組み合わせにより、4つの通信方式をサポートしています。

  1. Unary RPC: 一対一の通信(REST APIと近いイメージ)

  2. Server Streaming RPC: サーバーからクライアントへの複数リクエスト

  3. Client Streaming RPC: クライアントからサーバーへの複数リクエスト

  4. Bidirectional Streaming RPC: 双方向での複数リクエスト

動画のストリーミング処理や、チャットなどシームレスにリクエスト、レスポンスを実現したい際に使用されていることが多いようです。

gRPCの定義ファイル

以下のような.protoファイルを定義しておくことで、各言語のコードをコマンドから生成することができます。
RESTだとSwaggerなどを駆使してAPI定義からコードを生成できるイメージです。

syntax = "proto3";

// Use convenience types defined and published as a package by Google
import "google/protobuf/empty.proto";
// import "google/protobuf/timestamp.proto";
// import "google/protobuf/wrappers.proto";

// Specify the directory where the code is automatically generated
// option go_package = "./";
option go_package = "../server-go/pb";

// Specify packagepl name to avoid name collision
package user;

// Method definition
service UserService {
  // general
  // create 
  rpc Create (User) returns (User) {}
}

// ユーザー情報を表すメッセージ型
message User {
  int64 id = 1;
  string uuid = 2;
  string created_at = 3;
  string updated_at = 4;
  bool is_deleted = 5;
  string name = 6;
  string email = 7;
  string phone_number = 8;
}

生成コマンドには、ローカルに諸々インストールするのもあんまりなので、ProtocのDockerイメージをビルドして使用しました。

docker pull namely/protoc-all

# コード生成
docker run -v $PWD:/defs namely/protoc-all -d [protoファイルのpath] -o [生成先path] -l [言語: goやwebなど]

ゴミみたいなshell scriptにまとめてみた。

#!/bin/bash

# This script is used to generate the proto files
if [[ $1 = all ]]; then
		echo "Generating all server files PWD: $PWD"

		rm -rf ./server-go/pb

		docker run -d -v $PWD:/defs namely/protoc-all -d proto/ -o ./pb/ -l go

		docker stop $(docker ps -l -q)

		echo "removing" $(docker ps -l -q)

		docker rm $(docker ps -l -q)

		rm -rf ./client-web/pb

		docker run -v $PWD:/defs namely/protoc-all -d proto/ -o ./client-web/src/pb/ -l web

		docker stop $(docker ps -l -q)

		echo "removing" $(docker ps -l -q)

		docker rm $(docker ps -l -q)

elif [[ $1 = go ]]; then

	echo "Generating go server files proto: $2"

	if [[ -z $2 ]]; then

		rm -rf ./server-go/pb

		docker run -v $PWD:/defs namely/protoc-all -d proto/ -o ./pb -l go

		docker stop $(docker ps -l -q)

		echo "removing" $(docker ps -l -q)

		docker rm $(docker ps -l -q)

		echo "success lang: $1 proto: all"
		exit 0

	else 
		protoc --go_out=../server-go/pb --go_opt=paths=source_relative \
		--go-grpc_out=../server-go/pb --go-grpc_opt=paths=source_relative \
		$2.proto

		echo "success lang: $1 proto: $2"
		exit 0
	fi

elif [[ $1 = js ]]; then
	echo "Generating js server files proto: $2"

	if [[ -z $2 ]]; then

		rm -rf ./client-web/pb

		docker run -v $PWD:/defs namely/protoc-all -d proto/ -o ./client-web/src/pb/ -l web

		docker stop $(docker ps -l -q)

		echo "removing" $(docker ps -l -q)

		docker rm $(docker ps -l -q)

		echo "success lang: $1 proto: all"
		exit 0

	else
		protoc --proto_path=. --js_out=import_style=commonjs,binary:. $2.proto

		echo "success lang: $1 proto: $2"
		exit 0

	fi

else
	echo "Invalid argument"
fi
sh ./scripts/genpb.sh ${LANG} ${FILE}

gRPC Goサーバーの実装

さて、ここからが本題です。
今回はシンプルなUnary RPCによる通信で実装しています。

以下は、簡略化したサーバー側のコードです。

router部分

package router

import (
	"context"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"

	"github.com/hidenari-yuda/grpc-go-react-template/domain/config"
	"github.com/hidenari-yuda/grpc-go-react-template/infra/database"
	"github.com/hidenari-yuda/grpc-go-react-template/infra/di"
	"github.com/hidenari-yuda/grpc-go-react-template/usecase"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

type Router struct {
	db       *database.Db
	firebase usecase.Firebase
}

func NewRouter(
	db *database.Db,
	firebase usecase.Firebase,
) *Router {
	return &Router{
		db:       db,
		firebase: firebase,
	}
}

func (r *Router) Start() {

	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.App.Port))
	if err != nil {
		panic(err)
	}

	s := grpc.NewServer()

	ctx := context.Background()
	di.RegisterServiceServer(ctx, s, r.db, r.firebase)

	// for using grpcurl
	reflection.Register(s)

	go func() {
		log.Printf("start gRPC server, port: %d", config.App.Port)
		err = s.Serve(listener)
		if err != nil {
			log.Fatalf("failed to serve: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt)
	<-quit
	log.Println("stopping gRPC server...")
	s.GracefulStop()

}

handler部分

// create user
func (s *UserServiceServer) Create(ctx context.Context, req *pb.User) (*pb.User, error) {

	tx, err := s.Db.Begin()
	if err != nil {
		return nil, handleError(err)
	}

	res, err := s.UserInteractor.Create(req)
	if err != nil {
		tx.Rollback()
		return nil, handleError(err)
	}

	tx.Commit()

	return res, nil

}

この後のinteractorやrepository部分はRESTと近い形で書いています。

router部分はエラーログをもっと詳細に捌けるよう試行錯誤中ですが、その他の部分はRESTのEntity部分をprotoで生成されたgoファイルをimportする形に変えていくイメージになります。

Reactクライアントの実装

React上では、あまりわかりやすい例ではありませんが、submit時にprotoファイルから生成されたEntityを呼び出し、値をセットしています。

その後、cllient.create()を実行することで、リクエストを飛ばすことができます。

項目1つ毎になる点(解決策ありそう?)は少し面倒ですが、API毎にリクエストやレスポンス、パスなどをまとめた関数などを書く必要がなくなった点はいい感じです!

// submit
  const handleSubmit = (
    isOpen: boolean,
  ) => {
    const client = new ContentServiceClient(process.env.REACT_APP_ENVOY_URL);
    const request = new Content();

    request.setUserId(user?.id);
    request.setTitle(title);
    request.setDescription(description);

    client.create(request, {}, (err, res) => {
      if (err) {
        console.error(err);
      } else {
        navigate("/");
      }
    });
  };

ハマったポイントと解決策

開発中、いくつかの難関にぶつかりました。主なものを紹介します:

  1. Proxyの設定: gRPCではHTTP2通信を使用するため、Load Balancingサービスで設定 or 自前でReverseProxyを立てる方法などでHTTP2に統一する必要があります。
    今回はEnvoyのコンテナを立ててClient->Envoy->Serverのような順番でリクエストを捌くようにしました。Envoyの設定などはとにかく動くものといった形でセキュリティ的にもっと設定しようがありそうです。

# Envoy configuration file
# References:
# https://github.com/grpc/grpc-web/blob/master/net/grpc/gateway/examples/echo/envoy.yaml
# https://qiita.com/yutachaos/items/b982575971746c222864
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 9090 }
      filter_chains:
        - filters:
          - name: envoy.filters.network.http_connection_manager
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
              codec_type: auto
              stat_prefix: ingress_http
              route_config:
                name: local_route
                virtual_hosts:
                  - name: local_service
                    domains: ["*"]
                    routes:
                      - 
                        name: server-go
                        match: 
                          prefix: "/go/"
                        route:
                          prefix_rewrite: "/"
                          cluster: server-go
                          timeout: 0s
                          max_stream_duration:
                            grpc_timeout_header_max: 0s
                        # redirect:
                        #   path_redirect: "/"
                        #   https_redirect: true
                      - 
                        name: server-py
                        match: 
                          prefix: "/py/"
                        route:
                          prefix_rewrite: "/"
                          cluster: server-py
                          timeout: 0s
                          max_stream_duration:
                            grpc_timeout_header_max: 0s
                        # redirect:
                        #   path_redirect: "/"
                        #   https_redirect: true
                    cors:
                      allow_origin_string_match:
                        - prefix: "*"
                      allow_methods: GET, PUT, POST
                      allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                      max_age: "1728000"
                      expose_headers: custom-header-1,grpc-status,grpc-message
            # http_filters:
            # - name: envoy.router
            #   config: {}
                # - name: envoy.filters.http.grpc_web
                #   typed_config:
                #     "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
                # - name: envoy.filters.http.cors
                #   typed_config:
                #     "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
                # - name: envoy.filters.http.router
                #   typed_config:
                #     "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          # tls_context:
          #   common_tls_context:
          #     tls_certificates:
          #       - certificate_chain:
          #           filename: "/etc/envoy/certs/example-com.crt"
          #         private_key:
          #           filename: "/etc/envoy/certs/example-com.key"
              # TLS 終端のための設定
              # transport_socket:
              #   name: envoy.transport_sockets.tls
              #   typed_config:
              #     "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
              #     # mTLS のための設定
              # common_tls_context:
              # validation_context:
              #   trusted_ca:
              #     filename: /etc/envoy/certs/ca.crt
              #   match_typed_subject_alt_names: []  # SAN の検証をする場合はここに書く
              # tls_certificates:
              # - certificate_chain:
              #     filename: /etc/envoy/certs/server.crt
              #   private_key:
              #     filename: /etc/envoy/certs/server.key
              # alpn_protocols: ["h2,http/1.1"]  # Python クライアントは ALPN を要求する
  clusters:
    - name: server-go
      connect_timeout: 0.25s
      type: logical_dns
      http2_protocol_options: {}
      lb_policy: round_robin
      load_assignment:
        cluster_name: server-go
        endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: host.docker.internal
                    port_value: 8080
    - name: server-py
      connect_timeout: 0.25s
      type: logical_dns
      http2_protocol_options: {}
      lb_policy: round_robin
      load_assignment:
        cluster_name: server-py
        endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: host.docker.internal
                    port_value: 8081

Certificateを使用してHTTPS通信の指定ドメインのリクエストを許可することもできるよう。
テスト用にPythonサーバーも作成して、Pathに応じてGoとPythonのサーバーに捌くようにもできました。

また、費用をギリギリまで削ろうとして、APIサーバーとEnvoyを1コンテナ内で実行するという不毛なことをしていました。
一応動きますが、安定性、可用性の観点から1コンテナ詰め込み手法は終わってます。

[supervisord]
nodaemon=true

[program:python]
command=python3 manage.py rungrpc
autostart=true
autorestart=true

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:envoy]
command=/usr/local/bin/envoy -c /etc/envoy/envoy.yml
autostart=true
autorestart=true

stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

日付の扱い

goサーバーでは、ORMは使用せずライブラリにsqlx、RDBにはMySQLを使用しているのですが、.protoファイル内の日付型がgoやMySQLのDATETIME型と形式一致せずエラーになってしまいした。
そのため、今回はcreated_atやupdated_atをstring型で入れる形にしましたが、いい感じの対処法を探す必要ありそうです。

まとめと今後の展望

gRPC関連はRESTと比べて情報も少ないですし、キャッチアップは大変ですが、興味深い技術のため今後も学んでいきたいと思っています!

特に、ストリーミングサービスやチャットサービスなど、大量のデータをリアルタイムで処理する必要があるアプリケーションには必須の技術になっていきていると感じるので、学習して損はなさそうですね。

今後は、今回触れれなかった以下の点について更に実装していきたいと考えています!

  • gRPCの双方向ストリーミングを活用したリアルタイム機能の実装

  • マイクロサービスアーキテクチャでの実装

最後まで読んでいただき、ありがとうございました。質問やコメントがあれば、ぜひ下のコメント欄でお聞かせください!

参考


この記事が気に入ったらサポートをしてみませんか?