every Tech Blog

株式会社エブリーのTech Blogです。

実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うこと

はじめに

こんにちは!トモニテにて開発を行なっている吉田です。 この記事は every Tech Blog Advent Calendar 2023 の 21 日目の記事となります。
今回は、私が実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うことについて取り上げます。

経緯

私は今年の 2 月にエブリー入社し、エンジニアとしてのキャリアも同じタイミングでスタートしました。

入社してもうすぐ1年経つのですが、日々の業務に取り組む中でさまざまなサービスや技術にふれてきました。 出会うもの全てが未知との遭遇でエブリー入社当初、知らないことだらけでまずい!と思ったことを覚えています。

そこで今回はタイトルの通り「実務に入る前に理解していたらもう少し開発速度を上げられたかなと思うこと」、その中でもアークテクチャとテストについて取り上げます。 エンジニアとしての初心者の立場からの視点で、同じような境遇の方や興味を持っている方の参考になれば幸いです。

アーキテクチャについて

アーキテクチャは英語で「構造」という意味、開発における意味合いでも同様で「システムやソフトウェアの構造」という意味になります。 実際に開発に参加しサービスのコードを見ていると処理がいくつかの層に分かれていることに気づきました。そこで思ったことはそれぞれの層は何のために分かれていて、それぞれはどんな役割を持っているんだろうということです。 調べてみるとこの実装方法がレイヤードアーキテクチャであることが分かりました。その他にも以下のようなアーキテクチャがあります。

  • ヘキサゴナルアーキテクチャ
  • オニオンアーキテクチャ
  • クリーンアーキテクチャ etc...

それぞれについて調べてみましたが何を言っているのかよく分かりません…

そもそも上記で述べた層とはレイヤーのことです。では各レイヤーはどんな役割を持つのでしょうか。 アーキテクチャについての考え方を理解する上では、私は上記のアーキテクチャの元になっている3層アーキテクチャがシンプルで一番理解しやすいと思ったのでこちらを例に説明します。

3 層の各名称と役割は以下の通りです。

  • プレゼンテーション層…クライアント(アプリやブラウザ)からリクエストを受け付ける
  • ファンクション層…受け取ったデータに加工・処理を実行
  • データアクセス層…データベースにアクセスする
    wikipediaより

これを見ると何となく各層の役割が分かりそうです。 ではなぜ、このように分けるのでしょう。

私もエンジニア勉強期間中はこのようなことは全く気にしていませんでした。あくまで自分の作りたいサービスが完成できればいいという考えです。 しかし、世の中に出ているサービスはそうもいきません。むしろサービスをリリースしてからがスタートでその後保守や新機能追加と様々な開発を続けていく必要があります。

そのような場面で 1 つのメソッドにたくさんの処理がまとまっていると新機能追加時には処理全体を1から追ってどこに新しい機能を加えるべきなのが適切で、その処理を加えたことによってどこに影響が出るのかの洗い出し+それに伴う修正が必要なります。 他にも実装したコードによってバグが発生し改修が必要になった時にも同様の理由で原因特定に多くの時間が必要になることもあります。

これがアーキテクチャを採用することで各処理がレイヤーに分かれ、レイヤー内での変更は他のレイヤーに影響を与えない変更や拡張に強いコードになります。 さらにアーキテクチャを実現することで各レイヤーが疎結合になり実装がシンプルでテストも書きやすくソースレビューしやすいというメリットも生まれます。
※疎結合...システム構造間の結びつきや依存度が弱く独立性が高い状態のこと

では具体例を交えて説明します。ここではユーザー ID を元にユーザー情報を取得する GetUserInfo メソッドを例とします。 このメソッドで必要な処理は以下になります。

  1. クライアントからリクエストを受け付ける
  2. クライアントから受け取るパラメータが適切なものか確認
  3. 受け取ったパラメーターを用いて DB へ接続しデータを取得
  4. 受け取ったデータを加工
  5. クライアントへレスポンスを返す

