はじめに
RailsとNuxt3でtodoリストの作り方を
初めから丁寧に説明したいと思います。
使用pcはmacを想定しています。
完成した構成図は以下の通りです。
また、githubレポジトリはこちらです。
各シリーズは以下の通りです。
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その1、Rails基本設定編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その2、Rails API編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その3、Nuxt.js編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その4、TerraformECS前編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その5、TerraformECS後編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その6、Blue/Greenデプロイ前編
RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その7、Blue/Greenデプロイ後編
Nuxt.jsをインストールする
node:18.15-buster-slim
WORKDIR /usr/src/app
コンテナの中に入り、Nuxt.jsをインストールします。
docker compose exec webserver bash
myappはアプリケーション名です。
お好きな名前にしましょう。
npx nuxi init myapp
cd myapp
npm install
Dockerの構成を見直す
理由は以下の通りです。
- railsとport番号が衝突しているので回避する
- コンテナデプロイができるように、コンテナ起動時にアプリを立ち上げたい
- Nuxt.jsでホットリロードをしたい
- ロードバランサーを導入しCORS対策にリバースプロキシをしたい
- 本番環境用と練習環境用で分けたい
apserver側(rails)
本番環境では、レッドチームにリバースシェル攻撃を受けた際、パッケージのインストールができないように、ユーザーを変えておきます。
# For development
FROM ruby:3.1.3-slim-buster as development
WORKDIR /usr/src
ENV HOST dbserver
ENV DBNAME todoproject
ENV USERNAME rubyMySql1
ENV PASSWORD rubyMySql1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
g++=4:8.3.0-1 make=4.2.1-1.2 \
libmariadb-dev=1:10.3.38-0+deb10u1 \
mariadb-client=1:10.3.38-0+deb10u1 \
git=1:2.20.1-2+deb10u8 \
&& apt-get -y clean \
&& rm -rf /var/lib/apt/lists/*
COPY . .
WORKDIR /usr/src/app
RUN bundle install
# For staging
FROM development as staging
RUN groupadd -r rails && useradd -r -g rails rails
RUN chown -R rails:rails /usr/src/app
USER rails
EXPOSE 8080
CMD ["rails", "s", "-b", "0.0.0.0","-p", "8080"]
.git
*Dockerfile*
*docker-compose*
#!/usr/bin/env bash
set -euo pipefail
function tmp_file_exits() {
if [ -e /usr/src/app/tmp/pids/server.pid ]; then
rm -f /usr/src/app/tmp/pids/server.pid
echo "delete successful server.pid file"
else
echo "file doesn't exits"
fi
}
function main() {
tmp_file_exits
cd /usr/src/app/app
bundle exec rails s -p 8080 -b '0.0.0.0'
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
webserver側(Nuxt.js)
WATCHPACK_POLLING=trueはホットリロードを許容しています。
# For development
FROM node:18.15-buster-slim as development
COPY ./docker-entrypoint /docker-entrypoint
WORKDIR /usr/src/app/myapp
ENV WATCHPACK_POLLING=true
ENV NODE_ENV development
COPY ./myapp/package*.json ./
RUN npm install
COPY ./myapp .
ENTRYPOINT [ "/docker-entrypoint" ]
# For build
FROM node:18.15-buster-slim as build
WORKDIR /app
COPY ./myapp/package*.json ./
RUN npm ci
COPY ./myapp .
RUN npm run build
# For staging
FROM gcr.io/distroless/nodejs18-debian11:nonroot as staging
WORKDIR /app
ENV NODE_ENV production
ENV HOST 0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/.output /app/.output
USER nonroot
EXPOSE 3000
CMD [ "/app/.output/server/index.mjs" ]
.git
*Dockerfile*
*docker-compose*
node_modules
/tmp/nitro/worker-30-1.sockが存在するため、エラーが出て
立ち上がらないので、コンテナ起動時に削除しています。
#!/usr/bin/env bash
set -euo pipefail
function tmp_file_exits() {
if [ "$(ls -A /tmp/nitro/)" ]; then
rm /tmp/nitro/*
echo "delete successful tmp file"
else
echo "file doesn't exits"
fi
}
function main() {
tmp_file_exits
npm run dev
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
loadbalancer側
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
proxy_pass http://webserver:3000;
}
location /api/ {
proxy_pass http://apserver:8080;
}
}
docker-compose
24678はNuxt.jsのホットリロード時の待ち受けportです。
version: "3"
services:
apserver:
build:
context: ./apserver
target: development
container_name: "ruby"
tty: true
entrypoint: /usr/src/docker-entrypoint
volumes:
- ./apserver:/usr/src
ports:
- "8080:8080"
webserver:
build:
context: ./webserver
target: development
volumes:
- ./webserver/myapp:/usr/src/app/myapp
- /usr/src/app/myapp/node_modules
ports:
- "3000:3000"
- '24678:24678'
loadbalancer:
image: nginx:1.22-bullseye
ports:
- "80:80"
volumes:
- ./loadbalancer:/etc/nginx/conf.d:ro
dbserver:
build: ./dbserver
container_name: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: todoproject
MYSQL_USER: rubyMySql1
MYSQL_PASSWORD: rubyMySql1
TZ: 'Asia/Tokyo'
command: mysqld
volumes:
- ./dbserver/conf/my.cnf:/etc/mysql/conf.d/my.cnf
ports:
- 3306:3306
cap_add:
- SYS_NICE
Nuxt.jsを作ろう
railsを少しいじる
出力をselectする。
def index
@todos = Todo.all.select(:id, :content)
render status: :ok, json: @todos #200
共通部分を作る
外部apiからデータを受け取りたいので、ssr: falseする。
また、cssフレームワークPrimer CSSをインポートします。
export default defineNuxtConfig({
ssr: false,
app: {
head: {
title: "Todolist",
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
],
link: [
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
{ rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/@primer/css@20.8.3/dist/primer.min.css" }
],
script: [
{ src: "https://cdn.jsdelivr.net/npm/@primer/css@20.8.3/dist/primer.min.js" }
],
},
}
})
全体のtemplate設定
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
header、hooterなど共通レイアウトを設定できます。
<template>
<div>
<Header />
<slot />
</div>
</template>
<script>
import Tables from '~~/components/Tables.vue';
import Forms from '~~/components/Forms.vue';
</script>
<template>
<div class="d-flex flex-justify-center flex-items-center">
<div class="container mt-3">
<div class="columns mb-1">
<Forms />
</div>
<br>
<div class="columns mb-1">
<Tables />
</div>
</div>
</div>
</template>
<style>
p {
text-align: center;
}
</style>
import { Ref } from "nuxt/dist/app/compat/capi";
export const useFlagStore = () => {
const flag = useState("flag", () => (false))
return {
flag: readonly(flag),
setFlag: setFlag(flag),
deleteTodo: deleteTodo(flag)
};
};
const setFlag = (flag: Ref<boolean>) => (val: boolean) => { flag.value = val };
const deleteTodo = (flag: Ref<boolean>) => (num: string) => {
const currentUrl = window.location.href
const pending = useFetch(currentUrl + 'api/todos/' + num, { method: "delete", })
flag.value = true
};
comportnentを作る
<script setup lang="ts">
const { setFlag } = useFlagStore();
const task = ref('');
const handleClick = () => {
const currentUrl = window.location.href;
useFetch(currentUrl + 'api/todos', {
method: 'post',
body: { "content": task.value },
});
task.value = '';
setFlag(true);
};
</script>
<template>
<div>
<form>
<div class="input-group">
<input class="form-control" type="text" placeholder="write your something to do" v-model="task">
<span class="input-group-button">
<button class="btn" type="button" aria-label="Copy to clipboard" @click="handleClick">
confirm
</button>
</span>
</div>
</form>
</div>
</template>
<template>
<h1 class="header">todolist</h1>
</template>
<style>
.header {
z-index: 999;
top: 0;
left: 0;
width: 100%;
padding: 20px 40px;
background: #eee;
box-sizing: border-box;
}
</style>
<script setup lang="ts">
const currentUrl = window.location.href;
const { data, pending, error, refresh } = await useFetch(currentUrl + 'api/todos', { method: "get", });
const { flag, setFlag, deleteTodo } = useFlagStore();
watch(flag, () => {
if (flag) {
const startMsec = new Date();
while (new Date() - startMsec < 100);
refresh();
setFlag(false);
}
})
</script>
<template>
<div>
<p v-if="error">{{ error }}</p>
<table border="1" width="500">
<tbody>
<tr v-for="todo in data" :key="todo.id">
<td class="content">{{ todo.content }}</td>
<td class="button">
<button class="btn-mktg btn-signup-mktg full-button" type="button"
@click="deleteTodo(todo.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style>
.full-button {
width: 100%;
height: 100%;
}
.content {
width: 80%;
}
.button {
width: 20%;
}
</style>