LoginSignup
34
33

More than 3 years have passed since last update.

【初心者・クラシック中級者向け】RESTful デファクトになりつつあるフロント/バックエンドの完全分離サンプル(Nuxt.js + Rails API on Docker)

Posted at

はじめに

タイトルはあくまで個人的見解ですが、近年のプロダクト開発ではマイクロサービス/APIファースト、BFF(Backend For Frontends)といったアーキテクチャがよく取り込まれていると思われます。
noteのフロントエンドをNuxt.jsへ刷新します|こんぴゅ|note
SPA開発未経験者によるNuxt.jsを使った自社サービス開発の裏側 - ログミーTech
レガシーシステムの大規模リプレイスで分かった「Vue.jsでSPAならNuxt.jsが有力」 - エンジニアHub|若手Webエンジニアのキャリアを考える!
スモールスタートではじめるSSR - dely engineering blog
BFFに取り組む開発者たちが語る「UIT#3 The “Backends for Frontends” sharing」 - LINE ENGINEERING

筆者(3年目のソフトウェアエンジニア@_soyamaguchi_)はこれまで大規模でモノリシックなプロダクトの改修やスタンドアロンApp、ツール等の開発しか経験がなく、新規開発のAPIファーストな開発で当初慌てたため、本記事ではBFFとまではいかないにしてもRESTful APIでフロント/バックエンドを完全分離した簡易的なCRUD機能を持つWebAppサンプルをNuxt(SSR)+RailsAPIで作っていきます!

初学者の方にもご理解頂けるようにチュートリアル形式で環境構築から行っていきたいと思います。(本記事はHow to separate frontend + backend with Rails API, Nuxt.js and Devise-JWTReactJS + Ruby on Rails API + Heroku Appを参考に執筆しております)

ほな、大きく分けて以下の構成で書いていきます:open_hands:

環境及び技術スタック

今回はMacでDockerを用いて環境構築をしていきます。(他のPC(OS)でもDockerが使用できれば問題ないかと思われます)

 - Ruby 2.6.4
 - Rails 6.0.0
 - Node 12.10.0
 - yarn 1.17.3
 - PostgreSQL 11.5

DEMO

以下が成果物です。
本当にシンプルな機能(リスト読み込み、追加、編集、削除)のみを持つサンプルアプリとなっています。

nuxt-rails-sample.gif

本記事で作成したコード

本記事で作成した最終的なコードはGitHubにアップしてあります。
こちらも参考にしてみてください。
https://github.com/soyamaguchi/nuxt-rails-sample

それでは以下より本編となっております!

1 / 5 前提及び準備

まず、Ruby, Rails, Node, yarn, PostgreSQL, Dockerの環境を用意していきます。

Homebrewのインストール

全てパッケージマネージャーのHomebrew, Homebrew Caskでインストールしていきますので、インストールされていなければ以下のページに記載されているインストールコマンドをコピーして実行してください。
Homebrew:The missing package manager for macOS (or Linux) — Homebrew
Homebrew Cask:みんなhomebrew-caskって知ってるか? - Qiita

$ brew -v
Homebrew 2.1.11
Homebrew/homebrew-core
Homebrew/homebrew-cask

各種環境のインストール

Homebrewのインストールが完了したら、上記の環境を用意するために以下のサイトを参考に各種インストールしてください。
Ruby:rbenv を利用した Ruby 環境の構築 | DevelopersIO
Node, yarn:install nodebrew, node and yarn - Qiita
PostgreSQL:[DB]MacにPostgreSQLをインストールする方法 - Qiita
Docker:Homebrew で macOS に Docker をインストールして Hello world - Qiita

$ ruby -v
ruby 2.6.4p104 (2019-08-28 revision 67798) [x86_64-darwin18]

$ node -v
v12.10.0

$ yarn -v
1.17.3

$ psql -V
psql (PostgreSQL) 11.5

$ docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:26:49 2019
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:32:21 2019
  OS/Arch:          linux/amd64
  Experimental:     false

インストールできましたでしょうか?
環境ができましたら以下のコマンドでRailsをインストールしておいてください。

$ gem i rails -v 6.0.0

$ rails -v
Rails 6.0.0

とりあえず、ここまでで実行環境の準備は終わりです!
次に環境構築を行っていきます。

2 / 5 環境構築

プロジェクト生成

先にRails APIとNuxtAppを作成していきます。

