18
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2022

Day 25

ZOZOTOWNのマイクロサービス開発でバックエンドエンジニアとして学んだことをまとめます

Last updated at Posted at 2022-12-25

はじめに

ZOZOTOWNのシステムリプレイスプロジェクトにおいて、API GatewayやID基盤、会員基盤のマイクロサービス開発をバックエンドエンジニアとして2020年から約2年経験しました。その経験の中で私が技術的に学んだTipsをまとめます。なお、開発言語はGoですがGoに限らない内容も含まれます。

Go言語が関連すること

エラー系

wrap

Go1.13以降、errorsパッケージでエラーのwrapができるようになりました。wrapすることで元のエラーを保持したまま返すことができます。%w記法でエラーをwrapします。

return xerrors.Errorf("%w", ErrMemberNotExists) 

wrapの使いどきですが、エラーをハンドリングする箇所で、元のエラーに対してerrors.Isやerrors.Asメソッドを実行する場合はwrapします。それ以外ではwrapする必要がないため、代わりに%vを使います。

xerrors

xerrorsを使うとエラー発生箇所の追跡がしやすくなります。具体的には、どのファイルの何行目でエラーが発生したかが分かるようになります。pkg/errorsでも似たようなことはできますが、xerrorsはGo公式でメンテナンスされています。ただし、xerrorsも標準パッケージには組み込まれていません。

New関数(文字列から)かErrorf関数(既存エラーから)でxerrorsを作成します。

xerrors.New("new error") 

また、errorsと同様、wrapもできます。

concurrent map iteration and map writeの発生と対応例

原因

API Gatewayの開発で concurrent map iteration and map write というエラーが発生しました。その際の原因と対応を具体的に説明します。
private変数clientCountはkeyにクライアント(独自のClient型)でvalueに同時接続数(int型)を持つmap型です。クライアント種類ごとの同時接続数を管理しています。当初、getterとして単純に変数clientCountを返すClientCount関数を実装していました。

var clientCount = map[client.Client]int{}

func ClientCount() map[client.Client]int {
	clientCount
}

ClientCount関数はtickerにより毎秒実行され、返り値である変数clientCountは拡張for文でイテレーションされます。

for cli, conn := range middleware.ClientCount() {
	...
}

一方、変数clientCountは別の箇所で、API処理時に同時接続数をインクリメントする処理がされます。

mutex.Lock()
clientCount[c]++
count := clientCount[c]
mutex.Unlock()

つまり、これら2つの処理がそれぞれ別スレッドで実行され、変数clientCountに関する競合エラーが発生したことが原因でした。

対応

ClientCount関数を改修して、「参照ロックしてコピーを返す」という実装にしました。また、map型変数にそのまま代入すると参照型変数のため参照渡しになるため、for文で各要素を代入するようにしました。これによりエラーが発生しなくなりました。

var mutex = &sync.RWMutex{}
var clientCount = map[client.Client]int{}

func ClientCount() map[client.Client]int {
	mutex.RLock()
	defer mutex.RUnlock()

	copied := make(map[client.Client]int)
	for k, v := range clientCount {
		copied[k] = v
	}
	return copied
}

標準エラー出力とSentryへの送信を同一方法でやる

同一のエラーログ出力方法でありながらも、環境変数の設定に応じてエラーログの出力先を標準エラー出力とSentryで自動で振り分けます。なお、ここではツールをSentryとしていますが、ツールは別のものでも構いません。

以下は、HTTPサーバ起動に関する初期化処理です。環境変数SENTRY_DSNが未設定の場合は標準エラー出力、設定されている場合はSentryにします。

app/adapter/http/server.go
var sentryDSN string

func init() {
	...
	sentryDSN = os.Getenv("SENTRY_DSN")
	if sentryDSN == "" {
		if env.IsProduction() {
			panic("SENTRY_DSN is unset")
		}
		lib.SetErrorLogger(logger.NewStandardErrorLogger())
	} else {
		lib.SetErrorLogger(logger.SentryErrorLogger{})
	}
	...
}

以下は、エラーログに関する実装です。
変数errorLoggerは、ErrorLoggerインターフェース型です。
ErrorLoggerインターフェースは、Logメソッドを持ちます。
SetErrorLogger関数は、引数loggerを変数errorLoggerにセットします。標準エラー出力とSentryではLogメソッドを実装しているため、同一interfaceとして扱えます。
LogError関数は、errorLoggerのLogメソッドを実行します。

lib/error_logger.go
var errorLogger ErrorLogger

type ErrorLogger interface {
	Log(ctx context.Context, err error)
}

type nopErrorLogger struct{}

func (nopErrorLogger) Log(_ context.Context, _ error) {}

func init() {
	errorLogger = nopErrorLogger{}
}

func SetErrorLogger(logger ErrorLogger) {
	errorLogger = logger
}

func LogError(ctx context.Context, err error) {
	errorLogger.Log(ctx, err)
}

標準エラー出力のLogメソッドの実装は以下です。

type standardErrorLogger struct {
	logger *log.Logger
}

func NewStandardErrorLogger() lib.ErrorLogger {
	return standardErrorLogger{logger: log.New(os.Stderr, "", log.LstdFlags)}
}

func (s standardErrorLogger) Log(ctx context.Context, err error) {
	s.logger.Printf("%+v\n", err)
}

SentryのLogメソッドの実装は以下です。

type SentryErrorLogger struct{}

