はじめに
今回、以下の様な技術stackで個人開発を行ったので、備忘録として残そうと思います
- Go(API)
- Next.js・TypeScript(フロント)
- AWS・Terraform(インフラ)
- github actions(CI/CD)
本記事では、バックエンド(API)側の取り組み内容について触れたいと思います
フロント側、インフラ・CI/CD側の記事については以下に置いておきます。
フロント側
インフラ・CI/CD側
github repository
アプリケーション側
インフラ側
各version
- Go: 1.20
- Echo: 4.5.0
ディレクトリ構成
※ awsデプロイ用のdefファイルやgithub actions, frontのdirは省力します
アプリディレクトリ
-
controller
- comment_controller.gp
- company_controller.go
- company_technology_controller.go
- like_controller.go
- technology_controller.go
- technology_tag_controller.go
- technology_technology_tag_controller.go
- user_controller.go
-
db
- db.go
-
migrate
- migrate.go
-
model
- comment.gp
- company.go
- company_technology.go
- like.go
- technology.go
- technology_tag.go
- technology_technology_tag.go
- user.go
-
repository
- comment_repository.gp
- company_repository.go
- company_technology_repository.go
- like_repository.go
- technology_repository.go
- technology_tag_repository.go
- technology_technology_tag_repository.go
- user_repository.go
-
router
- router.go
-
tests
- controller
- comment_controller_test.gp
- company_controller_test.go
- company_technology_controller_test.go
- like_controller_test.go
- technology_controller_test.go
- technology_tag_controller_test.go
- technology_technology_tag_controller_test.go
- user_controller_test.go
- repository
- comment_repository_test.go
- company_repository_test.go
- company_technology_repository_test.go
- like_repository_test.go
- technology_repository_test.go
- technology_tag_repository_test.go
- technology_technology_tag_repository_test.go
- user_repository_test.go
- usecase
- comment_usecase_test.go
- company_usecase_test.go
- company_technology_usecase_test.go
- like_usecase_test.go
- technology_usecase_test.go
- technology_tag_usecase_test.go
- technology_technology_tag_usecase_test.go
- user_usecase_test.go
- controller
-
usecase
- comment_usecase.gp
- company_usecase.go
- company_technology_usecase.go
- like_usecase.go
- technology_usecase.go
- technology_tag_usecase.go
- technology_technology_tag_usecase.go
- user_usecase.go
-
validator
comment_validator.go- company_validator.go
- company_technology_validator.go
- like_validator.go
- technology_validator.go
- technology_tag_validator.go
- technology_technology_tag_validator.go
- user_validator.go
-
main.go
詳細はまた記述しますが、今回は簡易的なクリーンアーキテクチャを採用しています
アーキテクチャ
1. クリーンアーキテクチャについて
クリーンアーキテクチャは、Robert C. Martinによって提唱されたソフトウェア設計のアプローチであり、ソフトウェアの変更と拡張を容易にすること、そしてビジネスロジックを外部の技術的詳細から隔離することに関心がある。
2. アーキテクチャ図
3. 各層の責務について
a. router
- クライアントからのリクエストを適切なcontrollerへのルーティング
- APIのパスの定義
b. controller
- 受け取ったリクエストを適切なusecaseへと変換し、その後の処理を渡す
- usecaseからの結果をクライアントにJSON形式レスポンスを返す
c. usecaase
- アプリケーションのビジネスロジックをゆうし、具体的なデータの永続化や取得方法は知らず、そのためのインターフェースを通して操作
d. model
- データの構造やビジネスロジックに関連する操作を定義し、ドメインのエンティティを返す
e. validator
- リクエストデータの妥当性の定義・検証を担う
f. repository
- データの永続化層との間のインターフェースを提供し、Usecaseが定義するロジックの具体的なCRUD操作やその他DB操作を担う
4. 特徴
ここではクリーンアーキテクチャの特徴を述べた上で、今回の実装で感じたメリットについて説明していきます
a. 独立性
フレームワーク、UI、データベース、外部エージェンシーからの独立性を重視する
- 今回の実装でいえば、controller層でEchoフレームワークを使用しているが、プロダクトの成長に合わせて「Ginに変更したい」といった要件にも柔軟に対応することができる
b. テスト可能性
ビジネスロジックは外部の要素から独立しているため、単体テストが容易
- 今回の実装では、モックテストを用いているが、それぞれ単体テストが容易になる
goのモックテストについて
c. UIとビジネスロジックの分離
ビジネスロジックがUIやデータベースの詳細から分離されているため、変更や拡張が容易でである
- ビジネスロジックがusecase層にまとまっているので、ロジックの変更が容易である
d. 依存関係
依存関係の方向は、中心(主にビジネスロジック)から外部へと向かう
e. 柔軟性
一部のコンポーネントの変更やアップデートが他の部分に影響を与えにくい構造を有する
- クリーンアーキテクチャは外部の実装が内部の実装に影響を与えない構造をとるので、例えばvalidator package内で用いるpackageのversion変更などをしても他のそうに影響が出る可能性は低い
f. スケーラビリティ
システムの成長や拡張に対応しやすい設計思想がある
g. 明確な境界
各層やコンポーネント間には明確な境界が設けられており、それぞれの役割がはっきりしている
- 今回の実装で言えば、controller層はフロントからのリクエストパラメータを受け取り、それをusecase層が受け取りビジネスロジックを処理して、repository層でDBとやり取りをするという、各フローで責務が完全に分かれており、実装追加や仕様変更に強い
5. DI(Dependency Injection)にていて
DIとは、依存性の注入を意味します。クリーンアーキテクチャでは、外部層から内部層に向かって依存性が定義されるため、具体的な実装がインターフェースを介して内部のロジックにアクセスする
例えば、controller層はInterface usecaseを経由して、usecase層のビジネスロジックを呼び出すことができる。同様に、usecase層はInterface repositoryを経由してDBアクセスロジックにアクセスする。
このような構造にすることで、各層が疎結合になり、変更やテストが容易になる。DIは、これらの層間の依存関係を動的に注入する役割を持っています。
具体的には、例えばcontrollerが起動時にusecaseの具体的なインスタンスを注入されることで、実際のビジネスロジックを実行することができます。これにより、コードの再利用性やモジュラリティが向上します。
ここから今回実装した、全企業を取得する一連の処理の流れの中でDIについて説明しいきます
type ICompanyController interface {
GetAllCompanies(c echo.Context) error
...
}
type companyController struct {
cu usecase.ICompanyUsecase
}
func NewCompanyController(cu usecase.ICompanyUsecase) ICompanyController {
return &companyController{cu}
}
func (cc *companyController) GetAllCompanies(c echo.Context) error {
companiesRes, err := cc.cu.GetAllCompanies()
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, companiesRes)
}
- まず、インターフェース:
ICompanyController
type ICompanyController interface {
GetAllCompanies(c echo.Context) error
...
}
メソッドのシグニチャのみを定義し、具体的な実装は定義しない
インターフェースを利用することで、メソッドの実装の詳細を隠蔽し、それを利用するコードから独立させることができる。これにより、実装が変更されても、その影響が他の部分に及ばない
- 構造体:
companyController
type companyController struct {
cu usecase.ICompanyUsecase
}
ICompanyController インターフェースの具体的な実装を提供する構造体
usecase.ICompanyUsecaseインターフェースへの参照を有する。これにより、ビジネスロジックとのやり取りが可能となる
- 関数:
NewCompanyController
func NewCompanyController(cu usecase.ICompanyUsecase) ICompanyController {
return &companyController{cu}
}
この関数は、controllerのコンストラクタとして機能する
具体的なICompanyUsecaseの実装(cu)を引数として受け取り、新しいcompanyControllerのインスタンスを返します。この関数がDependency Injectionの役割を果たす
メソッド: GetAllCompanies
func (cc *companyController) GetAllCompanies(c echo.Context) error {
companiesRes, err := cc.cu.GetAllCompanies()
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, companiesRes)
}
ICompanyControllerインターフェースで抽象定義されているメソッドの実装部分であり、ICompanyUsecaseインターフェースの GetAllCompaniesメソッドを呼び出して、すべての企業情報を取得します。
上記がcontroller層 <=> usecase層のDIを含むやり取りの一例でです。
長くなるので示しませんが、usecase層 <=> repository層も同様にデータのやり取りをしています
ユーザー認証フロー
今回、バックエンド側では以下ののフローを介することで、ユーザー認証を確立します
1. 新規登録の際、/signupのエンドポイントでemailとpasswordをDBに保存する
2. ログインの際、/loginのエンドポイントで以下のことを行う
- まず、/csrfのエンドポイントを叩き、cookieにcsrf tokenをセットする
func (uc *userController) CsrfToken(c echo.Context) error {
token := c.Get("csrf").(string)
return c.JSON(http.StatusOK, echo.Map{
"csrf_token": token,
})
}
- DBに含まれるpassword情報を取得して、有効期限つきでtokenを生成
- そのtokenをcookiesに対して、jwt tokenとしてSetする
以下にコードの一部を示す
func (uu *userUsecase) LogIn(user model.User) (string, error) {
if err := uu.uv.UserValidate(user); err != nil {
return "", err
}
preUser := model.User{}
if err := uu.ur.GetUserByEmail(&preUser, user.Email); err != nil {
return "", err
}
err := bcrypt.CompareHashAndPassword([]byte(preUser.Password), []byte(user.Password))
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": preUser.ID,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
tokenStr, err := token.SignedString([]byte(os.Getenv("fuga")))
if err != nil {
return "", err
}
return tokenStr, nil
}
func (uc *userController) LogIn(c echo.Context) error {
user := model.User{}
if err := c.Bind(&user); err != nil {
return err
}
tokenStr, err := uc.uu.LogIn(user)
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
cookie := new(http.Cookie)
cookie.Name = "token"
cookie.Value = tokenStr
cookie.Expires = time.Now().Add(24 * time.Hour)
cookie.Path = "/"
cookie.Domain = os.Getenv("hogehoge")
cookie.Secure = true
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteNoneMode
c.SetCookie(cookie)
return c.NoContent(http.StatusOK)
}
- 上記より、フロントからのリクエストには有効なCSRFトークンとJWTトークンが含まれている必要がある。
それらをバックエンド側のミドルウェアが確認する。
これらの検証が成功したユーザのみ、各エンドポイントを叩き、レスポンスを受け取ることができる。
DB構成
- userテーブルでユーザーの認証情報を管理します
- Companyテーブルは今回のサービスのメインとなる企業データを管理します
- Comment, Likeテーブルは共にUserの企業に対する「いいね」や「コメント」を管理します
- TechnologyテーブルはWeb技術情報を管理します
- CompanyTechnologyテーブルは企業が使用する技術情報を管理する中間テーブルです
- TechnologyTagテーブルは、その技術がフロントエンドの技術なのか、またはバックエンド、インフラの技術なのかをタグ付けすることで技術をグルーピングします
- TechnologyTechnologyTagrテーブルは、特定の技術がどの分野の技術なのかを管理する中間テーブルです
テスト手法
今回、Go(API側)のテストにはmockテストを用いました
mockgenは使っていないので、手動でモックを作成しています
これより、実際のテストコードを用いて今回のテスト手法を説明していきます
まず、testsのディレクトリディレクトリを示します(簡略化のためcompanyのみとします)
- tests
- controller
- company_controller_test.go
- repository
- comment_repository_test.go
- usecase
- company_usecase_test.go
- controller
上記のようにtestsディレクトリを作成します
まず、controllerのtestについて
- Mockの定義
type MockCompanyUsecase struct {
mock.Mock
}
MockCompanyUsecase構造体を定義し、mock.Mockを埋め込むことでUsecase層のモックオブジェクトとして用いる
- モックメソッドの定義
func (m *MockCompanyUsecase) GetAllCompanies() ([]model.CompanyResponse, error) {
args := m.Called()
return args.Get(0).([]model.CompanyResponse), args.Error(1)
}
GetAllCompaniesメソッドをモック化し、後で設定したモックの戻り値を返す
- テスト関数の定義
func TestGetAllCompanies(t *testing.T) {
mockUsecase := &MockCompanyUsecase{}
expectCompaniesRes := []model.CompanyResponse{
{
ID: 1,
Name: "Company A",
Description: "Description A",
OpenSalary: "OpenSalary A",
Address: "Address A",
},
{
ID: 2,
Name: "Company B",
Description: "Description B",
OpenSalary: "OpenSalary B",
Address: "Address B",
},
}
mockUsecase.On("GetAllCompanies").Return(expectCompaniesRes, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/companies", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
controller := controller.NewCompanyController(mockUsecase)
err := controller.GetAllCompanies(c)
if assert.NoError(t, err) {
assert.Equal(t, http.StatusOK, rec.Code)
var response []model.CompanyResponse
err := json.Unmarshal(rec.Body.Bytes(), &response)
if assert.NoError(t, err) {
assert.Equal(t, expectCompaniesRes, response)
}
}
mockUsecase.AssertExpectations(t)
}
ここではgoのtestingパッケージを用いて、本番コードの動作をtestコードを記述していきます
この関数でやっていることは主に以下のようです
- モックの初期化と設定
-
GetAllCompanies
が呼び出されたときにどのような結果を返すか設定
-
- Echoフレームワークの初期化
- Echoフレームワークを使用してHTTPリクエストとレスポンスを模倣
- コントローラの呼び出し
- 下記部分でモックオブジェクトを引数としてコントローラを初期化し、実際の関数をテスト
controller := controller.NewCompanyController(mockUsecase)
err := controller.GetAllCompanies(c)
- 結果の検証
if assert.NoError(t, err) {
assert.Equal(t, http.StatusOK, rec.Code)
...
assert.Equal(t, expectCompaniesRes, response)
}
アサーションを使用して、関数の実行結果が期待通りであることを確認
- モックの呼び出しの確認
mockUsecase.AssertExpectations(t)
モックメソッドが期待した回数だけ呼び出されたことを確認
上記をまとめると、このcontroller testはGetAllCompanies
関数が正しく動作するかをテストする
正しいHTTPステータスコードが返され、期待したデータが正しく返されるかを確認する
次にusecaseについて
- MockCompanyRepositoryの定義
type MockCompanyRepository struct {
mock.Mock
}
MockCompanyRepositoryはCompanyのリポジトリインターフェースをモック化したもの
- TestGetAllCompanies関数の定義
func (m *MockCompanyRepository) GetAllCompanies(companies *[]model.Company) error {
args := m.Called(companies)
return args.Error(0)
}
func TestGetAllCompanies(t *testing.T) {
mockRepo := &MockCompanyRepository{}
mockValidator := &MockCompanyValidator{}
mockUsecase := usecase.NewCompanyUsecase(mockRepo, mockValidator)
expectCompanies := []model.Company{
{ID: 1, Name: "Company A", Description: "Description A", OpenSalary: "OpenSalary A", Address: "Address A"},
{ID: 2, Name: "Company B", Description: "Description B", OpenSalary: "OpenSalary B", Address: "Address B"},
}
mockRepo.On("GetAllCompanies", mock.AnythingOfType("*[]model.Company")).Return(nil).Run(func(args mock.Arguments) {
companies := args.Get(0).(*[]model.Company)
*companies = expectCompanies
})
companiesRes, err := mockUsecase.GetAllCompanies()
assert.NoError(t, err)
assert.Len(t, companiesRes, 2)
assert.Equal(t, "Company A", companiesRes[0].Name)
assert.Equal(t, "Company B", companiesRes[1].Name)
mockRepo.AssertExpectations(t)
}
controller_test同様にusecase_testでは、repogitoryのモックオブジェクトを作成し、
mockUsecase := usecase.NewCompanyUsecase(mockRepo, mockValidator)
上記でコンストラクタに対して、モックオブジェクトで新しいテスト用インスタンスを作成する
そして、
companiesRes, err := mockUsecase.GetAllCompanies()
としてusecaseのメソッドを呼び出した際の値を格納し、それに対してアサーションを用いて結果を検証する
最後に、repogitoryテストについて
- インターフェースを定義
type CompanyDBHandler interface {
GetAllCompanies() ([]model.Company, error)
}
会社データを取得するためのメソッドGetAllCompanies
を定義
- 構造体の定義
type RealCompanyDBHandler struct {
db *gorm.DB
}
func NewRealCompanyDBHandler(db *gorm.DB) *RealCompanyDBHandler {
return &RealCompanyDBHandler{db}
}
RealCompanyDBHandler
構造体は、実際のデータベースとのやり取りをするためのハンドラであり、
NewRealCompanyDBHandler
関数は、このハンドラの新しいインスタンスを作成するためのコンストラクタ関数
- DBハンドラのモックを定義
type MockCompanyDBHandler struct {
mock.Mock
}
- 新しいインスタンスを作成するためのコンストラクタ関数
func NewMockCompanyDBHandler() *MockCompanyDBHandler {
return &MockCompanyDBHandler{}
}
上記モックハンドラの新しいインスタンスを作成するためのコンストラクタ関数
- モックハンドラの実装メソッド
func (m *MockCompanyDBHandler) GetAllCompanies() ([]model.Company, error) {
args := m.Called()
return args.Get(0).([]model.Company), args.Error(1)
}
具体的な返り値はモックの設定によって決定
- テスト関数実装
func TestGetAllCompanies(t *testing.T) {
mockDB := NewMockCompanyDBHandler()
expectCompanies := []model.Company{
{ID: 1, Name: "Company A"},
{ID: 2, Name: "Company B"},
}
mockDB.On("GetAllCompanies").Return(expectCompanies, nil)
companies, err := mockDB.GetAllCompanies()
mockDB.AssertExpectations(t)
assert.NoError(t, err)
assert.Equal(t, expectCompanies, companies)
}
モックハンドラを使用することで、GetAllCompanies
の振る舞いをテストする
アサーションを用いてerrがnilであること、DBMockオブジェクトからメソッドを呼んだ結果とmodelから直接生成した仮のオブジェクトが一致するかを検証している
この層のテストの肝はMockCompanyDBHandlerです。これはデータベース操作をエミュレートするモックオブジェクトであり、データベースにアクセスせずにその振る舞いをシミュレートしている
この手法を用いると、テストを高速に実行できるだけでなく、外部の依存関係(データベース)の影響を受けずにコードのロジックをテストするときに重宝する
今後取り組みたいこと
-
ユーザー認証に外部のIDaaSを用いる
- Firebase Authenticationを用いることで、ユーザーの認証情報を自社DBに保存する必要がなくなり、保守性の高いサービス運用が可能となる
-
OpenAPI(Swagger)を用いたスキーマ駆動開発を採用してみる
- 今回は一人でAPI, 画面, インフラを作成したのでSwaggerの旨味を感じることはないと判断し、導入しなかったが、今後複数人で開発する際には導入してみたい