実務でGoを書く前に予習しておくと良かったこと
この記事は、GENDA Advent Calendar 2024 の13日目の記事です。
TL;DR
- ポインタと構造体、エラーハンドリングをA Tour of Goで学ぶ
- レイヤードアーキテクチャで書いてみる
- テーブル駆動テストを書いてみる
- Linterを設定しておく
はじめに
8月のはじめにGENDAに入社してから本日までずっとGoを書いています。前職や副業では主にJavaScript/TypeScript/PHPを書いており、実務でGoを書いたことはありませんでした。
元々Goは興味あったものの触ってはおらず、バックエンドでGoを採用していることが多いと事前に聞いていたので入社前に勉強しておこうと思い下記エントリのようなTodoアプリを(ChatGPTが出力したコードをコピペしながら)作って雰囲気を掴んでいました。事前に触れていたおかげで、プロジェクトに入ってからスムーズに開発を行うことが出来ました。
とはいえ最初の1〜2ヶ月はコードレビューでの指摘事項がいくつもあり、ようやく慣れてきた今振り返ると初学の段階でやっておけばもっと良かったというトピックもいくつか出てきました。今回はそれぞれを紹介していきたいと思います。
事前に勉強しておいて良かったこと
とりあえず A Tour of Goを一通り読んで触ってざっくり文法を知ることは重要でした。特にポインタと構造体は、他のWeb系の開発言語には無い概念なので理解できるまで丁寧に学ぶと良いと思います。エラーハンドリングも特徴的で、try-catchに慣れている自分は最初戸惑っていました。
また、レイヤードアーキテクチャについては私が携わっているプロジェクトだけでなく、別のプロジェクトのコードをみても概ねレイヤードアーキテクチャに沿って開発されており、事前に勉強しておいて正解でした。多人数での開発のしやすさもそうですがテストの書きやすさが大きなメリットだなと実感しています。
以下、それぞれのトピックを紹介していきます。
ポインタと構造体
最初は*
や&
をGPTが吐いたコードの通り書いていただけで意味を理解せずに使っていましたが、おおよそTypeScriptでいうOptionalのようなものだと理解すると腑に落ちた感覚があります。また、構造体は最初オブジェクトの型のようなものという認識でしたが、どちらかというとClassのようなものと認識すると混乱せずにコードを扱えるようになりました。
type TUser = {
id: number;
name?: string;
};
class User {
id: number;
name?: string;
constructor({id, name}:TUser){
this.id = id;
this.name = name;
}
isAnonymous(){
return this.name === undefined;
}
}
const user = new User({id:1, name:"user"});
console.log(user.isAnonymous());
上記のTypeScriptのコードが下記のGoのコードと概ね同じ挙動として認識すると、私の場合はコードが理解しやすくなりました。
type User struct{
ID int
Name *string
}
func (u User) IsAnonymous() bool {
return u.Name == nil
}
func main() {
userName := "user"
user := User{ID: 1, Name: &userName}
fmt.Println(user.IsAnonymous())
}
エラーハンドリング
Goでは関数が複数の値を返却でき、エラーも値として扱う設計思想から基本的には結果とエラーをセットで返すように関数を作っていきます。エラーに値が入っていたら早期リターンして上位の関数にエラーを渡していくことで、処理が見通しやすくなります。
// repository
func (r *UserRepository) GetUserByID(ctx context.Context, userID int) (*User, error) {
user, err := r.db.GetUserQuery(ctx, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("user not found")
}
return nil, err
}
return user, nil
}
// usecase
func (u *UserUsecase) GetUser(ctx context.Context, userID int) (*User, error) {
user, err := u.userRepository.GetUserByID(ctx, userID)
if err != nil {
return nil, err // handlerにrepositoryから渡ってきたエラーを返す
}
if !user.IsActive() {
return nil, errors.New("inactive user")
}
return user, nil
}
レイヤードアーキテクチャ
ググると良く出てくるアーキテクチャです。詳しい概念や解説は他の記事にお任せします。実際にデータソースを差し替えてみたり、モックに差し替えてテストを書いたりしてDIのメリットを体験しておくと良いと思います。
├── cmd
│ └── main.go
├── internal
│ ├── handler
│ │ └── user_handler.go
│ ├── usecase
│ │ └── user_usecase.go
│ └── repository
│ └── user_repository.go
├── pkg
│ └── model
│ └── user.go
└── ...
-
main.go
で初期化やルーティング、DIなどを行う- それぞれ処理をファイルで分けて記述していることもある
-
handler/xxx.go
でリクエストパラメータを扱い、usecaseの実行結果をレスポンスとして返す -
usecase/xxx.go
でビジネスロジックを取り扱い、repository層から取得したデータの統合や加工、必要に応じてレスポンススキーマへの変換を行なって返す -
repository/xxx.go
でusecaseから受け取った値を使ってDBに問い合わせを行い、クエリの実行結果や必要に応じてドメインモデルに値を詰め替えて返す
物凄く大雑把ですが、大体こんな認識でコードを読むと理解しやすいと思います。(各ディレクトリ名はプロジェクトによって違うことが多いです、main.goから辿りましょう)
やっておけば良かったこと
テスト
実装をする分には特に問題はなかったのですが、実装に対してテストを書く段階で初めて他のテストコードを見た時は面食らいました。テストコードの構造が理解できず、にらめっこしたまま時間が経過していたのでtimesで助けを求めたりしました。
助けを求めたらテックリードが助けてくれた図
この頃はテーブル駆動テストという単語が分からず、これを手がかりに色々調べることでようやくGoのテストコードがどういうものか理解できました。やっていること自体は単純で、入力と期待する出力を配列に並べてループでテストを実行するだけです。
私が携わっているプロジェクトではモックの生成にgomockを用いており、例えばusecaseのテストでは各repositoryに対して期待する実行結果などもテーブルに含まれていたのも混乱の元でしたが分かってしまえばとてもシンプルな構造でした。以下は、実際に書いているテーブルの例です。
func (u *TaskUsecase) GetUserTasks (ctx context.Context,externalUserID uuid.UUID) ([]Task, error) {
user, err := u.userRepo.GetUserByExternalID(ctx, externalUserID)
if err != nil {
return nil, err
}
tasks, err := u.taskRepo.GetTasksByUserID(ctx, user.ID)
if err != nil {
return nil, err
}
return tasks, nil
}
var (
mockExternalID = uuid.New()
)
type args struct {
externalUserID uuid.UUID
}
type want struct {
tasks []Task
isErr bool
}
type mocks struct {
userRepo userRepository
taskRepo taskRepository
}
tests := []struct {
name string
mock func(*mocks)
args args
want want
}{
{
name: "Success / Get all user tasks",
mock: func(m *mocks) {
m.userRepo.EXPECT(). // usecase内で呼び出している関数のモックを呼んで
GetUserByExternalID(gomock.Any(), mockExternalID). // モックに入力値を渡して
Return(&User{ID:1}, nil) // モックで期待値を返す
m.taskRepo.EXPECT(). // 同様にusecase内でrepositoryを呼び出していれば並べて記述する
GetTasksByUserID(gomock.Any(), 1).
Return([]Task{{ID:1, UserID:1}}, nil)
},
args: args{
externalUserID: mockExternalID,
},
want: want{
tasks: []Task{{ID:1, UserID:1}},
isErr: false,
},
},
// 他のテストケース
}
// テスト実行
学習でシンプルなアプリケーションを作るぶんにはモックやテストコードも最小限のシンプルな構成で実装出来るので、学習段階で取り組んでおくと良いと思います。
Linterの設定
私が携わっているプロジェクトではそれなりに強い規約がかかっており、初めの方は毎回CIで怒られていました。今は以下のような設定を入れることで、VSCodeの保存時にLintがかかるようにしています。
{
"go.lintTool":"golangci-lint",
"go.lintFlags": [
"--config=${workspaceFolder}/.golangci.yml",
"--fast",
"--new-from-rev=HEAD"
]
}
コードのお作法は書籍やサンプルコードを読んでもいざ自分で書く段階になると中々身についていないことが多いので、初学の段階で強めの規約で慣れておくと後々苦労せずに済むのではと思います。
おわりに
簡単なTodoアプリの開発を通じて実務に活かせたトピックと、実務を通して事前にやっておけばよかったトピックの紹介をしました。最初は冗長かつ厳格で、PHPのような自由さやTypeScriptのような柔軟さが恋しいと感じていましたが、慣れればサクサク開発出来ますし、コンパイルが通れば大体動くので余計な悩みごとが少なくじわじわと良さを実感しています。
この記事がこれからGoに触る方の学習の助けになれば幸いです。
Discussion