$ mkdir nuxt-rails-sample
$ cd nuxt-rails-sample
$ rails new backend --api -d postgresql
$ yarn create nuxt-app frontend
? Project name frontend
? Project description nuxt-rails-sample frontend
? Author name Soya Yamaguchi
? Choose the package manager Yarn
? Choose UI framework Vuetify.js
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules (Press <space> to select, <a> to toggle all, <i> to invert selection)Axios, Progressive Web App (PWA) Support
? Choose linting tools (Press <space> to select, <a> to toggle all, <i> to invert selection)ESLint, Prettier, Lint staged files
? Choose test framework Jest
? Choose rendering mode Universal (SSR)
? Choose development tools (Press <space> to select, <a> to toggle all, <i> to invert selection)jsconfig.json (Recommended for VS Code)
  • Railsのプロジェクト生成時に--apiオプション付けてあげることでAPIモードになります。
  • Nuxtのプロジェクト生成時にいくつか質問されますが、今回は上記のように設定してください。(インストール - Nuxt.js)

Dockerでのコンテナ作成

次にDockerfileを作成してフロント/バックエンド環境を構築し、docker-compose.ymlを作成していきます。

DirStructure
/
├──frontend
│  ├──Dockerfile
├──backend
│  ├──Dockerfile
├──docker-compose.yml

frontend/Dockerfile
FROM node:12.10

ENV APP_DIR /app/frontend
ENV PATH /app/frontend/node_modules/.bin:$PATH
ENV TZ Asia/Tokyo
ENV HOST 0.0.0.0

RUN mkdir -p /app/frontend
WORKDIR $APP_DIR
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY package.json $APP_DIR/package.json
COPY yarn.lock $APP_DIR/yarn.lock
RUN yarn install
COPY . $APP_DIR
backend/Dockerfile
FROM ruby:2.6.4

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev postgresql-client nodejs

ENV APP_DIR /app/backend

RUN mkdir -p /app/backend
WORKDIR $APP_DIR
COPY Gemfile $APP_DIR/Gemfile
COPY Gemfile.lock $APP_DIR/Gemfile.lock
RUN bundle install
COPY . $APP_DIR
docker-compose.yml
version: '3'
services:
  db:
    image: postgres:11.5
    volumes:
      - ./backend/db/pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"  
    networks:
      app_net:
        ipv4_address: 192.168.32.2

  backend:
    build: ./backend
    command: /bin/bash -c "rm -rf tmp/pids/server.pid; bundle exec rails s -p 8080 -b 0.0.0.0"
    volumes:
      - ./backend:/app/backend
    ports:
      - "8080:8080"
    depends_on:
      - db
    networks:
      app_net:
        ipv4_address: 192.168.32.3
    tty: true
    stdin_open: true

  frontend:
    build: ./frontend
    command: yarn dev
    volumes:
      - ./frontend:/app/frontend
    ports:
      - "3000:3000"
    depends_on:
      - backend
    networks:
      app_net:
        ipv4_address: 192.168.32.4
    tty: true

networks:
  app_net:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 192.168.32.0/20

ここでコンテナのサブネットとIPを固定にしているのはなぜかコンテナ間の名前解決ができなかったためです。。。
本来はサービス名で名前解決できるみたいですが、筆者の環境ではfrontendからbackendに通信できず最終的にこのような形をとりました。(ハードコーディングしていますが、環境変数や.envファイルで読み込むべきでしょう)

次にbackend/.gitignoreを編集します。
docker-compose.ymlのvolumesでマウントしているbackend/db/pgdataと管理外にしたいbackend/vendor/bundleを追記し、Gitの管理下から外します。

backend/.gitignore
+/vendor/bundle
+/db/pgdata

次にdatabase.ymlを編集します。

backend/config/database.yml
default: &default
   adapter: postgresql
   encoding: unicode
   pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+  host: db
+  username: postgres
+  password:

ここまでくればあと少しで環境構築が終わります!

作成したDockerファイルをビルドしておきましょう。

$ docker-compose build

ビルドには少々時間が掛かるかもしれません:thinking:
ビルドするとdockerイメージが生成されます。

$ docker images
REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
nuxt-rails-sample_backend    latest              1d60ede04488        16 minutes ago      1.16GB
nuxt-rails-sample_frontend   latest              493c2ee51fdc        43 hours ago        1.31GB
ruby                         2.6.4               121862ceb25f        2 weeks ago         840MB
postgres                     11.5                e2d75d1c1264        2 weeks ago         313MB
node                         12.10               d8c33ae35f44        2 weeks ago         907MB

ビルドが完了しましたらbackendコンテナでDBを作成します。

$ docker-compose run --rm backend rails db:create

最後は以下のコマンドを実行します。

$ docker-compose up

無事にDB, backend, frontendのコンテナが生成され起動していますでしょうか?
以下のようにSTATUSがUpになっていたら起動している状態です。

