go言語でクリーンアーキテクチャっぽいもの

2018年12月06日 木曜日


【この記事を書いた人】
藤本 椋也

IIJ プロダクト本部 応用開発課 所属。2015年に新卒入社。webアプリケーションの実装・運用を中心にやりたいことがあれば何でも手を出してみる所存です。

「go言語でクリーンアーキテクチャっぽいもの」のイメージ

IIJ 2018 TECHアドベントカレンダー 12/6(木)の記事です】

はじめに

最近go言語でDDDやクリーンアーキテクチャを実践してみたという記事を多く目にするようになりました。
自分が半年ほど前に初めてgoでサーバーを実装することになった際も、先人達の記事のおかげでどうにか形にすることができました。

特に pospome さんの Goのサーバサイド実装におけるレイヤ設計とレイヤ内実装について考える はとても参考にさせていただきました。
(勝手に出してしまって申し訳ありません。)

恩返しというわけでは無いですが、自分なりに消化して実装したものを紹介したいと思います。サンプルコードは実際のコードを元にでっち上げて書いているので、ところどころ粗があるかもしれません。

アプリケーションの要件

まだ外部には未公開のサービスなので詳細は出せませんが、今回紹介するアプリケーションは以下のような要件を持ちます。

  • REST HTTP サーバーとして動作する
  • バックエンドとしてmysqlとredisを利用する
  • redisのpubsubを通して他のサーバーとやり取りする

つまり通常のRESTとは別に、pubsubからの入力も受けつつ、2種類のバックエンドをうまいこと使い分ける必要があります。
この要件を満たすサーバーを出来るだけシンプルに実装するために、処理の流れを整理しつつ各レイヤが隔離されたアーキテクチャが必要でした。

アーキテクチャ詳細

概要

最初にアーキテクチャの概要図を紹介します。矢印は依存の方向を示します。

基本的にはクリーンアーキテクチャに従い、handler -> usecase -> domain <- infra の流れに依存性を固定します。
後で具体的に書きますが、infra層のstructのinstanceを作成し、singleton として保持しておく「registry」を設けています。

handler層

handler層では主にrequestからパラメータを取り出しそれをusecase層に投げ、レスポンスの生成を行います。
大きく分けると

  • HTTP Request を受け取るhandler
  • Redis のsubscribeから受け取るhandler

の2種類を定義しています。Redisへのsubscribe処理はinfra層で行いますが、入力を受け取る時はhandler層をかますようにします。

またどの層もですが、テストを容易にするため必ず下の層はDIを通して呼び出します。
(例えばhandlerではdomain層のrepositoryやserviceを「new」して、usecase層へ依存性を注入しています。)

以下はHTTTP Requestを処理するhandlerの例です。

package handler

import (
    "net/http"
    "github.com/labstack/echo"
)

type TodoHandler interface {
    Show(ctx *Context) error
    Create(echo.Context) error
}

type todo struct {
    uc usecase.Todo
}

func NewTodo(repo registry.Repository, service registry.Service) TodoHandler {
    todoRepo := repo.NewTodoRepository()
    notifyService := service.NewNotifyService()

    uc := usecase.NewTodo(todoRepo, notifyService)

    return &todo{uc}
}

func (t *todo) Show(ctx *Context) error {
    re, err := t.uc.Show(ctx.Param("todo_id"))
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    return ctx.JSON(http.StatusOK, re)
}