func (l SentryErrorLogger) Log(ctx context.Context, err error) {
	...
	hub.CaptureEvent(event)
}

エラーログを出力する際は、 LogError 関数を実行するだけです。出力先は指定する必要はありません。

エラーが返った場合は他の返り値は参照しない

Goにはポインタでない型にnil値はないため、未定義の値と空値を区別できません。例えば、intを返すような関数を考えたときに、処理内でerrorが発生した場合、返り値のintは未定義となり0を返すことになりますが、呼び出し側ではそれが「未定義による0」なのか「正常な処理結果による0」なのかを判断できません。
そこで、チームでは返り値のerrorがnilでない場合にはその他の返り値は未定義として扱うことで、未定義と空値を区別できるようにしています。もし参照すると nil pointer dereference が発生する可能性もあります。しかし、このルールに沿っていない外部パッケージも存在するので、それを利用する場合はこの限りではありません。

API開発関連

Goでのmiddlewareの作り方

middlewareは func (http.Handler) http.Handler のシグネチャです。
引数はhttp.Handler型です。http.Handler型の変数に各種middlewareを入れ子にしたものを引数に渡します。
返り値もhttp.Handler型です。ハンドラチェーンでまとめたmiddlewareをnet/httpのServer構造体のHandlerプロパティに渡すことができます。これにより、簡単にミドルウェアを実装したHTTPサーバーを起動できます。(以下のコードはかなり簡略化しております。)

var handler http.Handler
handler = middleware.MiddlewareA(handler)
handler = middleware.MiddlewareB(handler)
handler = middleware.MiddlewareC(handler)

server := &http.Server{Addr: fmt.Sprintf(":%v", appPort), Handler: handler}
server.ListenAndServe()

middlewareの処理内にて、引数のHandlerのServeHTTPメソッドを実行し、制御を次のHandlerへ移します。これにより、複数のmiddlewareをハンドラチェーンできます。
http.HandlerFunc をreturnし、無名関数の中で各middlewareのロジックを実装します。ServeHTTP以前がリクエスト処理で、ServeHTTP以降がレスポンス処理になります。リクエスト処理時はhttp.Handler型の変数に代入した順序でmiddlewareが処理され、レスポンス処理時は逆の順序で処理されます。
以下はAPIアクセスログのmiddlewareの例です。リクエスト処理時にはアクセスログ用の構造体の変数を用意してコンテキストに詰めます。レスポンス処理時にはHTTPステータスも含めてログ出力します。

func (mw loggingMiddlewareImpl) LoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		accessLog := &httpLogger.AccessLog{
			...
		}
		ctx := requestContext.SetAccessLog(r.Context(), accessLog)
		sw := &StatusResponseWriter{ResponseWriter: w, status: http.StatusOK}

		next.ServeHTTP(sw, r.WithContext(ctx))
		...
		accessLog.Status = sw.status
		mw.accessLogger.Log(accessLog)
	})
}

GoでのGraceful shutdownの実装

SIGTERMシグナルを送るとサーバが新しいリクエストを受け付けなくなり、サーバが現在受けているリクエストに関して全てのレスポンスを返してからshutdownするように実装します。
下の実装では、goroutine内では <- sigCh となっているため、SIGTERMが来るまでは以降の処理はされず、来たらtimeout付きでshutdownされます。本スレッドではListenAndServeを実行します。

server := &http.Server{Addr: fmt.Sprintf(":%v", appPort), Handler: Router, ReadHeaderTimeout: readHeaderTimeout}
idleConnsClosed := make(chan struct{})
go func() {
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGTERM)
	<-sigCh

	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {
		// Error from closing listeners, or context timeout:
		log.Panic("Failed to gracefully shutdown ", err)
	}
	close(idleConnsClosed)
}()

if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
	// Error starting or closing listener:
	log.Panic(err)
}
<-idleConnsClosed

参考の実装:
https://pkg.go.dev/net/http#Server.Shutdown

API処理内のgoroutineをwaitする必要はない

goroutineの処理は、メインスレッドが完了しなければ打ち切られません。下の例では、1の出力が終わっても2の出力をするgoroutineの処理が打ち切られていないことを示しています。
自分は、goroutineの呼び出し元の処理が先に完了すると、goroutineの処理が途中で打ち切られてしまうと勘違いしていました。

...
func main() {
	go func() {
		go func() {
			time.Sleep(2 * time.Second)
			fmt.Println(2)
		}()
		fmt.Println(1)
	}()
	time.Sleep(3 * time.Second)
	fmt.Println(3)
}

実行結果:

1
2
3

Playground

APIサーバの場合はサーバ起動がメインスレッドになります。したがって、API処理内でgoroutineを使っても、goroutineの処理が終わるまでsync.Waitgroupを使ってwaitするといった実装をする必要はありません。

goroutine内で渡すcontextは新しいものにする

API処理内にて外部システムへのAPIリクエスト処理をgoroutine内で実装する場合、そのリクエスト処理にcontextを渡す必要がある場合は、それ専用の新しいcontextを作って渡しましょう。もし既存のcontextを渡してしまうと、goroutineを呼び出している側のcontextがdoneになり、外部システムへのリクエスト処理が打ち切られる可能性があります。
下は、goroutine内でメール送信APIを実行する例です。