$ docker-compose ps -a
CONTAINER ID        IMAGE                        COMMAND                  CREATED             STATUS              PORTS                    NAMES
15b56bba7d9d        nuxt-rails-sample_frontend   "docker-entrypoint.s…"   2 minutes ago       Up 2 minutes        0.0.0.0:3000->3000/tcp   nuxt-rails-sample_frontend_1
b7d00b7a30f0        nuxt-rails-sample_backend    "/bin/bash -c 'rm -r…"   2 minutes ago       Up 2 minutes        0.0.0.0:8080->8080/tcp   nuxt-rails-sample_backend_1
78daac31a23c        postgres:11.5                "docker-entrypoint.s…"   3 minutes ago       Up 3 minutes        0.0.0.0:5432->5432/tcp   nuxt-rails-sample_db_1

コンテナを止めるときはdocker-compose stopで止めて、開始させるときはdocker-compose startで開始させます。
またコンテナとネットワークを削除したいときはdocker-compose downで削除します。

dockerコマンドの詳細は調べて頂くとすぐに出てきますのでご自身で調べて頂きますようよろしくお願いします:bow:

ここまで順調に来ておればブラウザでNuxtとRailsのデフォルトページが表示されるはずです!

スクリーンショット 2019-09-26 23.19.03.png

お疲れ様です!これで環境構築が完了しました:raised_hands:
次からはフロント/バックエンドを作成していきます!

3 / 5 バックエンドAPI作成

リソースの生成

APIを作成していくのにまずリソースを生成します。
最初に以下のコマンドでbackendコンテナに接続します。(切断するときはexitまたはCtrl+Dで切断してくださいね)

$ docker-compose exec backend bash

次に以下のコマンドでリソースを生成します。

root@b7d00b7a30f0:/app/backend# rails g scaffold List title:string excerpt:text
Running via Spring preloader in process 55
      invoke  active_record
      create    db/migrate/20190928095923_create_lists.rb
      create    app/models/list.rb
      invoke    test_unit
      create      test/models/list_test.rb
      create      test/fixtures/lists.yml
      invoke  resource_route
       route    resources :lists
      invoke  scaffold_controller
      create    app/controllers/lists_controller.rb
      invoke    test_unit
      create      test/controllers/lists_controller_test.rb

root@b7d00b7a30f0:/app/backend# rails db:migrate
== 20190928095923 CreateLists: migrating ======================================
-- create_table(:lists)
   -> 0.0986s
== 20190928095923 CreateLists: migrated (0.1014s) =============================

上記が完了したらいくつかのデータをDBに追加していきます。

backend/db/seeds.rb
3.times {|n| List.create(title: "Test-title-#{n}", excerpt: "Test-excerpt-#{n}")}
root@b7d00b7a30f0:/app/backend# rails db:seed

APIコントローラの編集

DBにデータを追加し終えたらroutes.rbを編集します。

backend/config/routes.rb
Rails.application.routes.draw do
  namespace :api, format: 'json' do
    namespace :v1 do
      resources :lists
    end
  end
end

上記のnamespaceを使用してcontrollerにアクセスするためにlists_controller.rbも編集していきます。

まずbackend/controllers直下にapi/v1ディレクトリを作成し、lists_controller.rbを移動させます。
移動後は以下のような構成になります。

backend
├──app
   ├──controllers
      ├──api
      │   ├──v1
      │      ├──lists_controller.rb
      ├──concerns
      ├──application_controller.rb

移動後はroutes.rbで設定したnamespaceApi::V1でラップします。

backend/app/controllers/api/v1/lists_controller.rb
module Api::V1
  class ListsController < ApplicationController
    before_action :set_list, only: [:show, :update, :destroy]

    # GET /lists
    def index
      @lists = List.all

      render json: @lists
    end

    # GET /lists/1
    def show
      render json: @list
    end

    # POST /lists
    def create
      @list = List.new(list_params)

      if @list.save
        render json: @list, status: :created, location: @list
      else
        render json: @list.errors, status: :unprocessable_entity
      end
    end

    # PATCH/PUT /lists/1
    def update
      if @list.update(list_params)
        render json: @list
      else
        render json: @list.errors, status: :unprocessable_entity
      end
    end

    # DELETE /lists/1
    def destroy
      @list.destroy
    end

    private
      # Use callbacks to share common setup or constraints between actions.
      def set_list
        @list = List.find(params[:id])
      end

      # Only allow a trusted parameter "white list" through.
      def list_params
        params.require(:list).permit(:title, :excerpt)
      end
  end
end

後々のことを考えて少し修正しておきます。
ここでは単純に/api/v1/listsにgetリクエストが来たときにListを昇順で返す、/api/v1/listsにpostリクエストが来たときにRails側では画面遷移について操作しないように変更しています。

