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