目次
1.はじめに
2.ゴール
3.DB構築
4.Backend構築
5.Frontend構築
6.動作確認
1. はじめに
よくあるWebアプリケーションの超簡略版を作ってみた。
「ちゃちゃっと作って遊びたい」「コピペで作ってみてから学びたい」人向けの簡単な内容を掲載する。
対象 | 使うもの |
---|---|
Front-end | Vue3/Element Plus |
Back-end | FastAPI(python) |
Database | MySQL |
frontendはVue3を使うことにした。Composition APIなどが優秀等々話は聞くが、もともとVuejsが好きという個人的な理由により採用。
わざわざVueを使うまでもない内容かもしれないが、とりあえず触れてみる事は非常に大事だと思うため使う。
CSSフレームワークをElement Plusを使用。Vue3に対応しているCSSフレームワークは少ない印象。洗礼されたデザインなのでおすすめ。
backendはFastAPIを、DBはMySQLを使用。 FastAPIとmysqlについて本記事より詳しい内容・手順をこちらで掲載しているため、是非一読して頂きたい。
2. ゴール
「よくあるWebアプリケーションの超簡略版を作ってみた。」と冒頭に記載したが、具体的にはCRUD操作が可能な簡単なデータ管理アプリを作る。
データ管理アプリは下記を実現する
- ユーザはデータを閲覧する
- ユーザはデータを登録(追加)する
- ユーザはデータを削除する
- ユーザはデータを更新する
シンプルCRUD。
最終的な生成物は下記。
- build済み静的コンテンツ
- AWS S3などにポイっとおけば使える状態のfrontend
- API Docker image
- AWS ECSなどにポポイッとデプロイすれば良い状態のbackendのdocker image
- DB Docker image
- ローカルで検証するためのDB Conatiner用Docker image。front/bankをCloudにデプロイする場合は Managedサービスを使う方があるある(かもしれない)
3. DB構築
最初にDBを作る。Frontendから作りたい気持ちもわかるが、個人的にはデータ管理を行うためデータ層から順に着手した方が理解しやすかった。
まず全体の作業ディレクトリ(sample_app
)とDB用のディレクトリ(db
)を作成し、db
ディレクトリ配下に下記2ファイルを新規作成する。
.
└── sample_app/
└── db/
├── init.sql
└── Dockerfile
3-1. initialze sql
DBコンテナを立ち上げた際に、テーブル作成や初期データ投入を行うためにinit.sql
を作成しておく。
USE sample_db;
DROP TABLE IF EXISTS item;
CREATE TABLE item
(
item_id SERIAL PRIMARY KEY,
name VARCHAR(50),
price INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO item (name, price) VALUES ('apple', 100);
INSERT INTO item (name, price) VALUES ('orange', 200);
3-2. MySQL Container
続いてDockerfileについて。
FROM mysql:latest
ENV MYSQL_DATABASE sample_db
ENV MYSQL_USER sample_user
ENV MYSQL_PASSWORD sample_password
ENV TZ "Asia/Tokyo"
ENV MYSQL_ROOT_PASSWORD p@assw0rd
COPY ./init.sql /docker-entrypoint-initdb.d/init.sql
では早速imageをbuild。
cd db # Dockerfileのあるディレクトリまで移動
docker build -t sample-mysql:1.0.0 .
次にコンテナも立ち上げてみる。
docker run -d --name sample-mysql-container -p 3306:3306 sample-mysql:1.0.0
確認
docker ps -f "name=sample-mysql-container"
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9373afb82b08 sample-mysql:1.0.0 "docker-entrypoint.s…" 5 seconds ago Up 4 seconds 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp sample-mysql-container
いた。次にコンテナ内に入り、init.sql
内で記載したinsert通りにデータが存在するか確認する。
- コンテナ内に入る。
docker exec -it $(docker ps -q -f "name=sample-mysql-container") /bin/bash
- dbに接続。
mysql sample_db -h localhost -u sample_user -psample_password
- レコードを確認
mysql> select * from item; +---------+--------+-------+---------------------+---------------------+ | item_id | name | price | created_at | updated_at | +---------+--------+-------+---------------------+---------------------+ | 1 | apple | 100 | 2022-05-19 22:16:31 | 2022-05-19 22:16:31 | | 2 | orange | 200 | 2022-05-19 22:16:31 | 2022-05-19 22:16:31 | +---------+--------+-------+---------------------+---------------------+ 2 rows in set (0.00 sec)
無事構築が完了したので、exit
でmysql/containerのコンソールを抜けておこう。
後続の構築が完了した後、sample-mysql-container
に接続するため立ち上げっぱなしでOK。
4. Backend構築
続いてBackendを構築する。
API用のディレクトリ(backend
)を作成し、backend
ディレクトリ配下にAPIを構築する、
.
└── sample_app/
├── db/
│ ├── init.sql
│ └── Dockerfile
└── backend/
├── Dockerfile
├── .env
└── app/
├── requirements.txt
└── main.py
4-1. requirements.txt
パッケージ一括管理用にrequirements.txt
を(使用する内容は少数だが)作成しておく。
sqlalchemy==1.4.31
mysqlclient==2.1.0
4-2. app
早速コーディングしてみる。DBの接続先情報は環境変数から取得できるようにしておく。
import os
from fastapi import FastAPI, Depends, Query, status
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy import Column, TIMESTAMP, Integer, String, create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import FetchedValue
ROOT_PATH="/api"
app = FastAPI()
SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}:{}/{}'.format(
os.environ.get("DB_USER"),
os.environ.get("DB_PASSWORD"),
os.environ.get("DB_HOST"),
os.environ.get("DB_PORT"),
os.environ.get("DB_NAME")
)
engine = create_engine(SQLALCHEMY_DATABASE_URI)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def session():
db = Session()
try:
yield db
finally:
db.close()
Base = declarative_base(bind=engine)
# Entity Item
class Item(Base):
__tablename__ = "item"
__table_args__ = {"autoload": True}
item_id = Column(Integer, primary_key = True, nullable=False)
name = Column(String(50), nullable=False)
price = Column(Integer, nullable=False)
created_at = Column(TIMESTAMP, FetchedValue())
updated_at = Column(TIMESTAMP, FetchedValue())
# Request Body
class ItemRequest(BaseModel):
name: str = Query(..., max_length=50)
price: float
# GetItemByName
@app.get('%s/item' % ROOT_PATH)
def get_items(db: Session = Depends(session)):
result_set = db.query(Item).all()
response_body = jsonable_encoder({"list": result_set})
return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)
# CreateItem
@app.post('%s/item' % ROOT_PATH)
def create_item(request: ItemRequest, db: Session = Depends(session)):
item = Item(
name = request.name,
price = request.price
)
db.add(item)
db.commit()
response_body = jsonable_encoder({"item_id" : item.item_id})
return JSONResponse(status_code=status.HTTP_200_OK, content=response_body)
# UpdateItem
@app.put('%s/item/{id}' % ROOT_PATH)
def update_item(id: int, request: ItemRequest, db: Session = Depends(session)):
item = db.query(Item).filter(Item.item_id == id).first()
if item is None:
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND)
item.name = request.name
item.price = request.price
db.commit()
return JSONResponse(status_code=status.HTTP_200_OK)
# DeleteItem
@app.delete('%s/item/{id}' % ROOT_PATH)
def delete_item(id: int, db: Session = Depends(session)):
db.query(Item).filter(Item.item_id == id).delete()
db.commit()
return JSONResponse(status_code=status.HTTP_200_OK)
4-3. 環境変数
次にローカルでの稼働確認時にコンテナに渡す環境変数を定義する。
DB_HOST=host.docker.internal
DB_PORT=3306
DB_NAME=sample_db
DB_USER=sample_user
DB_PASSWORD=sample_password
注意 ※1
本記事ではdocker-composeを使用しないため無理にHOST指定をhost.docker.internal
としているが、いつまでサポートされているか不明なため可能であればcomposeした方が良い。
公式より引用
ホストの IP アドレスは変動します(あるいは、ネットワークへの接続がありません)。18.03 よりも前は、特定の DNS 名 host.docker.internal での接続を推奨していました。これはホスト上で内部の IP アドレスで名前解決します。これは開発用途であり、Docker Desktop forMac 外の本番環境では動作しません。
4-4. Api Container
次にDockerfile。
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.9-slim
COPY ./app /app
RUN apt-get update && \
apt-get -y install gcc libmariadb-dev && \
pip install --no-cache-dir --upgrade -r /app/requirements.txt
では早速imageをbuild。
cd ../backend # Dockerfileのあるディレクトリまで移動
docker build -t sample-backend:1.0.0 .
次にコンテナを立ち上げるが、立ち上げの際に環境変数定義ファイルを渡す。
docker run -d --env-file=.env --name sample-backend-container -p 80:80 sample-backend:1.0.0
docker ps -f "name=sample-backend-container"
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
db4751373a62 sample-backend:1.0.0 "/start.sh" 3 seconds ago Up 2 seconds 0.0.0.0:80->80/tcp, :::80->80/tcp sample-backend-container
無事コンテナの立ち上げが完了したので、ログを確認して動いているか見てみる。
docker logs --tail=-1 $(docker ps -q -f "name=sample-backend-container")
#省略
[2022-05-19 13:19:25 +0000] [1] [INFO] Listening at: http://0.0.0.0:80 (1)
#省略
動いてそう。念のためcurlで呼び出せるか確認してみる。
curl -s -X GET 'http://localhost:80/api/item' | jq
{
"list": [
{
"created_at": "2022-05-19T22:16:31",
"item_id": 1,
"updated_at": "2022-05-19T22:16:31",
"price": 100,
"name": "apple"
},
{
"created_at": "2022-05-19T22:16:31",
"item_id": 2,
"updated_at": "2022-05-19T22:16:31",
"price": 200,
"name": "orange"
}
]
}
とれた。本来ならばfrontやdbと結合する前にUTを行うべきだが、本記事はちゃちゃっと作って遊びたい人向けなため行わない。
5. Frontend構築
続いてFrontendを構築する。
Frontendはコンテナとして立ち上げない想定なので、専用のディレクトリを手動では作成せず、npmに任せてみる。
本記事では下記実行環境で開発を進めている。
node -v
v17.6.0
npm -v
8.9.0
では早速構築してみる。
cd ../ # sample_appへ移動
npm init vue@latest # project作成
Vue.js - The Progressive JavaScript Framework
✔ Project name: … frontend
✔ Add TypeScript? … Yes # Yesを選択
# 以降は本記事では全てNoで問題なし。
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … No
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add Cypress for both Unit and End-to-End testing? … No
✔ Add ESLint for code quality? … No
projectが作成されたので構築を進める。
一旦作成されたprojectを立ち上げてみる。
cd frontend
npm install
npm run dev
下記のような表示がでればOK.(viteで立ち上がってて嬉しい。)
vite v2.9.8 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 287ms.
ではwebブラウザでhttp://localhost:3000/
アクセスしてみる。
作成されたtemplate projectではダークモードに対応しており、OSの設定や作業時間によっては配色が異なる。
鬱陶しさを感じた場合は、OSのダークモードを切るか frontend/src/assets/base.css
の @media (prefers-color-scheme: dark)
オブジェクトをごっそりコメントアウトしてしまっても良いと思う。
ctrl+cなどで停止できるが、停止せずともファイルに更新があれば自動で反映される。pcが重い場合や複数コンソールを開くのが嫌な場合はctrl+cなどで停止しておこう。
現在のファイル構成を書きに記す。frontendのモジュールはファイル数が多いため割愛。
.
└── sample_app/
├── db/
│ ├── init.sql
│ └── Dockerfile
├── backend/
│ ├── Dockerfile
│ ├── .env
│ └── app/
│ ├── requirements.txt
│ └── main.py
└── frontend/
├── 割愛
└── src/
├── assets/
│ ├── 割愛
│ └── base.css
├── components/
│ └── 割愛(vueが気になる方は是非中身も見て欲しい)
└── main.ts
次にelement-plusの導入
npm install element-plus # element-plusとを導入
npm install axios # api call用にとaxiosを導入
npm install vue-router@latest # vue-routerの導入
jq .dependencies package.json # jqが無ければcatでもOK
{
"axios": "^0.27.2",
"element-plus": "^2.1.11",
"vue": "^3.2.33",
"vue-router": "^4.0.15"
}
次に、使用できるようにsrc/main.ts
を編集する。
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router';
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.mount('#app')
他にも追加する場合は同様にimportとして.use(xxx)
すれば良いが、広げすぎるとパパッとではなくなるため詳しくは割愛。
backendを呼び出すためのファイルを作成する。
src/
└── service/
├── httpclient.ts
└── DataApiService.ts
api呼び出しの機能はaxiosを使い、共通化してhttpclient.tsとして作成しておく。
import type { AxiosInstance } from "axios";
import axios from "axios";
const apiClient: AxiosInstance = axios.create({});
export default apiClient;
わざわざ別定義するまでもないかもしれないが、拡張性を考慮して定義しておく。
次に、実際のcall部分を定義する。
import http from "@/service/httpclient";
const APPLICATION_JSON : string = "application/json";
class DataApiService {
getAll(): Promise<any> {
return http.get("/api/item");
}
create(name: string, price: number): Promise<any> {
return http.post(
`/api/item`,
{
name: `${name}`,
price: `${price}`
}
);
}
update(id: number, name: string, price: number): Promise<any> {
return http.put(
`/api/item/${id}`,
{
name: `${name}`,
price: `${price}`
}
);
}
delete(id: number): Promise<any> {
return http.delete(`/api/item/${id}`);
}
}
export default new DataApiService();
backendに用意した4つのAPIを呼び出す単純なクラスを作成した。
続いてroutingを行う定義体をsrc
直下にrouter.ts
として作成する。
import { createRouter,createWebHistory } from 'vue-router';
import List from './components/ListPage.vue';
import Add from './components/AddPage.vue';
import Edit from './components/EditPage.vue';
const routes = [
{ path: '/', name: 'list', component: List },
{ path: '/add', name: 'add', component: Add },
{ path: '/edit', name: 'edit', component: Edit, props: true },
]
const router = createRouter({
history: createWebHistory(), // HTML5 History モード
routes,
})
export default router;
さらにApp.vue
を編集し、routingされたcomponentを表示するrouter-view
を追加する。
<template>
<el-main>
<router-view></router-view>
</el-main>
</template>
<style>
@import './assets/base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 0rem;
font-weight: normal;
}
@media (min-width: 1024px) {
#app {
padding: 0 2rem;
}
}
.dialog-footer {
text-align: center;
}
.dialog-footer button {
width: 100px;
margin: 0 0 10px 0;
}
@media (min-width: 1024px) {
.dialog-footer button{
width: 100px;
margin: 0 10px 0 0;
}
}
</style>
とてもシンプル。フロントが専門ではない私はこれぐらいシンプルな方が理解しやすくvueは好き。
router-view
に込められるComponentを3つ定義する。
src/
└── components/
├── ListPage.vue
├── AddPage.vue
└── EditPage.vue
まずはListPage.vueについて。こちらは単にDBをselectして一覧表示するというもの。
<script lang='ts' setup>
import {
ref,
} from 'vue'
import {
useRouter,
} from 'vue-router'
import {
Edit,
Delete,
} from '@element-plus/icons-vue'
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import DataApiService from "@/service/DataApiService";
const router = useRouter()
const isLoading = ref(false)
const data = ref([])
const initilize = async () => {
console.log('initilize')
return new Promise(async (resolve, reject) => {
isLoading.value = true;
await DataApiService.getAll()
.then((response) => {
data.value = response.data.list
resolve(200);
})
.catch((error) => {
console.log(error)
data.value = [];
reject(500);
})
.finally(() => {
isLoading.value = false;
})
});
}
const deleteData = (index: number) => {
console.log( data.value[index])
ElMessageBox.confirm(
'本当に削除しますか?',
'確認',
{
confirmButtonText: '削除する',
cancelButtonText: 'キャンセル',
type: 'warning',
center: true,
}
)
.then(() => {
console.log('start api call');
DataApiService.delete(data.value[index]["item_id"])
.then(() => {
initilize();
ElMessage({
type: 'success',
message: '削除が完了しました。',
})
})
.catch((error) => {
console.log('error_%s',error);
ElMessage({
type: 'error',
message: '削除に失敗しました。',
})
});
})
.catch(() => {
ElMessage({
type: 'info',
message: '中止しました。',
})
})
}
const editData = (index: number) => {
router.push({ name:'edit',params: {
id: data.value[index]["item_id"],
name: data.value[index]["name"],
price: data.value[index]["price"]
}
})
}
const addData = () => {
router.push({name:'add'})
}
initilize()
</script>
<template>
<el-table :data='data' style='width: 100%' max-height='500' v-loading.fullscreen.lock="isLoading">
<el-table-column fixed prop='item_id' label='ID' width='50' />
<el-table-column prop='name' label='名称' width='100' />
<el-table-column prop='price' label='価格' width='100' />
<el-table-column prop='created_at' label='作成日時' width='200' />
<el-table-column prop='updated_at' label='更新日時' min-width='200' />
<el-table-column fixed='right' label='操作' width='120'>
<template #default='scope'>
<el-button type='primary' :icon='Edit' circle @click.prevent='editData(scope.$index)'/>
<el-button type='danger' :icon='Delete' circle @click.prevent='deleteData(scope.$index)'/>
</template>
</el-table-column>
</el-table>
<el-button class='mt-4' style='width: 100%' @click='addData'>Add Item</el-button>
</template>
長くなってしまった。関数群は別ファイルに出したいところだが、componentごとに紐づいたものは1ファイルに纏めておきたかったので上記のようなボリュームになってしまった。
初期表示時に、initilize
が呼び出され、DBから情報を取得するAPIをcall(DataApiService.getAll()
)をしている。
<template>
については、element plusの要素を置いているだけ。
Add Item
ボタンを押下すると発火される関数は単純でrouter.push
を呼び、nameがadd
として定義された画面に遷移する。
router.push({name:'add'})
Edit
ボタンを押下した場合は同じくrouter.pushしてname:edit
に遷移しているが、選択した項目の情報を渡している。
router.push({ name:'edit',params: {
id: data.value[index]["item_id"],
name: data.value[index]["name"],
price: data.value[index]["price"]
}
})
Delete
ボタンを押下すると、確認popupを挟んでからDeleteのAPI Call(DataApiService.delete()
)をし、正常終了後に再びinitilize
を呼ぶ。
続いてListPageにてAdd Item
を押下し遷移する先(AddPage.vue
)を定義する。
<script lang="ts" setup>
import {
reactive,
} from 'vue';
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import {
useRouter,
} from 'vue-router'
import DataApiService from "@/service/DataApiService";
const router = useRouter()
const formLabelWidth = '120px'
const form = reactive({
name: '',
price: 0,
})
const cancel = () => {
router.push({name:'list'})
}
const add = (index: number) => {
ElMessageBox.confirm(
'本当に追加しますか?',
'確認',
{
confirmButtonText: '追加する',
cancelButtonText: 'キャンセル',
type: 'warning',
center: true,
}
)
.then(() => {
console.log('start api call');
DataApiService.create(form.name,form.price)
.then(() => {
router.push({name:'list'})
ElMessage({
type: 'success',
message: '追加が完了しました。',
})
})
.catch((error) => {
console.log('error_%s',error);
ElMessage({
type: 'error',
message: '追加に失敗しました。',
})
});
})
.catch(() => {
ElMessage({
type: 'info',
message: '中止しました。',
})
})
}
</script>
<template>
<el-form :model="form">
<el-form-item label="ID" :label-width="formLabelWidth">
<el-input :disabled="true" placeholder="自動採番"/>
</el-form-item>
<el-form-item label="名称" :label-width="formLabelWidth">
<el-input v-model="form.name" autocomplete="off" />
</el-form-item>
<el-form-item label="価格" :label-width="formLabelWidth">
<el-input v-model="form.price" autocomplete="off" />
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="cancel">Cancel</el-button>
<el-button type="primary" @click="add">Confirm</el-button>
</div>
</template>
template
については特筆すべきことはなく、element plusの要素をペタペタ置いただけ。
Confirm
ボタンを押下すると関数add
が呼び出され、deleteと同様に確認popupを挟みAPIcall(DataApiService.create()
)を呼び出す。
完了後は、name:listつまり一覧表示画面(ListPage.vue
)に遷移するようにした。
続いてListPageにてEdit
を押下し遷移する先(EditPage.vue
)を定義する。
<script lang="ts" setup>
import {
reactive,
} from 'vue';
import {
ElMessage,
ElMessageBox,
} from 'element-plus'
import {
useRouter,
} from 'vue-router'
import DataApiService from "@/service/DataApiService";
const router = useRouter()
const formLabelWidth = '120px'
const form = reactive({
id: 0,
name: '',
price: 0,
})
const cancel = () => {
router.push({name:'list'})
}
const edit = () => {
ElMessageBox.confirm(
'本当に更新しますか?',
'確認',
{
confirmButtonText: '更新する',
cancelButtonText: 'キャンセル',
type: 'warning',
center: true,
}
)
.then(() => {
console.log('start api call');
DataApiService.update(form.id,form.name,form.price)
.then(() => {
router.push({name:'list'})
ElMessage({
type: 'success',
message: '更新が完了しました。',
})
})
.catch((error) => {
console.log('error_%s',error);
ElMessage({
type: 'error',
message: '更新に失敗しました。',
})
});
})
.catch(() => {
ElMessage({
type: 'info',
message: '中止しました。',
})
})
}
const props = defineProps({
id: Number,
name: String,
price: Number
})
const initilize = () => {
if(props.id === undefined) {
router.push({name:'list'})
ElMessage({
type: 'error',
message: '情報の取得に失敗しました。',
})
} else {
form.id = props.id;
form.name = props.name == undefined ? '' : props.name;
form.price = props.price == undefined ? 0 : props.price;
}
}
initilize();
</script>
<template>
<el-form :model="form">
<el-form-item label="ID" :label-width="formLabelWidth">
<el-input v-model="form.id" :disabled="true"/>
</el-form-item>
<el-form-item label="名称" :label-width="formLabelWidth">
<el-input v-model="form.name" autocomplete="off" />
</el-form-item>
<el-form-item label="価格" :label-width="formLabelWidth">
<el-input v-model="form.price" autocomplete="off" />
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="cancel">Cancel</el-button>
<el-button type="primary" @click="edit">Confirm</el-button>
</div>
</template>
基本的にはAddPage.vue
と同じである。違いは遷移時に値を受け渡している箇所だ。
前述のrouter.ts
で定義したがpropsプロパティをtrueと設定する必要がある。
{ path: '/edit', name: 'edit', component: Edit, props: true }
さらに、値を受け取る側であるEditPage.vue
では defineProps
を定義する。
const props = defineProps({
id: Number,
name: String,
price: Number
})
以上で完成。
6. 動作確認
では早速動かしてみる。
npm run dev
説明は掻い摘んだがコピペで作成可能な量で、とりあえず動かすものを手に入れてから遊びたい方は是非参考にしていただければと思う。
以上。