...
go func() {
	// contextを新しく用意する
	emailContext := context.Background()

	e = s.sendEmailService.Send(emailContext, toEmail, s.emailBuilder.BuildEmailSubject(), emailBody, service.BodyTypeText)
	if e != nil {
		...
	}
}()
...

また、外部システムへのAPIリクエストはトレースしたいです。Datadogの場合は、SpanFromContextにより既存のcontextから取得したspanを、ContextWithSpanにより新たに用意したcontextへ付与します。

...
go func() {
	emailContext := context.Background()
	span, ok := tracer.SpanFromContext(ctx)
	if ok {
		emailContext = tracer.ContextWithSpan(emailContext, span)
		emailContext = service.SetMemberID(emailContext, member.ID)
	}
	e = s.sendEmailService.Send(emailContext, toEmail, s.emailBuilder.BuildEmailSubject(), emailBody, service.BodyTypeText)
	if e != nil {
		...
	}
}()
...

nullableに対応する

nullableな変数を実現するためにOptionalという独自の型を用意しています。必須でないリクエストパラメータの値を処理するなどの状況で利用します。
Optional構造体はvalue(値)とvalid(nullかどうか)のプロパティとIsNullとUnwrapメソッドを持ちます。
IsNullメソッドはnullかどうかを返します。Unwrapメソッド内で実行されます。
Unwrapメソッドはnullかどうかをチェックし、nullでなければ値を返します。
New関数は引数のany型の値を[]で指定された型に型アサーションし、その結果を返します。型アサーションに失敗した場合はvalidはfalseです。
Some関数はOptional構造体に引数の値とvalidをtrueにセットして返します。引数はany型でないので、既に型が決まっている場合を想定しています。

type Optional[T any] struct {
	value T
	valid bool
}

func (o Optional[T]) IsNull() bool {
	return !o.valid
}

func (o Optional[T]) Unwrap() (T, error) {
	if o.IsNull() {
		var result T
		return result, xerrors.New("optional value should not be null")
	}
	return o.value, nil
}

func New[T any](v any) Optional[T] {
	if tValue, ok := v.(T); ok {
		return Optional[T]{value: tValue, valid: true}
	}
	return Optional[T]{valid: false}
}

func Some[T any](v T) Optional[T] {
	return Optional[T]{
		value: v,
		valid: true,
	}
}

any型の変数をOpitonal型に変換する際には、New関数を使います。

var value any
var genderID Optional[int]

typed, ok := value.(json.Number)
if !ok {
	...
}
i64, e := typed.Int64()
if e != nil {
	...
}
genderID = New[int](int(i64))

既に型アサーションした変数をOptional型に変換する際には、Some関数を使います。

var value any
var email Optional[string]

...

typed, ok := value.(string)
if !ok {
	...
}
email = optional.Some(typed)

New関数を使ってしまうと無駄にもう一回型アサーションすることになってしまうか、以下のようにNew関数内の型アサーションの失敗をIsNullで判定する違和感のある実装になってしまいます。

var value any
var email Optional[string]

...

email = optional.New[string](value)
if email.IsNull() {
	...
}

Optional型の変数から値を取り出す際には、Unwrapメソッドを使います。

var optEmail Optional[string]
var email string

...

if v, err := optEmail.Unwrap(); err == nil {
	email = v
}

sql.NullStringとの使い分けは、DBに関連する処理かどうかです。DBにINSERTやUPDATEする場合はoptionalからsql.NullStringへ変換します。DBからデータをSELECTし何かしらの処理をする場合は逆の変換をします。
sql.NullStringとOption間の変換は以下の関数で行います。

func toNullString(s optional.Optional[string]) sql.NullString {
if v, e := s.Unwrap(); e == nil {
	return sql.NullString{String: v, Valid: true}
}
return sql.NullString{Valid: false}
}
func toOptionalString(str sql.NullString) optional.Optional[string] {
	if str.Valid {
		return optional.New[string](str.String)
	}
	return optional.New[string](nil)
}

リクエストパラメータの取得処理を汎用化する

APIごとにリクエストパラメータの仕様に応じた構造体を用意する必要なく、汎用的に扱えるようにしています。
RequestParameters構造体は、パスパラメータとクエリパラメータとリクエストボディをそれぞれmapで保持するフィールドを持ちます。単一のmapでなくわざわざ構造体を用意している理由は、各種パラメータでのkey被りが発生する可能性を考慮したためです。
Pathメソッドはパスパラメータを返します。Queryメソッドはクエリパラメータを返します。Bodyメソッドはリクエストボディを返します。controller層でこれらメソッドを必要に応じて実行します。

type RequestParameters struct {
	path  map[string]string
	query map[string]string
	body  map[string]interface{}
}

func (p RequestParameters) Path() map[string]string {
	return p.path
}

func (p RequestParameters) Query() map[string]string {
	return p.query
}

func (p RequestParameters) Body() map[string]interface{} {
	return p.body
}

各パラメータの取得処理は以下で実装しています。
パスパラメータは、mux.Varsで取得しています。
クエリパラメータは、url.ParseQueryで取得しています。
リクエストボディは、jsonをmapにデコードして取得しています。

