Go言語を実務で使うとき知っておきたいこと

🎄 [Schoo Advent Calender 2024](https://qiita.com/advent-calendar/2024/schoo) 🎄 の 5 日目の記事です 。

Schoo では、大学や専門学校がオンライン授業を提供可能にするための学習管理プラットフォーム、[Schoo Swing](https://dx.schoo.jp/) を提供しています。本日は Swing チームでエンジニアをしている佐藤が Go に関する記事をお届けします。

## 🖋️ はじめに

実務で Go 言語を使うようになって 6 年ほど経ち、業務委託として 6 社のチームで働く機会がありました。Go は「シンプルさ」と「明確さ」を重視する設計思想なので、誰が書いても同じような実装になり、可読性や保守性が高いと言われています。

一方で開発が組織内で完結してしまうと、その言語特有の流儀や手法が浸透していなかったり、馴染みのプログラミング言語のやり方を踏襲してしまっていたりといったこともありがちだと思います。Go 言語を実務で使う際に知っておくとうれしい内容を書いていきます。


## 🔗 IO 処理を伴う関数は "必ず" 第一引数で context.Context を受け取る

データベースや外部 API へのアクセス処理には context を引き継ぎましょう。


以下は ORM ライブラリ [gorm](https://github.com/go-gorm/gorm) の例です。context を使わなくても `First` 関数で DB から見つかった最初の1件を取得するコードが書けます。

```go:gormの悪い例
func (r *UserRepository) FindByID(id uint32) (*User, error) {
    var user User
    if err := r.db.First(&user, id).Error; err != nil {
        return nil, err
    }
    return &user, nil
}
```

しかしこれはよくない書き方で、正しくは `WithContext` 関数で context を渡すことにより、キャンセルやタイムアウトの伝播が可能になります。

```go:gormの良い例
func (r *UserRepository) FindByID(ctx context.Context, id uint32) (*User, error) {
    var user User
    if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
        return nil, err
    }
    return &user, nil
}
```

このコードでは以下の制御ができます。

#### タイムアウトが設定されている場合

- 時間超過と共にデータベース操作を中断し、エラーレスポンスを返せる

#### クライアントがリクエストをキャンセルした場合

- データベース操作を適切に中断し、エラーレスポンスを返せる



### 🤔 なぜ context が必要になるのか

Go 言語で Web サービスのバックエンド開発をしている方が多いと多いますが、context は一貫したタイムアウト設計や、統一的なキャンセル管理を行うために必要です。

例えば API のタイムアウトをサービス全体で10秒と定めている場合、context を gorm に引き継がないと DB 処理で数十秒の遅延が発生した場合でも規定値通りにタイムアウトしないということが起こります。


### 😔 context を後から追加するのは大きな労力を伴う

IO 処理があるにも関わらず context を関数のシグニチャに含めないのは**技術的負債**になり得ると思います。後から追加するにはインタフェースの変更となるだけでなく、実装コードも修正が必要です。すでに本番環境でリリースしているプロダクトの変更には安全確認のための QA 工程が必要なので、既存コードの修正には大きな労力がかかります。適切な QA をしないまま一か八かの賭けに出て強制リリースするわけにもいかず、開発リソースの限られる組織では結局塩漬けにされてしまう、みたいなことも避けなければなりません。

### 💪 さらに便利な context の活用方法

context を関数の第一引数に用意しておけば他にも便利なことがあります。

#### トレーシング対応

マイクロサービスは高い拡張性と障害耐性を実現できる一方で、複雑性をもたらします。特に無数のサービスの連携によってピタゴラスイッチが完成したとき、全体像を把握できているエキスパートは限られてることが多いです。
全体像を可視化したいときに便利なのが分散トレーシングと呼ばれるもので、Schoo では [Datadog APM](https://www.datadoghq.com/ja/product/apm/) を導入しています。

トレーシングのためにはサービス内の処理の流れはもちろん、サービス間の処理の連携も一繋ぎに捕捉する必要がありますが、ここで活躍するのも context です。context の中にトレース情報を格納することでサービス内の処理は引き継がれます。

```go:sample.go
import (
	"context"
 
	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

func Run() {
	// ctxの中に生成されたトレース情報が含まれる
	span, ctx := tracer.StartSpanFromContext(context.Background(), "service.run")
	defer span.Finish()

	process(ctx) // ctxを次の処理へと引き継いでいく
}
```

なお、サービス間連携には HTTP ヘッダーや、gRPC ではメタデータという領域にトレース情報を格納して送受信します。


#### リクエストコンテキスト毎の Logger

Web 系で開発機会の多い API サーバーでは、リクエストを受け取ってからレスポンスを返すまでの一連の処理（リクエストコンテキスト）において、リクエスト固有の情報が含まれるため、リクエストコンテキスト単位で Logger を生成しログ出力すると便利です。

```go:logger.go
package applog

import (
	"context"

	"github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

type contextKey struct{}
var loggerKey = contextKey{}

func WithContext(ctx context.Context, logger *zerolog.Logger) context.Context {
	return context.WithValue(ctx, loggerKey, logger)
}

func FromContext(ctx context.Context) *zerolog.Logger {
	logger, ok := ctx.Value(loggerKey).(*zerolog.Logger)
	if !ok {
		return &log.Logger
	}
	return logger
}
```

ミドルウェアで context に Logger を格納します。この context および Logger のライフサイクルはリクエストコンテキストの中だけなので、レスポンスを返したタイミングで破棄される一時的なものです。

```go:middleware.go
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // リクエスト固有の必要な情報をLoggerにセット
        logger := zerolog.New(os.Stderr).
            With().
            Timestamp().
            Str("method", r.Method).
            Str("path", r.URL.Path).
            Logger()

        ctx := applog.WithValue(r.Context(), logger)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
```

ビジネスロジック実装時には context から取得した Logger を使って出力します。

```go:sample_service.go
func (s *schoolService) GetIDs(ctx context.Context) []string {
	schoolIDs, err := s.repo.FindSchoolIDs()
	if err != nil {
		applog.FromContext(ctx).Error().Msg("failed to get school IDs")
		return nil
	}
	return schoolIDs
}
```

```json:出力例
{
  "level": "error",
  "timestamp": "2024-12-04T15:04:05.999Z",
  "method": "GET",
  "path": "/api/schools",
  "message": "failed to get school IDs"
}
```

前述したトレーシング対応についても、この Logger 生成タイミングでトレース情報をログ出力項目としてセットすることで、トレース情報とログ出力の連携ができます。

```go:middleware.go
import (
	"net/http"
	"os"

	"github.com/rs/zerolog"
	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext"
	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"

    "github.com/your_org/repo/applog"
)

func LoggerMiddleware() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			logger := zerolog.New(os.Stderr).
				With().
				Timestamp().
				Str("request_id", r.Header.Get("X-Request-ID")).
				Logger()

            // Datadogのトレース情報があれば追加する
			if span, ok := tracer.SpanFromContext(r.Context()); ok {
				logger = logger.With().
					Uint64(ext.LogKeyTraceID, span.Context().TraceID()).
					Uint64(ext.LogKeySpanID, span.Context().SpanID()).
					Logger()
			}

			ctx := applog.WithContext(r.Context(), &logger)
			r = r.WithContext(ctx)
			next.ServeHTTP(w, r)
		})
	}
}
```

#### time.Now() を入れる

同じリクエストコンテキスト内なら `time.Now()` の値も context に入れておけばリクエスト単位で一貫した時間を使用できるのと、現在日時を扱うテストが書きやすくなります。

```go:user.go
package user

import (
	"context"
	"time"

	"github.com/your_org/repo/timeutil"
)

const legalAdultAge = 18 // 法的な成人年齢

type User struct {
	BirthDate time.Time
}

func (u *User) IsLegalAdult(ctx context.Context) bool {
	now := timeutil.Now(ctx) // contextから現在時間を取得
	age := now.Year() - u.BirthDate.Year()

	if now.Month() < u.BirthDate.Month() ||
		(now.Month() == u.BirthDate.Month() && now.Day() < u.BirthDate.Day()) {
		age--
	}

	return age >= legalAdultAge
}
```

単体テストでは context にセットすることで現在日時を書き替えられるため、任意の時間でテストできます。

```go:user_test.go
package user_test

import (
	"context"
	"testing"
	"time"

	"github.com/your_org/repo/timeutil"
	"github.com/your_org/repo/user"
)

func TestIsLegalAdult(t *testing.T) {
	baseDate := time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)

	tests := []struct {
		name      string
		birthDate time.Time
		now       time.Time
		want      bool
	}{
		{
			name:      "ちょうど18歳の誕生日",
			birthDate: time.Date(2006, 6, 15, 0, 0, 0, 0, time.UTC),
			now:       baseDate,
			want:      true,
		},
		{
			name:      "18歳の誕生日の前日",
			birthDate: time.Date(2006, 6, 16, 0, 0, 0, 0, time.UTC),
			now:       baseDate,
			want:      false,
		},
		{
			name:      "18歳と1日",
			birthDate: time.Date(2006, 6, 14, 0, 0, 0, 0, time.UTC),
			now:       baseDate,
			want:      true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			u := &user.User{BirthDate: tt.birthDate}

			// テストしやすい任意の時間をcontextに格納する
			ctx := timeutil.NowWithContext(context.Background(), tt.now)

			if got := u.IsLegalAdult(ctx); got != tt.want {
				t.Errorf("IsLegalAdult() = %v, want %v", got, tt.want)
			}
		})
	}
}
```


## 🔖 Go Modules のモジュール名は実在するリポジトリの URL にする

プロジェクト作成時に `go mod init` すると生成されるのが Go Modules を管理するファイル `go.mod` です。この中の [module ディレクトティブ](https://github.com/kubernetes/kubernetes/blob/v1.31.3/go.mod#L7)に書かれているのがモジュール名です。

```go:go.mod
module k8s.io/kubernetes
```

GitHub でソースコードを管理しているなら以下のような名称になります。

```go:go.mod
module github.com/your_org_name/repo_name
```

モジュール名をインターネット上に存在しない独自形式の名称にすると弊害があるので注意しましょう。

```shell:bash
go mod init my-server
```

```:悪い例
module my-server
```

### 🧐 なぜリポジトリ URL にする必要があるのか

Go は作成したコードをライブラリとして公開するのが全プログラミング言語中もっとも簡単です。GitHub の公開リポジトリであれば push した瞬間から `go get` してパッケージ利用できます。

```
go get github.com/your_org/repo
```

しかし前述した独自形式のモジュール名にしてしまうと、下記エラーとなって参照に失敗し、コードの再利用ができません。

```
go get github.com/your_org/my-server
go: downloading github.com/your_org/my-server v0.0.0-20241204042137-3fae28ba9839
go: github.com/your_org/my-server@upgrade (v0.0.0-20241204042137-3fae28ba9839) requires github.com/your_org/my-server@v0.0.0-20241204042137-3fae28ba9839: parsing go.mod:
        module declares its path as: my-server
                but was required as: github.com/your_org/my-server
```


### 🛠️ src ディレクトリは作らない

様々なプログラミング言語で、ソースコードは `src` 配下に置く流儀があると思います。しかし Go の場合はモジュール名にも `src` が含まれてしまい、見栄えよくありません。特別な理由がない限り、リポジトリ直下に Go のコードを置きましょう。

```
my-project/
├── src/
│   ├── go.mod        # module "github.com/your_org/my-server/src"
│   ├── main.go
│   ├── user/
│   │   └── user.go
│   └── auth/
│       └── auth.go
└── README.md
```

```go:go.mod
module "github.com/your_org/my-server/src"
```


## 🚀 samber/lo を使う

一昔前、Go 言語はその簡素さゆえに冗長なコードを何度も書かなければいけないというデメリットもありました。配列から map を作り直すコードや対象を絞るコード、フィルタをかけるコード等です。

```go:冗長なコード
func (s *Service) GetUsers(ctx context.Context, schoolID int) ([]*User, error) {
	users, err := s.repo.FindBySchoolID(ctx, schoolID)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch users: %w", err)
	}

	// 別の処理から戻り値の対象とするユーザーIDの一覧を取得
	targetUserIDs, err := s.getTargetUserIDs()
	if err != nil {
		return nil, fmt.Errorf("failed to get target user ids: %w", err)
	}

    // 冗長なコード
	userMap := make(map[int]*User, len(users))
	for _, user := range users {
		userMap[user.ID] = user
	}

    // 冗長なコード
	var result []*User
	for _, id := range targetUserIDs {
		if user, exists := userMap[id]; exists {
			result = append(result, user)
		}
	}

	return result, nil
}
```

上記のように書くとビジネスロジックの中に本質的ではない処理が混じるため読みにくくなります。プライベート関数に分離して整理することもできます。

```go:リファクタリング例
func (s *Service) GetUsers(ctx context.Context, schoolID int) ([]*User, error) {
	users, err := s.repo.FindBySchoolID(ctx, schoolID)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch users: %w", err)
	}

	// 別の処理から戻り値の対象とするユーザーIDの一覧を取得
	targetUserIDs, err := s.getTargetUserIDs()
	if err != nil {
		return nil, fmt.Errorf("failed to get target user ids: %w", err)
	}

    // プライベート関数に分離
	userMap := s.groupByUserID(users)
	result := s.extractUsersFromMap(userMap, targetUserIDs)

	return result, nil
}

func (s *Service) groupByUserID(users []*User) map[int]*User {
	userMap := make(map[int]*User, len(users))
	for _, user := range users {
		userMap[user.ID] = user
	}
	return userMap
}

func (s *Service) extractUsersFromMap(userMap map[int]*User, targetIDs []int) []*User {
	var result []*User
	for _, id := range targetIDs {
		if user, exists := userMap[id]; exists {
			result = append(result, user)
		}
	}
	return result
}
```

しかし JavaScript や Ruby であればワンライナーで書けそうなものが、Go だとコードの総量が増えてしまいますね。

Go 1.18 ではジェネリクスが導入されたため、汎用的なコードは `util` パッケージのようなものを独自に実装しているプロジェクトも多いのではないでしょうか。
現在は JavaScript の lodash を意識した、[samber/lo](https://github.com/samber/lo) というライブラリが便利なので、たくさんの人がハードテストしているライブラリをそのまま使った方が恩恵は大きいです。

```go:samber/lo
import (
	"context"

	"github.com/samber/lo"
)

func (s *Service) GetUsers(ctx context.Context, schoolID int) ([]*User, error) {
	users, err := s.repo.FindBySchoolID(ctx, schoolID)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch users: %w", err)
	}

	targetUserIDs, err := s.getTargetUserIDs()
	if err != nil {
		return nil, fmt.Errorf("failed to get target user ids: %w", err)
	}

    // samber/loに置き換える
	userMap := lo.KeyBy(users, func(u *User) int { return u.ID })
	result := lo.Map(targetUserIDs, func(id int, _ int) *User { return userMap[id] })

	return result, nil
}
```

また、Go ではリテラルから直接ポインタ型にすることができません。

```go
type School struct {
	ID          int     // 学校ID
	Name        string  // 学校名
	Description *string // 学校の説明（任意）
}
```

```go
school := School{
	ID:          1,
	Name:        "大学1",
	Description: &"大学の説明です", // コンパイルエラー
}
```
以下のようにあらかじめ変数にしておく必要があります。
```go
description := "大学の説明です"
school1 := School{
	ID:          1,
	Name:        "大学1",
	Description: &description,
}
 ```

この書き方が冗長になることもあるので、ユーティリティ関数を作るか、あるいは文脈と無関係に [aws-sdk-go](https://github.com/aws/aws-sdk-go-v2/blob/v1.32.6/aws/to_ptr.go#L45) のコードが使われているケースもありました。

 ```go
import "github.com/aws/aws-sdk-go-v2/aws"

func createSchool() *School {
    return School{
        ID:          1,
        Name:        "大学1",
        Description: aws.String("大学の説明です") ,
    }
}
 ```

プロジェクト内のどこかで AWS のコードが使われているからといって、関係ないところで AWS のコードが出てくると少しびっくりしますよね。こういったものも準標準ライブラリとして `samber/lo` を使うと便利です。
 
 ```go
import "github.com/samber/lo"

func createSchool() *School {
    return School{
        ID:          1,
        Name:        "大学1",
        Description: lo.ToPtr("大学の説明です") ,
    }
}
 ```
 

## 🕊️ おわりに

本稿で紹介した内容は一見些細に思えるかもしれません。しかしこれらの知識を事前に知っておくだけで、後から修正するには面倒な負債を残さずに済み、長期的な開発効率の向上に貢献します。

プロジェクトの成功には、顧客への価値提供と同時に、保守性の高いコードベースの維持が不可欠です。開発者の生産性向上につながります。

優れた開発者体験（Developer Experience）の実現のため、言語ごとの特性をチーム内で共有しながら開発を進めていきましょう。


---
Schooでは一緒に働く仲間を募集しています！
https://corp.schoo.jp/careers/graduates
https://corp.schoo.jp/careers