ผลต่างระหว่างรุ่นของ "01204223/flask-backend-db"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
| แถว 658: | แถว 658: | ||
ส่วน relationship ทั้ง <tt>comments</tt> และ <tt>todo</tt> จะเป็นสิ่งที่ ORM จะจัดการให้ และไม่มีผลต่อ schema ในฐานข้อมูล | ส่วน relationship ทั้ง <tt>comments</tt> และ <tt>todo</tt> จะเป็นสิ่งที่ ORM จะจัดการให้ และไม่มีผลต่อ schema ในฐานข้อมูล | ||
| + | |||
| + | {{กล่องเทา| | ||
| + | '''หมายเหตุ''': ตอนเขียนตัวอย่างนี้ รีบเกินไป ทำให้ relationship จาก Comment กลับไป TodoItem ชื่อ todo แทนที่จะเป็น todo_item ทำให้การเรียกชื่อดูไม่คงเส้นคงวา ขออภัยด้วยครับ}} | ||
=== Migration === | === Migration === | ||
รุ่นแก้ไขเมื่อ 01:23, 23 มกราคม 2569
- หน้านี้เป็นส่วนหนึ่งของ 01204223
เราจะแก้ส่วน backend ให้ทำงานกับ database แทนที่เราจะเขียน SQL ตรง ๆ เราจะให้ไลบรารีที่เรียกว่า ORM (Object Relational Mapper) ที่ทำให้เราเขียน-อ่านกับข้อมูลในฐานข้อมูลผ่านทาง object
เนื้อหา
โมเดล
- ส่วนนี้ใช้ gemini เขียนให้
ในบริบทของการพัฒนาซอฟต์แวร์ (Software Development) โดยเฉพาะในส่วนของ Backend หรือ Web Application คำว่า "โมเดล" (Model) คือส่วนประกอบที่ทำหน้าที่เป็น ตัวแทนของโครงสร้างข้อมูลและกฎเกณฑ์ทางธุรกิจ (Business Logic) ที่เกี่ยวข้องกับข้อมูลนั้นๆ
เพื่อให้เข้าใจง่ายที่สุด: โมเดลคือ "พิมพ์เขียว" (Blueprint) ที่บอกโปรแกรมว่าข้อมูลของเราหน้าตาเป็นอย่างไร และจะคุยกับฐานข้อมูล (Database) อย่างไร
หน้าที่หลักของ Model
Model ทำหน้าที่เป็น ตัวกลาง ระหว่างโค้ดของโปรแกรม (Application Code) กับ ฐานข้อมูล (Database) โดยมีหน้าที่หลัก 3 ประการ:
- กำหนดโครงสร้าง (Structure Definition): บอกว่าข้อมูลชุดนี้มีช่องข้อมูล (Fields/Columns) อะไรบ้าง และเป็นชนิดใด (เช่น ข้อความ, ตัวเลข, วันที่)
- จัดการข้อมูล (Data Manipulation): ใช้สำหรับ ดึง (Read), เพิ่ม (Create), แก้ไข (Update), และ ลบ (Delete) ข้อมูล หรือที่เรียกว่า CRUD
- ตรวจสอบความถูกต้อง (Validation): ตรวจสอบว่าข้อมูลที่จะบันทึกลงฐานข้อมูลนั้นถูกต้องตามกฎหรือไม่ (เช่น อีเมลต้องมีเครื่องหมาย @)
แนวคิด ORM (Object-Relational Mapping)
ในการเขียนโปรแกรมสมัยใหม่ เรามักใช้เทคนิคที่เรียกว่า ORM ผ่าน Model ซึ่งช่วยให้โปรแกรมเมอร์ไม่ต้องเขียนคำสั่ง SQL (ภาษาฐานข้อมูล) ที่ซับซ้อนโดยตรง แต่จะเขียนเป็น "Code" แทน
- Class (ในโค้ด): เปรียบเสมือน ตาราง (Table) ในฐานข้อมูล
- Object/Instance (ตัวแปร): เปรียบเสมือน แถว (Row) ของข้อมูล 1 รายการ
- Attribute (ตัวแปรในคลาส): เปรียบเสมือน คอลัมน์ (Column)
ตัวอย่างโค้ด (SQLAlchemy)
สมมติว่าเราต้องการเก็บข้อมูล "ผู้ใช้งาน" (User) โดยใช้ SQLAlchemy แบบดั้งเดิม (Declarative Base)
การนิยามโมเดล (Model Definition)
เราจะสร้างคลาสเพื่อเป็นตัวแทนตารางในฐานข้อมูล
class User(Base):
# กำหนดโครงสร้างข้อมูล (Columns)
id = Column(Integer, primary_key=True)
name = Column(String(100))
email = Column(String(100), unique=True)
age = Column(Integer)
# กฎทางธุรกิจ (Business Logic)
def is_adult(self):
return self.age >= 18
ในการใช้งานเราสามารถสั่ง
user.is_adult()
ได้โดยตรง
Code เริ่มต้นของ project
เพื่อความสะดวกเราจะเริ่มต้นจากโค้ดตัวอย่างที่รวบรวมมาแล้ว นอกจากนี้เราจะให้นิสิตส่งงานที่ทำผ่านทาง github repo ด้วย ดังนั้นอย่าลืม commit งานเป็นระยะ ๆ ด้วย
ให้ fork repository https://github.com/jittat/01204223-flask-react-todo-68 บน github จากนั้นให้ clone repository ที่ fork แล้ว มาทำงานในเครื่อง
หมายเหตุ โค้ดของเก่ามีผิดพลาดในส่วนของ python backend ตอนที่สั่ง getattr(data,'done',False) จริง ๆ แล้วต้องสั่ง data.get('done', False) เพราะว่า data เป็น dict ไม่ใช่ object ใน repo นี้แก้ไขแล้ว
เราจะแยกไดเร็กทอรีภายในเป็น backend และ frontend ให้ไปติดตั้งไลบรารีของทั้งสองส่วนดังนี้
Backend
เข้าไปใน backend (ถ้าเป็น windows อย่าลืมใช้ cmd อย่าใช้ powershell)
cd backend
เราจะสร้าง virtual environment และติดตั้งไลบรารี ขั้นแรกสร้าง virtual environment ก่อน
python -m venv venv
แล้ว activate ถ้าเป็น linux/mac เรียก
./venv/bin/activate
ถ้าเป็น windows เรียก
venv\Scripts\activate.bat
เมื่อ activate venv แล้ว ให้เรียก pip เพื่อติดตั้งไลบรารี เราได้รวบรวมรายกายไลบรารีไว้ในไฟล์ requirements.txt แล้ว ดังนั้นเราจะสั่ง
pip install -r requirements.txt
เพื่อติดตั้งได้เลย ถ้าติดตั้งเรียบร้อยทดลองเรียก flask backend ได้
ขั้นแรก ถ้าเป็น linux/mac ให้เรียก
export FLASK_APP=main.py
ถ้าเป็น windows เรียก
set FLASK_APP=main.py
แล้วเรียก
flask run --debug
Frontend
เปิดอีก terminal หนึ่ง แล้วเรียก
cd frontend
เรียก npm เพื่อติดตั้งไลบรารี
npm install
แล้วเรียก
npm run dev
เพื่อเปิดเซิร์ฟเวอร์ของ frontend react
ระบบฐานข้อมูล
เพื่อความง่าย ในตอนแรกเราจะใช้ระบบฐานข้อมูล sqlite ซึ่งเป็น database system ที่เก็บข้อมูลในไฟล์ และมากับ Python แล้ว เมื่อเราทำเสร็จ เราจะย้ายไปทดลองกับ MySQL หรือ dbms ตัวอื่นที่นิสิตได้เคยติดตั้งตอนเรียนวิชา database
การติดตั้งไลบรารี SQLAlchemy และ SQLAlchemy-Flask
เราจะใช้ SQLAlchemy และ Flask-SQLAlchemy โดยจะต้องติดตั้งไลบรารีเพิ่มเติม ให้เข้าไปในส่วน backend และ activate virtual environment ก่อน โดยสั่ง
./venv/bin/activate (สำหรับ mac/linux)
หรือ
venv\Scripts\activate.bat (สำหรับ Windows บน cmd)
จากนั้นสั่ง
pip install Flask-SQLAlchemy
เมื่อติดตั้งเรียบร้อยแล้ว เราจะอัพเดทไฟล์ requirements.txt เพื่อให้รวมไลบรารีเหล่านี้ด้วย ถ้าเราลองสั่ง
pip freeze
เราจะได้รายการของไลบรารีทั้งหมดที่เราติดตั้งผ่าน pip เราจะเก็บข้อมูลดังกล่าวลงใน requirements.txt โดยสั่ง (เป็นการทำ redirection)
pip freeze > requirements.txt
การเก็บรายการไลบรารีแบบนี้จะมีประโยชน์เมื่อเราต้องการรันระบบของเราที่เครื่องอื่นนอกเหนือจากเครื่องที่เราใช้พัฒนาเอง (และจำเป็นตอนสร้าง container image ตอนที่เราเรียน DevOp ต่อไปด้วย)
โมเดลแรก
เราจะเพิ่มโมเดล TodoItem เพื่อเก็บรายการใน app todolist
เราจะแก้ไข main.py โดยเริ่มจากการ import ไลบรารีที่จำเป็นที่หัวโปรแกรม
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
ในการใช้งาน SQLAlchemy ใน Flask นั้นเราจะต้อง init ไลบรารีก่อน รวมทั้งตั้งค่าเกี่ยวกับฐานข้อมูลที่เราจะใช้ ให้เพิ่มโค้ดต่อไปนี้หลังบรรทัด app = Flask(__name__) และ CORS(app)
ส่วนแรกเป็นการตั้งค่าการเชื่อมต่อฐานข้อมูล ถ้าเราจะเปลี่ยนไปใช้ระบบฐานข้อมูลอื่น เราจะแก้ไขตรงนี้
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db'
จากนั้นจะสร้างตัวแปร db ที่จะเป็นจุดที่เราเรียกไลบรารีทั้งหมด โดยต้องมีการผูกเข้ากับ Flask app รวมทั้งมีการระบุด้วยว่าในการสร้างโมเดลทั้งหมดจะใช้อะไรเป็น base class (ในที่นี้เราใช้คลาส Base ที่เป็น DeclarativeBase อีกที
class Base(DeclarativeBase):
pass
db = SQLAlchemy(app, model_class=Base)
จากนั้นเราจะประกาศโมเดลแบบด้านล่าง โดยจะประกาศเป็นคลาสที่เป็นคลาสย่อยของ db.Model ซึ่งคลาส db.Model ก็จะเป็นคลาสเดียวกับที่เราระบุตอนสร้าง db = SQLAlchemy ด้านบน
การประกาศคลาสนี้ เราจะเขียนในรูปแบบการประกาศ SQLAlchemy แบบใหม่ (มีการระบุ type ของ id, title, done) นอกจากนี้เราเตรียมฟังก์ชัน to_dict เอาไว้ใช้เพื่อความสะดวกด้วย
class TodoItem(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(100))
done: Mapped[bool] = mapped_column(default=False)
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"done": self.done
}
การสร้างไฟล์ฐานข้อมูล
โมเดลของเราจะ กลายเป็น ตารางในฐานข้อมูล อย่างไรก็ตามเมื่อเราประกาศโมเดลเสร็จ เราจะยังไม่มีไฟล์ฐานข้อมูลที่เราระบุไว้ (todos.db) ดังนั้นเราจะต้องไปสร้างเสียก่อน
เราสามารถสั่งให้ SQLAlchemy สร้างฐานข้อมูลรวมถึงตารางตามโมเดลที่เราระบุไว้ได้โดยเรียก db.create_all() ในที่นี้เราจะแอบสั่งแบบทางลัดไปก่อน (***ชั่วคราว***) เดี๋ยวเราจะมีวิธีอื่นที่ดีกว่านี้
การสั่งอะไรที่เกี่ยวข้องกับฐานข้อมูลด้วย SQLAlchemy ใน Flask จะต้องเรียกภายในโค้ดที่มี context ของ Flask application (สั่งลอย ๆ ด้านนอกไม่ได้) ดังนั้นเราต้องเขียนโค้ดใน block with ดังด้านล่าง ให้เติมโค้ดต่อไปนี้ลงไปหลังส่วนที่ประกาศคลาส TodoItem
with app.app_context():
db.create_all()
จากนั้นให้ restart โค้ดส่วนของ backend (ที่เรารันด้วยคำสั่ง flask run --debug) เพื่อให้บรรทัด db.create_all() นี้ทำงาน
ทดสอบดูไฟล์ฐานข้อมูล
ถ้าโค้ดทำงานเรียบร้อย ให้เปิดอีก terminal มาดูไฟล์ในไดเรกทอรี backend เราจะพบว่ามีไฟล์ instance/todos.db ซึ่งเป็นไฟล์ฐานข้อมูลของ sqlite เกิดขึ้น เราสามารถใช้โปรแกรม sqlite ไปสั่งงานจัดการได้ (เช่นทดสอบคำสั่ง SQL เช่น SELECT, INSERT ได้) เราจะไปลองดูว่ามีตาราง todo_item เกิดขึ้นหรือไม่
เราจะเปลี่ยนไปในไดเรกทอรี backend/instance แล้วสั่ง
sqlite3 todos.db
ถ้าไม่มีคำสั่งดังกล่าว อาจจะต้องไปติดตั้ง sqlite ก่อน (แต่ถ้าไม่สะดวก อาจจะขอดูเพื่อนข้าง ๆ ได้) เมื่อเรียกแล้วจะขึ้นข้อความประมาณนี้
SQLite version 3.46.1 2024-08-13 09:16:08 Enter ".help" for usage hints. sqlite>
ใน sqlite shell นี้เราสามารถสั่งคำสั่ง SQL ได้ ส่วนคำสั่งพิเศษอื่น ๆ จะขึ้นด้วย . (จุด) เราสามารถกดดูคำสั่งโดยสั่ง .help
เราจะดูรายการตารางโดยสั่ง
.tables
จะเห็นว่ามีตาราง todo_item ซึ่งสร้างขึ้นตามที่เราระบุในโมเดล
ถ้าเราสั่ง
.schema todo_item
เราจะเห็นคำสั่งที่สร้างตารางดังกล่าวขึ้นมา
CREATE TABLE todo_item ( id INTEGER NOT NULL, title VARCHAR(100) NOT NULL, done BOOLEAN NOT NULL, PRIMARY KEY (id) );
ซึ่งจะสอดคล้องกับที่เรานิยามไว้ในคลาส TodoItem
เมื่อดูเสร็จแล้วให้ออกจาก sqlite โดยสั่ง .quit
การแก้ปัญหาเกี่ยวกับไฟล์ฐานข้อมูล ตอนนี้ถ้ามีปัญหาอะไร และต้องการเริ่มจัดการกับฐานข้อมูลใหม่ เราจะลบไฟล์ instance/todos.db ดังกล่าวทิ้งและ restart Flask server ใหม่ เพื่อให้มีการสร้างฐานข้อมูลใหม่ได้เลย
Git commit
เมื่อทดลองและแก้ไขตรวจสอบโค้ดเรียบร้อยแล้ว เราจะ commit สิ่งที่เราทำมา แต่ก่อนอื่น ให้เพิ่มบรรทัดด้านล่างลงใน backend/.gitignore เสียก่อน เราจะได้ไม่พลาดเพิ่มไฟล์ฐานข้อมูลทดสอบเราลงไปใน git ด้วย
/instance
แล้วสั่ง commit อะไรที่แก้ไขให้เรียบร้อย
เราจะกลับมา commit งานเป็นระยะ ๆ อีกครั้งแล้วในการทำงานส่วนนี้ จะได้ทำเป็นนิสัย
อนาคต: migration
การลบและสร้างไฟล์ฐานข้อมูลใหม่ทุกครั้งไม่ใช่ทางเลือกที่ดี โดยเฉพาะถ้าเราพัฒนาซอฟต์แวร์แบบค่อยเป็นค่อยไป การเพิ่มฟีเจอร์แต่ละครั้งอาจมีการแก้ไขตารางในฐานข้อมูล ทำให้เราต้องเก็บรักษาข้อมูลเดิมไว้ด้วย แต่จัดการแก้การเปลี่ยนแปลงโครงสร้างของฐานข้อมูลเท่านั้น
กระบวนการนี้เรียกว่าการทำ database migration เราจะได้ทดลองใช้ไลบรารีดังกล่าว เมื่อเราได้ลองสร้างตาราง comment ที่เก็บข้อมูลความเห็นที่สัมพันธ์กับ TodoItem
การอ่านรายการ TodoItem
เราจะเขียน query ฐานข้อมูลผ่านทางโมเดล วิธีที่เราเขียนที่ทำผ่านโมเดล .query นี้ถูกจัดว่าเป็นวิธีแบบ legacy ของ SQLAlchemy ซึ่งวิธีที่ไลบรารีออกแบบมาล่าสุดจะให้ทำผ่านทาง db.execute และ db.select ซึ่งผู้สอนพิจารณาแล้วว่าไม่สะดวก แม้จะมีประสิทธิและดีกว่าในอนาคต อย่างไรก็ตามวิธีที่เราเขียนนั้นจะคล้ายคลึงกับการเขียนด้วย Django ซึ่งเป็นเฟรมเวิร์คมาตรฐานของ python ทำให้ถ้าในอนาคตถ้าต้องเปลี่ยนไปทำ backend บนเฟรมเวิร์คที่มีโครงสร้างมากขึ้นก็ทำได้ และถ้าเข้าใจการเขียนแล้ว การเปลี่ยนไปเขียนในรูปแบบใหม่ของ SQLAlchemy ก็ไม่ได้ยากอะไรที่จะเรียนรู้
เราสามารถ query ข้อมูลในโมเดลผ่านทางทาง attribute .query เช่น TodoItem.query ถ้าเราต้องการดึงข้อมูลทั้งหมด เราจะสั่ง TodoItem.query.all()
ให้แก้ฟังก์ชัน get_todos ที่จัดการ api ส่วน /api/todos/ ให้เป็นดังนี้
@app.route('/api/todos/', methods=['GET'])
def get_todos():
todos = TodoItem.query.all()
return jsonify([todo.to_dict() for todo in todos])
สังเกตว่าเราเรียก to_dict เพื่อคืนค่า dict representation ของ TodoItem ก่อนส่งให้ jsonify
ทดลอง restart Flask app แล้วลองดูที่ frontend ว่ายังสามารถทำงานได้หรือไม่ ที่ React frontend ควรจะเห็นรายการเปล่า ๆ
ใส่ TodoItem ตัวอย่าง
เพื่อความสะดวกในการทดสอบ เราจะเพิ่มโค้ดด้านล่างให้สร้าง TodoItem ตัวอย่างลงในฐานข้อมูล ถ้าไม่มี TodoItem เลย ให้เพิ่มโค้ดส่วนนี้หลังโค้ดที่สั่ง create_all แล้ว
INITIAL_TODOS = [
TodoItem(title='Learn Flask'),
TodoItem(title='Build a Flask App'),
]
with app.app_context():
if TodoItem.query.count() == 0:
for item in INITIAL_TODOS:
db.session.add(item)
db.session.commit()
คำอธิบายโค้ด
- เราสร้าง object TodoItem ไว้ในรายการ INITIAL_TODOS
- โค้ดจะอยู่ใน block with app.app_context()
- เราถามจำนวนแถวโดยสั่ง TodoItem.query.count()
- วิธีเพิ่มของในฐานข้อมูล เราจะ add เข้าไปใน db.session โดยสั่ง db.session.add(item) แล้วสั่ง db.session.commit() เพื่อเก็บการเปลี่ยนแปลง ถ้าไม่สั่ง commit สิ่งที่เราแก้ไขจะไม่ถูกเก็บลงฐานข้อมูล
เมื่อเพิ่มโค้ดแล้ว ให้ลอง restart Flask app อีกครั้ง แล้วให้ refresh React เพื่อดูว่าเห็นรายการเบื้องต้หรือไม่
เราอาจจะทดลองใช้ devtool เพื่อดูการส่งรับข้อมูลระหว่าง frontend กับ backend ได้ ในส่วนนี้ ถ้าเรา refresh หน้าจอ frontend แล้วอาจจะเห็นว่า React มีการเรียกไปยัง backend api GET /api/todos/ เป็นจำนวนสองครั้ง พฤติกรรมแบบนี้เป็นพฤติกรรมปกติถ้าเราทำงานใน development mode และสร้าง react component ใน strict mode เพราะว่า React จะเรียก request ซ้ำเพราะพยายามทำให้เราพบบั๊กต่าง ๆ ง่ายขึ้น
- สำหรับการ query ข้อมูลแบบอื่น ๆ นอกจาก all() และ count() ดูได้ที่ Legacy Query Interface และ เอกสารอ้างอิงของ SQLAlchemy
ทดสอบด้วย curl (optional)
นอกจากการทดสอบ backend จากบน browser แล้ว เรายังสามารถใช้ command line ในการทดสอบได้ เราจะใช้โปรแกรม curl โดยเรียก
curl http://localhost:5000/api/todos/
เราจะเห็น response เป็น json จาก Flask
เพิ่ม TodoItem
เราจะแก้ส่วนเพิ่ม TodoItem ให้ใช้โมเดล TodoItem ขั้นแรกปรับโค้ดฟังก์ชัน new_todo ให้เป็นดังนี้
def new_todo(data):
return TodoItem(title=data['title'],
done=data.get('done', False))
สังเกตว่าฟังก์ชันจะสั้นมาก จริง ๆ เราอาจจะเขียนโค้ดนี้ใน add_todo เลยก็ยังได้ แต่ขอคงฟังก์ชันนี้ไว้ก่อน
ในส่วนของฟังก์ชัน add_todo ที่รองรับ api endpoint POST /api/todos/ เราก็จะใช้คำสั่ง db.session.add และ db.session.commit ในการเพิ่มข้อมูลใหม่ในโมเดล ให้แก้ให้เป็นดังนี้
@app.route('/api/todos/', methods=['POST'])
def add_todo():
data = request.get_json()
todo = new_todo(data)
if todo:
db.session.add(todo) # บรรทัดที่ปรับใหม่
db.session.commit() # บรรทัดที่ปรับใหม่
return jsonify(todo.to_dict()) # บรรทัดที่ปรับใหม่
else:
# return http response code 400 for bad requests
return (jsonify({'error': 'Invalid todo data'}), 400)
ให้ทดลองเทสผ่านทาง frontend
ถ้าใช้งานได้ อย่าลืม commit
การแก้ไขและลบ TodoItem
การแก้ไข TodoItem
ในส่วนของ toggle_todo เราจะต้องค้น TodoItem ที่มีหมายเลขตรงกับที่ได้รับมา เราสามารถค้นแบบตรงไปตรงมาโดยสั่ง TodoItem.query.get(id)
@app.route('/api/todos/<int:id>/toggle/', methods=['PATCH'])
def toggle_todo(id):
todo = TodoItem.query.get(id)
if not todo:
return (jsonify({'error': 'Todo not found'}), 404)
todo.done = not todo.done
db.session.commit()
return jsonify(todo.to_dict())
ให้ลองทดสอบกับ frontend
อย่างไรก็ตาม การค้นข้อมูลและตรวจสอบว่าถ้าไม่พบให้คืน response หมายเลข 404 เป็นกิจกรรมที่ทำบ่อยมาก Flask-SQLAlchemy จึงมีฟังก์ชัน get_or_404 ให้ใช้เลย เราจึงจะเปลี่ยนโค้ดใหม่ที่สั้นลงเป็นดังนี้
@app.route('/api/todos/<int:id>/toggle/', methods=['PATCH'])
def toggle_todo(id):
todo = TodoItem.query.get_or_404(id)
todo.done = not todo.done
db.session.commit()
return jsonify(todo.to_dict())
สังเกตว่าเราสามารถตัดส่วนตรวจสอบว่าพบ TodoItem หรือไม่ทิ้งไปได้เลย
การลบ TodoItem
ในส่วนการลบ เราจะใช้ get_or_404 เช่นเดียวกัน โดยเราจะแก้โค้ด delete_todo ให้เป็นดังนี้
@app.route('/api/todos/<int:id>/', methods=['DELETE'])
def delete_todo(id):
todo = TodoItem.query.get_or_404(id)
db.session.delete(todo)
db.session.commit()
return jsonify({'message': 'Todo deleted successfully'})
ให้ทดสอบผ่านทาง frontend ถ้าทำงานได้ อย่าลืม
commit...
ความสัมพันธ์ระหว่างโมเดล (โมเดล Comment)
เพื่อแสดงความสามารถและฝึกใช้งาน ORM ให้มากขึ้น เราจะเพิ่มโมเดล Comment เพื่อเก็บความเห็นของแต่ละ TodoItem โดยมีความสัมพันธ์แบบ one-to-many (หนึ่ง TodoItem มีได้หลาย Comment) ถ้าพิจารณาเป็นแผนภาพจะแสดงด้านล่าง
นอกจากจะเพิ่มโมเดล Comment แล้ว เราจะเรียนรู้การใช้เครื่องมือที่สำคัญในการพัฒนาซอฟต์แวร์แบบค่อยเป็นค่อยไป (iterative development) คือการทำ database migration
- หมายเหตุ ส่วนนี้มีคลิปประกอบ เพราะว่าอาจจะทำตามแล้วเจอปัญหาได้มาก อย่าลืมดูประกอบ https://www.youtube.com/watch?v=S-9IIfQc5eQ
Initial migration
นอกจากโค้ดที่มีการเปลี่ยนแปลงไปตลอดการพัฒนาแล้ว โครงสร้างของฐานข้อมูลรวมไปถึงข้อมูลพื้นฐานในฐานข้อมูลก็มีการปรับเปลี่ยนไปด้วยเช่นเดียวกัน การจัดการกับ version ของโครงสร้างฐานข้อมูล (schema) จึงเป็นสิ่งที่แทบจะขาดไม่ได้ในการพัฒนาซอฟต์แวร์
SQLAlchemy มีไลบรารีคู่ใจในการทำ database migration คือ Alembic ซึ่งเราสามารถนำมาใช้งานใน Flask แอพได้โดยใช้ไลบรารี Flask-Migrate
เราจะติดตั้งโดยสั่ง (อย่าลืมเรียกใช้ virtual environment)
pip install Flask-Migrate
จากนั้นให้อัพเดทรายการไลบรารีล่าสุดลงใน requirements.txt โดยสั่ง
pip freeze > requirements.txt
เราจะต้องเชื่อมโค้ดของ Flask-Migrate กับ Flask application ของเราก่อน โดยเราจะเพิ่มโค้ดใน main.py ดังนี้
# .. ละไว้
from sqlalchemy.orm import Mapped, mapped_column
from flask_migrate import Migrate # import library
app = Flask(__name__)
# .. ละไว้
db = SQLAlchemy(app, model_class=Base)
migrate = Migrate(app, db) # บรรทัดที่เรียก Migrate (ต้องหลังสร้าง app และ db)
ถ้าแก้ไขถูกต้อง เราจะสามารถเรียกคำสั่ง
flask db --help
ได้จาก command line ซึ่งคำสั่งดังกล่าวจะแสดงคำสั่งที่เราเรียกเกี่ยวกับการทำ database migration คำสั่งที่สำคัญได้แก่
init Creates a new migration repository. migrate Autogenerate a new revision file (Alias for 'revision... revision Create a new revision file. upgrade Upgrade to a later version downgrade Revert to a previous version history List changeset scripts in chronological order.
ในการทำ migration นั้น เราจะต้องอธิบายพัฒนาการของโครงสร้างฐานข้อมูลตั้งแต่เริ่มต้น (ตั้งแต่ยังไม่มีตารางอะไรในฐานข้อมูล) อย่างไรก็ตาม สถานะของฐานข้อมูลของเราในปัจจุบันนั้นไม่ได้เป็นเช่นนั้น ถ้าเราทำงานจากจุดนี้เลย Flask-Migrate จะไม่สามารถตรวจสอบได้ว่าถ้าเริ่มทำงานตั้งแต่แรกจะต้องมีการสร้างตารางสำหรับโมเดล TodoItem ดังนั้นเราจะต้องลบฐานข้อมูลปัจจุบันที่เราทำมาทิ้งให้หมดก่อน รวมทั้งโค้ดใน main.py ที่สร้างตารางใหม่โดยอัตโนมัติด้วย เพราะว่าจะทำให้โค้ดของ Flask-Migrate ไม่สามารถตรวจสอบการเปลี่ยนแปลงของฐานข้อมูลได้
ก่อนอื่นให้ปิด Flask server ก่อน
ในการลบให้ดำเนินการดังนี้ ขั้นแรกลบโค้ดใน main.py ที่ create_all และเพิ่มข้อมูล TodoItem เริ่มต้น
# ลบโค้ดสองบรรทัดนี้ จริง ๆ เราต้องลบส่วนสร้างฐานข้อมูลเท่านั้น แต่ถ้าไม่มีส่วนนี้โค้ดใส่ข้อมูลเบื้องต้นก็จะทำงานไม่ได้ด้วยเช่นกัน
# with app.app_context():
# db.create_all()
# ส่วนด้านล่างนี้ให้ comment ทิ้งเป็น string ไว้ก่อน
"""
INITIAL_TODOS = [
TodoItem(title='Learn Flask'),
TodoItem(title='Build a Flask App'),
]
with app.app_context():
if TodoItem.query.count() == 0:
for item in INITIAL_TODOS:
db.session.add(item)
db.session.commit()
"""
จากนั้นให้ลบไฟล์ฐานข้อมูล instance/todos.db ทิ้ง
rm instance/todos.db # บน max, linux
หรือ
del instance\todos.db # บน windows
เราน่าจะพร้อมสำหรับการสร้างเริ่มต้นสร้าง initial migration แล้ว
ขั้นแรกเราจะ init Flask-Migrate ซึ่งเราจะสั่งครั้งเดียว โดยสั่ง
flask db init
คำสั่งดังกล่าวจะสร้างไดเร็กทอรี migrations เพื่อเก็บข้อมูลและไฟล์ migration ต่าง ๆ ลองสั่ง
ls migrations
เพื่อดูไฟล์ต่าง ๆ (หรือสั่ง dir ใน windows) ในไดเรกทอรี migrations นอกจากจะมีไฟล์ config ต่าง ๆ แล้ว ยังมีไดเรกทอรี versions ที่เก็บรายการ migration ทั้งหมด
เราจะสั่งให้สร้าง migration อัตโนมัติโดยพิจารณาจากโมเดลในปัจจุบัน โดยสั่ง
flask db migrate -m "Initial migration"
Flask-Migrate จะตรวจสอบและรายงานว่าพบว่ามีตารางเพิ่มชื่อ todo_item และสร้างไฟล์ migration ใน migrations/versions
INFO .... ละไว้ .... INFO [alembic.autogenerate.compare.tables] Detected added table 'todo_item' Generating /home/jittat/prog/test/flask-react-todo-start/backend/migrations/versions/45b68ed6f7cf_initial_migration.py ... done
สตริงที่นำหน้า initial migration อาจจะไม่ตรงกับในด้านบน น่าจะเป็นสตริงที่สุ่มมาเพื่อป้องกันชื่อไฟล์ migration ตรงกัน ถ้าเปิดไฟล์ดังกล่าวดูจะพบว่ามีสองฟังก์ชันที่สำคัญคือ upgrade และ downgrade ดังแสดงด้านล่าง
ถ้าเราอ่านดูจะพบว่าฟังก์ชัน upgrade จะสั่งให้สร้างตาราง todo_item และฟังก์ชัน downgrade จะลบตารางดังกล่าว ฟังก์ชันทั้งสองจะแทนการเปลี่ยนแปลงไป และเปลี่ยนแปลงกลับ ของ migration นี้
##
################# อ่านเป็นตัวอย่างอย่างเดียว ไม่ต้องสร้างเอง ไฟล์นี้ควรถูกสร้างโดยอัตโนมัติเมื่อสั่ง flask db migrate
##
"""Initial migration
Revision ID: 45b68ed6f7cf
Revises:
Create Date: 2026-01-23 02:54:51.207223
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '45b68ed6f7cf'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('todo_item',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('done', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('todo_item')
# ### end Alembic commands ###
หมายเหตุ สำหรับการ migrate ที่ซับซ้อน บางครั้งเราอาจจะจำไปต้องแก้ไขฟังก์ชันทั้งสองเพิ่มเติมเองด้วย
คำสั่ง db migrate จะสร้างไฟล์ migration แต่จะไม่ได้ดำเนินการอะไรกับฐานข้อมูล เราจะต้องสั่ง upgrade เอง โดยสั่ง
flask db upgrade
เมื่อสั่งแล้ว เราจะเห็นว่ามีไฟล์ instance/todos.db กลับมา
เราสามารถเรียก sqlite3 เพื่อเปิดไฟล์ฐานข้อมูลดังกล่าว แล้วลองสั่ง
.tables
เพื่อดูตาราง (จะเห็น 2 ตาราง) และสั่ง
.schema todo_item
เพื่อดูรายละเอียดของตารางได้แบบเดิม สำหรับตาราง alembic_version นั้น จะเป็นตารางสำหรับเก็บ migrations ที่ดำเนินการแล้ว
ถ้าทุกอย่างเรียบร้อยเราจะไปเอา comment ที่สร้าง TodoItem เริ่มต้นออก
# นำ comment ส่วนนี้ออก
INITIAL_TODOS = [
TodoItem(title='Learn Flask'),
TodoItem(title='Build a Flask App'),
]
with app.app_context():
if TodoItem.query.count() == 0:
for item in INITIAL_TODOS:
db.session.add(item)
db.session.commit()
แล้วเรียก flask run --debug จากนั้นทดสอบดูว่าโค้ดยังทำงานได้แบบเดิมหรือไม่
เราควรจัดเก็บงานที่ทำได้แล้ว ในการ commit อย่าลืมเพิ่มไดเรกทอรี migrations เข้าไปด้วย
สร้างโมเดลและการเพิ่ม relationship
เราจะเพิ่มโมเดล Comment ตามที่ได้ระบุไว้ใน ER-diagram ด้านบน ตัวโมเดลแบบพื้นฐานถ้าเราจะเพิ่มไปเฉพาะคอลัมน์ที่ออกแบบจะเป็นดังนี้
## ให้อ่านก่อน ยังไม่ใช่โมเดลจริงที่เราจะเพิ่ม ##
class Comment(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
content: Mapped[str] = mapped_column(String(250))
todo_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id'))
สังเกตว่ามีการสร้าง attribute todo_id ให้เป็น ForeignKey ไปที่ todo_item.id คอลัมน์ดังกล่าวจะสร้างความสัมพันธ์แบบ one-to-many จาก TodoItem มายัง Comment
อย่างไรก็ตาม ORM จะสามารถเชื่อมความสัมพันธ์ดังกล่าว ให้เราสามารถอ้างถึงข้อมูลที่เชื่อมต่อกันได้ โดยถ้าเราจัดการเรียบร้อย เมื่อเรามี Comment เราสามารถอ้างถึงโมเดล TodoItem ที่เชื่อมกันได้ รวมถ้าจาก TodoItem ก็สามารถอ้างถึงรายการ Comment ได้เลย ดังเช่น โค้ดตัวอย่างด้านล่าง
# Comment อ้าง TodoItem
print(comment.todo.title)
# TodoItem อ้างถึง Comment
for comment in todo_item.comments:
print(comment.message)
ซึ่งจะทำให้การเขียนโปรแกรมสะดวกขึ้นมา อย่างไรก็ตามการใช้ ORM ก็มีข้อควรระวัง ถ้าไม่จัดการให้ดีอาจจะมีการเรียก query ระบบฐานข้อมูลจำนวนมาก (หลายร้อย-พันครั้งต่อหนึ่ง request) ซึ่งอาจส่งผลกระทบต่อประสิทธิภาพการทำงานได้
เราจะสร้างโมเดล Comment ก่อนอื่นต้องไป import ForeignKey และ relationship ก่อน เพิ่มส่วน import ดังด้านล่าง
from sqlalchemy import Integer, String, ForeignKey # เพิ่ม import Foreignkey
from sqlalchemy.orm import Mapped, mapped_column, relationship # เพิ่ม import relatiohship
จากนั้นเราจะปรับส่วนนิยามโมเดลดังนี้ โดยเพิ่ม relationship ใน TodoItem (บรรทัดที่เพิ่มในคลาส TodoItem จะถูกระบุไว้ด้วย) และเพิ่มคลาส Comment
class TodoItem(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(100))
done: Mapped[bool] = mapped_column(default=False)
##### เพิ่มส่วน relationship ซึ่งตรงนี้จะไม่กระทบ schema database เลย (เพราะว่าไม่มีการ map ไปยังคอลัมน์ใดๆ)
comments: Mapped[list["Comment"]] = relationship(back_populates="todo")
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"done": self.done
}
class Comment(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
content: Mapped[str] = mapped_column(String(250))
todo_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id'))
todo: Mapped["TodoItem"] = relationship(back_populates="comments")
ส่วน relationship ทั้ง comments และ todo จะเป็นสิ่งที่ ORM จะจัดการให้ และไม่มีผลต่อ schema ในฐานข้อมูล
หมายเหตุ: ตอนเขียนตัวอย่างนี้ รีบเกินไป ทำให้ relationship จาก Comment กลับไป TodoItem ชื่อ todo แทนที่จะเป็น todo_item ทำให้การเรียกชื่อดูไม่คงเส้นคงวา ขออภัยด้วยครับ
Migration
สั่ง
flask db migrate -m "Add Comment"
จะเห็นว่าพบตาราง comment เพิ่มเติม และมีการสร้าง migration file ขึ้นใน migrations/versions
INFO [alembic.runtime.plugins] setting up autogenerate plugin alembic.autogenerate.defaults INFO [alembic.runtime.plugins] setting up autogenerate plugin alembic.autogenerate.comments INFO [alembic.autogenerate.compare.tables] Detected added table 'comment' Generating /home/jittat/prog/test/flask-react-todo-start/backend/migrations/versions/8ad44c2919e1_add_comment.py ... done
สั่งคำสั่งด้านล่างเพื่อเพิ่ม migrate ฐานข้อมูล
flask db upgrade
เราสามารถดูไฟล์ฐานข้อมูลได้โดยเรียก sqlite3 instance/todos.db แล้วทดลองสั่งดังด้านล่าง (คำสั่งแสดงหลัง sqlite> )
sqlite> .tables alembic_version comment todo_item sqlite> .schema comment CREATE TABLE comment ( id INTEGER NOT NULL, content VARCHAR(250) NOT NULL, todo_id INTEGER NOT NULL, PRIMARY KEY (id), FOREIGN KEY(todo_id) REFERENCES todo_item (id) );
เพื่อดูได้ว่ามีการสร้างตาราง comment เรียบร้อย
เราจะทดลองเพิ่ม comment ลงไปตรง ๆ ในฐานข้อมูลเพื่อทดสอบส่วนอื่นๆ โดยสั่งคำสั่ง SQL ดังด้านล่าง
ก่อนอื่นดูก่อนว่ายังมี TodoItem ในฐานข้อมูล ถ้าไม่มีแล้วให้ลอง restart Flask server ใหม่
sqlite> SELECT * FROM todo_item; 1|Learn Flask|0 2|Build a Flask App|0
จากนั้นเพิ่ม comment ไปหลาย ๆ อัน โดยให้เชื่อมกับ TodoItem 1 บ้าง 2 บ้าง เช่น
sqlite> INSERT INTO comment VALUES (1,"Very good",1); sqlite> INSERT INTO comment VALUES (2,"Interesting",1); sqlite> INSERT INTO comment VALUES (3,"Why not",2); sqlite> INSERT INTO comment VALUES (4,"Yes",1);
ลองสั่ง SELECT ดู
sqlite> SELECT * FROM comment;
เพื่อดูว่าเพิ่มเรียบร้อย
เพิ่มโค้ดให้คืน comments
class Comment(db.Model):
# ... ละไว้ ...
def to_dict(self):
return {
"id": self.id,
"content": self.content,
"todo_id": self.todo_id
}
class TodoItem(db.Model):
# ... ละไว้ ...
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"done": self.done,
"comments": [
comment.to_dict() for comment in self.comments
]
}
ทดลองดูค่าที่คืนจาก api ว่ามีการแสดงรายการ comment หรือไม่ ยกตัวอย่างเช่น ถ้าสั่ง curl http://localhost:5000/api/todos/ จะเห็นผลลัพธ์ที่มี comments ด้วย เช่น (แสดงบางส่วน)
[
{
"comments": [
{
"content": "Very good",
"id": 1,
"todo_id": 1
},
{
"content": "Interesting",
"id": 2,
"todo_id": 1
}
],
"done": false,
"id": 1,
"title": "Learn Flask"
}
]
แสดงผลบน React
ก่อนอื่นเราควรปรับ css สักเล็กน้อย ไม่ให้แสดงทุกอย่างเป็นตรงกลาง ให้แก้ไฟล์ frontent/src/App.css โดยตัดบรรทัดใน #root ออก
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
/* **** ลบบรรทัดถัดไปออก *** */
/* text-align: center; */
}