func ParseRequestParameters(r *http.Request) (RequestParameters, error) {
	// パスパラメータ
	pathParams := map[string]string{}
	vars := mux.Vars(r)
	for key, value := range vars {
		pathParams[key] = value
	}

	// クエリパラメータ
	queryParams := map[string]string{}
	if r.URL != nil {
		p, err := url.ParseQuery(r.URL.RawQuery)
		if err != nil {
			return RequestParameters{}, xerrors.Errorf("parse query: %w", ErrInvalidParameter)
		}
		for key, values := range p {
			// 配列で受け取ることは想定せずに末尾のみを取得する。配列で渡す場合はカンマ区切りなどにする。
			queryParams[key] = values[len(values)-1]
		}
	}

	// リクエストボディ
	bodyParams := map[string]interface{}{}
	if r.Body == http.NoBody && (r.Method == http.MethodPut || r.Method == http.MethodPatch) {
		return RequestParameters{}, xerrors.Errorf("%w", ErrInvalidParameter)
	}
	if r.Body != http.NoBody {
		if !(r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch) {
			return RequestParameters{}, xerrors.Errorf("%w", ErrInvalidParameter)
		}
		contentType, _, err := mime.ParseMediaType(r.Header.Get(constant.HeaderKeyContentType))
		if err != nil {
			return RequestParameters{}, xerrors.Errorf("parse medita type: %w", ErrInvalidParameter)
		}
		if contentType != constant.MediaTypeJSON {
			return RequestParameters{}, xerrors.Errorf("%w", ErrInvalidParameter)
		}
		p := map[string]interface{}{}
		decoder := json.NewDecoder(r.Body)
		decoder.UseNumber()
		err = decoder.Decode(&p)
		if err != nil {
			return RequestParameters{}, xerrors.Errorf("decode json: %w", ErrInvalidParameter)
		}
		for key, value := range p {
			bodyParams[key] = value
		}
	}

	return RequestParameters{path: pathParams, query: queryParams, body: bodyParams}, nil
}

テスト・lint系

export_test.go

テストファイルのパッケージを _test の形式にすることで、テスト対象とテストコードが別パッケージでありながらも、同一ディレクトリに混在できます。別パッケージにするメリットは、テスト対象とテストコードを疎結合にできる点です。「テストコードはあくまでもテスト対象パッケージのユーザ」という立場で、ブラックボックステストになるので、テスト対象は必然とそれを意識した利用しやすい関数の実装がされるそうです。
パッケージを別にすると、当然ながらテストコードからはプライベートな変数や関数などにアクセスできません。しかしながら、同一ディレクトリに export_test.go というファイルを用意し、そのファイルにテスト時のみ一時的にパブリックで宣言し直すことができます。
ただし、パブリックにしても別ディレクトリからは参照できません。

参考:
https://engineering.mercari.com/blog/entry/2018-08-08-080000/

deferかt.Cleanupか

deferとt.Cleanupは呼ばれるタイミングが異なります。deferは関数がreturnされるタイミングで呼ばれます。t.Cleanupはそのテスト(サブテスト含む全てのテスト)が完了したタイミングで呼ばれます。deferだとサブテストの終了を待たずに後処理が実行されてしまう(サブテストは開始されるとすぐに一時停止して、親テストが戻ってから再開します。)ため、t.Parallelによりサブテストが並列で動いている場合に、予期せぬ動作を起こす可能性があります。
また、テストのヘルパー関数でt.Cleanupを使用するとテスト終了時に後処理をしてくれますが、deferだとヘルパー関数を呼び出した後に後処理がされてしまいます。
以上のことから、deferとt.Cleanupの使い分けが面倒な場合は、「テストコードではt.Cleanupを使う」と統一してしまってもいいかもしれません。

参考:
https://engineering.mercari.com/blog/entry/how_to_use_t_parallel/

runオプション

go test ではrunオプションが用意されています。 go test -run XXX で一部のテスト関数を指定して実行できます。
(かなり昔から存在している機能ですが自分が知らなかったです。)

DBアクセスを伴ったテスト

DBアクセスを伴ったテストは、docker-composeで定義したDBコンテナを利用してテストしています。
sqlmockという手段もありますが、実際にDBにデータを出し入れできるかを念の為確認しておきたいという思いもあり、使いませんでした。

DB関連のテストのヘルパー関数を紹介します。
SetupDBTestはDB接続・全テーブルの外部キー制約外し・全テーブルのTruncate・全テーブルの外部キー制約戻しを行います。

func SetupDBTest() *sql.DB {
	db, err := mysqlAdapter.NewDB()
	if err != nil {
		panic(err)
	}
	rows, err := db.Query("SHOW TABLES")
	if err != nil {
		db.Close()
		panic(err)
	}
	defer rows.Close()
	tables := []string{}
	for rows.Next() {
		var table string
		if err = rows.Scan(&table); err != nil {
			db.Close()
			panic(err)
		}
		tables = append(tables, table)
	}
	if err = rows.Err(); err != nil {
		db.Close()
		panic(err)
	}

	var tx *sql.Tx
	tx, err = db.Begin()
	if err != nil {
		db.Close()
		panic(err)
	}
	_, err = tx.Exec("SET FOREIGN_KEY_CHECKS = 0")
	if err != nil {
		db.Close()
		panic(err)
	}
	for _, table := range tables {
		_, err = tx.Exec(fmt.Sprintf("TRUNCATE TABLE `%s`", table))
		if err != nil {
			db.Close()
			panic(err)
		}
	}
	_, err = tx.Exec("SET FOREIGN_KEY_CHECKS = 1")
	if err != nil {
		db.Close()
		panic(err)
	}
	err = tx.Commit()
	if err != nil {
		db.Close()
		panic(err)
	}

	return db
}

