LoginSignup
126
128

More than 5 years have passed since last update.

Nuxt.js(Vue.js)とGoでSPA + API(レイヤードアーキテクチャ)でチャットアプリを実装してみた

Last updated at Posted at 2019-05-06

概要

Nuxt.js(Vue.js)とレイヤードアーキテクチャのお勉強のために簡単なチャットアプリを実装してみた。
SPA + APIと言った形になっている。

機能

機能はだいたい以下のような感じ。

  • ログイン機能
  • サインアップ機能
  • スレッド一覧表示機能
  • スレッド作成機能
    • ログインしたユーザーは誰でもスレッドを作成できること
  • コメント一覧表示機能
    • スレッドをクリックすると、そのスレッド内のコメント一覧が表示されること
  • スレッド内でのコメント作成機能
    • ログインしたユーザーは誰でもどのスレッド内でもコメントできること
  • スレッド内でのコメント削除機能
    • 自分のコメントのみ削除できること
  • ログアウト機能

コード

  • コード全体はここ
  • コードは一例でもっと他の実装や良さそうな実装はありそう

技術

サーバーサイド

アーキテクチャ

DDD本に出てくるレイヤードアーキテクチャをベースに以下の書籍や記事を参考にさせていただき実装した。超厳密なレイヤードアーキテクチャというわけではない。

実際のpackage構成は以下のような感じ。

├── interface
│   └── controller // サーバへの入力と出力を扱う責務。
├── application // 作業の調整を行う責務。
├── domain
│   ├── model // ビジネスの概念とビジネスロジック(正直今回はそんなにビジネスロジックない...)
│   ├── service // EntityでもValue Objectでもないドメイン層のロジック。
│   └── repository // infra/dbへのポート。
├── infra // 技術に関すること。
│    ├── db // DBの技術に関すること。
│    ├── logger // Logの技術に関すること。
│    └── router // Routingの技術に関すること。 
├── middleware // リクエスト毎に差し込む処理をまとめたミドルウェア
├── util 
└── testutil

packageの切り方は以下を大変参考にさせていただいている。

上記のpackage以外に application/mockdomain/service/mockinfra/db/mock というmockを格納する用のpackageもあり、そこに各々のレイヤーのmock用のファイルを置いている。(詳しくは後述)

依存関係

依存関係としてはざっくり、interface/controllerapplicationdmain/repository or dmain/serviceinfra/db という形になっている。

参考: GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck

domain/~infra/db で矢印が逆になっているのは、依存関係が逆転しているため。
詳しくは その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiitaを参照。

先ほどの矢印の中で、domain/model は記述しなかったが、 domain/model は、interface/controllerapplication 等からも依存されている。純粋なレイヤードアーキテクチャでは、各々のレイヤーは自分の下のレイヤーにのみ依存するといったものがあるかもしれないが、それを実現するためにDTO等を用意する必要があって、今回の実装ではそこまで必要はないかなと思ったためそうした。(厳格にやる場合は、実装した方がいいかもしれない)

各レイヤーでのinterfaceの定義とテスト

applicaiondomain/serviceinfra/db (定義先は、/domain/repository ) には interface を定義し、他のレイヤーからはその interface に依存させるようにしている。こうするとこれらを使用する側は、抽象に依存するようになるので、抽象を実装する具象を変化させても使用する側(依存する側)はその影響を受けにくい。

実際に各レイヤーを使用する側のレイヤのテストの際には、使用されるレイヤーを実際のコードではなく、Mock用のものに差し替えている。各々のレイヤーに存在する mock というpackageにmock用のコードを置いている。このモック用のコードは、gomockを使用して自動生成している。

この辺のことについては、
その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiita という記事を以前書いたので、詳しくはこちらを参照いただきたい。

エラーハンドリング

エラーハンドリングは以下のように行なっている。

  • 以下のような形で errors.Wrap を使用してオリジナルのエラーを包む
