39
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SchooAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-12-04

🎄 Schoo Advent Calender 2024 🎄 の 5 日目の記事です 。

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

🖋️ はじめに

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

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

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

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

以下は ORM ライブラリ gorm の例です。context を使わなくても First 関数で DB から見つかった最初の1件を取得するコードが書けます。

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 を渡すことにより、キャンセルやタイムアウトの伝播が可能になります。

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 を導入しています。

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

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 を生成しログ出力すると便利です。

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 のライフサイクルはリクエストコンテキストの中だけなので、レスポンスを返したタイミングで破棄される一時的なものです。

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 を使って出力します。

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
}
出力例
{
  "level": "error",
  "timestamp": "2024-12-04T15:04:05.999Z",
  "method": "GET",
  "path": "/api/schools",
  "message": "failed to get school IDs"
}

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

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 に入れておけばリクエスト単位で一貫した時間を使用できるのと、現在日時を扱うテストが書きやすくなります。

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 にセットすることで現在日時を書き替えられるため、任意の時間でテストできます。

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 ディレクトティブに書かれているのがモジュール名です。

go.mod
module k8s.io/kubernetes

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

go.mod
module github.com/your_org_name/repo_name

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

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.mod
module "github.com/your_org/my-server/src"

🚀 samber/lo を使う

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

冗長なコード
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
}

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

リファクタリング例
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 というライブラリが便利なので、たくさんの人がハードテストしているライブラリをそのまま使った方が恩恵は大きいです。

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 ではリテラルから直接ポインタ型にすることができません。

type School struct {
	ID          int     // 学校ID
	Name        string  // 学校名
	Description *string // 学校の説明(任意)
}
school := School{
	ID:          1,
	Name:        "大学1",
	Description: &"大学の説明です", // コンパイルエラー
}

以下のようにあらかじめ変数にしておく必要があります。

description := "大学の説明です"
school1 := School{
	ID:          1,
	Name:        "大学1",
	Description: &description,
}

この書き方が冗長になることもあるので、ユーティリティ関数を作るか、あるいは文脈と無関係に aws-sdk-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 を使うと便利です。

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

39
11
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
39
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?