TeardownDBTestはDB接続断をします。

func TeardownDBTest(db *sql.DB) {
	db.Close()
}

一般的にはSetuUpとTearDownの処理が逆のケースが多いかもしれません。何らかの不具体でテストが途中で止まったり、ローカルでの動作確認でテストデータが残っていたなどのケースを考慮すると、SetUpでデータ削除した方が確実にテストが通りやすそうという理由でこのようにしています。テスト実行に問題なければどちらでもよいと思います。

ダミーデータ

ダミーデータをINSERTするヘルパー関数

テストに必要なダミーデータをテーブルにINSERTするヘルパー関数SetupFixturesを用意しています。テーブルに対応したGoのstructを用意しており、SetupFixturesの第二引数にそのstructを使ったダミーデータを渡します。第二引数はinterface{}型になっているので、全てのテーブルに対して汎用的に扱えるようになっています。

SetupFixtures関数の定義:

testing/fixtures.go
func SetupFixtures(db *sql.DB, fixtures []interface{}) {
	ctx := context.Background()
	for _, fixture := range fixtures {
		var e error
		switch f := fixture.(type) {
		case model.Member:
			e = repositoryImpl.NewMemberRepository(db).Create(ctx, f)
		...
		default:
			panic(fmt.Sprintf("SetupFixtures for %T is not implemented.", f))
		}
		if e != nil {
			handleError(e)
		}
	}
}

func handleError(err error) {
	var isDuplicateKeyError bool
	if errors.Is(err, repository.ErrDuplicateData) {
		isDuplicateKeyError = true
	} else {
		var mysqlErr *mysql.MySQLError
		if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlAdapter.DuplicateEntryErrorNumber {
			isDuplicateKeyError = true
		}
	}
	if err != nil && !isDuplicateKeyError {
		panic(err)
	}
}

ダミーデータの定義:

testing/data.go
var Member = model.Member{
	ID:              1,
	Email:           optional.New[string]("taro.tanaka@example.com"),
	ZozoID:          optional.New[string]("taro.tanaka"),
	Password:        optional.New[string]("XXXXX"),
	LastName:        optional.New[string]("田中"),
	FirstName:       optional.New[string]("太郎"),
	LastNameKana:    optional.New[string]("タナカ"),
	FirstNameKana:   optional.New[string]("タロウ"),
	GenderID:        optional.New[int](1),
	Birthday:        optional.New[lib.Date](lib.Date{Year: 2004, Month: 12, Day: 15}),
	Zipcode:         optional.New[string]("1020094"),
	PrefectureID:    1,
	Address:         optional.New[string]("東京都千代田区紀尾井町1-3"),
	AddressBuilding: optional.New[string]("東京ガーデンテラス紀尾井町 紀尾井タワー"),
	Phone:           optional.New[string]("0120-55-0697"),
	ZozoEmployeeID:  optional.New[string]("1"),
	RegisteredAt:    time.Date(2004, 12, 15, 12, 0, 0, 0, time.UTC),
}

SetupFixturesを使う:

import (
	testingHelper "github.com/st-tech/zozo-member-api/testing"
	dummy "github.com/st-tech/zozo-member-api/testing/data"
	...
)	
...
t.Run(tt.name, func(t *testing.T) {
	db := testingHelper.SetupDBTest()
	defer testingHelper.TeardownDBTest(db)
	testingHelper.SetupFixtures(db, []interface{}{dummy.Member})
	...
}

テストケース毎に必要なダミーデータが異なる場合

テストケース毎に必要なダミーデータは変わってくると思います。何も考えずにやると、t.Run内でif文でテスト名などを条件にテストケース毎にSetupFixturesを実行する感じになり、テストケースが多いとt.Run内でelse-ifがたくさんでてきてうんざりしまいます。
そこで、テーブルドリブンなテストコードのstructに fixtures []interface{} というフィールドを用意し、そのstructの中身を定義する時にテストケース毎に必要なダミーデータを指定するようにしました。それにより、t.Run内ではSetupFixtures(db, tt.fixtures)を一律に実行するだけになるので、t.Run内がすっきりします。

...
tests := []struct {
	name          string
	fixtures      []interface{}
	...
}{
	...
	{
		name: "yahoo login web",
		fixtures: []interface{}{
			dummy.MemberWithoutPassword,
			dummy.SocialLoginMemberYahoo,
		},
		...
	},
	...
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db := testingHelper.SetupDBTest()
			defer testingHelper.TeardownDBTest(db)
			testingHelper.SetupFixtures(db, tt.fixtures)
			...
		}

httptest.Server

httptest.NewServerでhttptest.Serverを用意します。NewServerの引数はhttp.Handlerのため、http.HandlerFuncを使って下記のように外部システムのモックを簡単に設定できます。tt.statusとtt.bodyはテストパラメータです。
リクエストはこのテストサーバーのURLに向けます。レスポンスはモックに設定したものが返ります。

...
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(tt.status)
	_, _ = w.Write([]byte(tt.body))
}))
t.Cleanup(ts.Close)
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
...

また、外部システムのモックとしてだけではなく、実装したミドルウェアのテストもhttptest.Serverで簡単に実装できます。なぜならば、引数がhttp.Handlerなのでmiddlewareのシグネチャ的にそのまま代入してNewServerすれば良いからです。テスト対象のmiddlewareを実装したテストサーバへHTTPリクエストして、middlewareが想定通りの動きをしているかを確認します。