backend/app/controllers/api/v1/lists_controller.rb
     # GET /lists
     def index
-      @lists = List.all
+      @lists = List.all.order(:id)

       render json: @lists
     end

     # POST /lists
     def create
       @list = List.new(list_params)

       if @list.save
-        render json: @list, status: :created, location: @list
+        render json: @list, status: :created
       else
         render json: @list.errors, status: :unprocessable_entity
       end
     end

これでhttp://localhost:8080/api/v1/listsにアクセスしてAPIの動作確認ができるようになりました!

スクリーンショット 2019-09-28 22.02.36.png

とりあえずここまででAPIの作成は以上になります。

ここからはフロントエンドを作成してバックエンドに接続していきます!

4 / 5 フロントエンド作成

Proxy設定

まずフロントエンドからのリクエストをAPIのエンドポイントにプロキシさせるために@nuxtjs/axiosproxyの設定を追加します。

以下のように設定することでフロントエンドから/api/v1/*を叩くことによって、先程作成したbackendコンテナのAPIエンドポイントにアクセス可能になります。

frontend/nuxt.config.js
   /*
    ** Axios module configuration
    ** See https://axios.nuxtjs.org/options
    */
-  axios: {},
+  axios: {
+    proxy: true
+  },
+  proxy: {
+    '/api/v1/': {
+      // backendのコンテナip
+      target: 'http://192.168.32.3:8080',
+      pathRewrite: {
+        '^/api/v1/': '/api/v1/'
+      },
+    }
+  },

CORS設定

また他のドメインのCORSヘッダーを適切に設定しない限り、他のドメインのエンドポイントにアクセスすることは許可されません。(CORSまとめ - Qiita)
なのでここでRails APIの設定を変更します。

デフォルトで用意されているGemfileのgem 'rack-cors'の行のコメントアウトを解除し、backend/config/initializers/cors.rbを編集します。
編集後は以下のようになります。

backend/Gemfile
-# gem 'rack-cors'
+gem 'rack-cors'
backend/config/initializers/cors.rb
-# Rails.application.config.middleware.insert_before 0, Rack::Cors do
-#   allow do
-#     origins 'example.com'
-#
-#     resource '*',
-#       headers: :any,
-#       methods: [:get, :post, :put, :patch, :delete, :options, :head]
-#   end
-# end
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+  allow do
+    # frontendのコンテナip
+    origins '192.168.32.4:3000'
+
+    resource '*',
+      headers: :any,
+      methods: [:get, :post, :put, :patch, :delete, :options, :head]
+  end
+end

上記のように編集が完了したらコメントアウトしたgemをインストールします。

root@b7d00b7a30f0:/app/backend# bundle

一応ここらで一回コンテナを削除して作り直しておきましょう。

$ docker-compose down

$ docker-compose up

これでフロントエンドからバックエンドAPIへのリクエスト処理の準備が完了しました!

フロントエンドの作成

次にNuxtAppのlayouts/default.vuepages/index.vueを以下のように修正してください。
またpages/inspire.vueは不要なのでファイルごと削除しておきます。

frontend/layouts/default.vue
<template>
  <div id="app">
    <v-app>
      <v-toolbar>
        <n-link id="top" to="/">Nuxt-Rails-Sample</n-link>
      </v-toolbar>
      <v-content>
        <v-container fluid>
          <nuxt />
        </v-container>
      </v-content>
    </v-app>
  </div>
</template>

<style lang="scss">
body {
  .v-toolbar {
    &__content {
      padding: 0 2rem;
      a {
        text-decoration: none;
        color: #616161;
        font-weight: bold;
        font-size: 1.5rem;
      }
    }
  }
  .container {
    display: flex;
    min-height: 100vh;
    padding: 2rem;
  }
}
</style>
frontend/pages/index.vue
<template>
  <div class="home">
    <template v-if="editTargetList">
      <EditListForm :list="editTargetList" @set="editingList" />
    </template>
    <template v-else>
      <NewListForm />
      <ListsContainer @set="editingList" />
    </template>
  </div>
</template>

<script>
import ListsContainer from '~/components/ListsContainer.vue'
import NewListForm from '~/components/NewListForm.vue'
import EditListForm from '~/components/EditListForm.vue'

export default {
  components: {
    ListsContainer,
    NewListForm,
    EditListForm
  },
  data() {
    return {
      lists: [],
      editTargetList: ''
    }
  },
  async asyncData({ $axios }) {
    const data = await $axios.$get('/api/v1/lists')
    return { lists: data }
  },
  methods: {
    editingList(list = '') {
      this.editTargetList = list
    }
  }
}
</script>

