はじめに
こんにちは。ECプラットフォーム部の北原です。普段はZOZOTOWNのバックエンドの開発、運用に携わっており、現在は会員機能を司るマイクロサービスの開発を進めています。
今回はZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るための取り組みを紹介します。
マイクロサービス開発の課題
ZOZOTOWNでは複数のマイクロサービスでGo言語を使っています。マイクロサービスではトレース、ヘッダー処理、認証関連などの機能をサービス毎に持つことはよくあると思います。一方で、マイクロサービス開発ではサービス毎に別のチームが開発することもよくあるため、実装者による認識の齟齬、漏れなどで同一機能の実装に差異が生じてしまうかもしれないという課題があります。
開発の当初から共通機能の管理への課題感はあり、ZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るため、標準的な機能を盛り込んだ開発テンプレートを作り課題に取り組んでいます。
開発テンプレート
開発テンプレートの共通規約の実装、共通機能の例、構成を紹介します。
共通規約の実装例
開発テンプレートはバックエンドの共通規約の実装、共通ライブラリ、ドメインロジックのサンプルコードを開発プロジェクトとして展開できるようにしたものとなります。
バックエンドの共通規約の実装例として次のようなものがあります。
- トレース
- ヘッダー処理
- 認証
それぞれ紹介していきます。
トレース
マイクロサービスのログやトレースではいくつかのサービスを使っており次のような住み分けをしています。
機能・サービス 用途
アクセスログ 障害調査やユーザからの問い合わせ対応
Datadog トレース分析、アラート検知
Sentry 未知のエラー検知、エラーの管理
例として、アクセスログの実装を抜粋したものとなります。まず、アクセス時にロガーを呼び出すミドルウェア(HTTPハンドラにおけるミドルウェアパターン)を、次にロガーの実装を示します。共通の項目を定義しサービス毎にズレがないよう共通としています。TraceID を持っていますが、Datadogでは全てのトレースを保持できないため、Datadogで破棄されたトレースの調査用に保持しています。
- アクセスログコード抜粋
func (mw loggingMiddlewareImpl) LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessLog := &httpLogger.AccessLog{
Method: r.Method,
Host: r.Host,
Path: r.URL.Path,
Query: r.URL.RawQuery,
RequestSize: r.ContentLength,
UserAgent: header.GetUserAgent(r),
...
TraceID: r.Header.Get(constant.HeaderKeyZozoTraceID),
UID: r.Header.Get(constant.HeaderKeyZozoUID),
}
ctx := setAccessLog(r.Context(), accessLog)
sw := &StatusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r.WithContext(ctx))
accessLog.Status = sw.status
requestedAt, err := GetRequestedAt(ctx)
if err != nil {
lib.LogError(ctx, err)
} else {
accessLog.Latency = time.Since(requestedAt).Seconds()
}
mw.accessLogger.Log(accessLog)
})
}
type loggingMiddlewareImpl struct {
accessLogger httpLogger.AccessLogger
}
httpLogger.AccessLogger では現在は zap.Logger を利用しています。ユーザからの問い合わせ調査など、サービスを横断した調査を円滑にするためサービス間で出力される情報が揃っていることが求められます。フォーマットや日時の精度など、機能が提供されることでサービス横断的な品質担保が容易になります。
- ロガーコード抜粋
type AccessLogger interface {
Log(accessLog *AccessLog)
}
type accessLoggerImpl struct {
*zap.Logger
}
func (l accessLoggerImpl) Log(a *AccessLog) {
l.Info(
zap.Int("status", a.Status),
zap.String("method", a.Method),
zap.String("host", a.Host),
zap.String("path", a.Path),
...
)
}
ヘッダー処理
APIではエンドポイントに送る値としてユーザの入力パラメータでない値はヘッダーでやりとりすることが多いと思われます。User Agentなどのユーザ情報や、認証情報、APIの呼び出し元の情報など必要なヘッダー情報はマイクロサービス間でも持ち回る共通規約となっています。
APIの処理中で別のマイクロサービスのAPIを呼び出す際も同等のヘッダーを付加する必要があるためContextで持ち回っています。これもマイクロサービスとして共通の実装にすることで抜け漏れのない機能として提供できます。
- ヘッダー処理のミドルウェアコード抜粋
func RequestMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if userAgent := r.Header.Get(constant.HeaderKeyForwardedUserAgent); userAgent != "" {
ctx = setForwardedUserAgent(ctx, userAgent)
}
if userIP := r.Header.Get(constant.HeaderKeyUserIP); userIP != "" {
ctx = setUserIPAddress(ctx, userIP)
}
if traceID := r.Header.Get(constant.HeaderKeyZozoTraceID); traceID != "" {
ctx = SetTraceID(ctx, traceID)
}
if uid := r.Header.Get(constant.HeaderKeyZozoUID); uid != "" {
ctx = setUID(ctx, uid)
}
if xForwardedFor := r.Header.Get(constant.HeaderKeyXForwardedFor); xForwardedFor != "" {
ctx = setXForwardedFor(ctx, xForwardedFor)
}
if r.RemoteAddr != "" {
ctx = setRemoteAddress(ctx, r.RemoteAddr)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
- 別のマイクロサービスのAPI呼び出しHTTPクライアントのヘッダー追加コード抜粋
func setHeaders(ctx context.Context, request *http.Request) error {
if encodedInternalIDToken, err := middleware.GetEncodedInternalIDToken(ctx); err == nil {
request.Header.Set(constant.HeaderKeyZozoInternalIDToken, encodedInternalIDToken)
}
if ipAddress, err := middleware.GetUserIPAddress(ctx); err == nil {
request.Header.Set(constant.HeaderKeyUserIP, ipAddress)
}
if userAgent, err := middleware.GetForwardedUserAgent(ctx); err == nil {
request.Header.Set(constant.HeaderKeyForwardedUserAgent, userAgent)
}
if traceID, err := middleware.GetTraceID(ctx); err == nil {
request.Header.Set(constant.HeaderKeyZozoTraceID, traceID)
}
if uid, err := middleware.GetUID(ctx); err == nil {
request.Header.Set(constant.HeaderKeyZozoUID, uid)
}
if apiClient, err := middleware.GetAPIClient(ctx); err == nil {
request.Header.Set(constant.HeaderKeyAPIClient, apiClient)
}
if xForwardedFor, err := middleware.GetXForwardedFor(ctx); err == nil {
if remoteAddr, err := middleware.GetRemoteAddress(ctx); err == nil {
host, _, e := net.SplitHostPort(remoteAddr)
if e != nil {
return xerrors.Errorf("split remote address: %v", e)
}
request.Header.Set(constant.HeaderKeyXForwardedFor, xForwardedFor+", "+host)
}
}
return nil
}
実装自体は非常にシンプルなものですが、シンプルであっても個別に実装せずヘッダーを伝播させる規約を守るためコピー用のコードを用意しています。
認証
認証はバックエンドの前段にあるAPI Gatewayで行われており、認証が成功した場合にはバックエンドへの通信のヘッダーに認証されたことを表すトークンが付与されます。 ヘッダーで渡されるトークンからユーザの情報を取得するためデコード処理を行っていますが、デコードの仕様や取得した値のバリデーションなど実装の差異がないよう共通処理としています。
- 認証ユーザ情報取得コード抜粋
func InternalIDTokenMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get(constant.HeaderKeyZozoInternalIDToken)
internalIDToken, err := model.DecodeInternalIDToken(token)
if err != nil {
writeRespInvalidInternalIDToken(w)
return
}
ctx := setInternalIDToken(r.Context(), internalIDToken)
...
next.ServeHTTP(w, r.WithContext(ctx))
})
}
API Gatewayで認証されたユーザ情報をDecodeInternalIDTokenで復元しContextにセットします。ミドルウェアのパッケージ外で値の変更ができないようレイヤーに閉じた実装として提供しています。
共通機能の例
バックエンドやAPIとしてよく利用され共通化できる機能もテンプレートとして提供しています。 ヘルスチェックのように各サービスで実装する機能は重複実装しないようテンプレートに含めています。
- ヘルスチェックコード抜粋
type clientOptions struct {
healthCheckReadiness func() error
}
func NewHealthCheckControllerWithReadiness(readiness func() error) func(*http.Request, RequestParameters) ([]byte, error) {
options.healthCheckReadiness = readiness
return healthCheckController
}
func healthCheckController(r *http.Request, _ RequestParameters) ([]byte, error) {
switch r.Method {
case http.MethodGet:
switch r.URL.String() {
case "/health/liveness":
return healthCheckLiveness()
case "/health/readiness":
return healthCheckReadiness()
default:
return nil, view.ErrNotFound
}
default:
return nil, view.ErrNotFound
}
}
func healthCheckLiveness() ([]byte, error) {
return []byte("{}"), nil
}
func healthCheckReadiness() ([]byte, error) {
if err := options.healthCheckReadiness(); err != nil {
return nil, view.ErrServiceUnavailable
}
return []byte("{}"), nil
}
- HTTPサーバーのrouter設定コード抜粋
func init{
...
opt := controller.ClientOptions{HealthCheckReadiness: mysql.Readiness}
healthCheckController := controller.NewHealthCheckControllerWithReadiness(opt)
routes := map[string]func(*http.Request, controller.RequestParameters) ([]byte, error){
"/health/liveness": healthCheckController,
"/health/readiness": healthCheckController,
}
for route, controller := range routes {
Router.Handle(route, buildHandler(controller))
}
...
}
- MySQLヘルスチェックコード抜粋
func Readiness() error {
db, err := mysql.NewDB()
if err != nil {
return err
}
defer db.Close()
err = db.Ping()
if err != nil {
return err
}
return nil
}
マイクロサービス作成の度に再実装するコストをかけないようにバックエンドの共通規約を守る機能やプロジェクトでよく利用される機能群をテンプレートにまとめています。
開発テンプレートの構成
テンプレートの責務はバックエンドの共通規約を守る、再利用性の向上、開発の立ち上げスピード向上にあると考えています。 要素としては、業務共通的なミドルウェア、共通ライブラリ、ドメインロジックのサンプルコードとなります。
続きはこちら