21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

go + Gin + クリーンアーキテクチャを用いたWebAPI構築

Last updated at Posted at 2022-08-16

まずはじめに

今年から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で識別されます。

domain/entity/photo.go
package entity

// 投稿写真のカード
type PhotoCard struct {
	Id        string `xorm:"id"`
	Photo     string
	Likes     int
	CreatedAt string
	UserId    string
	Name      string
	Icon      string
}

repository

エンティティのライフサイクルに関わる操作を定義します。
ここでは実際にデータをどこから取得するのか?どんなORMを使用するか?などは記述しません。

domain/repository/photo.go
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
 クエリパラメータによって指定された検索条件で写真カード一覧を取得

未指定とゼロ値を区別するため、型はポインタにしています。

domain/repository/photo.go
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からダウンロードしています。

interface/database/photo_repository.go
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に変換、結合してから値を渡しています。

usecase/photo.go
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

common/dto/photo.go
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形式で返す。

common/dto/photo.go

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つに繋げます。

common/di/di.go
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のルーティングを定義しています。

router/router.go
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、アーキテクチャの理解を深める事が出来て良かったです。
まだまだハードコーディングをしている箇所や、上手く責務を切り分けられていない箇所があるので必要に応じてリファクタを続けて行こうと思います。

21
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?