カルキチブログ

Goのキャッチアップ

業務でGoを使う機会が増えたので、自分のようにに、他のウェブ系の言語の知見はあるが、Goを使わないといけなくなった人向けに、Goを学ためのナレッジをまとめてみた。

想定読者

  • 業務、個人関係なくバックエンドの開発経験がある
  • レイヤードアーキテクチャやクリーンアーキテクチャ、DDDのようなアーキテクチャをある程度理解している
  • DI(依存性注入)・DIP(依存逆転の原則)等の疎結合なモジュールを実現するための設計原則をある程度理解している

筆者自身はバックエンドだと、サーバーサイドJS(NestJS, Honoあたり)に慣れ親しんでいて、他言語だと、PHPが読み書きできて、Rubyもちょいと書いたことがあるという感じのスクリプト言語畑の人です。

とりあえず Tour of Goを流し読む

A Tour of Goを流し読んだ。

とりあえず、ざっくり以下は理解できた。

  • 意味のあるコードのまとまりをpackageとして扱うとことができる
  • primitiveな型が厳密に決まっている
    • int32
    • int64
    • float32
    • float64
    • byte etc...
  • コンパイラ言語: コンパイルするとバイナリが生成される。生成したバイナリを実行する
  • ループはforしかない
  • 配列は固定長、可変長のデータを扱うにはスライスを使う
  • クラスの概念がない、クラスっぽいことしたい場合は構造体・メソッド・レシーバーを組み合わせて実装する
  • 並行処理はgoroutineとchannelを組み合わせて実装する
  • JS / PHPのような 例外ベースのエラーハンドリング(try-catch) はない。
  • 通常のエラー処理は error を返し、if err != nil で明示的に扱う。

    なお、panic / recover / defer は存在するが、panic は主に致命的な異常状態向けで、通常の業務エラー処理にはあまり使われない。(エラーハンドリングは逐一 if err != nil といった形で、ハンドリングしないといけないため、エラーハンドリングは冗長になりがち?)

エラーハンドリングに関しては、逆に言えば、エラーハンドリングの抜け漏れや実装の揺れは言語仕様的に起きづらそうな印象。

詰まったポイント

Goのキャッチアップをする上で、理解に苦労したポイントをまとめてみた。

基本はAIに壁打ちしつつ、分からない部分は書いて動かしたり、調べたりしつつ進めていった。

ポインタによる参照渡し

スクリプト言語畑の人がつまづくポイントの一つは、ここではないだろうか。

Goはポインタを扱うことができ、ポインタは値のメモリアドレスを指す。

Goは基本的に値渡しだが、ポインタを渡すことで同じデータを共有・変更することができる。

package main

import "fmt"

func main() {
    // var i int
    // var j int
    i, j := 1, 2

    // &で変数iのメモリアドレスを取得して、ポインタpに代入
    // var p *int
    p := &i
    // var q *int
    q := &j

    fmt.Println("p pointer address:", p)
    fmt.Println("q pointer address:", q)
}

&を使うと、変数が格納されているメモリアドレスを取得できる。

*を使うと、ポインタが指すアドレスに格納されている値を取得でき、変数pは var p *int といったポインタ型の値しか格納できない。

p pointer address: 0x45d34885a220
q pointer address: 0x45d34885a228

ポインタのアドレスから定義元の変数の値を変更することもできる。

package main

import "fmt"

func main() {
    i, j := 42, 2701

    p := &i
    q := &j

    // アドレス参照で変数iとjの値を変更
    *p = 10000
    *q = *p / 100

    fmt.Println("i:", i)
    fmt.Println("j:", j)
}

実行結果

i: 10000
j: 100

*の挙動がなかなか理解しづらいと感じたが、Tour of GoのDereferencingを深掘ることで、なんとなくは理解できた。

デリファレンス (Dereferencing): ポインタ変数が格納している「メモリアドレス」をたどり、そのアドレスに保存されている「実際のデータ(値)」を取り出したり変更したりする。

といったイメージで理解した。
→Go以外の言語だと、C言語 / C++、Rust、Perlなどの言語で出てくるらしい。

fmt.Println("p:", *p) // メモリアドレス経由で実際のデータを参照
*p = 10000 // メモリアドレス経由で実際のデータを取得し、上書きすると定義元の変数の値iも変わる

フィーリングはつかめた。

ポインタ型にした方が良いケースはどういったケースなのか

Gemini先生曰く、以下のようなケースではポインタ型にした方が良いらしい。

  1. 巨大な構造体のコピーによる「性能低下」を防ぐケース
  2. リソースを共有しないといけないケース
  3. 「値の不在(nil)」を表現したいケース
  4. 構造体(レシーバや引数)の値を「変更」したいケース

巨大な構造体のコピーによる「性能低下」を防ぐケース

