0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go初心者がレビューで止まりがちなポイントをまとめてみた

0
Posted at

Goは文法だけ見るとかなりシンプルです。
でも、いざ実務で書き始めると意外とこうなります。

  • とりあえず動くけどレビューで直される
  • goroutineを使ったら不安定になる
  • interfaceを切ったのに逆に複雑になる
  • errorを返しているのに原因調査しづらい

これはGoが難しいというより、Goっぽい設計と書き方に慣れていないだけです。

この記事では、Goの入門を一通り終えた人が、実務に入る前に押さえておくとかなりラクになるポイントを5つに絞って紹介します。
派手なテクニックではなく、レビューで効く・保守で効く・障害対応で効く内容です。


1. errorはそのまま返さず、文脈を足して返す

Goを書き始めた頃にやりがちなのが、受け取ったerrorをそのまま返すことです。

func loadConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	return nil
}

これでも動きます。
でも、呼び出し側で見えるのは file not found のような断片的な情報だけで、どこで何をして失敗したのかが分かりにくいです。

実務では、エラーに文脈を足して返すだけで調査コストがかなり下がります。

func loadConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config file %s: %w", path, err)
	}
	return nil
}

これだけでログに出たときの情報量が一気に変わります。

ポイントは3つです。

  • 何をしていたかを書く
  • 可能なら対象も書く
  • 元のerrorは %w で包む

Goは例外を投げる文化ではなく、errorを運びながら文脈を積み上げていく文化です。
この感覚を早めに掴むと、かなりGoらしいコードになります。


2. contextはとりあえず受けるのではなく、ちゃんと最後まで流す

GoでWeb APIやバッチを書くなら、context.Context は避けて通れません。
でも最初は、引数に置いただけで満足しがちです。

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
	return s.repo.FindByID(id)
}

これだと ctx を受け取っている意味がほぼありません。
重要なのは、キャンセルやタイムアウトの意思を下の層まで流すことです。

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
	return s.repo.FindByID(ctx, id)
}

func (r *Repository) FindByID(ctx context.Context, id int) (*User, error) {
	row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)

	var u User
	if err := row.Scan(&u.ID, &u.Name); err != nil {
		return nil, fmt.Errorf("find user by id %d: %w", id, err)
	}
	return &u, nil
}

さらに handler 側で timeout を付けると、全体の責務がきれいにつながります。

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	user, err := h.service.GetUser(ctx, 1)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	_ = json.NewEncoder(w).Encode(user)
}

実務でよくあるのは、DBや外部APIの呼び出しがダラダラ残ってしまうケースです。
context をちゃんと流すだけで、無駄な処理や詰まりをかなり防げます。


3. interfaceは作る側ではなく、使う側に置く

Goを覚え始めると、なんでもinterfaceにしたくなります。

type UserRepository interface {
	FindByID(id int) (*User, error)
	Save(user *User) error
	Delete(id int) error
	List() ([]User, error)
}

一見きれいですが、実務ではこの大きいinterfaceがだんだんつらくなります。

  • 実装の自由度が下がる
  • テスト用モックが肥大化する
  • 使っていないメソッドまで抱えることになる

Goでは、小さいinterfaceを使う側に置くほうがうまくいくことが多いです。

type UserFinder interface {
	FindByID(ctx context.Context, id int) (*User, error)
}

type Service struct {
	finder UserFinder
}

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
	return s.finder.FindByID(ctx, id)
}

これなら Service に必要な依存だけを定義できます。
テストもしやすくなります。

type mockUserFinder struct{}

func (m *mockUserFinder) FindByID(ctx context.Context, id int) (*User, error) {
	return &User{ID: id, Name: "Taro"}, nil
}

Goのinterfaceは、未来の拡張のために先回りして作るものではなく、今必要な振る舞いを最小限で切り出すためのものです。
ここを理解すると設計が一気に軽くなります。


4. goroutineは増やす前に、どう止めるかを決める

