はじめてのWebアプリ開発
何を作る?
ヌメロンを作ります。4つの数字を当てるゲームです。
このサイトに沿って進めていきます。
デザイン性は無視します。
完成図
アプリケーション構成
Vue.js
Flask
Ajax
フロントエンド
環境構築
Node.jsのバージョンを比較的新しいものに変更しました。
$ nodenv versions
system
* 12.20.1
16.14.0
16.20.0
17.6.0
$ node local 17.6.0
Vue CLIをインストールします。
$ npm install -g @vue/cli
Vue.jsのプロジェクトを作成
vueコマンドのパスが通っていなかったので通しました。
$ vue create fronend
-zsh: vue: command not found
$ export PATH=$PATH:`npm bin -g`
参考にしているサイトに従ってVue2を選択。
$ vue create frontend
Vue CLI v5.0.8
? Please pick a preset:
Default ([Vue 3] babel, eslint)
❯ Default ([Vue 2] babel, eslint)
Manually select features
(Enter押す)
Vue CLI v5.0.8
✨ Creating project in /Users/user/myApp/numer0n/frontend.
⚙️ Installing CLI plugins. This might take a while...
(略)
🎉 Successfully created project frontend.
👉 Get started with the following commands:
$ cd frontend
$ npm run serve
指示通りにコマンドを打ちます。
$ cd frontend
$ npm i axios vue-axios
$ vue add vuetify
$ npm run serve
> frontend@0.1.0 serve
> vue-cli-service serve
INFO Starting development server...
DONE Compiled successfully in 4487ms
ブラウザでhttp://localhost:8080/
にアクセスすると無事にチュートリアル的な画面が表示されました。
GUI作成
Vue.js内でAxiosを使うためmain.js
に追記
import axios from 'axios'
import VueAxios from 'vue-axios'
Vue.use(VueAxios, axios)
テンプレート部分です。
<template>
<v-app>
<v-app-bar
app
color="primary"
dark
>
<span class="text-h5">numer0n (数字列当てゲーム)</span>
</v-app-bar>
<v-main>
<v-container>
<div>
相異なる4つの数字(0〜9)を入力してください。<br>
正解の数字列と比べて、数字と位置が同じものの数をEAT、数字は同じで位置が違うものの数をBITEとします。<br>
{{ maxGuessCount }} 回以内に 4 EAT、 0 BITE を達成すれば成功です。
</div>
<v-container>
<v-row>
<v-col>
<v-text-field v-model="input"></v-text-field>
</v-col>
<v-col>
<v-btn @click="guess" :disabled="!isValidInput || isFinished || isCorrect">決定</v-btn>
</v-col>
</v-row>
</v-container>
</v-container>
<v-container>
<div v-for="(result, index) in history" :key="index">
<span>【{{ index + 1 }}回目】 {{ result.input }} は {{ result.eat }} EAT、 {{ result.bite }} BITE です。</span>
</div>
</v-container>
<v-container>
<v-alert v-if="!success" type="error">{{ this.message }}</v-alert>
</v-container>
<v-container v-if="isFinished || isCorrect">
<v-row>
<v-col>
<v-alert v-if="isCorrect" type="success">成功🥳 おめでとうございます!</v-alert>
<v-alert v-else type="error">失敗😔 答えは {{ history.at(-1).answer }} です!</v-alert>
</v-col>
<v-col>
<v-btn @click="retry">もう一回やる</v-btn>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
以下はスクリプト部分です。
回答を送信するguess()
関数では、POSTリクエストで入力値と乱数シード値を送っています。サーバ側でランダムに生成する答えが回答送信で更新されないように、適当にシード値を設定しました。ただしリロードするとdata
が初期化されてシード値が変わり、答えも変わります。
<script>
export default {
name: 'App',
data: () => ({
// 最大回答回数
maxGuessCount: 7,
// テキストフォームの入力値
input: null,
// 通信に成功したか
success: true,
//通信に失敗した場合のメッセージ
message: null,
// 回答履歴
history: [],
// ランダムに生成される正解のシード
seed: Math.floor(Math.random() * 100),
}),
computed: {
// 入力をばらばらの数字にしたリスト
// 入力が重複なしの数字4桁でない場合は空配列を返す
isValidInput() {
// 入力がstring型かチェック
if (typeof this.input !== "string") {
return false;
}
// 入力が重複なしの数字4桁かチェック
const inputList = this.input.split('');
if (inputList.length !== 4 ||
inputList.some((char) => Number.isNaN(Number(char))) ||
inputList.length !== new Set(inputList).size
) {
return false;
}
return true;
},
// 最大回答回数に達しているか
isFinished() {
return this.history.length >= this.maxGuessCount;
},
// 最後の回答が正解か
isCorrect() {
return this.history.length > 0 && this.history.at(-1).eat === 4;
},
},
methods: {
// 回答を送信する
guess() {
const req = JSON.stringify({
input: this.input,
seed: this.seed,
isFinalGuess: this.history.length === this.maxGuessCount - 1,
});
this.axios
.post(`http://127.0.0.1:5000/api/guess`, req)
.then((res) => {
this.history.push(res.data);
})
.catch(() => {
// 通信に失敗した場合
this.success = false;
this.message = "通信中にエラーが発生しました。";
});
this.input = null;
},
// リロードする
retry() {
window.location.reload();
}
}
};
</script>
バックエンド
プロジェクトルート直下(frontend
があるディレクトリ)にmain.py
を作成。
numer0n
┣ main.py
┗ frontend
Flaskによるサーバ構築
Flaskをインストール
$ pip install flask flask-cors
Vue.jsアプリケーションをビルドした際、numer0n/dist
に諸々のファイルが作成されるようにします。参考にしているサイト通りにやるとnumer0n/data/dist
に作られてしまうので注意。
const path = require("path");
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: [
'vuetify'
],
assetsDir: "static",
outputDir: path.resolve(__dirname, "../dist"),
})
フロントエンド側をビルドします。
$ npm run build --prefix frontend
予定通り、numer0n/dist
以下にいろいろなものが作成されます。次にmain.py
でFlaskオブジェクトを作成します。
import sys
import webbrowser
from pathlib import Path
import random
from flask import Flask, render_template, request
from flask_cors import CORS
def base_dir():
if hasattr(sys, "_MEIPASS"):
# 実行ファイルで起動した場合、展開先ディレクトリを基点とする。
return Path(sys._MEIPASS)
else:
# python コマンドで起動した場合、プロジェクトディレクトリを基点とする。
return Path(".")
app = Flask(
__name__,
template_folder=base_dir() / "dist",
static_folder=base_dir() / "dist/static",
)
CORS(app)
API作成
<hostname>/
にアクセスしたとき、フロントエンド側のindex.html
を返すようにルーティングします。
@app.route("/")
def index():
"""フロントエンド側のページを表示する。
Returns:
str: HTML
"""
return render_template("index.html")
<hostname>/api/guess
にPOSTリクエストが来たとき、EATとBITEを計算して返すAPIを作成します。
レスポンスには入力値を結合した文字列とEAT, BITEを入れました。最後の回答のときは答えも一緒に返します。
@app.route("/api/guess", methods=["POST"])
def guess():
"""与えられた4つの数字からeat, biteを返す API
Returns:
dict: レスポンス
"""
data = request.get_json(force=True)
# シード値から答えを生成
random.seed(data["seed"])
target = random.sample(list(range(10)), 4)
# EAT, BITEを計算
input = data["input"]
eat, bite = 0, 0
for inputIndex in range(4):
for targetIndex in range(4):
if int(input[inputIndex]) == target[targetIndex]:
if inputIndex == targetIndex:
eat += 1
else:
bite += 1
# 最後の回答の場合、答えを返す
answer = "".join([str(num) for num in target]) if data["isFinalGuess"] else "";
return {"input": input, "eat": eat, "bite": bite, "answer": answer}
サーバ起動
最後にサーバ起動部分です。
def main():
app.run(debug=True, host="0.0.0.0", port=5000)
if __name__ == "__main__":
main()
実行
サーバを起動します。
$ python main.py
localhost:8080
にアクセスするとアプリケーションが起動しています。
遊んでみましょう。
少し運がよかったですね。
また、以下のコマンドを打つとPyinstallerでビルドできることがわかりました。
$ pip install pyinstaller
$ pyinstaller main.py --add-data "dist:dist" --onefile --name numer0n
最後に
問題なく遊べるので、以上で完成ということにします。
言葉の意味など分からないところは、今後アプリを作りながら調べていこうと思います。
ちなみに、次は画像認識か自然言語処理を取り入れて何か作る予定です。
ありがとうございました。
Discussion