参照渡しにすれば、アドレス値のコピーくらいで済みそうだが、多くのフィールドを持った構造体を値渡しにすると、関数を呼び出すたびに構造体全体のコピーが発生するため、パフォーマンス効率が悪くなるといったところだろうか。

type User struct {
    Name string
}

// ポインタレシーバ:元の構造体を書き換えられる
func (u *User) UpdateName(newName string) {
    u.Name = newName 
}

// 値レシーバ:コピーを書き換えるだけなので、呼び出し元には反映されない
func (u User) FailedUpdate(newName string) {
    u.Name = newName 
}

usecaseやrepository等のレイヤーごとにメソッドをinterfaceで定義して、構造体に詰めるみたいなのはよくやるので、これは結構あるケースな気がした。

type Repositories struct {
    UserRepo    *UserRepository
    ArticleRepo *ArticleRepository
    CommentRepo *CommentRepository
    AuthRepo    *AuthRepository
    LikeRepo    *LikeRepository
}

リソースを共有しないといけないケース

典型例としてはこの辺りか。

  • DBクライアント: プロセス内で1つにしないと、リクエストごとにDBのコネクションがはられてしまうため、コネクションプールが枯渇 → DB側の接続上限に到達する問題が発生する。
  • HTTPクライアント: 最初にサーバにアクセスした時のみで十分なTCP接続がリクエストごとに確立されてしまうため。
  • logger: アプリ全体で共有しないとログの出力やフォーマットがバラバラになってしまうため。

「値の不在(nil)」を表現したいケース

言語仕様的にそうなのでしょうという感じ。

package main

import (
    "encoding/json"
    "fmt"
)

type UserRequest struct {
    Name string `json:"name"`
    // ポインタ型(*int)にすることで、JSONに項目がない場合に nil になる
    Age *int `json:"age,omitempty"`
}

func parseAndCheck(jsonStr string) {
    var req UserRequest

    err := json.Unmarshal([]byte(jsonStr), &req)
    if err != nil {
        fmt.Println("パースエラー:", err)
        return
    }

    fmt.Printf("入力JSON: %s\n", jsonStr)
    fmt.Printf("  -> Name: %q\n", req.Name)

    // ポインタが nil かどうかで「値の不在」を判定する
    if req.Age == nil {
        fmt.Println("  -> Age: 【未指定(nil)です】")
    } else {
        // nil でなければ、ポインタをデリファレンス(*)して中身の値を取り出す
        fmt.Printf("  -> Age: %d 歳(値が明示的に存在します)\n", *req.Age)
    }
    fmt.Println("-------------------------------------------")
}

func main() {
    // パターン1: 年齢が「0歳」として明示的に送られてきた場合
    json1 := `{"name": "赤ちゃん", "age": 0}`
    parseAndCheck(json1)

    // パターン2: 年齢の項目自体が「未指定」の場合
    json2 := `{"name": "年齢不詳"}`
    parseAndCheck(json2)
}

構造体(レシーバや引数)の値を「変更」したいケース

OOPの文脈でよく出てくるインスタンスを生成して、処理をカプセル化したいケースとかで使うイメージだろうか。

package todo

import "errors"

type Item struct {
    ID    int
    Title string
    Done  bool
}

type TodoList struct {
    items  []Item
    nextID int
}

func NewTodoList() *TodoList {
    return &TodoList{
        items:  []Item{},
        nextID: 1,
    }
}

func (t *TodoList) Add(title string) Item {
    item := Item{
        ID:    t.nextID,
        Title: title,
        Done:  false,
    }
    t.items = append(t.items, item)
    t.nextID++
    return item
}

func (t *TodoList) Remove(id int) error {
    for i, item := range t.items {
        if item.ID == id {
            t.items = append(t.items[:i], t.items[i+1:]...)
            return nil
        }
    }
    return errors.New("todo not found")
}

func (t *TodoList) Complete(id int) error {
    for i := range t.items {
        if t.items[i].ID == id {
            t.items[i].Done = true
            return nil
        }
    }
    return errors.New("todo not found")
}

func (t *TodoList) All() []Item {
    return t.items
}

使うときは、こんな感じ。

package main

import (
    "fmt"
    "myapp/todo"
)

func main() {
    list := todo.NewTodoList()  // インスタンス生成

    list.Add("Goを学ぶ")         // → ID:1
    list.Add("記事を書く")       // → ID:2
    list.Add("コーヒー飲む")     // → ID:3

    list.Complete(1)             // ID:1 を完了に
    list.Remove(2)               // ID:2 を削除

    for _, item := range list.All() {
        status := "[ ]"
        if item.Done {
            status = "[x]"
        }
        fmt.Printf("%s %d: %s\n", status, item.ID, item.Title)
    }
}

TodoList の内部状態 (`items`, nextID) は非公開であり、状態変更は Add / Remove / Complete メソッド経由に制御できる。

また、ポインタレシーバを使うことで、生成した同一インスタンスの状態を共有・更新できるため、内部状態を一箇所で管理できるため、カプセル化ができていると言えそう。