<アーキテクチャ採用前>

package XXX

import (
    "database/sql"
    "fmt"
    "net/http"

    _ "github.com/go-sql-driver/mysql"
    "github.com/go-playground/validator/v10"
    "github.com/labstack/echo"
)

type User struct {
    ID int64
}

type UserParams struct {
    ID string `json:"id" validate:"required"`
}

func GetUserInfo(c echo.Context) error {
    // 1. クライアントからリクエストを取得
    userID := c.Param("id")
    userParams := UserParams{
        ID: userID,
    }
    // 2. クライアントから受け取るパラメータが適切なものか確認
    if err := validate.Struct(userParams); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("Invalid parameters: %s", err.Error())})
    }

    // 3-1. 受け取ったパラメーターを用いて データベースへの接続
    db, err := sql.Open("mysql", "user:password@tcp(host:portNo)/dbname")
    if err != nil {
        return err
    }
    defer db.Close()

    query := "SELECT * FROM users WHERE id = ?"
    row := db.QueryRow(query, userID)

    var user User
    // 3-2. ユーザー情報を取得
    err = row.Scan(&user.ID)
    if err != nil {
        return nil, err
    }
    // 4. 受け取ったデータを加工(firstNameとlastNameを結合)
    name := user.FirstName + user.LastName

    // 5. クライアントにレスポンスを返す
    return c.JSON(http.StatusOK, &userInfo{
        ID:    user.ID,
        Email: user.Email,
        name:  name,
    })
}

※コードは必要な箇所を抜粋したものになります

比較的単純な処理ではありますが、長くなっていて読みやすいかといえばそうではないですよね...

次いでアーキテクチャを採用した例です。プレゼンテーション層、ファンクション層、データアクセス層の 3 層に分けます。

<アーキテクチャ採用>

/*
    ファイル名:presentation/userInfo.go
    役割:     クライアントからリクエストを受け取りその結果を返します
*/

package XXX

import (
    "fmt"
    "net/http"

    "github.com/go-playground/validator/v10"
    "github.com/labstack/echo/v4"
    "github.com/hogehoge_server/function"
)

type User interface {
    Get (c echo.Context) error
}

//依存するメソッドを呼び出すため定義
type UserInfoImpl struct {
    UserInfo function.UserInfo
}

func (p *UserInfoImpl) Get(c echo.Context) error {
    // 1. クライアントからリクエストを取得
    userID := c.Param("id")
    userParams := UserParams{
        ID: userID,
    }
    // 2. クライアントから受け取るパラメータが適切なものか確認
    if err := validate.Struct(userParams); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("Invalid parameters: %s", err.Error())})
    }
    res, err := p.UserInfo.GetUserInfo(userParams.ID) // ファンクション層のメソッド呼び出し
    // 5. クライアントにレスポンスを返す
    return c.JSON(http.StatusOK, res)
}
/*
   ファイル名:function/userInfo.go
   役割:     データアクセス層からの返ってきたデータをを加工してプレゼンテーション層へ返します
*/
package XXX

import "github.com/hogehoge_server/db"


type User interface {
    GetUserInfo(userID int64) (*userInfo, error)
}

//依存するメソッドを呼び出すため定義
type UserInfoImpl struct {
    User db.User
}

func (s *UserImpl) GetUserInfo(userID int64) (*userInfo, error) {
    userInfo, err := s.User.GetByID(userID) // データアクセス層のメソッド呼び出し
    if err != nil {
        return nil, err
    }
    // 4. 受け取ったデータを加工(firstNameとlastNameを結合)
    name := userInfo.FirstName + userInfo.LastName
    return &userInfo{
        ID:    userInfo.ID,
        Email: userInfo.Email,
        name:  name,
    }, nil
}
/*
    ファイル名:db/userInfo.go
    役割:     データベースから取得した値をファンクション層へ返します
*/
package XXX

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID   int
    Email string
    FirstName string
    LastName string
}