func (t *todo) Create(ctx echo.Context) error {
    todo := &model.Todo{}
    if err := ctx.Bind(todo); err != nil {
      return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    re, err := t.uc.Create(todo.Title, todo.Text)
    if err != nil {
      return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    return ctx.JSON(http.StatusOK, re)
}

usecase層

usecase層ではhandlerから入力を受け取り、ビジネスロジックに従ってdomain層を呼び出します。
例として Create メソッドでは、受け取ったパラメーターからデータを作成し、他サービスに通知する流れを記述しています。

package usecase

import (
    "strings"
    "github.com/pkg/errors"
)

type Todo interface {
    Show(todoID int64) (todo *model.Todo, err error)
    Create(email, name string) (*model.Todo, error)
}

type todo struct {
    todoRepo    repository.Todo
    notifyService service.NotifyService
}

func NewTodo(todo repository.Todo, notifyService service.NotifyService) Todo {
    return &todo{
        todoRepo:    todo,
        notifyService: notifyService,
    }
}

func (t *todo) Show(todoID int64) (todo *model.Todo, err error) {
    return a.todoRepo.Find(todoID)
}

func (t *todo) Create(title, text string) (todo *model.Todo, err error) {
    todo = &model.Todo{
        Title: title,
        Text: text,
    }

    todo, err = a.todoRepo.Create(todo)
    if err != nil {
        return nil, err
    }

    err = t.notifyService.NotifyToOther(todo)
    if err != nil {
        return nil, err
    }
    return
}

domain層

domain層は中で3つの層に別れており、それぞれservice => repository => modelの順に依存しています。ここでも依存の方向は一方向に固定しています。

repository

repositoryには依存性の逆転を適用するためinterfaceのみを記述します。
DBへ保存や検索を行うメソッドが多いですが、実装は全てinfra層にあります。

package repository

type Todo interface {
    Find(int64) (*model.Todo, error)
    Create(*model.Todo) (*model.Todo, error)
    UpdateCache(*model.Todo) error
}

model

modelはドメインモデルの定義を記述します。
自分自身を変更するようなメソッドなど、若干の実装を含みます。

現状はほぼDBのカラムに引っ張られてmodelが定義されてしまっているため、今後その辺りはリファクタリングしていきたいところです。

package model

type Todo struct {
    ID     int64  `json:"id"`
    Title string `json:"email"`
    Text  string `json:"name"`
}

service

serviceにはdomain層に閉じる何らかの処理を記述するようにしています。usecase層と責務が曖昧になりがちですが

  • 処理の内容がdomain層に閉じるべきである時
  • テストが必要なロジックをusecaseから分離したい時
  • その他domainに関わるちょっとしたメソッド

あたりはserviceに実装するようにしています(usecase層でメソッドを作りたくなったらserviceにするイメージ)。
以下にサンプルを載せますが、「HTTP」や「Mail」といったinfra層にあるべき名前が出てきてしまっていて、あまりいい例ではないですね。

package service

import (
    "strings"
)

type NotifyService interface {
    NotifyToOther(todo model.Todo) error
}

type notifyService struct {
    httpRepo repository.HTTP
    mailRepo repository.Mail
}

func NewNotifyService(httpRepo repository.HTTP, mailRepo repository.Mail) NotifyService {
    return &httpService{
        httpRepo: httpRepo,
        mailRepo: mailRepo
    }
}

func (n *notifyService) NotifyToOther(todo model.Todo) error{
    err := n.httpRepo.notify(todo)
    if err != nil {
        return err
    }

    err := n.mailRepo.notify(todo)
    if err != nil {
        return err
    }
    return nil
}

infra層

infra層にはdomain層のrepositoryの具体的な実装を記述します。主にDBへの保存などある意味一番泥臭いコードを書いていきます。
下の例ではNewTodoRepositoryがrepository.Todoのinterfaceを返すことで、usecase層がdomain層に依存できるようになっています。

infra層の中でもDBとのコネクション管理などを行う部分はdaoとして独立させています。

package infra

type TodoRepository struct {
    db dao.DataBase
    store dao.RedisStore
}

func NewTodoRepository(db *dao.DataBase, store *dao.RedisStore) repository.Todo {
    newRepo := &TodoRepository{
        db: *db,
        store: *store,
    }

    return newRepo
}

func (r *TodoRepository) Find(todoID int64) (todo *model.Todo, err error) {
    todo = &model.Todo{}
    err = r.db.GetRead().First(todo, todoID).Error
    return
}

func (r *TodoRepository) Create(todo *model.Todo) (*model.Todo, error) {
    err := r.db.Get().Create(&todo).Error
    if err != nil {
        return nil, err
    }
    return todo, nil
}

registry

クリーンアーキテクチャなどにregistryは登場しませんが、 Goのサーバサイド実装におけるレイヤ設計とレイヤ内実装について考える で紹介されている通り、実装とinterfaceを結びつけてusecaseにDIするために利用します。

handlerとinfraの橋渡し的な存在で、シングルトンの管理やinfraの初期化処理を一箇所にまとめておけるため便利です。
さらにinfra層でpubsubからのinputをhandlerに渡すために、handlerを登録してinfraで利用するのにもregistryを利用しています。
(PubsubHandleでhandlerを登録して、EventRepositoryの初期化処理時に渡してるあたり)

package registry

type Repository interface {
    NewTodoRepository() repository.Todo
    PubsubHandle(channelName string, handler func(msgBytes []byte))
}

type repositoryImpl struct {
    database *dao.DataBase
    redisStore *dao.RedisStore
    todoRepo repository.Todo
    eventRepo repository.Event
    pubsubHandlers map[string]func(msgBytes []byte)
}

func NewRepository() Repository {
    db := dao.NewDataBase()
    redisStore := dao.NewRedisStore()
    return &repositoryImpl{
        database: &db,
        redisStore: &redisStore,
        pubsubHandlers: map[string]func(msgBytes []byte)
    }
}

func (r *repositoryImpl) NewTodoRepository() repository.Todo {
    if r.todoRepo == nil {
        r.todoRepo = infra.NewTodoRepository(r.database, r.redisStore)
    }
    return r.todoRepo
}

func (r *repositoryImpl) NewEventRepository() repository.Event {
    if r.eventRepo == nil {
        r.eventRepo = infra.NewEventRepository(r.redisStore, r.pubsubHandlers)
    }
    return r.eventRepo
}

func (r *repositoryImpl) PubsubHandle(channelName string, handler func(msgBytes []byte)) {
    r.pubsubHandlers[channelName] = handler
}

終わりに

goでクリーンアーキテクチャっぽい設計を実践するための具体的なコードを紹介しました。
当然これで完成という訳ではなく、domain層を含めて日々リファクタリングを続けています。それでもアーキテクチャとしての破綻は今の所なく、テストも容易に書けているため、まずまず成功したのではないかと思っています。

もし~した方がいいというアドバイスや、その他理解不足なところがあれば、はてなブックマークやTwitterでコメントなど頂けると幸いです。

余談

実際のコードをぼかして書いたことで、何を見せたかったのかぼやけてしまったところが多々あったかもしれません。
サンプルはサンプルとして一通り動作するようなものを用意できれば良かったかなーと思います。

藤本 椋也

2018年12月06日 木曜日

IIJ プロダクト本部 応用開発課 所属。2015年に新卒入社。webアプリケーションの実装・運用を中心にやりたいことがあれば何でも手を出してみる所存です。

Related
関連記事