labstack/echo
などを用いて開発をする際、公式サンプルのように、query, path, jsonをバインドする処理や、レスポンスを行う処理がecho.Contextに強く依存しています。
このwebフレームワーク独自の実装をclean-architectureで言うところのweb
層に隠蔽できないかと言うことでトライしてみました。↓結果
前提:ここでのclean-architectureの解釈
-
web
でhttpリクエストを受け付け、パス情報をもとにcontroller
を呼び出す -
controller
でリクエスト時のパラメータを解釈しusecase
を呼び出す -
usecase
で処理した結果をpresenter
に渡す -
presenter
でレスポンス用の形に変換 -
web
でhttpリクエストに対するレスポンスを返却する
echo.Contextの取り扱い
上記前提を実現するにはecho.Context
をweb
->controller
->usecase
->presenter
->web
と伝搬させなければいけません。
しかし、この記事のタイトルの通り、echo
と言う要素をweb層以外には知らせたくはありません。
そこで、controllerを呼び出す前にecho.Context
をcontext.Context
でラップします。各層へはcontext.Context
が伝搬されていきます。
web -> controller
web層とcontroller層との連携部分を一部載せます。
routerはcontrollerを呼び出す前にecho固有の情報をラップするため、同じweb層の処理を呼びます
package web
import (
"github.com/labstack/echo/v4"
"github.com/sYamaz/clean-architecture/controller"
)
type (
router struct {
e *echo.Echo
}
Router interface {
Echo() *echo.Echo
}
)
func NewRouter(e *echo.Echo,
// webhandler
handler Handler,
// controller
hello controller.HelloController,
// ...
) Router {
e.GET("/hello", handler.Func(hello.Get))
return &router{
e: e,
}
}
func (r *router) Echo() *echo.Echo {
return r.e
}
echo.Context
をecho.Context.Request().Context()
内にラップし、実際にcontrollerに渡すコンテキストはcontext.Context
になります。
package web
import (
"github.com/labstack/echo/v4"
"github.com/sYamaz/clean-architecture/controller"
)
type (
handler struct {
operator ContextOperator
}
Handler interface {
Func(controllerFunc controller.ControllerFunc) echo.HandlerFunc
}
)
func NewWebHandler(operator ContextOperator) Handler {
return &handler{
operator: operator,
}
}
func (h *handler) Func(controllerFunc controller.ControllerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := h.operator.Wrap(c.Request().Context(), c)
return controllerFunc(ctx)
}
}
最後に呼び出されるcontrollerです。bindのタグ情報がcontroller層にありますが、これはechoでなくてもreflectでパッケージで実現可能なのでcontroller層に残しています。
package controller
import (
"context"
"github.com/sYamaz/clean-architecture/usecase"
)
type (
helloController struct {
binder Binder
service usecase.GreetingService
}
HelloController interface {
Get(ctx context.Context) error
}
)
func NewHelloController(binder Binder, service usecase.GreetingService) HelloController {
return &helloController{
binder: binder,
service: service,
}
}
func (c *helloController) Get(ctx context.Context) error {
type Request struct {
Name string `query:"name"`
}
req := new(Request)
if err := c.binder.Bind(ctx, req); err != nil {
return err
}
return c.service.Hello(ctx, req.Name)
}
例えば、echoを使ったバインドの他にもreflectを使ってバインドさせることもできます(コメントアウト部)
以下例ではヘッダーをバインドさせていますが、クエリやパス、jsonも力をかければできるはず。
package web
import (
"context"
"github.com/sYamaz/clean-architecture/controller"
customerror "github.com/sYamaz/clean-architecture/custom-error"
)
type (
binder struct {
operator ContextOperator
}
)
func NewBinder(operator ContextOperator) controller.Binder {
return &binder{
operator: operator,
}
}
func (b *binder) Bind(ctx context.Context, i interface{}) error {
c := b.operator.Unwrap(ctx)
// echoのbind機能を使う場合
if err := c.Bind(i); err != nil {
return customerror.NewBindError(err)
}
return nil
}
// func (b *binder) Bind(ctx context.Context, i interface{}) error {
// // 自力で`header:"KEY"`を文字列型としてbindしたい場合の例
// v := reflect.ValueOf(i).Elem()
// t := reflect.TypeOf(i).Elem()
// for index := 0; index < v.NumField(); index++ {
// headerKey := t.Field(index).Tag.Get("header")
// headerValue := c.Request().Header.Get(headerKey)
// v.Field(index).SetString(headerValue)
// }
// return nil
// }
presenter -> web
※データの流れはpresenter -> webですが、参照は逆です。
ここでもecho要素は出てこないようにしています。
package presenter
import (
"context"
"github.com/sYamaz/clean-architecture/usecase"
)
type (
greetingPresenter struct {
sender ResultSender
}
GreetingPresenter interface {
Reply(ctx context.Context, name string) error
}
)
func NewGreetingPresenter(sender ResultSender) usecase.GreetingPresenter {
return &greetingPresenter{
sender: sender,
}
}
func (p *greetingPresenter) Reply(ctx context.Context, message string) error {
type Response struct {
Greeting string `json:"greeting"`
}
res := Response{
Greeting: message,
}
return p.sender.SendJson(ctx, &res)
}
echo要素が出てくるのはやっぱり最後の最後、web層です
package web
import (
"context"
"net/http"
"github.com/sYamaz/clean-architecture/presenter"
)
type (
httpResultSender struct {
operator ContextOperator
}
)
func NewResultSender(operator ContextOperator) presenter.ResultSender {
return &httpResultSender{
operator: operator,
}
}
func (s *httpResultSender) SendJson(ctx context.Context, i interface{}) error {
c := s.operator.Unwrap(ctx)
return c.JSON(http.StatusOK, i)
}
まとめ
clean architectureを意識しつつ、webフレームワーク固有の実装をweb層に止めることができました。
とはいえ、webフレームワークをすげかえるなんてことは早々起きなさそうではあります。どこまでクリーンにしたいか?はプロジェクト初期時によく考えることは必要そうです。