まずはじめに
今年からGoを学んでいるのですが、
バックエンドにGoを使用してWebサービスを作成することにしました。
今回は作成したAPIサーバの一部の機能を、設計の観点からご紹介したいと思います。
ソースコードの全体や機能の詳細についてはこちらから。
開発環境
言語: Go
フレームワーク: Gin
ORM:XORM
DB:MySQL
ストレージ:AmazonS3
ディレクトリ構成
server
├── common
│ ├── di
│ │ └── di.go
│ ├── dto
│ │ ├── photo.go
│ │ └── user.go
│ └── common.go
│
├── domain
│ ├── repository
│ │ ├── photo.go
│ │ └── user.go
| └── entity
│ ├── photo_factory.go
│ ├── photo.go
│ ├── user.go
│ └── user_factory.go
│
├── handler
│ ├── photo_handler.go
│ └── user_handler.go
│
├── interface
│ ├── aws
│ │ ├── awsS3.go
│ │ └── config.go
│ └── database
│ ├── photo_repository.go
│ ├── user_repository.go
│ └── transaction.go
│
├── router
│ └── router.go
│
├── transaction
│ └── transaction.go
│
├── usecase
│ ├── photo.go
│ └── user.go
│
└── main go
機能
具体的なコードや設計については以下の機能を抜粋し解説します。
・写真の一覧取得(GET /photo)
画面イメージ
まずはこのAPIを呼び出す画面とその機能について説明します。
投稿された写真を一覧表示するページ
・写真をクリックすることで、その写真の詳細ページに遷移する。
・ユーザをクリックすることで、ユーザの詳細ページに遷移する。
・新着順/いいね順でソート可。
・一回の取得を60件とし、ページネーションによって取得位置を操作する。
・キーワード検索で投稿文章から検索可。
写真一覧の検索条件は下記のクエリパラメータによって操作します。
項目 | 指定例 | 備考 |
---|---|---|
keyword | ゲーマー | 投稿文章の部分検索 |
tag_ids | タグID | タグ検索(投稿ページ内のタグを選択) |
sort | like | いいね順 |
page | 2 | ページ番号 |
レスポンスデータ
表示に必要なリソースからレスポンスデータの設計を行いました。
投稿写真やユーザのアイコンには画像が必要となるのですが、
フロントとの受け渡しはJSON形式で行いたかったため、画像をbase64でエンコードし文字列として返す仕様にしました。
{
"photo_counts": 2,
"photo_list": [
{
"id": "56c2646e",
"photo": "xxxxxxxxxx",
"likes": 3,
"created_at": "2022-07-29 18:10:33",
"user": {
"id": "99b530ef",
"name": "太郎",
"icon": "xxxxxxxxxx",
}
},
{
"id": "45966d21",
"photo": "xxxxxxxxxx",
"likes": 0,
"created_at": "2022-08-01 20:50:07",
"user": {
"id": "99b530ef",
"name": "太郎",
"icon": "xxxxxxxxxx",
}
}
]
}
実装
クリーンアーキテクチャを用いて実装しました。
責務を切り分けたレイヤごとに解説していきます。
ドメイン層
要素 | 説明 |
---|---|
entity | ドメインを表現するモデル |
repository | ドメインオブジェクトを取得・保存するインタフェース |
service | ドメインオブジェクトに責務を持たせるものではないケース |
entity
ここに記載するエンティティはデータストアの構成には依存しません。
各データはIDで識別されます。
package entity
// 投稿写真のカード
type PhotoCard struct {
Id string `xorm:"id"`
Photo string
Likes int
CreatedAt string
UserId string
Name string
Icon string
}
repository
エンティティのライフサイクルに関わる操作を定義します。
ここでは実際にデータをどこから取得するのか?どんなORMを使用するか?などは記述しません。
package repository
import (
"context"
"github.com/ryoh07/gin-clean-webapp/domain/entity"
)
type IPhotoRepository interface {
FindPhotoCard(opts *service.PhotoCardOpts) ([]*entity.PhotoCard, int64, error)
}
service
検索条件をインターフェース層に渡すための構造体をサービスに定義します。
インターフェース層では、構造体の中身を評価し格納されているフィールドによって取得パターンを切り替えます。
・UserId
ユーザが投稿した写真カード一覧を取得
・LikeUserId
ユーザがいいねした写真カード一覧を取得
・QueryCondition
クエリパラメータによって指定された検索条件で写真カード一覧を取得
未指定とゼロ値を区別するため、型はポインタにしています。
package service
type PhotoCardOpts struct {
UserId *string // ユーザが投稿した写真カード一覧を取得
LikeUserId *string // ユーザがいいねした写真カード一覧を取得
QueryCondition *QueryCondition // クエリパラメータによって指定された検索条件で写真カード一覧を取得
}
func NewPhotoCardOpts(userId *string, likeUserId *string, queryCondition *QueryCondition) *PhotoCardOpts {
return &PhotoCardOpts{
UserId: userId,
LikeUserId: likeUserId,
QueryCondition: queryCondition,
}
}
// クエリパラメータで取得した検索条件
type QueryCondition struct {
Keyword *string `form:"keyword"`
TagId *string `form:"tag_id"`
Sort *string `form:"sort"`
PageNo *int `form:"page_no"`
}
func NewQueryCondition(keyword *string, tagId *string, sort *string, pageNo *int) *QueryCondition {
return &QueryCondition{
Keyword: keyword,
TagId: tagId,
Sort: sort,
PageNo: pageNo,
}
}
インターフェース層
インターフェース層では、ドメイン層で定義したリポジトリを実装します。
画像データはS3に保存しているため、DBに保存しているS3への保存パスを取得後、S3からダウンロードしています。
package mysql
import (
"bytes"
"context"
"fmt"
"net/url"
"github.com/go-xorm/xorm"
"github.com/ryoh07/gin-clean-webapp/domain/entity"
"github.com/ryoh07/gin-clean-webapp/domain/repository"
"github.com/ryoh07/gin-clean-webapp/interface/aws"
"github.com/ryoh07/gin-clean-webapp/common"
"xorm.io/builder"
)
type PhotoRepository struct {
*xorm.Engine
}
func NewPhotoRepository(db *xorm.Engine) repository.IPhotoRepository {
return &PhotoRepository{db}
}
// 写真カードの一覧取得
func (r *PhotoRepository) FindPhotoCard(opts *service.PhotoCardOpts) ([]*entity.PhotoCard, int64, error) {
subQuery, _, err := builder.
Select("photo_id,user_id,count(*) as likes").
From("photo_likes").
ToSQL()
if err != nil {
return nil, 0, err
}
query := builder.Dialect("MYSQL").
Select("Distinct SQL_CALC_FOUND_ROWS p.id,p.photo,ifnull(l.likes,0) as likes,p.created_at,u.id as user_id,u.name,u.icon").
From("photos p").
Join("INNER", "users u", "p.user_id = u.id").
Join("INNER", "photo_tags pt", "p.id = pt.photo_id").
Join("LEFT", fmt.Sprintf("(%s) AS l", subQuery), "p.id = l.photo_id")
// 指定ユーザの投稿写真一覧を取得
if opts.UserId != nil {
query = query.Where(builder.Eq{"u.id": *opts.UserId})
}
// いいねした投稿写真一覧を取得
if opts.LikeUserId != nil {
query = query.Where(builder.Eq{"l.user_id": *opts.LikeUserId})
}
// クエリパラメータ条件
if opts.QueryCondition != nil {
// キーワード検索
if opts.QueryCondition.Keyword != nil {
query = query.Where(builder.Like{"p.contents", *opts.QueryCondition.Keyword})
}
// タグ検索
if opts.QueryCondition.TagId != nil {
query = query.Where(builder.Eq{"t.id": *opts.QueryCondition.TagId})
}
// ソート条件
if opts.QueryCondition.Sort != nil {
if *opts.QueryCondition.Sort == "like" {
query = query.OrderBy("l.likes DESC")
}
} else {
query = query.OrderBy("p.created_at DESC")
}
// ページネーション
if opts.QueryCondition.PageNo != nil {
query.Limit(60, (*opts.QueryCondition.PageNo-1)*60)
} else {
query = query.Limit(60, 0)
}
} else {
query = query.OrderBy("p.created_at DESC")
query = query.Limit(60, 0)
}
// 検索結果取得
sql, args, err := query.ToSQL()
engine := r.SQL(sql, args...)
if err != nil {
return nil, 0, err
}
PhotoCards := []*entity.PhotoCard{}
err = engine.Where("user_id = ?", "a").Find(&PhotoCards)
if err != nil {
return nil, 0, err
}
// 件数取得
var count int64
cntSql, args, err := builder.
Select("FOUND_ROWS()").From("DUAL").ToSQL()
if err != nil {
return nil, 0, err
}
_, err = r.SQL(cntSql, args...).Get(&count)
if err != nil {
return nil, 0, err
}
// 画像を取得しエンコード
awsS3 := aws.NewAwsS3()
var imgByte *bytes.Buffer
for _, v := range PhotoCards {
imgByte, _ = awsS3.TestS3Downloader(v.Photo)
v.Photo = common.Encode(imgByte)
imgByte, _ = awsS3.TestS3Downloader(v.Icon)
v.Icon = common.Encode(imgByte)
}
return PhotoCards, count, nil
}
ユースケース層
ユースケース層では、エンティティやリポジトリのメソッドを組み合わせてビジネスロジックを作成します。
また、上位レイヤーに対してはデータ転送用のDTOに変換、結合してから値を渡しています。
package usecase
import (
"context"
"fmt"
"github.com/ryoh07/gin-clean-webapp/common/dto"
"github.com/ryoh07/gin-clean-webapp/domain/entity"
"github.com/ryoh07/gin-clean-webapp/domain/repository"
"github.com/ryoh07/gin-clean-webapp/transaction"
)
type PhotoInputPort interface {
GetPhoto(photoId string) (*dto.PhotoPage, error)
CreatePhoto(ctx context.Context, inphoto *dto.InPhoto) error
}
type photoInteractor struct {
repository.IPhotoRepository
transaction.Transaction
}
func NewPhotoInteractor(repo repository.IPhotoRepository, tx transaction.Transaction) PhotoInputPort {
return &photoInteractor{repo, tx}
}
func (s *photoInteractor) GetPhotoCardList(dtoQC *dto.QueryCondition) (*dto.PhotoCardList, error) {
serviceQC := service.NewQueryCondition(dtoQC.Keyword, dtoQC.TagId,
dtoQC.Sort, dtoQC.PageNo)
photoCardListE, photoCounts, err := s.IPhotoRepository.FindPhotoCard(&service.PhotoCardOpts{
QueryCondition: serviceQC,
})
if err != nil {
return nil, err
}
// 取得結果をDTOに変換
PhotoCardListD := make([]*dto.PhotoCard, 0)
for _, v := range photoCardListE {
PhotoCardListD = append(PhotoCardListD, dto.NewPhotoCard(v.Id, v.Photo, v.Likes, v.CreatedAt, v.UserId, v.Name, v.Icon))
}
return dto.NewPhotoCardList(photoCounts, PhotoCardListD), nil
}
DTO
package dto
type PhotoCardList struct {
PhotoCounts int64 `json:"photo_counts"`
PhotoCardList []*PhotoCard `json:"photo_list"`
}
func NewPhotoCardList(counts int64, photoCardList []*PhotoCard) *PhotoCardList {
return &PhotoCardList{
PhotoCounts: counts,
PhotoCardList: photoCardList,
}
}
type PhotoCard struct {
PhotoId string `json:"id"`
Photo string `json:"photo"`
Likes int `json:"likes"`
CreatedAt string `json:"created_at"`
User PhotoUser `json:"user"`
}
func NewPhotoCard(id string, photo string, likes int, createdAt string, userId string, name string, icon string) *PhotoCard {
return &PhotoCard{
PhotoId: id,
Photo: photo,
Likes: likes,
CreatedAt: createdAt,
User: PhotoUser{
Id: userId,
Name: name,
Icon: icon,
},
}
}
ハンドラー層
ハンドラー層では下記の役割を担います。
・レクエストで渡されたデータをusecase層への受け渡す。
・戻り値として受け取ったデータを、JSON形式で返す。
package handler
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ryoh07/gin-clean-webapp/common/dto"
"github.com/ryoh07/gin-clean-webapp/usecase"
)
type IPhotoHandler interface {
// 検索条件より投稿写真一覧を取得
GetPhotoCardList(c *gin.Context)
}
type PhotoHandler struct {
usecase.PhotoInputPort
}
func NewPhotoHandler(srv usecase.PhotoInputPort) IPhotoHandler {
return &PhotoHandler{srv}
}
func (h *PhotoHandler) GetPhotoCardList(c *gin.Context) {
query := dto.QueryCondition{}
c.ShouldBindQuery(&query)
photo, _ := h.PhotoInputPort.GetPhotoCardList(&query)
c.JSON(http.StatusOK, photo)
}
DI
これまで作成したリポジトリ・ユースケース・ハンドラのインスタンスを生成し、オブジェクトを注入することで1つに繋げます。
package di
import (
"github.com/go-xorm/xorm"
"github.com/ryoh07/gin-clean-webapp/handler"
"github.com/ryoh07/gin-clean-webapp/interface/database"
"github.com/ryoh07/gin-clean-webapp/usecase"
)
func InitPhoto(db *xorm.Engine) handler.IPhotoHandler {
tx := database.NewTransaction(db)
r := database.NewPhotoRepository(db)
s := usecase.NewPhotoInteractor(r, tx)
return handler.NewPhotoHandler(s)
}
Router
依存関係を初期化後、URLのルーティングを定義しています。
package router
import (
"github.com/gin-gonic/gin"
"github.com/go-xorm/xorm"
"github.com/ryoh07/gin-clean-webapp/common/di"
)
func SetRoutes(engine *gin.Engine, db *xorm.Engine) {
photo := di.InitPhoto(db)
engine.GET("/photo", photo.GetPhotoCardList)
}
最後に
クリーンアーキテクチャを使用することで各層の役割が明確になり、疎結合な実装が可能となりました。
責務の切り分けで悩むことや、コードの記述量が多くなることで結構な時間がかかってしまいましたが、
開発を通して、Webアプリケーションの知識や、DDD、アーキテクチャの理解を深める事が出来て良かったです。
まだまだハードコーディングをしている箇所や、上手く責務を切り分けられていない箇所があるので必要に応じてリファクタを続けて行こうと思います。