Python Lab #1 ― Flask with React/Vue
Flask で Web API が動作する Web サーバーを用意し、Vue と React のそれぞれのログイン画面から Web API を呼び出すというものを開発してみました。
今にして思うに、FastAPI を採用したほうがよかったかもしれません。
画面ショット
今にして思うに、React-Bootstrap を使うのではなく、MUI(Material UI)を使って Vuetify に近い Look & Feel を実現すればよかったかもしれません。ただ、MUI(Material UI)を使いこなすのは React-Bootstrap や Vuetify より難しそうな感じがします。
[ログイン画面(React、React-Bootstrap)]
[ログイン後のホーム画面(React、React-Bootstrap)]
[ログイン画面(Vue、Vuetify)]
[ログイン後のホーム画面(Vue、Vuetify)]
動機
- 過去に “Web 開発再入門” にて、ある程度の Vue、Vuetify、Java、SpringBoot の基礎知識を習得したことがある。
- 今後のプロジェクトで React を使用することが多くなりそう。なので、今回、React、React 関連(React-Router-Dom、React-Redux、React-Bootstrap など)および TypeScript の基礎知識を習得をしようと考えた。
- Flask を使えば簡単に Web API が準備できそうなので、ついでに Flask および Flask 関連(FlaskLogin、FlaskForm、jsonify など)も勉強することにした。
- Vue と React それぞれでログイン画面を開発してみることにした。
体得できたこと
ささっと Web API を作るときには Flask は本当に便利
- 例えば、クライアント・サイド・デモを作るときに有用である。
- もし、しっかりした Web API を作るならば、Java SpringBoot のようにバリデーターのようなものや DAO のようなものなどをしっかり設計する必要がある。
Vue と React の違いについて感じたこと(ただの感想)
- 確かに Vue は、直観的な感じがする(他の動的 HTML テンプレートエンジン、例えば JSP、PHP、Thymeleaf とかを知っている人なら分かりやすい気がする)。
- React は、モダンな Javascript のさまざまな文法を駆使する必要があるらしく、ちょっと戸惑う気がする。ただ、画面パーツのモジュール化とかやり始めたら React のほうがやりやすいような気がする(大小さまざまなモジュール化が簡単にできそうな気がする)。
HTML 画面パーツ間のデフォルトのマージンについて(不勉強なため、よく分からない...)
- Vuetify は、マージンが適度に広くとられている。
- React-Bootstrap は、マージンがないようである(なので、複数の画面パーツがくっついてしまうみたいである)。CSS を学習ののち、マージン調整を実装する必要がある。
Java(Tomcat)と Flask で、セションの考え方や実装が大きく異なる
-
Java(Tomcat)においてセッションの情報をメモリに保存する。アプリケーション側のログアウトでセッションを破棄すると、メモリの情報が破棄される。また、アイドルタイムアウトが発生すれば、Java 側でセッションを破棄されることになる。
-
Flask においては、セションの情報をデータベース等に保持する作り込みが必要とのこと。
ログインの session には単に規則的な文字列を振っているだけなので、ログアウトで session を消す作りにした(レスポンスヘッダーに “Set-Cookie: sesson=;” で session を消すようにした)としても、次のリクエストのリクエストヘッダー “Cookie: session=” で前回にログインしたときの session の文字列を指定しなおせば、ログイン状態を継続した状態にできてしまう。また、タイムアウトが発生すれば(PERMANENT_SESSION_LIFETIME の時間を超えれば)、セションが無効とみなされる。
今後、研究したいこと
- セッションの仕組みを実装する。セッションの情報をデータベースに保持したり破棄したり。
- 見た目の向上。CSS の仕組みを研究するところから。
このページの読者の前提
初級を抜け出した人向けです。
- Web や HTTP に関する基礎知識を持っていること。
- 自分で MySQL、Node.js、Python、Vue、React の環境を構築し、ソースコードを取り込んで、プログラムの実行ができること。
- 自分でざっと Python、Vue、React のソースコードを読んで理解できること。
フォルダー・ファイル構成
- フォルダー・ファイル
フォルダー・ファイル
D:\Developments\PyCharmProjects\lab-flask │ LICENSE │ main.py → 後述 │ README.md │ requirements.txt │ settings.py → 後述 ├─ action │ auth_action.py → 後述 │ info_action.py │ unauth_action.py ├─ mapper │ select_mapper.py → 後述 ├─ resources-react ← ビルドして作成した React リソース │ │ asset-manifest.json │ │ favicon.ico │ │ index.html │ │ manifest.json │ │ robots.txt │ └─ static │ ・・・ ├─ resources-vue ← ビルドして作成した React リソース │ │ favicon.ico │ │ index.html │ └─ assets │ ・・・ ├─ sql │ lab-flask-ddl.bat │ lab-flask-ddl.sql → 後述 │ lab-flask-dml.bat │ lab-flask-dml.sql → 後述 ├─ validator │ auth_validator.py → 後述 ├─ web-react ← React プロジェクト │ │ package-lock.json │ │ package.json │ │ README.md │ │ tsconfig.json │ ├─ build │ │ │ asset-manifest.json │ │ │ favicon.ico │ │ │ index.html │ │ │ manifest.json │ │ │ robots.txt │ │ └─ static │ │ ・・・ │ ├─ node_modules │ │ ・・・ │ ├─ public │ │ favicon.ico │ │ index.html │ │ manifest.json │ │ robots.txt │ └─ src │ │ index.css │ │ index.tsx │ │ logo.svg │ │ react-app-env.d.ts │ │ reportWebVitals.ts │ ├─ store │ │ reducer.js │ └─ views │ FlaskHome.tsx │ FlaskLogin.tsx → 後述 │ FlaskSettings.tsx │ FlaskTop.tsx └─ web-vue ← Vue プロジェクト │ .eslintrc.cjs │ index.html │ package-lock.json │ package.json │ README.md │ vite.config.js ├─ node_modules │ ・・・ ├─ public │ favicon.ico └─ src │ App.vue │ main.js ├─ assets │ base.css │ logo.svg │ main.css ├─ router │ index.js ├─ stores │ store.js └─ views FlaskHome.vue → 後述 FlaskLogin.vue FlaskSettings.vue FlaskTop.vue
環境の構築からプログラムの実行まで
- Python をインストールする。
- MySQL をインストールする。
- Node.js をインストール、セットアップする。
- Pycharm などで “D:\Developments\PyCharmProjects” 配下に Python プロジェクト “lab-flask” を作成する。
- “D:\Developments\PyCharmProjects\lab-flask” 配下に、Vue プロジェクト “web-vue” を作成する。
コマンド・プロンブト
C:\Users\xxxx> D: D:> cd D:\Developments\PyCharmProjects\lab-flask D:\Developments\PyCharmProjects\lab-flask> npm create vue Vue.js - The Progressive JavaScript Framework √ Project name: ... web-vue √ Add TypeScript? ... No / Yes ← No √ Add JSX Support? ... No / Yes ← No √ Add Vue Router for Single Page Application development? ... No / Yes ← Yes √ Add Pinia for state management? ... No / Yes ← Yes √ Add Vitest for Unit Testing? ... No / Yes ← No √ Add an End-to-End Testing Solution? » No ← No √ Add ESLint for code quality? ... No / Yes ← No √ Add Prettier for code formatting? ... No / Yes ← No ・・・ D:\Developments\PyCharmProjects\lab-flask> cd web-vue D:\Developments\PyCharmProjects\lab-flask\web-vue> npm install D:\Developments\PyCharmProjects\lab-flask\web-vue> npm install @mdi/font D:\Developments\PyCharmProjects\lab-flask\web-vue> npm install axios D:\Developments\PyCharmProjects\lab-flask\web-vue> exit
- “D:\Developments\PyCharmProjects\lab-flask” 配下に、React プロジェクト “web-react” を作成する。
コマンド・プロンブト
C:\Users\xxxx> D: D:> cd D:\Developments\PyCharmProjects\lab-flask D:\Developments\PyCharmProjects\lab-flask> npx create-react-app web-react --template typescript ・・・ D:\Developments\PyCharmProjects\lab-flask> cd web-react D:\Developments\PyCharmProjects\lab-flask\web-react> npm install react-router-dom D:\Developments\PyCharmProjects\lab-flask\web-react> npm install react-bootstrap bootstrap D:\Developments\PyCharmProjects\lab-flask\web-react> npm install axios D:\Developments\PyCharmProjects\lab-flask\web-react> npm install redux react-redux D:\Developments\PyCharmProjects\lab-flask\web-react> @rem npm start ← テスト用 D:\Developments\PyCharmProjects\lab-flask\web-react> npm run build D:\Developments\PyCharmProjects\lab-flask\web-react> robocopy build ../resources-react /MIR /DCOPY:DAT D:\Developments\PyCharmProjects\lab-flask\web-react> exit
- “D:\Developments\PyCharmProjects\lab-flask” 配下の各種フォルダーに各種ソースコードを置く。
- Vue プロジェクトをビルドする。
コマンド・プロンブト
C:\Users\xxxx> D: D:> cd D:\Developments\PyCharmProjects\lab-flask\web-vue D:\Developments\PyCharmProjects\lab-flask\web-vue> @rem npm run dev ← テスト用コマンド D:\Developments\PyCharmProjects\lab-flask\web-vue> npm run build D:\Developments\PyCharmProjects\lab-flask\web-vue> exit
- React プロジェクトをビルドする。
コマンド・プロンブト
C:\Users\xxxx> D: D:> cd D:\Developments\PyCharmProjects\lab-flask\web-react D:\Developments\PyCharmProjects\lab-flask\web-react> @rem npm start ← テスト用コマンド D:\Developments\PyCharmProjects\lab-flask\web-react> npm run build D:\Developments\PyCharmProjects\lab-flask\web-react> robocopy build ../resources-react /MIR /DCOPY:DAT D:\Developments\PyCharmProjects\lab-flask\web-react> exit
- mysql コマンドでデータベースを作成する。
- Pycharm などで requirements.txt に記載されたパッケージをインストールする。
- Pycharm などで main.py を実行する。
- ブラウザーで “http://localhost:8080” をアクセスする。
データベースの簡単な説明
MySQL の DDL 文、DML 文です。
- DDL 文
lab-flask-ddl.sql
・・・ -- Role DROP ROLE IF EXISTS LAB_FLASK_ROLE; CREATE ROLE LAB_FLASK_ROLE; -- User DROP USER IF EXISTS 'LAB_FLASK_USER'@'localhost'; CREATE USER 'LAB_FLASK_USER'@'localhost' IDENTIFIED BY 'Asdf1234' DEFAULT ROLE LAB_FLASK_ROLE; -- Schema DROP SCHEMA IF EXISTS LAB_FLASK; CREATE SCHEMA LAB_FLASK; -- USER_LIST CREATE TABLE LAB_FLASK.USER_LIST ( USER_ID CHAR(26) NOT NULL UNIQUE, USER_NAME VARCHAR(32) NOT NULL UNIQUE, PASSWORD_AES VARCHAR(416) NOT NULL, -- 32 USER_DESC VARCHAR(768) NOT NULL, -- 128 * 6 = 768 PRIMARY KEY ( USER_ID, USER_NAME ), UNIQUE ( USER_ID ) ); GRANT SELECT, INSERT, UPDATE, DELETE ON LAB_FLASK.USER_LIST TO LAB_FLASK_ROLE; -- PRODUCT_LIST CREATE TABLE LAB_FLASK.PRODUCT_LIST ( PRODUCT_ID CHAR(26) NOT NULL UNIQUE, PRODUCT_NAME VARCHAR(32) NOT NULL UNIQUE, PRODUCT_DESC VARCHAR(768) NOT NULL, -- 128 * 6 = 768 PRIMARY KEY ( PRODUCT_ID, PRODUCT_NAME ) ); GRANT SELECT, INSERT, UPDATE, DELETE ON LAB_FLASK.PRODUCT_LIST TO LAB_FLASK_ROLE;
- DML 文
lab-flask-dml.sql
・・・ -- USER_LIST DELETE FROM LAB_FLASK.USER_LIST; INSERT INTO LAB_FLASK.USER_LIST ( USER_ID, USER_NAME, PASSWORD_AES, USER_DESC ) VALUES ( '2024-01-01T00:00:00.000000', 'root', HEX(AES_ENCRYPT('Asdf1234', 'Asdf1234Asdf1234')), 'Administrator' ); -- PRODUCT_LIST DELETE FROM LAB_FLASK.PRODUCT_LIST; INSERT INTO LAB_FLASK.PRODUCT_LIST ( PRODUCT_ID, PRODUCT_NAME, PRODUCT_DESC ) VALUES ( '2024-01-01T00:00:00.000001', 'Apple', 'Made in Japan.' ), ( '2024-01-01T00:00:00.000002', 'Orange', 'Made in America.' );
Python ソースコードの簡単な説明
- メイン処理。変数 “FOLDER = 0” または “FOLDER = 1” で、Vue または React のリソースを切り替えてください。
main.py
・・・ import os from flask import Flask, render_template, send_from_directory, jsonify, request, make_response from flask_login import LoginManager, login_required, login_user, current_user, logout_user, UserMixin from action import auth_action, unauth_action, info_action FOLDERS = [ {'static_folder': './resources-react/static', 'template_folder': './resources-react'}, {'static_folder': './resources-vue/assets', 'template_folder': './resources-vue'}, ] # Please select React environment or Vue environment. FOLDER = 0 # React # FOLDER = 1 # Vue app = Flask(__name__, static_folder=FOLDERS[FOLDER]['static_folder'], template_folder=FOLDERS[FOLDER]['template_folder']) app.config.from_pyfile("./settings.py") # def callback(): json_data = { 'status': 'action-ng', 'message': 'Session timeout has occurred.', 'list': [] } return jsonify(json_data) login_manager = LoginManager() login_manager.unauthorized_callback = callback login_manager.init_app(app) # class User(UserMixin): def __init__(self, id_): self.id = id_ # @login_manager.user_loader def load_user(user_id): return User(user_id) # @app.route('/', methods=['GET']) def index(): response = make_response(render_template('index.html')) return response # When Manipulate [Rewind] [Forward] [Refresh] Button [URL] Textbox at Browser @app.errorhandler(404) def not_found(e): response = make_response(render_template('index.html')) return response # @app.after_request def after_request(response): response.headers['Cache-Control'] = 'private, no-store, no-cache, max-age=0, must-revalidate' response.headers['Expires'] = '0' response.headers['Pragma'] = 'no-cache' response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['X-XSS-Protection'] = '1; mode=block' return response # favicon.ico @app.route('/favicon.ico', methods=['GET']) def favicon(): return send_from_directory(os.path.join(app.root_path, FOLDERS[FOLDER]['template_folder']), 'favicon.ico') # Web API: authorize @app.route('/auth', methods=['POST']) def auth(): return auth_action.auth(request, login_user) # Web API: un-authorize @app.route('/unauth', methods=['POST']) @login_required def unauth(): return unauth_action.unauth(logout_user) # Web API: information @app.route('/info', methods=['GET']) @login_required def info(): return info_action.info(request, current_user) # Web Server if __name__ == '__main__': app.run(host='0.0.0.0', port=8080)
また、ブラウザーで [戻る] ボタン、[進む] ボタン、[更新] ボタンを押下したり [URL] テキストボックスに “http://localhost:8080/FlaskLogin” 等を指定したりして 404(Not Found)になったときに index.html を表示することで、React ルーターに相応の処理を行わせる。 - 設定ファイル。session の暗号化キー、CSRF 処理なし、レスポンス・ヘッダー “Set-Cookie:” のオプション(HttpOnly; Secure;)、タイムアウト(30分)。
settings.py
・・・ from datetime import timedelta SECRET_KEY = 'LabFlask' WTF_CSRF_ENABLED = False SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True PERMANENT_SESSION_LIFETIME = timedelta(minutes=30)
- 認証処理。
auth_action.py
・・・ import binascii from flask import jsonify from flask_login import UserMixin from mapper import select_mapper from validator import auth_validator from Crypto.Cipher import AES # Crypto Key CRYPTO_KEY = 'Asdf1234Asdf1234' # Strip Padding String From ECB Decrypto String def unpad(s): return s[:-ord(s[len(s) - 1:])] # Decrypto def decrypto(password_aes) -> str: password_bin = binascii.unhexlify(password_aes) decipher = AES.new(CRYPTO_KEY.encode('utf-8'), AES.MODE_ECB) dec = decipher.decrypt(password_bin) return unpad(dec).decode('utf-8') # class User(UserMixin): def __init__(self, in_id): self.id = in_id # authorize def auth(request, login_user) -> jsonify: json_data = { 'status': '', 'message': '', 'list': [] } form = auth_validator.Form() if not form.validate_on_submit(): json_data['status'] = 'action-ng' json_data['message'] = 'User Name or Password is not correct.' return jsonify(json_data) user_name = request.form['user_name'] password = request.form['password'] try: sql = 'SELECT PASSWORD_AES FROM LAB_FLASK.USER_LIST WHERE USER_NAME = %s' result = select_mapper.select(sql, [user_name]) except Exception: # noqa json_data['status'] = 'action-ng' json_data['message'] = 'Unknown trouble has occurred.' return jsonify(json_data) if not len(result): json_data['status'] = 'action-ng' json_data['message'] = 'User Name or Password is not correct.' return jsonify(json_data) # Password Verification password_aes = result[0]['PASSWORD_AES'] password_dec = decrypto(password_aes) if password != password_dec: json_data['status'] = 'action-ng' json_data['message'] = 'User Name or Password is not correct.' return jsonify(json_data) login_user(User(user_name)) json_data['status'] = 'action-ok' json_data['message'] = '' return jsonify(json_data)
- バリデーション処理。
auth_validator.py
・・・ import re from flask_wtf import FlaskForm from wtforms import StringField, ValidationError # class Form(FlaskForm): user_name = StringField('') password = StringField('') def validate_user_name(self, user_name) -> None: # noqa if user_name.data == '': raise ValidationError('User ID or Password is not correct.') if len(user_name.data) > 32: raise ValidationError('User ID or Password is not correct.') if re.fullmatch("[a-zA-Z0-9_-]*", user_name.data) is None: raise ValidationError('User ID or Password is not correct.') return # Password verification is implemented in auth_action.py def validate_password(self, password) -> None: # noqa if password.data == '': raise ValidationError('User ID or Password is not correct.') return
- データベースアクセス処理。
select_mapper.py
・・・ import mysql.connector # def select(sql, in_params): try: db_host = 'localhost' db_port = '3306' db_name = 'LAB_FLASK' db_user = 'LAB_FLASK_USER' db_password = 'Asdf1234' connect = mysql.connector.connect( host=db_host, port=db_port, db=db_name, user=db_user, passwd=db_password, charset="utf8") cursor = connect.cursor(dictionary=True) cursor.execute(sql, in_params) result = cursor.fetchall() connect.commit() connect.close() except Exception as ex: raise Exception(ex) return result
React と Vue のソースコードの簡単な説明
- ログイン画面 “FlaskLogin” の動作
- axios を使用して Web API “/auth” を呼び出す。
- 認証に成功した場合、ログイン後のホーム画面 “FlaskHome” に遷移する。
- 認証に失敗した場合、ログイン画面のメッセージ行にエラーメッセージを表示する。
- React ログイン画面。
FlaskLogin.tsx
import 'bootstrap/dist/css/bootstrap.min.css'; import {useEffect} from 'react'; import {useState} from 'react'; import {useSelector, useDispatch} from 'react-redux'; import {mapStateToProps, mapDispatchToProps} from '../store/reducer'; import {connect} from 'react-redux'; import {Navbar} from 'react-bootstrap'; import {Form, Button} from 'react-bootstrap'; import {Container, Row, Col} from 'react-bootstrap'; import {useNavigate} from "react-router-dom"; import axios from 'axios'; var styles = { margin: { margin: "55pt 10pt 10pt 10pt", }, white: { color: "white", } }; function FlaskLogin() { const [userName, setUserName] = useState(''); const [password, setPassword] = useState(''); const [message, setMessage] = useState(''); const navigate = useNavigate(); const sessionId = useSelector((state: {sessionId: string}) => state.sessionId); const dispatch = useDispatch(); useEffect(() => { if (sessionId !== '') { dispatch({type: 'UPDATE', payload: ''}); const formData = new FormData(); axios.post('/unauth', formData); } }, []); const login = async () => { if (! userName) { setMessage('User Name is required.'); return } if (! password) { setMessage('Password is required.'); return } const formData = new FormData(); formData.append('user_name', userName); formData.append('password', password); await axios.post('/auth', formData) .then (function(response) { if (response.data.status === 'action-ok') { dispatch({type: 'UPDATE', payload: 'in session'}); navigate('/FlaskHome'); return; } else { setMessage(response.data.message); return; } }) .catch (function(error) { setMessage('Network trouble has occurred.'); return }); }; return ( <> <header> <Navbar expand="lg" className="fixed-top navbar navbar-expand-lg px-lg-3 navbar-dark bg-primary"> <Navbar.Brand><b>lab-flask</b></Navbar.Brand> </Navbar> </header> <footer className="footer fixed-bottom mt-auto p-lg-2 navbar-dark bg-primary"> <span style={styles.white}>Copyright © Xxxx Co., Ltd.</span> </footer> <main style={styles.margin}> <h5>Login</h5> <Container fluid="true"> <Row noGutters="true"> <Col>{message}</Col> </Row> <Row noGutters="true"> <Col xs={12} md={4}> <Form.Control type="text" value={userName} id="userName" placeholder="User Name" onChange={(e) => setUserName(e.target.value)} /> </Col> </Row> <Row noGutters="true"> <Col xs={12} md={4}> <Form.Control type="password" value={password} id="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} /> </Col> </Row> <Row noGutters="true"> <Col md="auto"> <Button variant="outline-success" id="login" onClick={login}>Login</Button> </Col> </Row> </Container> </main> </> ); } export default connect(mapStateToProps, mapDispatchToProps)(FlaskLogin);
- Vue ログイン画面。
FlaskLogin.vue
<template> <v-app> <v-app-bar color="blue" app dark> <v-toolbar-title><h3 class="display-1">lab-flask</h3></v-toolbar-title> </v-app-bar> <v-footer color="blue" app dark>Copyright © Xxxx Co., Ltd.</v-footer> <v-main> <v-container fluid> <v-row no-gutters> <v-col><h3 class="display-1">Login</h3></v-col> </v-row> <v-row no-gutters> <v-col>{{ message }}</v-col> </v-row> <v-row> <v-col cols="4"> <v-form> <v-text-field id="userName" autocomplete="off" prepend-icon="mdi-account" clearable label="User Name" v-model="userName" /> <v-text-field id="password" autocomplete="off" prepend-icon="mdi-lock" clearable label="Password" v-model="password" v-bind:type="showPassword ? 'text' : 'password'" v-bind:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'" @click:append="showPassword = ! showPassword" /> <v-btn id="login" variant="outlined" color="success" @click="login" style="text-transform: none">Login</v-btn> </v-form> </v-col> </v-row> </v-container> </v-main> </v-app> </template> <script setup> import axios from 'axios' import { ref } from 'vue' import { useRouter } from 'vue-router' import { useStore } from '../stores/store' const router = useRouter() const store = useStore() const message = ref('') const showPassword = ref(false) const userName = ref('') const password = ref('') if (store.sessionId != '') { const formData = new FormData() axios.post('/unauth', formData) store.sessionId = '' } function login() { if (! userName.value) { message.value = 'User Name is required.' return } if (! password.value) { message.value = 'Password is required.' return } const formData = new FormData() formData.append('user_name', userName.value) formData.append('password', password.value) axios.post('/auth', formData) .then (function(response) { if (response.data.status == 'action-ok') { store.sessionId = "in session" router.push('/FlaskHome') return } else { message.value = response.data.message return } }) .catch (function(error) { message.value = 'Network trouble has occurred.' return }) } </script>
全ソースコードの置き場所
参考
React と Vue.js の比較
Vue から React への乗り換え
Flask と FastAPI
React Router
画面設計(いつか実践してみたい)