<style lang="scss" scoped>
.home {
  width: 100vw;
}

.flex {
  margin-bottom: 2rem;
}
</style>

後程、処理内容は説明しますが、一旦ここでindex.vueでimportしているcomponentも以下のように作成しておきましょう。

frontend/components/ListsContainer.vue
<template>
  <v-card>
    <v-list-item v-for="list in this.$parent.lists" :key="list.id" two-line>
      <v-list-item-content>
        <h2>{{ list.title }}</h2>
      </v-list-item-content>
      <v-btn class="mx-2" fab dark color="pink" @click="removeList(list.id)">
        <v-icon>mdi-minus</v-icon>
      </v-btn>
      <v-btn class="mx-2" fab dark color="cyan" @click="$emit('set', list)">
        <v-icon>mdi-pencil</v-icon>
      </v-btn>
    </v-list-item>
  </v-card>
</template>

<script>
export default {
  methods: {
    removeList(id) {
      this.$axios
        .delete(`/api/v1/lists/${id}`)
        .then((res) => {
          const lists = this.$parent.lists.filter((l) => l.id !== id)
          this.$parent.lists = lists
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>
frontend/components/NewListForm.vue
<template>
  <v-flex>
    <v-card>
      <v-card-text>
        <v-form>
          <v-text-field v-model="title" label="title" />
          <v-text-field v-model="excerpt" label="excerpt" type="text" />
        </v-form>
        <v-card-actions>
          <v-btn class="mx-2" fab dark color="indigo" @click="addList">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-card-actions>
      </v-card-text>
    </v-card>
  </v-flex>
</template>

<script>
export default {
  data() {
    return {
      title: '',
      excerpt: ''
    }
  },
  methods: {
    addList() {
      this.$axios
        .$post('/api/v1/lists', { title: this.title, excerpt: this.excerpt })
        .then((res) => {
          this.$parent.lists.push(res)
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>
frontend/components/EditListForm.vue
<template>
  <v-flex>
    <v-card>
      <v-card-text>
        <v-form>
          <v-text-field v-model="title" label="title" />
          <v-text-field v-model="excerpt" label="excerpt" />
        </v-form>
        <v-card-actions>
          <v-btn class="mx-2" fab dark color="teal" @click="editList">
            <v-icon dark>mdi-pencil</v-icon>
          </v-btn>
        </v-card-actions>
      </v-card-text>
    </v-card>
  </v-flex>
</template>

<script>
export default {
  props: {
    list: {
      type: Object,
      default: null
    }
  },
  data() {
    return {
      id: this.list.id,
      title: this.list.title,
      excerpt: this.list.excerpt
    }
  },
  methods: {
    editList() {
      this.$axios
        .$put(`/api/v1/lists/${this.id}`, {
          title: this.title,
          excerpt: this.excerpt
        })
        .then((res) => {
          const lists = this.$parent.lists.map((l) => {
            return l.id === this.id ? res : l
          })
          this.$parent.lists = lists
          this.$emit('set')
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>

また現状はVuetifyの設定でダークモードになっているため、以下のようにデフォルト(白)に戻しておきます。

frontend/nuxt.config.js
   vuetify: {
     customVariables: ['~/assets/variables.scss'],
     theme: {
-      dark: true,
+      // dark: true,
       themes: {
         dark: {

とりあえずこれでフロントエンドは作成できました!
以下のように表示されていますでしょうか?
スクリーンショット 2019-09-29 17.05.27.png

フロントエンドはバックエンドからのレスポンスデータ(JSON)を表示しています!
titleとexcerptを入力後、+ボタンを押してDBにリストを追加して、フロントエンドが実際にバックエンドからデータを取得していることを確認してみてください。

次は今回作成したフロントエンドからのリクエスト処理(CRUD機能)について簡単に説明していきます!

5 / 5 CRUD

先に前提とレイアウトについて少しだけ説明しておきます。

前提

前提として、まず共通のレイアウトとなるdefault.vue<nuxt />コンポーネントにpages配下のページコンポーネントがレンダリングされます。
ページコンポーネントのディレクトリ構成に従って、自動的にVue Router(紹介 | Vue Router)の設定を行ってくれているのでNuxtを使用する場合、特にルーティングに気を配ることはないかと思われます。

frontend/layouts/default.vue
<template>
  <div id="app">
    <v-app>
      <v-toolbar>
        <n-link id="top" to="/">Nuxt-Rails-Sample</n-link>
      </v-toolbar>
      <v-content>
        <v-container fluid>
          <nuxt />
        </v-container>
      </v-content>
    </v-app>
  </div>
</template>

<style lang="scss">
body {
  .v-toolbar {
    &__content {
      padding: 0 2rem;
      a {
        text-decoration: none;
        color: #616161;
        font-weight: bold;
        font-size: 1.5rem;
      }
    }
  }
  .container {
    display: flex;
    min-height: 100vh;
    padding: 2rem;
  }
}
</style>

今回の場合、http://localhost:3000にアクセスした時、default.vue<nuxt />pages/index.vueのページコンポーネントがレンダリングされます。

レイアウト

全体的にレイアウトについてはCSSをほとんど書かず、Vuetify(マテリアルデザインのコンポーネントフレームワーク)でUIを作成しています。
Vuetifyを使用したことのない方(筆者は初めて使いました)からすると身に覚えのないタグが多数存在しているかと思われますが、以下の公式より検索できます。
API explorer — Vuetify.js

CRUD - READ

まずhttp://localhost:3000にアクセス時、pages/index.vue内のasyncDataが実行されます。
asyncDataはページコンポーネントが読み込まれる前やSSR(Server Side Rendering)、ページ遷移前に呼び出され、コンポーネントのdataに値をセットすることができます。

frontend/pages/index.vue
<template>
  <div class="home">
    <template v-if="editTargetList">
      <EditListForm :list="editTargetList" @set="editingList" />
    </template>
    <template v-else>
      <NewListForm />
      <ListsContainer @set="editingList" />
    </template>
  </div>
</template>

<script>
import ListsContainer from '~/components/ListsContainer.vue'
import NewListForm from '~/components/NewListForm.vue'
import EditListForm from '~/components/EditListForm.vue'

export default {
  components: {
    ListsContainer,
    NewListForm,
    EditListForm
  },
  data() {
    return {
      lists: [],
      editTargetList: ''
    }
  },
  async asyncData({ $axios }) {
    const data = await $axios.$get('/api/v1/lists')
    return { lists: data }
  },
  methods: {
    editingList(list = '') {
      this.editTargetList = list
    }
  }
}
</script>

<style lang="scss" scoped>
.home {
  width: 100vw;
}

.flex {
  margin-bottom: 2rem;
}
</style>

上記のasyncDataの処理内容はaxiosを使ってAPIを叩き(Rails APIの/api/v1/listsにGETリクエストを送り)、Listを昇順にしたJSONをdataのlistsにマージします。
ここでリクエスト送信先の/api/v1/listsnuxt.config.jsで以下のように設定したAPIエンドポイントになります。
コンテナ間の通信なのでipで指定していますが、実際はhttp://localhost:8080/api/v1/listsにリクエストを送信していると思っていただければ問題ないかと思われます。

frontend/nuxt.config.js
axios: {
    proxy: true
  },
  proxy: {
    '/api/v1/': {
      // backendのコンテナip
      target: 'http://192.168.32.3:8080',
      pathRewrite: {
        '^/api/v1/': '/api/v1/'
      }
    }
  }

初期表示時はdataのeditTargetList""のため<template v-if=false>になり、importされているNewListForm.vueListsContainer.vueが表示されます。

NewListForm.vueは新たにListを追加するためのコンポーネントです。
ListsContainer.vueは親コンポーネントであるindex.vuelistsを表示し、削除、更新を行うコンポーネントとなっています。

実際にthis.$parent.listsで親のlistsを参照し、lists分のtitleと削除ボタン、編集ボタンを表示しています。

frontend/components/ListsContainer.vue
<template>
  <v-card>
    <v-list-item v-for="list in this.$parent.lists" :key="list.id" two-line>
      <v-list-item-content>
        <h2>{{ list.title }}</h2>
      </v-list-item-content>
      <v-btn class="mx-2" fab dark color="pink" @click="removeList(list.id)">
        <v-icon>mdi-minus</v-icon>
      </v-btn>
      <v-btn class="mx-2" fab dark color="cyan" @click="$emit('set', list)">
        <v-icon>mdi-pencil</v-icon>
      </v-btn>
    </v-list-item>
  </v-card>
</template>

<script>
export default {
  methods: {
    removeList(id) {
      this.$axios
        .delete(`/api/v1/lists/${id}`)
        .then((res) => {
          const lists = this.$parent.lists.filter((l) => l.id !== id)
          this.$parent.lists = lists
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>

ここまでで以下の画面ができあがることになります。
スクリーンショット 2019-09-29 17.05.27.png

以上がRead機能の説明になります。
かなり簡単に説明させて頂きましたが、残りの3機能もこのような感じで説明していきます。

CRUD - CREATE

CRUD - READNewListForm.vueは新たにListを追加するためのコンポーネントです。と記載した内容について説明していきます。

NewListForm.vueはtitleとexcerpt項目が入力できるテキストボックスと@clickイベント付きの追加ボタンが存在しているくらいです。

v-modelは双方向データバインディングと呼ばれており、ユーザの入力に基づいて自動的にdataのオブジェクトが更新されます。なのでテキストボックスの入力値とdata.title, data.excerptは常に同じ状態になります。

上記になにか入力して、+ボタンを押下するとボタンの@clickイベントが発火し、addList()メソッドが実行されます。
addList()メソッドはaxiosでRails APIの/api/v1/listsにPOSTリクエストとパラメータ(data)を送り、API側は送られてきたパラメータに基づいてListを作成し、保存します。
リクエスト成功時にthis.$parent.lists.push(res)が実行されindex.vuelistsの末尾にPOST成功したListが追加されます。

frontend/components/NewListForm.vue
<template>
  <v-flex>
    <v-card>
      <v-card-text>
        <v-form>
          <v-text-field v-model="title" label="title" />
          <v-text-field v-model="excerpt" label="excerpt" type="text" />
        </v-form>
        <v-card-actions>
          <v-btn class="mx-2" fab dark color="indigo" @click="addList">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-card-actions>
      </v-card-text>
    </v-card>
  </v-flex>
</template>

<script>
export default {
  data() {
    return {
      title: '',
      excerpt: ''
    }
  },
  methods: {
    addList() {
      this.$axios
        .$post('/api/v1/lists', { title: this.title, excerpt: this.excerpt })
        .then((res) => {
          this.$parent.lists.push(res)
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>

ListsContainer.vueindex.vuelists分表示しているため、上記のaddList()で追加されたListも以下のように表示されます。

ListsContainer.gif

CRUD - UPDATE

編集画面の表示

まずListsContainer.vueの編集ボタンを押下すると@clickイベントが発火します。

frontend/components/ListContainer.vue
<v-btn class="mx-2" fab dark color="cyan" @click="$emit('set', list)">
  <v-icon>mdi-pencil</v-icon>
</v-btn>

@clickイベントは、$emit('set', list)を実行します。
処理内容は親コンポーネントであるindex.vueで登録された@setイベントを発火します。
@setイベントには以下のようにeditingListメソッドが登録されており、こちらが実行されます。

frontend/pages/index.vue
<template v-else>
  <NewListForm />
  <ListsContainer @set="editingList" />
</template>

引数として$emitの第2引数に設定している選択対象のlisteditingListメソッドに渡されます。
その渡されたlistをdataのeditTargetListに設定しています。

frontend/pages/index.vue
data() {
  return {
    lists: [],
    editTargetList: ''
  }
},
async asyncData({ $axios }) {
  const data = await $axios.$get('/api/v1/lists')
  return { lists: data }
},
methods: {
  editingList(list = '') {
    this.editTargetList = list
  }
}

簡単に説明すると、編集ボタン押下後、index.vueeditingListメソッドが実行され、引数で渡された選択対象のListをdataのeditTargetListに設定する流れとなっています。

ここでdataのeditTargetListに値が設定されると<template v-if="editTargetList">trueになりEditListForm.vueが表示対象になり編集画面が表示されます!

frontend/pages/index.vue
<template v-if="editTargetList">
  <EditListForm :list="editTargetList" @set="editingList" />
</template>

編集処理

EditListForm.vueでも@setイベントが使えるように@setイベントにeditingListメソッドを登録しています。
またdataのeditTargetListも使えるようにするために:list="editTargetList"としています。

frontend/pages/index.vue
<template v-if="editTargetList">
  <EditListForm :list="editTargetList" @set="editingList" />
</template>

上記をEditListForm.vueで受け取るためにプロパティ(props)を使います。
EditListForm.vueは編集画面であり、初期値として受け取ったプロパティを変化させたいため、propsで受け取った後、ローカルデータとして使用するためにプロパティの値をdataの初期値として定義します。

frontend/components/EditListForm.vue
<template>
  <v-flex>
    <v-card>
      <v-card-text>
        <v-form>
          <v-text-field v-model="title" label="title" />
          <v-text-field v-model="excerpt" label="excerpt" />
        </v-form>
        <v-card-actions>
          <v-btn class="mx-2" fab dark color="teal" @click="editList">
            <v-icon dark>mdi-pencil</v-icon>
          </v-btn>
        </v-card-actions>
      </v-card-text>
    </v-card>
  </v-flex>
</template>

<script>
export default {
  props: {
    list: {
      type: Object,
      default: null
    }
  },
  data() {
    return {
      id: this.list.id,
      title: this.list.title,
      excerpt: this.list.excerpt
    }
  },
  methods: {
    editList() {
      this.$axios
        .$put(`/api/v1/lists/${this.id}`, {
          title: this.title,
          excerpt: this.excerpt
        })
        .then((res) => {
          const lists = this.$parent.lists.map((l) => {
            return l.id === this.id ? res : l
          })
          this.$parent.lists = lists
          this.$emit('set')
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>

このようにすることでList追加時(CRUD - CREATE)と同様にtitleとexcerptのテキストボックスはv-modelで双方向データバインディングされ、以下の修正ボタンを押下するとeditListメソッドが実行され、PUTリクエストが送信されます。

frontend/components/EditListForm.vue
<v-btn class="mx-2" fab dark color="teal" @click="editList">
  <v-icon dark>mdi-pencil</v-icon>
</v-btn>

editListメソッドはRails APIの/api/v1/lists/:id(編集対象ListId)にPUTリクエストとパラメータ(data)を送り、API側は送られてきたパラメータで対象のListを更新します。
リクエスト成功時にindex.vuelistsに修正した新たなListを含むlistsを設定して、最後に@setイベントを発火させます。

frontend/components/EditListForm.vue
data() {
  return {
    id: this.list.id,
    title: this.list.title,
    excerpt: this.list.excerpt
  }
},
methods: {
  editList() {
    this.$axios
      .$put(`/api/v1/lists/${this.id}`, {
        title: this.title,
        excerpt: this.excerpt
      })
      .then((res) => {
        const lists = this.$parent.lists.map((l) => {
          return l.id === this.id ? res : l
        })
        this.$parent.lists = lists
        this.$emit('set')
      })
      .catch((err) => {
        console.log(err)
      })
  }
}

@setイベントに登録されているeditingListメソッドが実行されますが、引数(List)が存在しないため、デフォルト引数で指定されている""がdataのeditTargetListに設定されます。

frontend/pages/index.vue
methods: {
  editingList(list = '') {
    this.editTargetList = list
  }
}

dataのeditTargetList""になると<template v-if="editTargetList">falseになるため、編集画面は表示されず初期画面が表示され、更新処理が完了となります。

frontend/pages/index.vue
<template v-else>
  <NewListForm />
  <ListsContainer @set="editingList" />
</template>

CRUD-Update.gif

CRUD - DELETE

DELETEも他の機能とほとんど要領です。

まず初期画面に表示されているListの削除ボタン押下で@clickイベントが発火し、removeListメソッドが実行されます。

frontend/components/ListsContainer.vue
<v-btn class="mx-2" fab dark color="pink" @click="removeList(list.id)">
  <v-icon>mdi-minus</v-icon>
</v-btn>

removeListメソッドではRails APIの/api/v1/lists/:id(削除対象ListId)にDELETEリクエストを送り、API側で対象のListを削除します。
リクエスト成功時にindex.vuelistsに削除したListを含まないlistsを設定します。

frontend/components/ListsContainer.vue
<script>
export default {
  methods: {
    removeList(id) {
      this.$axios
        .delete(`/api/v1/lists/${id}`)
        .then((res) => {
          const lists = this.$parent.lists.filter((l) => l.id !== id)
          this.$parent.lists = lists
        })
        .catch((err) => {
          console.log(err)
        })
    }
  }
}
</script>

これで削除処理は完了となります。

CRUD-DELETE.gif

簡易的なCRUDでしたが、説明は以上となります。

最後に

これで最初のDEMOで紹介した通りのWebAppができているかと思われます。

フロントエンドとバックエンドを完全に分離すると設計が綺麗になったように思います。
唯一の接点がレスポンスデータであるJSONになっていることで各分野のエンジニアは自分の担当分野に専念でき、リポジトリ管理も別々にすることも可能ですし、開発スピードの向上にも繋がるのではないでしょうか。もちろんフロントとバックでしっかりとネゴを取っておくことは重要ですが。

フロント/バックエンドを完全分離し責務をしっかりと分け、リソースごとにAPIエンドポイントを作って(マイクロサービス)、BFFにすれば割とモダンなアーキテクチャになるのではないでしょうか:thinking:
あとはプロダクトごとでフロント/バックエンドの設計が変わるくらいなのではと思っています。

今回はただフロント/バックエンドを完全分離しただけの記事になってしまいましたが、またこのような類の記事を執筆するときは、機能をいくつか追加し、BFFにして本番環境へのデプロイまで行うような流れを書けたらと思っております。

かなり長くなってしまいましたが、ご覧いただきありがとうございました:bow_tone1:

参考文献

本文中にも明記しておりますが、以下にもまとめておきます。

34
33
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
34
33