if err := Hoge(); err != nil {
    return errors.Wrap(オリジナルエラー, "状況の説明"
}
  • 独自のエラー型を定義している
  • エラーは基本的に各々のレイヤーで握りつぶさず、interface/controller レイヤーまで伝播させる
  • 最終的には、interface/controller でエラーの型によって、レスポンスとして返すメッセージやステータスコードを選択する

参考
Golangのエラー処理とpkg/errors | SOTA

ログイン周り

DB周り

package query

import (
    "context"
    "database/sql"
)

// DBManager is the manager of SQL.
type DBManager interface {
    SQLManager
    Beginner
}

// TxManager is the manager of Tx.
type TxManager interface {
    SQLManager
    Commit() error
    Rollback() error
}

// SQLManager is the manager of DB.
type SQLManager interface {
    Querier
    Preparer
    Executor
}

type (
    // Executor is interface of Execute.
    Executor interface {
        Exec(query string, args ...interface{}) (sql.Result, error)
        ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    }

    // Preparer is interface of Prepare.
    Preparer interface {
        Prepare(query string) (*sql.Stmt, error)
        PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    }

    // Querier is interface of Query.
    Querier interface {
        Query(query string, args ...interface{}) (*sql.Rows, error)
        QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
    }

    // Beginner is interface of Begin.
    Beginner interface {
        Begin() (TxManager, error)
    }
)

  • application レイヤーでは以下のようにフィールドで query.DBManager を所持する
    • そうすることで SQLManagerTxManager (Begin() で生成)のどちらも application レイヤーで扱うことができる( application レイヤで直接使用するわけではなく、 domain/repository に渡す)
// threadService is application service of thread.
type threadService struct {
    m        query.DBManager
    service  service.ThreadService
    repo     repository.ThreadRepository
    txCloser CloseTransaction
}
  • domain/repository の引数では query.SQLManager を受け取る
    • query.TxManager は、query.SQLManager も満たしているので、query.TxManager は、query.SQLManager のどちらも受け取ることができる
// ThreadRepository is Repository of Thread.
type ThreadRepository interface {
    ListThreads(ctx context.Context, m query.SQLManager, cursor uint32, limit int) (*model.ThreadList, error)
    GetThreadByID(ctx context.Context, m query.SQLManager, id uint32) (*model.Thread, error)
    GetThreadByTitle(ctx context.Context, m query.SQLManager, name string) (*model.Thread, error)
    InsertThread(ctx context.Context, m query.SQLManager, thead *model.Thread) (uint32, error)
    UpdateThread(ctx context.Context, m query.SQLManager, id uint32, thead *model.Thread) error
    DeleteThread(ctx context.Context, m query.SQLManager, id uint32) error
}
  • 以下のようなRollbackやCommitを行う関数を作成しておく
// CloseTransaction executes post process of tx.
func CloseTransaction(tx query.TxManager, err error) error {
    if p := recover(); p != nil { // rewrite panic
        err = tx.Rollback()
        err = errors.Wrap(err, "failed to roll back")
        panic(p)
    } else if err != nil {
        err = tx.Rollback()
        err = errors.Wrap(err, "failed to roll back")
    } else {
        err = tx.Commit()
        err = errors.Wrap(err, "failed to commit")
    }
    return err
}
  • application レイヤでは、deferCloseTransaction を呼び出す(ここでは a.txCloser になっている)
// CreateThread creates Thread.
func (a *threadService) CreateThread(ctx context.Context, param *model.Thread) (thread *model.Thread, err error) {
    tx, err := a.m.Begin()
    if err != nil {
        return nil, beginTxErrorMsg(err)
    }

    defer func() {
        if err := a.txCloser(tx, err); err != nil {
            err = errors.Wrap(err, "failed to close tx")
        }
    }()

    yes, err := a.service.IsAlreadyExistTitle(ctx, tx, param.Title)
    if yes {
        err = &model.AlreadyExistError{
            PropertyName:    model.TitleProperty,
            PropertyValue:   param.Title,
            DomainModelName: model.DomainModelNameThread,
        }
        return nil, errors.Wrap(err, "already exist id")
    }

    if _, ok := errors.Cause(err).(*model.NoSuchDataError); !ok {
        return nil, errors.Wrap(err, "failed is already exist id")
    }

    id, err := a.repo.InsertThread(ctx, tx, param)
    if err != nil {
        return nil, errors.Wrap(err, "failed to insert thread")
    }
    param.ID = id
    return param, nil
}
// threadService is application service of thread.
type threadService struct {
    m        query.DBManager
    service  service.ThreadService
    repo     repository.ThreadRepository
    txCloser CloseTransaction
}

所感

  • レイヤードアーキテクチャは
    • 依存関係がはっきりするのが良い
    • 各レイヤが疎結合なので変更しやすく、テストもしやすいのは良い
    • 各レイヤの責務がはっきり別れているので、どこに何を書けばいいかはわかりやすい
    • コード量は増えるので、実装に時間がかかる
      • 決まったところは自動化できると良いかも
      • CRUDだけの小さなアプリケーションでは、大げさすぎるかもしれない

フロントエンド

アーキテクチャ

  • 基本的には、Nuxt.jsのアーキテクチャに沿って実装を行なった
  • 状態管理に感じては、Vuexを使用した
    • 各々の Component 側( pagescomponents )からデータを使用したい場合には、Vuexを通じて使用した
    • データ、ロジックとビュー部分が綺麗に別れる

見た目

大きな流れ

大きな流れとしては、以下のような流れ。
pasgescomponents 等のビューでのイベントの発生 → actions 経由でAPIへリクエスト → mutationsstate 変更 → pasgescomponents 等のビューに反映される

他の流れもたくさんあるが、代表的なList処理とInput処理の流れを以下に記す。

List処理

  • pagescomponentsasyncData 内で、store.dispatch を通じて、データ一覧を取得するアクション( actions )を呼び出す
  • storeactions 内での処理を行う
    • axiosを使用してAPIにリクエストを送信する
    • APIから返却されたデータを引数に mutationscommit する。
  • mutations での処理を行う
    • state を変更する
  • pagescomponents のビューで取得したデータが表示される

Input処理

  • pagescomponentsstores に定義した actionstate を読み込んでおく
  • pagescomponentsdata 部分とformのinput部分等に v-model を使用して双方向データバインディングをしておく
  • pagescomponents で表示しているビュー部分でイベントが生じる
    • form入力→submitなど
  • sumitする時にクリックされるボタンに @click=hoge という形でイベントがそのElementで該当のイベントが生じた時に呼び出されるメソッド等を登録しておく
  • 呼び出されたメソッドの処理を行う
    • formのデータを元にデータを登録するアクション( actions )を呼び出す
  • storeactions 内での処理を行う
    • axiosを使用してAPIにリクエストを送信する
    • APIから返却されたデータを引数に mutationscommit する。
  • mutations での処理を行う
    • state を変更する
    • 登録した分のデータを一覧の state に追加する
  • pagescomponents のビューで登録したデータが追加された一覧表示される

非同期部分

所感

  • Nuxt.jsを使用すると、レールに乗っかれて非常に楽
    • どこに何を実装すればいいか明白になるので迷わないで済む
    • 特にVuexを使用すると
      • データの流れが片方向になるのはわかりやすくて良い
      • ビュー、ロジック、データの責務がはっきりするのが良い
  • Vuetifyを使用するとあまり凝らない画面であれば、短期間で実装できそう
  • Componentの切り方をAtomic Designに則ったやり方とかにするともっといい感じに切り分けられたかもしれない

参考文献

サーバーサイド

  • InfoQ.com、徳武 聡(翻訳) (2009年6月7日) 『Domain Driven Design(ドメイン駆動設計) Quickly 日本語版』 InfoQ.com
  • エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
  • pospome『pospomeのサーバサイドアーキテクチャ』

フロントエンド

  • 花谷拓磨 (2018/10/17)『Nuxt.jsビギナーズガイド』シーアンドアール研究所
  • 川口 和也、喜多 啓介、野田 陽平、 手島 拓也、 片山 真也(2018/9/22)『Vue.js入門 基礎から実践アプリケーション開発まで』技術評論社

参考にさせていただいた記事

サーバーサイド

フロントエンド

関連記事

126
128
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
126
128