(追記)
こちらの記事のビジネスロジック部分をAPI化してLINEミニアプリ(LIFF)を作った記事を以下にまとめました。良かったらこちらも読んでみてください!
GoとNuxtで飲食店検索ができるLINE BOTとLINEミニアプリ(LIFF)を作ってみた
はじめに
Go / LINE Messaging API / Google Maps API / クリーンアーキテクチャ で、飲食店検索やお気に入り登録できるLINE BOTを作ってみました!
ざっくりとした技術構成や概要の説明、感想などをこの記事にまとめようと思います。
クリーンアーキテクチャ厚めです。
読んだら幸せになれそうな人
- 個人開発に興味がある人
- モダンな技術に興味がある人
- Goに興味がある人
- LINE Messaging API (LINE BOT) やGoogle Maps APIに興味がある人
- 単なるCRUDの例ではなく、実践的にクリーンアーキテクチャを採用している例を知りたい人
こんなのつくりました
gifにしたらもっさりしました。。
デモ動画のロングバージョンはこちら(Twitter)にあります。
以下のURLにアクセスするか、QRコードをLINEで読み取るとお試しできるので、良ければ試してみてください!
ソースコードはこちら(Github)
つくった目的
以下を目的として作ってみました。
- Goとクリーンアーキテクチャの習熟
- LINE関係のノウハウの蓄積
- 好きな飲食店を気軽にメモしたかった(でも食べログなどのアプリは入れたくなかった)
Goは人気急上昇中の言語なので、今のうちに勉強してGo案件にジョインしたいという下心強めです笑
クリーンアーキテクチャを採用した理由は、自分がMVCパターンにしか触れたことしかなく、そろそろ新しいフレームワークを習得したいと思ったからです。
また、LINE関係のノウハウを貯めておきたかったのでLINE BOTを題材にしました。
LINE上でミニアプリが動作できるようになる LINEミニアプリ が近いうちにリリースされるみたいなので波乗りしたいところです。
技術面
使用技術
- Go 1.14
- echo
- Goのフレームワーク
- wire
- GoのDIライブラリ
- gomock
- Goのmock作成ライブラリ
- gorm
- GoのORM
- 使わない方がいい説もありますが、便利なので、、
- その他ライブラリ
- LINE Messaging API
- LINE BOTが作れるAPI
- Google Maps API
- Google Mapの情報を取得できるAPI
- クリーンアーキテクチャ
- Heroku
ディレクトリ構成
一部略。詳細はGithubをご確認ください。
クリーンアーキテクチャで実装したので、ファイル/ディレクトリ数がめっちゃ多くなった。。
|--.env
|--docker-compose.yml
|--go.mod // Goのバージョン管理
|--Procfile // Herokuデプロイ
|--server.go // main関数
|--wire.go // DI用
|--domain
| |--model
| |--repository // databaseのinterface
|--infrastructure
| |--database
| |--mysql.go
| |--router.go // Routing
|--interfaces
| |--controllers
| |--gateway // 外部API通信
| |--presenter // LINE BOT出力
|--mock // 各層のmock
| |--gateway
| |--presenter
| |--repository
|--tools // DBマイグレーション
|--usecases
| |--dto // usecase用のDTO
| | |--favoritedto
| | |--googlemapdto
| | |--searchdto
| |--igateway // interfaces/gatewayのinterface
| |--interactor
| | |--usecase // usecases/interactorのinterface
| |--ipresenter // interfaces/presenterのinterface
Go
Goは本当にシンプルに書けるので、書いていて気持ちがいいですね!
以下の2行でサーバが立ち上がるのは本当に楽です。
まあこれだと本当に立ち上がるだけで、実際にはルーティング設定など必要ですが。
(echoフレームワークを使ってはいますが、なくても数行で書けたと思います)
func main() {
e := echo.New()
e.Logger.Fatal(e.Start(":8080"))
}
後にも同じことを書きますが、SDKが非常に読みやすいです。(LINEとGoogleMapしか見てませんが)
JavaやPHPのSDKを読もうとすると謎の超絶技巧を使っていたりして正直何やっているのかわからないことが多いですが、Goはシンプルなので自分でもすらすら読むことができました。
wire
DIライブラリのwireも使いやすくて良かったです。簡単にDIを実現できました。
wire.goというファイルにコンストラクタを書いてwire
コマンドを実行すると、依存性の注入を行うwire_gen.goというファイルを自動で生成してくれます。
interfaceを実装している場合は、wire.Bind()
が必要なので注意です。
$ go get github.com/google/wire/cmd/wire
//+build wireinject
package main
import (
"github.com/yagi-eng/place-search/domain/repository"
"github.com/yagi-eng/place-search/infrastructure"
"github.com/yagi-eng/place-search/infrastructure/database"
"github.com/yagi-eng/place-search/interfaces/controllers"
"github.com/yagi-eng/place-search/interfaces/gateway"
"github.com/yagi-eng/place-search/interfaces/presenter"
"github.com/yagi-eng/place-search/usecases/igateway"
"github.com/yagi-eng/place-search/usecases/interactor"
"github.com/yagi-eng/place-search/usecases/interactor/usecase"
"github.com/yagi-eng/place-search/usecases/ipresenter"
"github.com/google/wire"
"github.com/jinzhu/gorm"
"github.com/labstack/echo"
)
var superSet = wire.NewSet(
// Database
database.NewFavoriteRepository,
wire.Bind(new(repository.IFavoriteRepository), new(*database.FavoriteRepository)),
database.NewUserRepository,
wire.Bind(new(repository.IUserRepository), new(*database.UserRepository)),
// Gateway
gateway.NewGoogleMapGateway,
wire.Bind(new(igateway.IGoogleMapGateway), new(*gateway.GoogleMapGateway)),
// Presenter
presenter.NewLinePresenter,
wire.Bind(new(ipresenter.ILinePresenter), new(*presenter.LinePresenter)),
// Interactor
interactor.NewFavoriteInteractor,
wire.Bind(new(usecase.IFavoriteUseCase), new(*interactor.FavoriteInteractor)),
interactor.NewSearchInteractor,
wire.Bind(new(usecase.ISearchUseCase), new(*interactor.SearchInteractor)),
// Controller
controllers.NewLinebotController,
// Router
infrastructure.NewRouter,
)
// Initialize DI
func Initialize(e *echo.Echo, db *gorm.DB) *infrastructure.Router {
wire.Build(superSet)
return &infrastructure.Router{}
}
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
package main
import (
"github.com/google/wire"
"github.com/jinzhu/gorm"
"github.com/labstack/echo"
"github.com/yagi-eng/place-search/domain/repository"
"github.com/yagi-eng/place-search/infrastructure"
"github.com/yagi-eng/place-search/infrastructure/database"
"github.com/yagi-eng/place-search/interfaces/controllers"
"github.com/yagi-eng/place-search/interfaces/gateway"
"github.com/yagi-eng/place-search/interfaces/presenter"
"github.com/yagi-eng/place-search/usecases/igateway"
"github.com/yagi-eng/place-search/usecases/interactor"
"github.com/yagi-eng/place-search/usecases/interactor/usecase"
"github.com/yagi-eng/place-search/usecases/ipresenter"
)
// Injectors from wire.go:
func Initialize(e *echo.Echo, db *gorm.DB) *infrastructure.Router {
userRepository := database.NewUserRepository(db)
favoriteRepository := database.NewFavoriteRepository(db)
googleMapGateway := gateway.NewGoogleMapGateway()
linePresenter := presenter.NewLinePresenter()
favoriteInteractor := interactor.NewFavoriteInteractor(userRepository, favoriteRepository, googleMapGateway, linePresenter)
searchInteractor := interactor.NewSearchInteractor(googleMapGateway, linePresenter)
linebotController := controllers.NewLinebotController(favoriteInteractor, searchInteractor)
router := infrastructure.NewRouter(e, linebotController)
return router
}
// wire.go:
var superSet = wire.NewSet(database.NewFavoriteRepository, wire.Bind(new(repository.IFavoriteRepository), new(*database.FavoriteRepository)), database.NewUserRepository, wire.Bind(new(repository.IUserRepository), new(*database.UserRepository)), gateway.NewGoogleMapGateway, wire.Bind(new(igateway.IGoogleMapGateway), new(*gateway.GoogleMapGateway)), presenter.NewLinePresenter, wire.Bind(new(ipresenter.ILinePresenter), new(*presenter.LinePresenter)), interactor.NewFavoriteInteractor, wire.Bind(new(usecase.IFavoriteUseCase), new(*interactor.FavoriteInteractor)), interactor.NewSearchInteractor, wire.Bind(new(usecase.ISearchUseCase), new(*interactor.SearchInteractor)), controllers.NewLinebotController, infrastructure.NewRouter)
func main() {
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.CORS())
// DB Connect
db, err := infrastructure.Connect()
defer db.Close()
// output sql query
db.LogMode(true)
// Routes
r := Initialize(e, db)
r.Init()
// Start server
e.Logger.Fatal(e.Start(":" + os.Getenv("PORT")))
}
gomock
mock生成ライブラリ、これも簡単に使えて良かったです。
$ go get github.com/golang/mock/gomock
$ go install github.com/golang/mock/mockgen
iuser_repository.goのmockファイル「mock_user_repository.go」を作りたい場合は以下の通りです。
このコマンドを実行するだけで、自動でmockファイルを生成してくれます。
mockgen -source domain/repository/iuser_repository.go -destination mock/repository/mock_user_repository.go
package repository
// IUserRepository ユーザレポジトリインターフェース
type IUserRepository interface {
Save(string) uint
FindOne(string) uint
}
// Code generated by MockGen. DO NOT EDIT.
// Source: domain/repository/iuser_repository.go
// Package mock_repository is a generated GoMock package.
package mock_repository
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockIUserRepository is a mock of IUserRepository interface
type MockIUserRepository struct {
ctrl *gomock.Controller
recorder *MockIUserRepositoryMockRecorder
}
// MockIUserRepositoryMockRecorder is the mock recorder for MockIUserRepository
type MockIUserRepositoryMockRecorder struct {
mock *MockIUserRepository
}
// NewMockIUserRepository creates a new mock instance
func NewMockIUserRepository(ctrl *gomock.Controller) *MockIUserRepository {
mock := &MockIUserRepository{ctrl: ctrl}
mock.recorder = &MockIUserRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockIUserRepository) EXPECT() *MockIUserRepositoryMockRecorder {
return m.recorder
}
// Save mocks base method
func (m *MockIUserRepository) Save(arg0 string) uint {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Save", arg0)
ret0, _ := ret[0].(uint)
return ret0
}
// Save indicates an expected call of Save
func (mr *MockIUserRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIUserRepository)(nil).Save), arg0)
}
// FindOne mocks base method
func (m *MockIUserRepository) FindOne(arg0 string) uint {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindOne", arg0)
ret0, _ := ret[0].(uint)
return ret0
}
// FindOne indicates an expected call of FindOne
func (mr *MockIUserRepositoryMockRecorder) FindOne(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockIUserRepository)(nil).FindOne), arg0)
}
LINE Messaging API
Goの実装例はまだ少ないので、SDKを読みながら進めることになります。
でもGoはシンプルなのでとても読みやすいです。
以下の記事にGoとLINE BOTの始め方をまとめていますので、良かったら参考にしてください!
GoとDockerとHerokuでLINEBOTをためしてみる
これを書いた頃はあまりGoをわかっておらず、どりあえずDockerを使ってしまいましたが、Goはビルドインサーバがあるので、凝った設定にしなければDockerは不要でした。
あと、今回はバックエンドの勉強をメインにしていたので、LINEBOTを採用することによりフロントエンドの実装をする手間が省けたのは非常に良かったです。
さすがにAPIサーバだけ作っても味気ないので。。
Google Maps API
こちらもSDKを読みながら進める感じですね。言うまでもなく読みやすい。
以下の記事にGoとGoogle Maps APIの始め方をまとめていますので、良かったら参考にしてください!
GoでGoogle Maps APIを使って場所の詳細情報を取得する(例:東京タワー)
クリーンアーキテクチャ
クリーンアーキテクチャ自体の解説は本稿では省きます。
こちら(実践クリーンアーキテクチャ)が参考になります。
感想
理解するのにめちゃくちゃ時間かかりました笑
実装のたびに、「この実装はどの層に置くのが正しいんだろう。。」「ディレクトリ構成どうしたらいいんだろう。。」と悩みました。
特に、gatewayのinterfaceをdomain層かusecase層のどちらに置くか迷いました。
presenterのinterface(上図でいう Use Case Output Port)はusecase層に置くようなので、gatewayのinterfaceも一緒にusecase層に置きました。
クリーンアーキテクチャは一見すると過剰過ぎる分割にも思えますが、今はしっくり来ています。
一番印象的だったのは、LINE BOT出力処理をクリーンアーキテクチャのルール的にどこに置いていいのかわからず、適当にControllerディレクトリ内の1ファイルにまとめておいていたのですが、それが実はPresenter層に相当するものだったということです。
ちょっと何言っているかわかりづらいですね。クリーンアーキテクチャのルールを無視して、自分の中でしっくりくる構成にしようと思ったら、いつの間にかクリーンアーキテクチャのルールに則っていた、という感じです。
ネットで色んなクリーンアーキテクチャの記事を読み漁った上でこの構成になったので、割とクリーンアーキテクチャの思想をきちんと取り入れられているのかなと思っています。
実際に実装するには、同心円の図より以下の方が参考になりました。
最低限守るべきこと
引用:世界一わかりやすいClean Architecture
ネットでクリーンアーキテクチャの実装例を調べてみると、人によって実装方法が微妙に異なるため「???」となります。
ただ、理解した(つもりの)今になって思うと、上の3つのことはどれも守られていたなと思います。
個人的には、クリーンアーキテクチャの細々したことはあまり気にせず、この3つのことさえ守ればいいと思ってます。
初めて実装する時はとりあえずサンプルコードそっくりの構成にして、理解できたら次回以降は3つのことを守りつつ実用面も考慮して崩す、みたいな守破離的な採用の仕方がベストです。
依存逆転の原則
これを理解しておくと、クリーンアーキテクチャの理解がだいぶ早くなると思います。
以下の引用元がとてもわかりやすかったです。
依存関係逆転の原則は以下の原則を満たす必要がある。
・上位のモジュールは下位のモジュールに依存してはならない。
・どちらのモジュールも「抽象」に依存すべきである。
・「抽象」は実装の詳細に依存してはならない。
・実装の詳細が「抽象」に依存すべきである。
引用: 【ボブおじさんのClean Architectureまとめ】オブジェクト指向 ~SOLIDの原則~
自分が感じたメリット
一般的に語られるメリットは以下の通りです。
・フレームワーク独立
アーキテクチャは、ソフトウェアのライブラリに依存しない。フレームワークを道具として使うことを可能にし、システムはフレームワークの限定された制約に縛られない。
・テスト可能
ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。
・UI独立
ビジネスルールの変更なしに、UIを置き換えられる。
・データベース独立
OracleあるいはSQL Serverを、Mongo, BigTable, CouchDBあるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
・外部機能独立
ビジネスルールは、単に外側についてなにも知らない。
引用元:Clean ArchitectureでAPI Serverを構築してみる
自分もこの通りだと思います。
今回はフレームワークにechoを採用したのですが、ルーティングとロガー機能しか使っておらず、フレームワークにほぼ依存しない実装になりました。
「Goは素のまま使うのが正義だ」という方もいるようなので、Goとクリーンアーキテクチャの相性はいいのかなと個人的に思います。
また、層の間には必ずinterfaceを挟んでいるのでスタブが作りやすかったです。gomockとの相性も良い。
ま、実装めんどくさいですけどね。
機能説明
クリーンアーキテクチャの理解に時間かけすぎて(言い訳)、大したもの作っていないのでさらっとだけ。
キーワード検索
場所の名前や調べたい情報をチャットで送信すると、検索結果を返す。
位置情報検索
トーク画面の左下の「+」ボタンをタップして「位置情報」を選択すると、位置情報に応じた検索結果を返す。
現在地などを送信することで、現在いるお店や付近のお店を調べることができる。
※送信された位置情報は保存していない。
お気に入り登録
検索結果の「Add to my favorites」をタップすると、場所をお気に入り登録できる。
お気に入り一覧表示
「My FAVORITE」バナーをタップすると、登録したお気に入りが確認できる。
お気に入り解除
お気に入り一覧で「Remove」をタップすると、場所をお気に入り解除できる。
今後の展望
ビジネスロジック部分をAPIとして使えるようにして、LINEミニアプリが来た時に使えるようにするんだ。。
後はGo案件にもジョインするんだ。。
Twitterの方でもLINEに限らず、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!
@yagi_eng
また、BOT開発を通じてGoとLINE BOTにまとめて入門する記事をZennに掲載していますので、良かったらそちらもご覧ください!
ZennでGoとLINE BOTの記事を書いてみました
— やぎぬ😇行動力エンジニア (@yagi_eng) November 7, 2020
⬇️BOT開発を通じてGoとLINE BOTにまとめて入門するhttps://t.co/QqsEESJMKa
5ステップに分け、「Hello Worldから始めて、飲食店検索ができるLINE BOTの実装まで」を解説しています
GoやLINE BOTに興味のある人は是非読んでみてください😇
24,000字超え😇
参考
- 【Go】Wireで実現する必要十分なDI
- GoのDIライブラリgoogle/wireの使い方
- Goでメソッドを簡単にモック化する【gomock】
- Go言語でテストコードを書いてみた!
- Heroku+echo(go)+MySQLでJSON APIサーバを立ててみる
- 他言語から来た人がGoを使い始めてすぐハマったこととその答え
- 実戦クリーンアーキテクチャ
- 実践クリーンアーキテクチャ
- 世界一わかりやすいClean Architecture
- 【ボブおじさんのClean Architectureまとめ】オブジェクト指向 ~SOLIDの原則~
- Clean ArchitectureでAPI Serverを構築してみる