func GetByID(userID int64) (*User, error) {
    // 3-1. 受け取ったパラメーターを用いて データベースへの接続
    db, err := sql.Open("mysql", "user:password@tcp(host:portNo)/dbname")
    if err != nil {
        return nil, err
    }
    defer db.Close()

    query := "SELECT * FROM users WHERE id = ?"
    row := db.QueryRow(query, userID)

    var user User
    // 3-2. ユーザー情報を取得
    err = row.Scan(&user.ID)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

※コードは必要な箇所を抜粋したものになります。

階層構造は以下のようになっています。

hogehoge_server
    ├ presentation
        └ userInfo.go
    ├ function
        └ userInfo.go
    └ db
        └ userInfo.go

ファイルこそ増えましたが アーキテクチャ採用前の 1 つのメソッドに複数の処理がまとまっていた時よりも以下のメリットが挙げられます。

  • 可読性の向上
  • 各レイヤーが特定の責務を担当することになったので変更が発生した場合でも関連する部分だけを修正できます。例えば、データベース接続情報の変更はデータアクセス層で対応し、プレゼンテーション層には影響を与えません。
  • 各レイヤーが独立することにより他の機能でも同じくユーザー情報が必要な場合、GetByID メソッドを再利用することで、重複したデータベースアクセスのコードを避けることができます。

アーキテクチャは、コードの構造をシンプルにし、開発者がコードを理解しやすくするための強力なツールです。レイヤードアーキテクチャなど他のアーキテクチャについても理解が進めば実装を進める上でとても強い味方になってくれるはずです!

テストについて

続いてテストについてです。ここでは Go 言語での単体テストについて取り上げます。 かくいう私もエンジニア勉強期間中はテストをコードで管理するようなことはしておらず、実際の画面で操作を行なってスプレッドシートにまとめたチェック項目でテストを行なっていました^^;

入社後は自分でテストコードを書く必要がありましたがここで私がつまずいたのがモックです。ちゃんと理解せず既存のテストコードをコピペしそれを修正して使っていたら痛い目に遭いました...

モックを使ったテストとは

モックとはテストの際に実際のオブジェクトや機能を模倣したものです。テスト対象のコードが期待どおりに動作するかどうかを確認するために使います。 メリットとしては実際のデータベースや外部 API などとの通信を避けることができます。テストのために追加したデータが実際のデータにも追加されるなど意図しない変更が起きてしまうと大変です。

モックを利用するために以下のライブラリを使います。
https://github.com/uber-go/mock

ここでは ID でユーザー情報を取得する GetUserInfo を例に話します。

type User interface {
    GetUserInfo(userID int64) (*userInfo, error)
}

type UserImpl struct {
    User db.User
}

// テストしたいメソッド:userIDでユーザーの基本情報を取得する
func (s *UserImpl) GetUserInfo(userID int64) (*userInfo, error) {
    userInfo, err := s.User.GetByID(userID) // DBにuserIDでユーザー情報を問い合わせる※
    if err != nil {
        return err
    }
    return userInfo
}

上記のメソッドの場合だと、userInfoが取得できる場合とDBが情報を取得できずにエラーを返す2パターンの挙動をテストする必要があります。
この実装では※の DB に userID でユーザー情報を問い合わせるところをモックで差し替えます。 手順は以下になります。

  1. モック生成(参考
    1-1. mockegen をインストールgo install go.uber.org/mock/mockgen@latest
    1-2. 対象ファイル指定 mockgen -source=<モックを作成したいファイル> [other options] (出来上がるファイルは下記<手順 1-2 によって出来上がるファイル>で記載)
  2. モック準備
  3. テストケース作成
  4. テストで呼ばれるべき関数と返り値を設定
  5. テストをかく

テストコード

func Test_GetUserInfo(t *testing.T) {
    type fields struct {
        UserImpl func(ctrl *gomock.Controller) User // 2. モック準備
    }
    tests := []struct {
        name     string
        fields   fields
        userID   int64
        want     *userInfo
        wantErr  bool
    }{ // 3. 以下テストケース作成
        {
            name:   "ユーザー情報の取得に成功",
            userID: 1,
            fields: fields{
                UserImpl: func(ctrl *gomock.Controller) User {
                    m := NewMockUser(ctrl) // ※₁
                    m.EXPECT().GetByID(int64(1)).Return(&userInfo{ /* 期待されるデータを記入 */ }, nil) // 4. テストで呼ばれるべき関数と返り値を設定 ※₂
                    return m
                },
            },
            want:    &userInfo{ /* 期待されるデータを記入 */ },
            wantErr: false,
        },
        {
            name:   "ユーザー情報の取得に失敗",
            userID: 2,
            fields: fields{
                UserImpl: func(ctrl *gomock.Controller) User {
                    m := NewMockUser(ctrl)
                    m.EXPECT().GetByID(int64(2)).Return(nil, errors.New("error")) // 4. テストで呼ばれるべき関数と返り値を設定
                    return m
                },
            },
            want:    nil,
            wantErr: true,
        },
    }
    for _, tt := range tests { // 5. テストをかく
        t.Run(tt.name, func(t *testing.T) {
            s := &UserImpl{
                User: tt.fields.UserImpl,
            }
            gotUserInfo, err := s.GetUserInfo(tt.userID)
            if (err != nil) != tt.wantErr {
                t.Errorf("UserImpl.GetUserInfo() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(gotUserInfo, tt.want) {
                t.Errorf("UserImpl.GetUserInfo() = %v, want %v", gotUserInfo, tt.want)
            }
        })
    }
}

<手順 1-2 によって出来上がるファイル>

// Code generated by MockGen. DO NOT EDIT.
// Source: <モックを作成したいファイル>

// Package service is a generated GoMock package.
package service

import (
    reflect "reflect"

    gomock "github.com/golang/mock/gomock"
)

// MockUser is a mock of User interface.
type MockUser struct {
    ctrl     *gomock.Controller
    recorder *MockUserMockRecorder
}

// MockUserMockRecorder is the mock recorder for MockUser.
type MockUserMockRecorder struct {
    mock *MockUser
}

// NewMockUser creates a new mock instance. ※₁
func NewMockUser(ctrl *gomock.Controller) *MockUser {
    mock := &MockUser{ctrl: ctrl}
    mock.recorder = &MockUserMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use. ※₂
func (m *MockUser) EXPECT() *MockUserMockRecorder {
    return m.recorder
}

// GetUserInfo mocks base method.
func (m *MockUser) GetUserInfo(userID int64) (*userInfo, error) {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "GetUserInfo", userID)
    ret0, _ := ret[0].(*userInfo)
    ret1, _ := ret[1].(error)
    return ret0, ret1
}

// GetUserInfo indicates an expected call of GetUserInfo.
func (mr *MockUserMockRecorder) GetUserInfo(userID interface{}) *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo", reflect.TypeOf((*MockUser)(nil).GetUserInfo), userID)
}

上記のテストコードのように書くことで GetUserInfo メソッド のモックを利用したテストが作成できます。
また、テストでは繰り返し処理によってテストケースを網羅し実行していますがこの方法をテーブル駆動テストといいます。 メリットとしては以下が挙げられます。

  • 入力と出力への期待値が容易に理解できる
  • テストケースの追加が簡単

ちなみに vscode の拡張機能をインストールした上で gotests をインストールしておけば、テストしたいファイルを開いて ctrl+shift+P または右クリックから Go: Generate Unit Tests for File を選択するとテストファイルを生成することができます。

終わりに

本記事では私が実務に入る前に理解していたら開発速度を上げられたかなと思うアークテクチャとテストついて紹介しました!
明日以降の Advent Calendar 投稿もぜひチェックしてみてください!