はじめに
タイトルはあくまで個人的見解ですが、近年のプロダクト開発ではマイクロサービス/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-JWT、ReactJS + Ruby on Rails API + Heroku Appを参考に執筆しております)
ほな、大きく分けて以下の構成で書いていきます
- 1 / 5 前提及び準備(インストールなど)
- 2 / 5 環境構築(Rails API, NuxtApp)
- 3 / 5 バックエンドAPI作成(Rails API)
- 4 / 5 フロントエンド作成(Nuxt)
- 5 / 5 CRUD
環境及び技術スタック
今回は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
以下が成果物です。
本当にシンプルな機能(リスト読み込み、追加、編集、削除)のみを持つサンプルアプリとなっています。
本記事で作成したコード
本記事で作成した最終的なコードは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
を作成していきます。
/
├──frontend
│ ├──Dockerfile
├──backend
│ ├──Dockerfile
├──docker-compose.yml
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
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
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の管理下から外します。
+/vendor/bundle
+/db/pgdata
次にdatabase.yml
を編集します。
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ host: db
+ username: postgres
+ password:
ここまでくればあと少しで環境構築が終わります!
作成したDockerファイルをビルドしておきましょう。
$ docker-compose build
ビルドには少々時間が掛かるかもしれません
ビルドすると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コマンドの詳細は調べて頂くとすぐに出てきますのでご自身で調べて頂きますようよろしくお願いします
ここまで順調に来ておればブラウザでNuxtとRailsのデフォルトページが表示されるはずです!
お疲れ様です!これで環境構築が完了しました
次からはフロント/バックエンドを作成していきます!
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に追加していきます。
3.times {|n| List.create(title: "Test-title-#{n}", excerpt: "Test-excerpt-#{n}")}
root@b7d00b7a30f0:/app/backend# rails db:seed
APIコントローラの編集
DBにデータを追加し終えたら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
でラップします。
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側では画面遷移について操作しないように変更しています。
# 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の動作確認ができるようになりました!
とりあえずここまででAPIの作成は以上になります。
ここからはフロントエンドを作成してバックエンドに接続していきます!
4 / 5 フロントエンド作成
Proxy設定
まずフロントエンドからのリクエストをAPIのエンドポイントにプロキシさせるために@nuxtjs/axios
にproxy
の設定を追加します。
以下のように設定することでフロントエンドから/api/v1/*
を叩くことによって、先程作成したbackendコンテナのAPIエンドポイントにアクセス可能になります。
/*
** 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
を編集します。
編集後は以下のようになります。
-# gem 'rack-cors'
+gem 'rack-cors'
-# 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.vue
とpages/index.vue
を以下のように修正してください。
またpages/inspire.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>
<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も以下のように作成しておきましょう。
<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>
<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>
<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の設定でダークモードになっているため、以下のようにデフォルト(白)に戻しておきます。
vuetify: {
customVariables: ['~/assets/variables.scss'],
theme: {
- dark: true,
+ // dark: true,
themes: {
dark: {
とりあえずこれでフロントエンドは作成できました!
以下のように表示されていますでしょうか?
フロントエンドはバックエンドからのレスポンスデータ(JSON)を表示しています!
titleとexcerptを入力後、+ボタンを押してDBにリストを追加して、フロントエンドが実際にバックエンドからデータを取得していることを確認してみてください。
次は今回作成したフロントエンドからのリクエスト処理(CRUD機能)について簡単に説明していきます!
5 / 5 CRUD
先に前提とレイアウトについて少しだけ説明しておきます。
前提
前提として、まず共通のレイアウトとなるdefault.vue
の<nuxt />
コンポーネントにpages
配下のページコンポーネントがレンダリングされます。
ページコンポーネントのディレクトリ構成に従って、自動的にVue Router(紹介 | Vue Router)の設定を行ってくれているのでNuxtを使用する場合、特にルーティングに気を配ることはないかと思われます。
<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に値をセットすることができます。
<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/lists
はnuxt.config.js
で以下のように設定したAPIエンドポイントになります。
コンテナ間の通信なのでipで指定していますが、実際はhttp://localhost:8080/api/v1/listsにリクエストを送信していると思っていただければ問題ないかと思われます。
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.vue
とListsContainer.vue
が表示されます。
NewListForm.vue
は新たにListを追加するためのコンポーネントです。
ListsContainer.vue
は親コンポーネントであるindex.vue
のlistsを表示し、削除、更新を行うコンポーネントとなっています。
実際にthis.$parent.lists
で親のlistsを参照し、lists分のtitleと削除ボタン、編集ボタンを表示しています。
<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>
以上がRead機能の説明になります。
かなり簡単に説明させて頂きましたが、残りの3機能もこのような感じで説明していきます。
CRUD - CREATE
CRUD - READでNewListForm.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.vue
のlistsの末尾にPOST成功したListが追加されます。
<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.vue
はindex.vue
のlists分表示しているため、上記のaddList()
で追加されたListも以下のように表示されます。
CRUD - UPDATE
編集画面の表示
まずListsContainer.vue
の編集ボタンを押下すると@click
イベントが発火します。
<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
メソッドが登録されており、こちらが実行されます。
<template v-else>
<NewListForm />
<ListsContainer @set="editingList" />
</template>
引数として$emit
の第2引数に設定している選択対象のlist
がeditingList
メソッドに渡されます。
その渡されたlist
をdataのeditTargetListに設定しています。
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.vue
のeditingList
メソッドが実行され、引数で渡された選択対象のListをdataのeditTargetListに設定する流れとなっています。
ここでdataのeditTargetListに値が設定されると<template v-if="editTargetList">
はtrue
になりEditListForm.vue
が表示対象になり編集画面が表示されます!
<template v-if="editTargetList">
<EditListForm :list="editTargetList" @set="editingList" />
</template>
編集処理
EditListForm.vue
でも@set
イベントが使えるように@set
イベントにeditingList
メソッドを登録しています。
またdataのeditTargetListも使えるようにするために:list="editTargetList"
としています。
<template v-if="editTargetList">
<EditListForm :list="editTargetList" @set="editingList" />
</template>
上記をEditListForm.vue
で受け取るためにプロパティ(props
)を使います。
EditListForm.vue
は編集画面であり、初期値として受け取ったプロパティを変化させたいため、props
で受け取った後、ローカルデータとして使用するためにプロパティの値をdataの初期値として定義します。
<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リクエストが送信されます。
<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.vue
のlistsに修正した新たなListを含むlistsを設定して、最後に@set
イベントを発火させます。
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に設定されます。
methods: {
editingList(list = '') {
this.editTargetList = list
}
}
dataのeditTargetListが""
になると<template v-if="editTargetList">
はfalse
になるため、編集画面は表示されず初期画面が表示され、更新処理が完了となります。
<template v-else>
<NewListForm />
<ListsContainer @set="editingList" />
</template>
CRUD - DELETE
DELETEも他の機能とほとんど要領です。
まず初期画面に表示されているListの削除ボタン押下で@click
イベントが発火し、removeList
メソッドが実行されます。
<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.vue
のlistsに削除したListを含まないlistsを設定します。
<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でしたが、説明は以上となります。
最後に
これで最初のDEMOで紹介した通りのWebAppができているかと思われます。
フロントエンドとバックエンドを完全に分離すると設計が綺麗になったように思います。
唯一の接点がレスポンスデータであるJSONになっていることで各分野のエンジニアは自分の担当分野に専念でき、リポジトリ管理も別々にすることも可能ですし、開発スピードの向上にも繋がるのではないでしょうか。もちろんフロントとバックでしっかりとネゴを取っておくことは重要ですが。
フロント/バックエンドを完全分離し責務をしっかりと分け、リソースごとにAPIエンドポイントを作って(マイクロサービス)、BFFにすれば割とモダンなアーキテクチャになるのではないでしょうか
あとはプロダクトごとでフロント/バックエンドの設計が変わるくらいなのではと思っています。
今回はただフロント/バックエンドを完全分離しただけの記事になってしまいましたが、またこのような類の記事を執筆するときは、機能をいくつか追加し、BFFにして本番環境へのデプロイまで行うような流れを書けたらと思っております。
かなり長くなってしまいましたが、ご覧いただきありがとうございました
参考文献
本文中にも明記しておりますが、以下にもまとめておきます。
- How to separate frontend + backend with Rails API, Nuxt.js and Devise-JWT
- ReactJS + Ruby on Rails API + Heroku App
- The missing package manager for macOS (or Linux) — Homebrew
- みんなhomebrew-caskって知ってるか? - Qiita
- rbenv を利用した Ruby 環境の構築 | DevelopersIO
- install nodebrew, node and yarn - Qiita
- [DB]MacにPostgreSQLをインストールする方法 - Qiita
- Homebrew で macOS に Docker をインストールして Hello world - Qiita
- インストール - Nuxt.js
- CORSまとめ - Qiita
- 紹介 | Vue Router
- API explorer — Vuetify.js