interfaceを利用したモック

外部システムへのリクエスト処理をする箇所をinterfaceで実装することで、本番コードでは実際のリクエストをする、テスト時はモックを呼ぶ出す、といったように簡単に入れ替えできます。モックはinterfaceで定義されたメソッドを全て実装することで、ダックタイピングにより同じinterfaceとして扱えるためです。

インターフェース定義:

client.go
type Client interface {
	RemoveMember(ctx context.Context, id int) error
}

実際の実装:

client_impl.go
func (c ClientImpl) RemoveMember(ctx context.Context, id int) error {
	// 実際の外部システムへのリクエスト処理
	...
}

モック:

client_mock.go
type ClientMock struct {
	RemoveMemberError error
}

func (c ClientMock) RemoveMember(ctx context.Context, id int) error {
	return c.RemoveMemberError
}

テストコード側では ClientMock{} を指定することでモックを使ったテストになります。

golangci-lint

golangci-lintは、複数のlintをまとめた百貨店のようなものです。とりあえずgolangci-lintを導入すれば複数のlinterをすぐに導入できます。
.golangci.yml で各種linterの設定ができます。元々は全てのlinterを無効(disable-all: true)にして有効にしたいものだけ有効にするという設定をしていました。しかしながら、golangci-lintには新しいlinterが活発的に追加されます。golangci-lintのバージョンを上げたときに、それらの新しいlinterを活用しやすくするためにいったん全てのlinterを有効(enable-all: true)にして、無効にする場合は理由を併記する運用に変えました。

run:
  timeout: 3m

linters-settings:
  ...

# golangci-lintのバージョンを上げたときに、新しいlinterを有効にするためにデフォルトで全てのlinterを有効にし、無効にする場合は理由を併記するようにする。
linters:
  enable-all: true
  disable:
    - exhaustivestruct # 公式のリポジトリに特殊なケースでしか使わないと書かれている
    - golint # deprecated
    - interfacer # deprecated
    ...

issues:
  exclude-rules:
    ...

また、各linterの変更がgolangci-lintまで反映されるのに数ヶ月かかる場合があるため、そこは注意が必要です。

初期化系

init関数

init関数は、環境変数読み込みや設定ファイルの読み込みなどの初期化処理を実装しておく特殊な関数です。実行されるタイミングは、init関数が定義されているパッケージがimportされたタイミングです。

func init() {
	AppEnv = strings.ToLower(os.Getenv("APP_ENV"))
	if AppEnv == "" {
		panic("APP_ENV is unset")
	}
}

正規表現のコンパイル場所

正規表現のコンパイル処理は一般的に重いと言われています。コンパイル処理を関数内に実装すると、関数呼び出しの度にコンパイルされることになるので、関数の外でパッケージ変数として用意するとよいです。

// 関数の外でregexp.MustCompile
var reStatusCode = regexp.MustCompile("^[0-9]{3}$")

func validateResponse(response *http.Response, route *routers.Route, key string) error {
	var expectedStatus int
	if reStatusCode.MatchString(key) {
		i, _ := strconv.Atoi(key)
		expectedStatus = i
	}
	...
}	

context系

contextは第一引数で渡す

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it

godocにて、「Contextは構造体の中に保存せずに、Contextを必要としている関数に渡してください。コンテキストは引数で渡すべきです。」と記されています。基本的には第一引数でctxという引数名で渡します。

参考:
https://qiita.com/sonatard/items/d97279086b24e588a82d

外部SDK利用時のcontextを使ったタイムアウト

SDKを利用して外部サービスへリクエストする際に、リクエストのタイムアウトを実現したい場合は、contextを引数で渡せる関数を探しましょう。XXXWithContextみたいな名前の場合が多い気がします。
また、contextパッケージのWithTimeout関数でコンテキストを生成する場合は、専用のコンテキスト変数に格納した方がよいです。既存のコンテキスト変数に再代入してしまうと、「リクエスト側のキャンセル」なのか「タイムアウトによるキャンセル」なのかが判別できなくなってしまうためです。
下はAWSのSESを利用する際の例です。

timeoutContext, cancelFn := context.WithTimeout(ctx, timeout)
defer cancelFn()

_, e = ses.New(awstrace.WrapSession(session.Must(session.NewSession(&aws.Config{})))).SendEmailWithContext(timeoutContext, input)
if e != nil {
	if aerr, ok := e.(awserr.Error); ok && aerr.Code() == request.CanceledErrorCode {
		select {
		case <-ctx.Done():
			return context.Canceled
		default:
			return ErrSESTimeout
		}
	}
	return e
}
return nil

その他

ダブルクォートとバッククォート内の改行とテンプレート

ダブルクォートの場合、改行が必要な箇所では改行コードが必要なのでごちゃごちゃしがちです。
バッククォートの場合、改行コードが不要です。ただし、左寄りになるためインデントは少し気持ち悪いかもです。

func main() {
	str1 := "こんにちわ。\n" +
		"今日は天気が良いですね。\n"
	str2 := `こんにちわ。
今日は天気がよいですね。`
}

文章量が多い(改行が多い)場合は、テンプレートを用意してそれを読み込んで使用するのも手です。

func init() {
	var e error
	tmpl, e = template.ParseFiles("hello.tpl")
	if e != nil {
		panic(e)
	}
}

