01204223/flask-backend-db

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
หน้านี้เป็นส่วนหนึ่งของ 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) ถ้าพิจารณาเป็นแผนภาพจะแสดงด้านล่าง

223-flask-relationship-TodoComment.png

นอกจากจะเพิ่มโมเดล 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)
    message: 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)
    message: 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

เราจะสร้าง migration และ upgrade ฐานข้อมูลให้มีตาราง comment โดยสั่งคำสั่งด้านล่างใน command line

flask db migrate -m "Add Comment"

จะเห็นว่า Flask-Migrate พบตาราง 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,
	message 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

เราจะจัดการปรับ api ให้คืน comments กลับไปด้วย

เราจะเพิ่มฟังก์ชัน to_dict ให้กับ Comment

class Comment(db.Model):
    # ... ละไว้ ...

    def to_dict(self):
        return {
            "id": self.id,
            "message": self.message,
            "todo_id": self.todo_id
        }

และแก้ to_dict ของ TodoItem ให้คืนรายการ Comment ด้วย สังเกตว่าเราอ้างถึงรายการ self.comments ได้เลย ซึ่งเมื่อมีการอ้างถึง ORM จะค้นข้อมูลที่เกี่ยวข้องมาให้โดยอัตโนมัติ

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": [
      {
        "message": "Very good",
        "id": 1,
        "todo_id": 1
      },
      {
        "message": "Interesting",
        "id": 2,
        "todo_id": 1
      }
    ],
    "done": false,
    "id": 1,
    "title": "Learn Flask"
  }
]

สำหรับเรื่องการจัดการ relationship ใน SQLAlchemy สามารถอ่านเพิ่มเติมได้ที่ https://docs.sqlalchemy.org/en/21/orm/basic_relationships.html

แสดงผลบน React

ก่อนอื่นเราควรปรับ css สักเล็กน้อย ไม่ให้แสดงทุกอย่างเป็น center ไปหมด ให้แก้ไฟล์ frontent/src/App.css โดยตัดบรรทัดใน #root ออก

#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  /* **** ลบบรรทัดถัดไปออก *** */
  /* text-align: center;    */
}

เราจะแก้ไฟล์ App.jsx ให้แสดงรายการ comments ด้วย โดยปรับส่วนแสดงรายการ TodoItem ให้เป็นดังนี้ (ส่วนที่เพิ่มจะถูกเน้น)

      <ul>
        {todoList.map(todo => (
          <li key={todo.id}>
            <span className={todo.done ? "done" : ""}>{todo.title}</span>
            <button onClick={() => {toggleDone(todo.id)}}>Toggle</button>
            <button onClick={() => {deleteTodo(todo.id)}}>❌</button>

            <!-- ************** เพิ่มส่วนแสดงรายการ comment ที่ตรงนี้ *********** -->

            {(todo.comments) && (todo.comments.length > 0) && (
              <>
                <b>Comments:</b>
                <ul>
                  {todo.comments.map(comment => (
                    <li key={comment.id}>{comment.message}</li>
                  ))}
                </ul>
              </>
            )}

            <!-- ************** สิ้นสุดส่วนที่เพิ่ม *********** -->

          </li>
        ))}
      </ul>

สังเกตวิธีการเขียนเงื่อนไขตอนต้น จะอยู่ในรูปแบบ (condition1) && (condition2) && (ส่วนแสดงผล) คือโค้ดจะทำงานถึงส่วนแสดงผลถ้าเงื่อนไขก่อนหน้าทั้งสองเป็นจริง

นอกจากนี้เรามี block element ว่าง ๆ (ส่วน <> และ </>) อยู่หนึ่งชั้น เพราะว่า element บนสุดจะมีได้แค่ element เดียว

เมื่อแก้เสร็จแล้วให้ทดลองดูผล ถ้าทำงานได้เรียบร้อย อย่าลืม

Commit งานด้วยครับ

เพิ่ม comment

ในการเพิ่ม comment เราจะทำในส่วน frontend ก่อน แล้วค่อยไปทำ backend เพื่อรองรับ request แล้วค่อยมาทำ frontend ให้เสร็จอีกที

ในการเขียน JavaScript ส่วนนี้ จะมีการใช้ spread operator ค่อนข้างเยอะ กรุณาสังเกตการใช้งาน

input สำหรับ newComments

เราจะทำคล้าย ๆ กับตอนที่เราทำส่วนเพิ่ม TodoItem ก็คือเราจะสร้าง state สำหรับเก็บค่าใน input สำหรับฟอร์มเพิ่ม comment อย่างไรก็ตาม ในกรณีนี้ เราจะมี input หลายอัน ทำให้ state ของเราแทนที่จะเป็นสตริง แต่จะเป็น Object (สำหรับเก็บ key-value แทน)

เราจะประกาศ state newComments

  // เพิ่มหลังส่วนที่ประกาศ useState อื่นๆ
  const [newComments, setNewComments] = useState({});

จากนั้นเราจะเพิ่ม input แบบ text ในส่วนรายการ comment โดยเพิ่มหลังโค้ดที่แสดงรายการ comments นอกจากนี้ เราจะเพิ่มปุ่มไว้เตรียมสำหรับส่ง request ด้วย อย่างไรก็ตาม ในตอนนี้เราให้ปุ่มแสดงข้อมูลใน state newComments ไปก่อน เพื่อจะได้ทดสอบโค้ดได้

            <button onClick={() => {deleteTodo(todo.id)}}>❌</button>
            {(todo.comments) && (todo.comments.length > 0) && (
              <> .... ละไว้ .... </>
            )}

            <!-- ** เพิ่มส่วนนี้ ** -->                
            <div className="new-comment-forms">
              <input
                type="text"
                value={newComments[todo.id] || ""}
                onChange={(e) => {
                  const value = e.target.value;
                  setNewComments({ ...newComments, [todo.id]: value });
                }}
              />

              <!-- *** ปุ่มนี้ทดสอบว่าอัพเดท state newComments ถูกต้อง *** -->
              <button onClick={() => {alert(newComments[todo.id])}}>Add Comment</button>
            </div>
            <!-- ************** -->

ให้ทดลองดูว่ามีช่องเพิ่ม comment ขึ้นหรือไม่ และถ้าพิมพ์แล้ว กดปุ่มข้อความขึ้นมาอย่างถูกต้องหรือไม่

backend api

@app.route('/api/todos/<int:todo_id>/comments/', methods=['POST'])
def add_comment(todo_id):
    """Add a comment to a todo."""
    # Verify todo exists
    todo_item = TodoItem.query.get_or_404(todo_id)

    data = request.get_json()
    if not data or 'message' not in data:
        return jsonify({'error': 'Comment message is required'}), 400

    comment = Comment(
        message=data['message'],
        todo_id=todo_item.id
    )
    db.session.add(comment)
    db.session.commit()
 
    return jsonify(comment.to_dict()), 201

ส่ง request และ reload ข้อมูล

ขั้นตอนต่อไป

โค้ด React เราค่อนข้างยุ่งเหยิงมาก เราควรจะแยกส่วน TodoItem ออกมาเป็น component การแยกส่วนดังกล่าวส่งผลต่อแนวทางการออกแบบที่สำคัญว่าเราจะเก็บ state ที่จุดใดใน application ด้วย