イミュータブルな値を生成したい時どうするの

典型的な例としては、DDDでValue Objectのようなイミュータブルな値を生成したいケース。

Newするための関数を定義して、構造体のフィールドを小文字で定義することで、外部から直接アクセスできないようにするのが一般的な手法のようです。
→Goでは先頭一文字が大文字になっていれば、パッケージの利用者側から参照可能であるため、命名はなんでも良さそうだが、慣例的に New〇〇 みたいな命名にするのが良さそう。

package user

type UserId struct {
    // 小文字で始まるフィールドは、同じパッケージ内でしかアクセスできない(外部からは見えない)
    value int64
}

func NewUserId(value int64) UserId {
    return UserId{value}
}

func (u UserId) GetUserId() int64 {
    return u.value
}

使う時はこう。

func main() {
    fmt.Println("Hello, World!")

    uid := user.NewUserId(123)
    fmt.Println(uid)
}

DI・DIPをどう実現するのか

API開発ではお馴染みのDI(依存性注入)・DIP(依存逆転の原則)をGoでは以下のように実現するそうです。

ディレクトリ設計

DDD、レイヤードアーキテクチャーという体で考えてみます。

myapp/
  ├── domain/
  │   └── todo/
  │       ├── todo.go
  │       └── repository.go
  ├── usecase/
  │   └── todo_service.go
  ├── infrastructure/
  │   └── postgres/
  │       └── todo_repository.go
  └── main.go

依存の向きは以下とします。

  infrastructure ──→ domain
                      ↑
  usecase ────────────┘

ドメイン層

package todo

type Todo struct {
    ID    int64
    Title string
    Done  bool
}

func New(title string) *Todo {
    return &Todo{Title: title}
}

func (t *Todo) Complete() {
    t.Done = true
}
package todo

import "context"

type Repository interface {
    Save(ctx context.Context, todo *Todo) error
    FindByID(ctx context.Context, id int64) (*Todo, error)
}

インフラ層

package postgres

import (
    "context"
    "database/sql"
    "myapp/domain/todo"
)

type TodoRepository struct {
    db *sql.DB
}

func NewTodoRepository(db *sql.DB) *TodoRepository {
    return &TodoRepository{db: db}
}

func (r *TodoRepository) Save(ctx context.Context, t *todo.Todo) error {
    if t.ID == 0 {
        return r.db.QueryRowContext(ctx,
            `INSERT INTO todos (title, done) VALUES ($1, $2) RETURNING id`,
            t.Title, t.Done,
        ).Scan(&t.ID)
    }
    _, err := r.db.ExecContext(ctx,
        `UPDATE todos SET title = $1, done = $2 WHERE id = $3`,
        t.Title, t.Done, t.ID,
    )
    return err
}

func (r *TodoRepository) FindByID(ctx context.Context, id int64) (*todo.Todo, error) {
    var t todo.Todo
    err := r.db.QueryRowContext(ctx,
        `SELECT id, title, done FROM todos WHERE id = $1`, id,
    ).Scan(&t.ID, &t.Title, &t.Done)
    if err != nil {
        return nil, err
    }
    return &t, nil
}

ユースケース層

package usecase

import (
    "context"
    "myapp/domain/todo"
)

type TodoUsecase struct {
    repo todo.Repository
}

func NewTodoUsecase(repo todo.Repository) *TodoUsecase {
    return &TodoUsecase{repo: repo}
}

func (s *TodoUsecase) Create(ctx context.Context, title string) (*todo.Todo, error) {
    t := todo.New(title)
    if err := s.repo.Save(ctx, t); err != nil {
        return nil, err
    }
    return t, nil
}

func (s *TodoUsecase) Complete(ctx context.Context, id int64) error {
    t, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return err
    }
    t.Complete()
    return s.repo.Save(ctx, t)
}

呼び出す時

package main

import (
    "context"
    "database/sql"
    "log"
    _ "github.com/lib/pq"
    "myapp/infrastructure/postgres"
    "myapp/usecase"
)

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/myapp?sslmode=disable")

    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // DIする
    todoRepo := postgres.NewTodoRepository(db)
    todoUsecase := usecase.NewTodoUsecase(todoRepo)

    ctx := context.Background()
    t, err := todoUsecase.Create(ctx, "Goを学ぶ")
    if err != nil {
        log.Fatal(err)
    }

    log.Printf("created: %+v", t)
    if err := todoUsecase.Complete(ctx, t.ID); err != nil {
        log.Fatal(err)
    }
    log.Printf("completed: id=%d", t.ID)
}

余談ですが、規模の大きいアプリケーションだとDIする対象のusecaseやinfraが増えていく問題が発生するため、DI用のpackageを使うのが良さそうです。

今さらだけど、Go言語のDIライブラリについて比較

まとめ

Goちょっと理解できてきたぞ。