mapよりもstructを使えないかを考えてみる

keyの値の種類が決まっている場合に、mapでなく、structで実装できないかを考えます。mapだと意図しないkeyに対して値が入る可能性があるからです。
補足すると、mapだと絶対だめなわけではないです。問題なく動きます。ただ、指定されるkeyが定まっているならば、structにしてしまおうということです。

func main() {
	// map
	var m = map[string]int{
		"hoge": 1,
		"fuga": 2,
	}
	
	// struct
	type hogeStruct struct{
		hoge int
		fuga int
	}
	var s = hogeStruct{
		hoge: 1,
		fuga: 2,
	}
}

バックエンド開発全般に関すること(Go言語に依らない)

ヘキサゴナルアーキテクチャでの開発

ヘキサゴナルアーキテクチャ概要

ヘキサゴナルアーキテクチャは、ドメイン領域を中心に見据えてそのほかを外側に押しやる設計をするアーキテクチャです。コンポーネント間が疎結合になり、例えば以下のメリットがあります。

  • 変更に強くなる
    • 例えば、DBがMySQLからPostgreSQLへ変わったとしても、Adapterの実装とAdapterの呼び出し部分を書き換えるだけで済む。
  • テストが楽
    • Adapterでモックに簡単に置き換えられる

ディレクトリ構成

ZOZOTOWNのGoでのマイクロサービス開発用に用意しているテンプレートを使って、ディレクトリ構成例を示します。

zozo-go-template.png

appディレクトリに関する説明は以下です。

  • app
    • adapter(配下にhttpやmysqlパッケージなどを用意しています。他にもAWSなどの外部システムごとのパッケージをこの配下に用意する想定です。)
      • http
        • server.go(初期化処理やサーバー起動処理など。)
        • middlewar(各種ミドルウェア)
        • context(リクエストスコープで扱うデータのSet/Getなど)
        • logger(アクセスログ)
        • controller(ルーティングで定められたAPIの最初の処理。リクエストパラメータの取得処理やDB接続、application層の呼び出し、レスポンス情報の生成など。)
      • impl(interfaceで定義したメソッドの実装)
      • mysql(mysqlへの接続処理)
    • application
      • service(そのアプリケーション固有のサービスをinterfaceで定義)
      • usecase(controllerから呼ばれる。domain/repositoryやapplication/service、domain/serviceなどの呼び出しを行い、APIレスポンスに必要な情報を返す。)
    • domain
      • model(Goの構造体やドメインルールなど)
      • repository(データストアとのやりとりに関するinterfaceを定義)
      • service(modelに実装してしまうと不自然なドメインの振る舞いを実装。何でもかんでもここに振る舞いを実装してしまうと、ドメインモデル貧血症を引き起こすことになるので要注意。)

呼び出し順

依存関係により adapter -> application -> domain の順の呼び出しのみを許可します。

条件式の並び順

リーダブルコードによると、左側を変化する「調査対象」の式とし、右側をあまり変化しない「比較対象」の式にすべきとしています。例えば、 if (10 <= length) よりも if (length >= 10) のほうが読みやすいとしています。個人的にはこの考えはしっくりきます。
コードコンプリートによると、数直線の並びと合わせるべきとしています。つまり、「<だけを使って>は使わない」ということです。算数や数学の世界で考えるとこちらのほうがわかりやすいのかもしれません。
「主眼を置いているもの(変化するもの)が比較対象より大きい」という条件の時に、リーダブルコード派かコードコンプリート派かそれぞれで書き方は変わってくるようです。

外部システムへのリクエストはAPMの対象にする

外部システムへのリクエストに関するレスポンスやリトライ状況、レイテンシーなどをAPMで確認できるようにしておいた方がよいでしょう。ZOZOではAPMのツールにDatadogを使用しています。
Goではdd-trage-goというライブラリが用意されています。一般的なライブラリに関してはdd-trace-goにcontribが用意されているのでそれを利用します。例えば、AWSへリクエストを送信する場合の初期化処理は、contrib/aws/aws-sdk-go/awsを使用します。

awstrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/aws/aws-sdk-go/aws"

...

_, err = ses.New(awstrace.WrapSession(session.Must(session.NewSession(&aws.Config{})))).SendEmailWithContext(timeoutContext, input)

OpenAPIにおけるHTTPステータスコードを拡張する

OpenAPIではステータスコード毎に1つのレスポンスしか記載できません。しかし、実際には同じステータスコードに対して複数のレスポンス形式が存在する場合もあると思います。例えばユーザ情報を取得するAPIで、「自分のユーザ情報」と「他人のユーザ情報」でレスポンスを分ける場合を考えてみます。どちらも200ですが、レスポンス内容が異なるため、別々のレスポンスを用意したいです。そこで、Responses Objectに対して独自に x-ステータスコード-タイトル といった形式のフィールドを追加して、複数のレスポンス種類を表現しています。つまり、OpenAPIの文法に準拠したまま1つのステータスコードに対して複数のレスポンス形式を記載できるようにしています。

openapi_expanded_status.png

クライアントからの接続断を考慮する

どのようなサービスであっても、クライアントからのネットワーク接続断は考慮すべきです。特に、スマートフォンなどからのリクエストも多いC向けのサービスでは、クライアントからリクエストを途中で打ち切られることは珍しくありません。
そこで、途中でクライアントから接続を切られた場合には、ALBの仕様に合わせて460を返すようにしています。