Goを触っていて楽しい瞬間のひとつが goroutine です。
ただし、気持ちよく増やしたgoroutineは、気づかないうちにリークの原因にもなります。

例えばこういうコードは、最初は書きがちです。

for _, id := range ids {
	go func(id int) {
		process(id)
	}(id)
}

件数が増えた瞬間に制御不能になります。
同時実行数も分からないし、失敗時の扱いも曖昧です。

実務では少なくとも次の3つを意識したいです。

  • 何個まで同時に動かすか
  • いつ終了するか
  • エラーやキャンセルをどう扱うか

シンプルなワーカープールの例です。

func worker(ctx context.Context, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()

	for {
		select {
		case <-ctx.Done():
			return
		case id, ok := <-jobs:
			if !ok {
				return
			}
			process(id)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	var wg sync.WaitGroup

	workerNum := 3
	for i := 0; i < workerNum; i++ {
		wg.Add(1)
		go worker(ctx, jobs, &wg)
	}

	for _, id := range []int{1, 2, 3, 4, 5, 6} {
		jobs <- id
	}
	close(jobs)

	wg.Wait()
}

goroutineは 起動できるか ではなく 安全に終われるか まで考えて初めて実務レベルです。
Goで並行処理を書くときは、この視点だけでもかなり事故が減ります。


5. 1つの関数で全部やらない。責務を分けるだけでGoコードは読みやすくなる

Goは短く書けるので、処理を1つの関数に詰め込みやすいです。
でも、長い関数はだいたい次の問題を起こします。

  • テストしにくい
  • 途中で何をしているか追いにくい
  • エラー処理が散らばる
  • 修正時の影響範囲が読みにくい

例えばこんな感じです。

func CreateUser(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	if req.Name == "" || req.Email == "" {
		http.Error(w, "name and email are required", http.StatusBadRequest)
		return
	}

	user := User{
		Name:  req.Name,
		Email: req.Email,
	}

	if err := saveUser(r.Context(), user); err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

この程度ならまだ読めますが、実務ではここにログ、権限、外部API、監査処理などがどんどん足されます。

なので、責務を分けます。

type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

func decodeCreateUserRequest(r *http.Request) (CreateUserRequest, error) {
	var req CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		return CreateUserRequest{}, fmt.Errorf("decode request: %w", err)
	}
	return req, nil
}

func (r CreateUserRequest) Validate() error {
	if r.Name == "" || r.Email == "" {
		return errors.New("name and email are required")
	}
	return nil
}

func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
	req, err := decodeCreateUserRequest(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if err := req.Validate(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if err := h.service.CreateUser(r.Context(), req.Name, req.Email); err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

派手さはないですが、Goのコードレビューではこういう読みやすさがかなり効きます。
Goっぽいコードは、賢く見えるコードではなく、数ヶ月後の自分でも読めるコードです。


まとめ

Goを勉強したのに実務でうまく書けないと感じるときは、文法ではなく設計のクセでつまずいていることが多いです。

今回の5つをもう一度まとめると、こんな感じです。

  • errorには文脈を足して返す
  • contextは受け取るだけでなく下まで流す
  • interfaceは小さく、使う側に置く
  • goroutineは増やす前に終了設計をする
  • 関数に詰め込まず責務を分ける

Goは一見シンプルですが、実務で強いコードにはちゃんと共通点があります。
逆にいうと、このあたりを押さえるだけでレビューの通り方も、保守のしやすさも、かなり変わります。

これからGoでAPIを書きたい人、バッチを書きたい人、転職や案件でGoを触る人は、まずこの5つを意識するだけでもかなり戦いやすくなるはずです。

もし反応が良ければ次は、

  • GoでWeb APIを書くときのディレクトリ設計
  • GoでSQLを書くときに最初に知るべきこと
  • Goの並行処理でやりがちなミス集

あたりもまとめようと思います。


以下はGoのUdemyの講座の半額クーポン自分の講座
https://www.udemy.com/course/go-31100/?couponCode=818EB20AF096FC6CC4FD

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?