case <-r.Context().Done():
	status = 460
	w.WriteHeader(status)
	return

リトライを実装する

他のWebサーバのAPI実行を実装する場合は、リクエスト先のサーバが原因でリクエストが失敗する可能性に備えて、リトライを考慮すべきです。もし、マイクロサービスでAPI Gatewayやサービスメッシュによりリトライが用意されている場合は不要ですが、そうでなければAPIクライアント側でリトライを実装します。
即時リトライしてしまうと、リクエストの多重度が増えてしまうため、Exponential Backoff And JitterのFull Jitterというアルゴリズムを採用しています。試行回数が多いほど長い傾向で、ランダム性がある待ち時間を経てリトライするようになります。

func SleepExponentialBackoffAndJitter(tryCount int, baseInterval time.Duration, maxInterval time.Duration) {
	interval := baseInterval * time.Duration(math.Pow(2, float64(tryCount)))
	if interval > maxInterval {
		interval = maxInterval
	}

	interval = time.Duration(mathRand.Float64() * float64(interval))
	time.Sleep(interval)
}

同じkeyの複数のクエリパラメータを配列で受け取る

クエリパラメータで color=red&color=blue&color=green のように同じkeyで値を渡すと、配列でパラメータとして受け取れます。
Goのサーバーでは[]stringで受け取っていて、この場合は["red", "blue", "green"]で受け取れます。

nullとプロパティ自体が無いことは同一セマンティクスとして使う

OpenAPIの3系ではプロパティにrequiredとnullableを付与することができます。required=falseでnullable=trueのケースで考えてみると、例えば、プロフィールを登録するようなアプリで職業の入力を考えたときに「未入力」「入力済み」「回答しない」といった3つの状態を扱うパターンがあります。この場合「未入力: {"age": 22}」「入力済み: {"age": 22, "job": "エンジニア"}」「回答しない: {"age":22, "job": null} 」の表現が可能です。この「未入力」と「回答しない」という微妙な違いをクライアントが理解して実装するのは難しいため「未入力」と「回答しない」を区別しないようなAPIを設計すべきです。

User Agentをパースして正確に情報を表示するのは難しいという感想

ZOZOTOWNのログイン履歴・通知機能でmileusna/useragentというライブラリを使ってUser Agentから端末情報やブラウザ情報を表示する機能を開発しました。しかし、想定外のケースが多く、完全に対応しきるのに難しさを感じました。例えば、iPhoneのSafariで「スマートフォンやタブレット端末でデスクトップ用Webサイトの表示」をすると、iPhoneでなくMacと判定され、想定外の結果になりました。この場合、そもそも想定が正しくないかもしれませんが、少なくともユーザには混乱をきたしやすいと感じました。また、他社のネイティブアプリのWebViewからリクエストされると、正しく端末情報を取得できなかった場合がありました。例えばFacebookのWebViewでログインすると [FBAN というブラウザ情報になりました。
もしかしたら、ライブラリ(mileusna/useragent)の問題もあるかもしれませんが、特にブラウザ情報をUser Agentから判別しきるのは難しいのかなと感じました。

モック

Nginxを用いた外部システムのモック

nginxのconfに静的なレスポンスを設定し、外部システムのモックを簡単に用意できます。
テストコード側はNginxで設定しているserver_nameやlistenポート、locationパスを指定してリクエストします。モックコードをわざわざ実装せずに済むため手軽にテストを実装できます。
以下は正常系のモック設定だけですが、異常系も用意できます。

test.conf
server {
    listen 8888;
    server_name zozo-id-get-members;
    location = /members/1 {
        add_header Content-Type application/json;
        return 200 '{
    "email": "id-backend@zozo.com",
    "zozo_id": "id-backend",
    "has_password": false
}';
    }
}
docker-compose.test.yml
...
  zozo-id-test:
    image: nginx:mainline-alpine
    volumes:
      - ./testing/docker/nginx/test.conf:/etc/nginx/conf.d/default.conf
    networks:
      zozo-id:
        aliases:
          - zozo-id-get-members
    ports:
      - 8888:8888   
...

Prismを活用した外部システムのモック

Prismを使ってモックを簡単に用意できます。以下のように設定することで、OpenAPIのyamlで定義した静的な情報を返すコンテナが起動します。

docker-compose.yml
...
  zozo-id:
    image: stoplight/prism:3
    command: mock -h 0.0.0.0 -p 4010 /zozo_id_spec.yml
    volumes:
      - ./testing/specs/zozo_id_spec.yml:/zozo_id_spec.yml
    networks:
      - zozo-id
    ports:
      - 4010:4010
...

DBスキーママイグレーションの自動化

DBのスキーママイグレーションにはsqldefというツールを利用しています。あるべきスキーマとしてCREATE文を書いておくと、現在の実際のDBのスキーマと比較して、ALTERを自動生成して実行してくれます。マイグレーション実行はCIで自動化されており、以下のタイミングで実行されるようにしています。

  • masterブランチへのPRをマージ → 本番環境以外(dev, stgなど) の マイグレーション
  • releaseブランチへのPRをマージ → 本番環境のマイグレーション

最後に

今回記事にしなかった内容や、技術以外のことも含めて、この約2年間でとても多くのことを学ぶことができました。

よろしければご応募ください。
https://hrmos.co/pages/zozotech/jobs/0000005?utm_source=techzozo&utm_medium=referral

18
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?