ผลต่างระหว่างรุ่นของ "01204223/flask-backend-db"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
| (ไม่แสดง 20 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน) | |||
| แถว 77: | แถว 77: | ||
แล้ว activate ถ้าเป็น linux/mac เรียก | แล้ว activate ถ้าเป็น linux/mac เรียก | ||
| − | ./venv/bin/activate | + | source ./venv/bin/activate |
ถ้าเป็น windows เรียก | ถ้าเป็น windows เรียก | ||
| แถว 116: | แถว 116: | ||
เพื่อเปิดเซิร์ฟเวอร์ของ frontend react | เพื่อเปิดเซิร์ฟเวอร์ของ frontend react | ||
| + | |||
| + | === ทดลองรัน === | ||
| + | |||
| + | ให้ทดลองใช้งานทาง React frontend ถ้าทำงานได้ตามปกติ ก็เริ่มทำต่อได้เลย | ||
| + | |||
| + | ถ้าใช้ Mac แล้วไม่สามารถโหลด api ได้ ให้แก้ใน App.jsx | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | const TODOLIST_API_URL = 'http://localhost:5000/api/todos/'; | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้เป็น | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | const TODOLIST_API_URL = 'http://127.0.0.1:5000/api/todos/'; | ||
| + | </syntaxhighlight> | ||
== ระบบฐานข้อมูล == | == ระบบฐานข้อมูล == | ||
| แถว 124: | แถว 140: | ||
เราจะใช้ [https://www.sqlalchemy.org/ SQLAlchemy] และ [https://flask-sqlalchemy.readthedocs.io/en/stable/ Flask-SQLAlchemy] โดยจะต้องติดตั้งไลบรารีเพิ่มเติม ให้เข้าไปในส่วน backend และ activate virtual environment ก่อน โดยสั่ง | เราจะใช้ [https://www.sqlalchemy.org/ SQLAlchemy] และ [https://flask-sqlalchemy.readthedocs.io/en/stable/ Flask-SQLAlchemy] โดยจะต้องติดตั้งไลบรารีเพิ่มเติม ให้เข้าไปในส่วน backend และ activate virtual environment ก่อน โดยสั่ง | ||
| − | ./venv/bin/activate (สำหรับ mac/linux) | + | source ./venv/bin/activate (สำหรับ mac/linux) |
หรือ | หรือ | ||
| แถว 605: | แถว 621: | ||
class Comment(db.Model): | class Comment(db.Model): | ||
id: Mapped[int] = mapped_column(Integer, primary_key=True) | 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_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id')) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| แถว 651: | แถว 667: | ||
class Comment(db.Model): | class Comment(db.Model): | ||
id: Mapped[int] = mapped_column(Integer, primary_key=True) | 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_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id')) | ||
| แถว 687: | แถว 703: | ||
CREATE TABLE comment ( | CREATE TABLE comment ( | ||
id INTEGER NOT NULL, | id INTEGER NOT NULL, | ||
| − | + | message VARCHAR(250) NOT NULL, | |
todo_id INTEGER NOT NULL, | todo_id INTEGER NOT NULL, | ||
PRIMARY KEY (id), | PRIMARY KEY (id), | ||
| แถว 727: | แถว 743: | ||
return { | return { | ||
"id": self.id, | "id": self.id, | ||
| − | " | + | "message": self.message, |
"todo_id": self.todo_id | "todo_id": self.todo_id | ||
} | } | ||
| แถว 756: | แถว 772: | ||
"comments": [ | "comments": [ | ||
{ | { | ||
| − | " | + | "message": "Very good", |
"id": 1, | "id": 1, | ||
"todo_id": 1 | "todo_id": 1 | ||
}, | }, | ||
{ | { | ||
| − | " | + | "message": "Interesting", |
"id": 2, | "id": 2, | ||
"todo_id": 1 | "todo_id": 1 | ||
| แถว 775: | แถว 791: | ||
{{กล่องเทา| | {{กล่องเทา| | ||
สำหรับเรื่องการจัดการ relationship ใน SQLAlchemy สามารถอ่านเพิ่มเติมได้ที่ [https://docs.sqlalchemy.org/en/21/orm/basic_relationships.html https://docs.sqlalchemy.org/en/21/orm/basic_relationships.html]}} | สำหรับเรื่องการจัดการ relationship ใน SQLAlchemy สามารถอ่านเพิ่มเติมได้ที่ [https://docs.sqlalchemy.org/en/21/orm/basic_relationships.html https://docs.sqlalchemy.org/en/21/orm/basic_relationships.html]}} | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | ถ้าผลลัพธ์ดูโอเค อย่าลืม '''commit''' อย่าลืมเพิ่มไฟล์ migration ใน <tt>migrations/versions/</tt> ด้วย | ||
| + | }} | ||
== แสดงผลบน React == | == แสดงผลบน React == | ||
| แถว 806: | แถว 826: | ||
<ul> | <ul> | ||
{todo.comments.map(comment => ( | {todo.comments.map(comment => ( | ||
| − | <li key={comment.id}>{comment. | + | <li key={comment.id}>{comment.message}</li> |
))} | ))} | ||
</ul> | </ul> | ||
| แถว 873: | แถว 893: | ||
=== backend api === | === backend api === | ||
| + | เราจะเพิ่ม api ที่รับ POST request ที่ URL /api/todos/[todo_item_id]/comments/ เพื่อเพิ่ม comment สำหรับ todo_item ดังกล่าว | ||
| + | |||
| + | ขั้นแรกในฟังก์ชันเราจะค้นก่อนว่า todo_item นั้นมีอยู่จริง ก่อนจะสร้าง comment และจัดเก็บ โดยเรียก db.session.add และ db.session.commit นอกจากนี้ในโค้ดด้านล่างมีการตรวจสอบว่ามีการส่ง parameter มาครบหรือไม่ด้วย (เช่นดูว่ามี message หรือไม่) | ||
| + | |||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
@app.route('/api/todos/<int:todo_id>/comments/', methods=['POST']) | @app.route('/api/todos/<int:todo_id>/comments/', methods=['POST']) | ||
def add_comment(todo_id): | def add_comment(todo_id): | ||
| − | |||
| − | |||
todo_item = TodoItem.query.get_or_404(todo_id) | todo_item = TodoItem.query.get_or_404(todo_id) | ||
| แถว 891: | แถว 913: | ||
db.session.commit() | db.session.commit() | ||
| − | return jsonify(comment.to_dict()) | + | return jsonify(comment.to_dict()) |
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | เราสามารถทดลอง backend ได้โดยทดสอบตรง ๆ กับ api ผ่านทาง extension ของ browser ดังตัวอย่างในรูปด้านล่าง ในส่วนของ todo_id ให้เลือกให้ตรงกับ todo_item ที่ยังมีอยู่ในฐานข้อมูล | ||
| + | |||
| + | [[Image:223-flask-backend-add-comment-api.png|500px]] | ||
| + | |||
| + | หรือจะเรียกผ่านทาง command line <tt>curl</tt> ก็ได้ | ||
| + | |||
| + | curl -i -X POST \ | ||
| + | -H "Content-Type:application/json" \ | ||
| + | -d '{"message": "test again"}' \ | ||
| + | 'http://localhost:5000/api/todos/1/comments/' | ||
| + | |||
| + | ถ้าสั่งแล้วเรียบร้อย ลอง refresh React frontend ดูว่า comment เพิ่มขึ้นมาหรือไม่ | ||
=== ส่ง request และ reload ข้อมูล === | === ส่ง request และ reload ข้อมูล === | ||
| + | เราจะเพิ่มฟังก์ชัน addNewComment โดยปรับโค้ดจาก addNewTodo อย่างไรก็ตามสิ่งที่แตกต่างคือ เมื่อเราเพิ่ม Comment แล้ว เพื่อความง่าย เราจะ fetchTodoList มาใหม่ทั้งหมดเลย | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | async function addNewComment(todoId) { | ||
| + | try { | ||
| + | const url = `${TODOLIST_API_URL}${todoId}/comments/`; | ||
| + | const response = await fetch(url, { | ||
| + | method: 'POST', | ||
| + | headers: { | ||
| + | 'Content-Type': 'application/json', | ||
| + | }, | ||
| + | body: JSON.stringify({ 'message': newComments[todoId] || "" }), | ||
| + | }); | ||
| + | if (response.ok) { | ||
| + | setNewComments({ ...newComments, [todoId]: "" }); | ||
| + | await fetchTodoList(); | ||
| + | } | ||
| + | } catch (error) { | ||
| + | console.error("Error adding new comment:", error); | ||
| + | } | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | {{กล่องเทา| | ||
| + | '''คำถามชวนคิด''' การ fetchTodoList ทั้งหมดเมื่อมีการเพิ่ม comment มีข้อดีข้อเสียอย่างไรบ้าง ถ้าเป็นแอพที่มีข้อมูลมาก ๆ ทำแบบนี้จะมีผลอย่างไร?}} | ||
| + | |||
| + | จากนั้นเราจะไปแก้ให้ปุ่ม Add comment มาเรียกฟังก์ชันนี้ | ||
| + | |||
| + | <syntaxhighlight lang="html"> | ||
| + | <button onClick={() => {addNewComment(todo.id)}}>Add Comment</button> | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองเพิ่ม comment และทดสอบการใช้งานคร่าว ๆ | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | ถ้าสามารถทำงานได้ถูกต้อง อย่าลืม commit งานของคุณ}} | ||
| + | |||
| + | == ปัญหาการลบ TodoItem ที่มี Comment == | ||
| + | |||
| + | สำหรับโค้ดที่เราเขียนไว้แล้ว ถ้าเราลองลบ todo item ที่มี comment จะไม่สามารถลบได้ และจะได้ error ดังนี้ | ||
| + | |||
| + | sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: comment.todo_id | ||
| + | [SQL: UPDATE comment SET todo_id=? WHERE comment.id = ?] | ||
| + | |||
| + | ทั้งนี้เนื่องจากในฐานข้อมูลจะมีเงื่อนไข (constraints) ที่บังคับว่า foreign key จะต้องชี้ไปที่แถวที่มีอยู่จริง | ||
| + | |||
| + | วิธีการแก้ปัญหานี้ทำได้สองแบบ เราจะเลือกวิธีที่ทำในระดับ ORM กล่าวคือเราจะระบุตอนสร้าง relationship ว่าถ้ามีการลบ TodoItem จะต้องมีการลบ Comment ไปด้วย (เรียกว่า cascade delete) โดยเพิ่ม argument <tt>cascade</tt> เข้าไปใน relationship | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | class TodoItem(db.Model): | ||
| + | id: Mapped[int] = mapped_column(Integer, primary_key=True) | ||
| + | title: Mapped[str] = mapped_column(String(100)) | ||
| + | ### | ||
| + | ### แก้บรรทัดนี้ ระบุ argument cascade="all, delete" | ||
| + | comments: Mapped[list["Comment"]] = relationship(back_populates="todo", | ||
| + | cascade="all, delete") | ||
| + | |||
| + | # ... | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | '''อีกวิธีหนึ่ง''' (ที่ดีกว่าถ้าใช้ระบบฐานข้อมูลที่รองรับ Foreign Key Constraint) การแก้อีกวิธีจะทำในระดับ database โดยให้ระบุเงื่อนไขการลบตั้งแต่ตอนการสร้าง Foreign Key เลย แต่เนื่องจากเราไม่ได้ระบุไว้ในตอนแรก (ในเอกสารเวอร์ชันนี้แก้ไขแล้ว) ทำให้ถ้าเราเพิ่มตอนนี้ จะต้องทำ migration และการลบ constraint ใน migration นั้นทำได้ยุ่งยาก ดังนั้นในที่นี้เราจะไม่ทำแบบนั้น | ||
| + | |||
| + | อย่างไรก็ตาม ถ้ามีการสร้าง one-to-many relationship ต่อไป เรา'''ควร'''ระบุเงื่อนไขนี้ไว้เลย โดยเขียนในส่วน Comment ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | #################### | ||
| + | ### ในอนาคตถ้ามีการสร้าง relationship ควรระบุ ondelete ไว้เลย | ||
| + | ### สำหรับงานนี้เราจะละไว้ก่อน | ||
| + | #################### | ||
| + | |||
| + | class Comment(db.Model): | ||
| + | id: Mapped[int] = mapped_column(Integer, primary_key=True) | ||
| + | message: Mapped[str] = mapped_column(String(250)) | ||
| + | ########### | ||
| + | ### แก้บรรทัดถัดไป ตอนประกาศ ForeignKey ควรระบุ ondelete ด้วย | ||
| + | todo_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id', ondelete="CASCADE")) | ||
| + | |||
| + | # .... | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | อย่างไรก็ตาม ถ้าเราใช้ SQLite เราจะไม่สามารถใช้เงื่อนไขแบบนี้ได้ ยกเว้นแต่จะมีการระบุไว้เพิ่มเติมตอนสร้างตาราง (อ่านเพิ่มที่ [https://docs.sqlalchemy.org/en/21/dialects/sqlite.html#sqlite-foreign-keys SQLite Foreign Key Support]) | ||
| + | |||
| + | == Refactor ส่วน Backend == | ||
| + | ในขั้นตอนสุดท้ายนี้ เราจะแยกโค้ดส่วน backend ออกเป็นสองส่วน คือส่วนจัดการกับ request กับส่วน models (database) | ||
| + | |||
| + | เราจะย้ายโค้ดนิยามโมเดลไปอยู่ในไฟล์ models.py ดังด้านล่าง สังเกตว่าตอนสร้าง db เราจะไม่มี app ให้เรียกใช้ เราจะต้องไปกำหนด app ให้อีกทีใน main.py | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | ################# | ||
| + | ### ไฟล์ models.py | ||
| + | ################# | ||
| + | |||
| + | from flask_sqlalchemy import SQLAlchemy | ||
| + | from sqlalchemy.orm import DeclarativeBase | ||
| + | from sqlalchemy import Integer, String, ForeignKey | ||
| + | from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
| + | |||
| + | class Base(DeclarativeBase): | ||
| + | pass | ||
| + | |||
| + | db = SQLAlchemy(model_class=Base) # แก้จาก db = SQLAlchemy(app, model_class=Base) | ||
| + | |||
| + | class TodoItem(db.Model): | ||
| + | # ... ละไว้ .... ให้คัดลอกมาทั้งหมด | ||
| + | |||
| + | class Comment(db.Model): | ||
| + | # ... ละไว้ .... ให้คัดลอกมาทั้งหมด | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นใน main.py เราจะ import โมเดล TodoItem และ Comment '''รวมไปถึง db มาจาก''' models.py | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | ################# | ||
| + | ### ไฟล์ main.py | ||
| + | ################# | ||
| + | |||
| + | from flask import Flask, request, jsonify | ||
| + | from flask_cors import CORS | ||
| + | # from flask_sqlalchemy import SQLAlchemy # ลบออกได้ | ||
| + | # from sqlalchemy.orm import DeclarativeBase # ลบออกได้ | ||
| + | # from sqlalchemy import Integer, String, ForeignKey # ลบออกได้ | ||
| + | # from sqlalchemy.orm import Mapped, mapped_column, relationship # ลบออกได้ | ||
| + | from flask_migrate import Migrate | ||
| + | |||
| + | from models import TodoItem, Comment, db # import จาก models | ||
| + | |||
| + | app = Flask(__name__) | ||
| + | CORS(app) | ||
| + | |||
| + | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db' | ||
| + | |||
| + | db.init_app(app) # แก้จาก db = SQLAlchemy(app, model_class=Base) | ||
| + | migrate = Migrate(app, db) | ||
| + | |||
| + | # ... | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | '''หมายเหตุ''' ทั้งสองไฟล์ต้องใช้ <tt>db</tt> ตัวเดียวกัน ดังนั้นเราจะสร้าง <tt>db</tt> ใน models.py จากนั้น import มาเรียก <tt>init_app</tt> ใน main.py | ||
| + | |||
| + | นอกจากนี้ เนื่องจากเราไม่ต้องการเพิ่ม TodoItem ในตอนแรกเพื่อทดสอบแล้ว เราควรจะลบโค้ดส่วน INITIAL_TODOS ทิ้งด้วย เพราะว่าโค้ดนี้จะทำให้เราทำ migration จากฐานข้อมูลว่าง ๆ ไม่ได้ (เพราะว่าจะ error เสียก่อน) | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | # ... | ||
| + | |||
| + | db.init_app(app) | ||
| + | migrate = Migrate(app, db) | ||
| + | |||
| + | ### ลบทิ้งได้เลย | ||
| + | #- 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() | ||
| + | #- | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เมื่อทดสอบว่าทำงานได้เหมือนเดิมแล้ว... | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''อย่าลืม commit งานของคุณ'''}} | ||
| + | |||
| + | === การส่งงาน === | ||
| + | |||
| + | เมื่อทำเสร็จ ให้ push งานที่ทำแล้ว กลับไปที่ github ยัง repository ที่เราสร้างขึ้นจากการ fork | ||
| + | |||
| + | ใน discord จะมีลิงก์ไปยัง google sheet สำหรับแปะ url ของ repository ดังกล่าว | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''push งานของคุณ และกรอก url ใน google sheet'''}} | ||
| + | |||
| + | == Optional: เชื่อมต่อกับ mysql == | ||
| + | |||
| + | สำหรับนิสิตที่มี mysql/mariadb server บนเครื่อง อาจจะอยากใช้ mysql แทน sqlite เราสามารถแก้ config ง่าย ๆ เพื่อทำให้โค้ดเราทำงานได้แทบจะทันที | ||
| + | |||
| + | ก่อนอื่น ต้องลงไลบรารีสำหรับเชื่อมต่อ mysql บน python ก่อน | ||
| + | |||
| + | pip install mysqlclient | ||
| + | |||
| + | จากนั้น ไปสร้าง database และ user บน mysql ด้วยเครื่องมือที่ถนัด หรือจะสั่งบน command line ก็ได้ | ||
| + | |||
| + | จากนั้นไปแก้ config SQLALCHEMY_DATABASE_URI โดยใส่ในรูปแบบ | ||
| + | |||
| + | mysql://[username]:[password]@localhost/[database] | ||
| + | |||
| + | สมมติว่าเราสร้าง database ชื่อ <tt>todotest</tt> และกำหนดให้ user <tt>todotest</tt> ที่มีรหัสผ่าน <tt>helloworld</tt> สามารถเข้าถึงและแก้ไขได้ เราจะแก้บรรทัด config เป็นดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://todotest:helloworld@localhost/todotest' | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ถ้าแก้เรียบร้อย ลองสั่ง | ||
| + | |||
| + | flask db --help | ||
| + | |||
| + | ว่าโค้ดคอมไพล์ได้ จากนั้นเราสามารถสั่ง migrate database เพื่อสร้างตารางบน mysql ได้ทันที โดยสั่ง | ||
| + | |||
| + | flask db upgrade | ||
| + | |||
| + | ถ้าทำงานได้เรียบร้อย โค้ดคุณก็จะสามารถใช้งานได้ทันที โดยที่มี mysql เป็น database server | ||
| + | |||
| + | {{กล่องเทา| | ||
| + | '''ข้อควรระวัง''': เราไม่ควรใส่รหัสผ่านลงไปใน URI ตรง ๆ เพราะว่าถ้าเราพลาด commit ไฟล์ main.py ขึ้นไปที่ git รหัสผ่านเราอาจจะหลุดได้ โดยปกติเราจะเก็บในไฟล์แยกที่ไม่ถูกนำเข้า version control หรือส่งให้โค้ดผ่านทาง environment variable ซึ่งส่วนนี้เราอาจจะได้พูดถึงต่อไป}} | ||
| + | |||
| + | เราสามารถทดลองดูได้ว่ามีการสร้างตารางอย่างถูกต้องผ่านทางเครื่องมือ mysql ที่มี หรือผ่านทาง command line ก็ได้ ดังตัวอย่างด้านล่าง | ||
| + | |||
| + | <pre> | ||
| + | mysql> use todotest; | ||
| + | Reading table information for completion of table and column names | ||
| + | You can turn off this feature to get a quicker startup with -A | ||
| + | |||
| + | Database changed | ||
| + | mysql> show tables; | ||
| + | +--------------------+ | ||
| + | | Tables_in_todotest | | ||
| + | +--------------------+ | ||
| + | | alembic_version | | ||
| + | | comment | | ||
| + | | todo_item | | ||
| + | +--------------------+ | ||
| + | 3 rows in set (0.00 sec) | ||
| + | |||
| + | mysql> describe todo_item; | ||
| + | +-------+--------------+------+-----+---------+----------------+ | ||
| + | | Field | Type | Null | Key | Default | Extra | | ||
| + | +-------+--------------+------+-----+---------+----------------+ | ||
| + | | id | int | NO | PRI | NULL | auto_increment | | ||
| + | | title | varchar(100) | NO | | NULL | | | ||
| + | | done | tinyint(1) | NO | | NULL | | | ||
| + | +-------+--------------+------+-----+---------+----------------+ | ||
| + | 3 rows in set (0.00 sec) | ||
| + | |||
| + | mysql> describe comment; | ||
| + | +---------+--------------+------+-----+---------+----------------+ | ||
| + | | Field | Type | Null | Key | Default | Extra | | ||
| + | +---------+--------------+------+-----+---------+----------------+ | ||
| + | | id | int | NO | PRI | NULL | auto_increment | | ||
| + | | message | varchar(250) | NO | | NULL | | | ||
| + | | todo_id | int | NO | MUL | NULL | | | ||
| + | +---------+--------------+------+-----+---------+----------------+ | ||
| + | 3 rows in set (0.01 sec) | ||
| + | |||
| + | mysql> select * from todo_item; | ||
| + | +----+-----------+------+ | ||
| + | | id | title | done | | ||
| + | +----+-----------+------+ | ||
| + | | 1 | Test 1 | 0 | | ||
| + | | 2 | Test test | 0 | | ||
| + | +----+-----------+------+ | ||
| + | 2 rows in set (0.00 sec) | ||
| + | </pre> | ||
== ขั้นตอนต่อไป == | == ขั้นตอนต่อไป == | ||
โค้ด React เราค่อนข้างยุ่งเหยิงมาก เราควรจะแยกส่วน TodoItem ออกมาเป็น component การแยกส่วนดังกล่าวส่งผลต่อแนวทางการออกแบบที่สำคัญว่าเราจะเก็บ state ที่จุดใดใน application ด้วย | โค้ด React เราค่อนข้างยุ่งเหยิงมาก เราควรจะแยกส่วน TodoItem ออกมาเป็น component การแยกส่วนดังกล่าวส่งผลต่อแนวทางการออกแบบที่สำคัญว่าเราจะเก็บ state ที่จุดใดใน application ด้วย | ||
รุ่นแก้ไขปัจจุบันเมื่อ 11:21, 23 มกราคม 2569
- หน้านี้เป็นส่วนหนึ่งของ 01204223
เราจะแก้ส่วน backend ให้ทำงานกับ database แทนที่เราจะเขียน SQL ตรง ๆ เราจะให้ไลบรารีที่เรียกว่า ORM (Object Relational Mapper) ที่ทำให้เราเขียน-อ่านกับข้อมูลในฐานข้อมูลผ่านทาง object
เนื้อหา
- 1 โมเดล
- 2 Code เริ่มต้นของ project
- 3 ระบบฐานข้อมูล
- 4 การติดตั้งไลบรารี SQLAlchemy และ SQLAlchemy-Flask
- 5 โมเดลแรก
- 6 การอ่านรายการ TodoItem
- 7 เพิ่ม TodoItem
- 8 การแก้ไขและลบ TodoItem
- 9 ความสัมพันธ์ระหว่างโมเดล (โมเดล Comment)
- 10 แสดงผลบน React
- 11 เพิ่ม comment
- 12 ปัญหาการลบ TodoItem ที่มี Comment
- 13 Refactor ส่วน Backend
- 14 Optional: เชื่อมต่อกับ mysql
- 15 ขั้นตอนต่อไป
โมเดล
- ส่วนนี้ใช้ 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 เรียก
source ./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
ทดลองรัน
ให้ทดลองใช้งานทาง React frontend ถ้าทำงานได้ตามปกติ ก็เริ่มทำต่อได้เลย
ถ้าใช้ Mac แล้วไม่สามารถโหลด api ได้ ให้แก้ใน App.jsx
const TODOLIST_API_URL = 'http://localhost:5000/api/todos/';
ให้เป็น
const TODOLIST_API_URL = 'http://127.0.0.1:5000/api/todos/';
ระบบฐานข้อมูล
เพื่อความง่าย ในตอนแรกเราจะใช้ระบบฐานข้อมูล sqlite ซึ่งเป็น database system ที่เก็บข้อมูลในไฟล์ และมากับ Python แล้ว เมื่อเราทำเสร็จ เราจะย้ายไปทดลองกับ MySQL หรือ dbms ตัวอื่นที่นิสิตได้เคยติดตั้งตอนเรียนวิชา database
การติดตั้งไลบรารี SQLAlchemy และ SQLAlchemy-Flask
เราจะใช้ SQLAlchemy และ Flask-SQLAlchemy โดยจะต้องติดตั้งไลบรารีเพิ่มเติม ให้เข้าไปในส่วน backend และ activate virtual environment ก่อน โดยสั่ง
source ./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)
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
ถ้าผลลัพธ์ดูโอเค อย่าลืม commit อย่าลืมเพิ่มไฟล์ migration ใน migrations/versions/ ด้วย
แสดงผลบน 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
เราจะเพิ่ม api ที่รับ POST request ที่ URL /api/todos/[todo_item_id]/comments/ เพื่อเพิ่ม comment สำหรับ todo_item ดังกล่าว
ขั้นแรกในฟังก์ชันเราจะค้นก่อนว่า todo_item นั้นมีอยู่จริง ก่อนจะสร้าง comment และจัดเก็บ โดยเรียก db.session.add และ db.session.commit นอกจากนี้ในโค้ดด้านล่างมีการตรวจสอบว่ามีการส่ง parameter มาครบหรือไม่ด้วย (เช่นดูว่ามี message หรือไม่)
@app.route('/api/todos/<int:todo_id>/comments/', methods=['POST'])
def add_comment(todo_id):
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())
เราสามารถทดลอง backend ได้โดยทดสอบตรง ๆ กับ api ผ่านทาง extension ของ browser ดังตัวอย่างในรูปด้านล่าง ในส่วนของ todo_id ให้เลือกให้ตรงกับ todo_item ที่ยังมีอยู่ในฐานข้อมูล
หรือจะเรียกผ่านทาง command line curl ก็ได้
curl -i -X POST \
-H "Content-Type:application/json" \
-d '{"message": "test again"}' \
'http://localhost:5000/api/todos/1/comments/'
ถ้าสั่งแล้วเรียบร้อย ลอง refresh React frontend ดูว่า comment เพิ่มขึ้นมาหรือไม่
ส่ง request และ reload ข้อมูล
เราจะเพิ่มฟังก์ชัน addNewComment โดยปรับโค้ดจาก addNewTodo อย่างไรก็ตามสิ่งที่แตกต่างคือ เมื่อเราเพิ่ม Comment แล้ว เพื่อความง่าย เราจะ fetchTodoList มาใหม่ทั้งหมดเลย
async function addNewComment(todoId) {
try {
const url = `${TODOLIST_API_URL}${todoId}/comments/`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ 'message': newComments[todoId] || "" }),
});
if (response.ok) {
setNewComments({ ...newComments, [todoId]: "" });
await fetchTodoList();
}
} catch (error) {
console.error("Error adding new comment:", error);
}
}
คำถามชวนคิด การ fetchTodoList ทั้งหมดเมื่อมีการเพิ่ม comment มีข้อดีข้อเสียอย่างไรบ้าง ถ้าเป็นแอพที่มีข้อมูลมาก ๆ ทำแบบนี้จะมีผลอย่างไร?
จากนั้นเราจะไปแก้ให้ปุ่ม Add comment มาเรียกฟังก์ชันนี้
<button onClick={() => {addNewComment(todo.id)}}>Add Comment</button>
ให้ทดลองเพิ่ม comment และทดสอบการใช้งานคร่าว ๆ
ถ้าสามารถทำงานได้ถูกต้อง อย่าลืม commit งานของคุณ
ปัญหาการลบ TodoItem ที่มี Comment
สำหรับโค้ดที่เราเขียนไว้แล้ว ถ้าเราลองลบ todo item ที่มี comment จะไม่สามารถลบได้ และจะได้ error ดังนี้
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: comment.todo_id [SQL: UPDATE comment SET todo_id=? WHERE comment.id = ?]
ทั้งนี้เนื่องจากในฐานข้อมูลจะมีเงื่อนไข (constraints) ที่บังคับว่า foreign key จะต้องชี้ไปที่แถวที่มีอยู่จริง
วิธีการแก้ปัญหานี้ทำได้สองแบบ เราจะเลือกวิธีที่ทำในระดับ ORM กล่าวคือเราจะระบุตอนสร้าง relationship ว่าถ้ามีการลบ TodoItem จะต้องมีการลบ Comment ไปด้วย (เรียกว่า cascade delete) โดยเพิ่ม argument cascade เข้าไปใน relationship
class TodoItem(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(100))
###
### แก้บรรทัดนี้ ระบุ argument cascade="all, delete"
comments: Mapped[list["Comment"]] = relationship(back_populates="todo",
cascade="all, delete")
# ...
อีกวิธีหนึ่ง (ที่ดีกว่าถ้าใช้ระบบฐานข้อมูลที่รองรับ Foreign Key Constraint) การแก้อีกวิธีจะทำในระดับ database โดยให้ระบุเงื่อนไขการลบตั้งแต่ตอนการสร้าง Foreign Key เลย แต่เนื่องจากเราไม่ได้ระบุไว้ในตอนแรก (ในเอกสารเวอร์ชันนี้แก้ไขแล้ว) ทำให้ถ้าเราเพิ่มตอนนี้ จะต้องทำ migration และการลบ constraint ใน migration นั้นทำได้ยุ่งยาก ดังนั้นในที่นี้เราจะไม่ทำแบบนั้น
อย่างไรก็ตาม ถ้ามีการสร้าง one-to-many relationship ต่อไป เราควรระบุเงื่อนไขนี้ไว้เลย โดยเขียนในส่วน Comment ดังนี้
####################
### ในอนาคตถ้ามีการสร้าง relationship ควรระบุ ondelete ไว้เลย
### สำหรับงานนี้เราจะละไว้ก่อน
####################
class Comment(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
message: Mapped[str] = mapped_column(String(250))
###########
### แก้บรรทัดถัดไป ตอนประกาศ ForeignKey ควรระบุ ondelete ด้วย
todo_id: Mapped[int] = mapped_column(ForeignKey('todo_item.id', ondelete="CASCADE"))
# ....
อย่างไรก็ตาม ถ้าเราใช้ SQLite เราจะไม่สามารถใช้เงื่อนไขแบบนี้ได้ ยกเว้นแต่จะมีการระบุไว้เพิ่มเติมตอนสร้างตาราง (อ่านเพิ่มที่ SQLite Foreign Key Support)
Refactor ส่วน Backend
ในขั้นตอนสุดท้ายนี้ เราจะแยกโค้ดส่วน backend ออกเป็นสองส่วน คือส่วนจัดการกับ request กับส่วน models (database)
เราจะย้ายโค้ดนิยามโมเดลไปอยู่ในไฟล์ models.py ดังด้านล่าง สังเกตว่าตอนสร้าง db เราจะไม่มี app ให้เรียกใช้ เราจะต้องไปกำหนด app ให้อีกทีใน main.py
#################
### ไฟล์ models.py
#################
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base) # แก้จาก db = SQLAlchemy(app, model_class=Base)
class TodoItem(db.Model):
# ... ละไว้ .... ให้คัดลอกมาทั้งหมด
class Comment(db.Model):
# ... ละไว้ .... ให้คัดลอกมาทั้งหมด
จากนั้นใน main.py เราจะ import โมเดล TodoItem และ Comment รวมไปถึง db มาจาก models.py
#################
### ไฟล์ main.py
#################
from flask import Flask, request, jsonify
from flask_cors import CORS
# from flask_sqlalchemy import SQLAlchemy # ลบออกได้
# from sqlalchemy.orm import DeclarativeBase # ลบออกได้
# from sqlalchemy import Integer, String, ForeignKey # ลบออกได้
# from sqlalchemy.orm import Mapped, mapped_column, relationship # ลบออกได้
from flask_migrate import Migrate
from models import TodoItem, Comment, db # import จาก models
app = Flask(__name__)
CORS(app)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db'
db.init_app(app) # แก้จาก db = SQLAlchemy(app, model_class=Base)
migrate = Migrate(app, db)
# ...
หมายเหตุ ทั้งสองไฟล์ต้องใช้ db ตัวเดียวกัน ดังนั้นเราจะสร้าง db ใน models.py จากนั้น import มาเรียก init_app ใน main.py
นอกจากนี้ เนื่องจากเราไม่ต้องการเพิ่ม TodoItem ในตอนแรกเพื่อทดสอบแล้ว เราควรจะลบโค้ดส่วน INITIAL_TODOS ทิ้งด้วย เพราะว่าโค้ดนี้จะทำให้เราทำ migration จากฐานข้อมูลว่าง ๆ ไม่ได้ (เพราะว่าจะ error เสียก่อน)
# ...
db.init_app(app)
migrate = Migrate(app, db)
### ลบทิ้งได้เลย
#- 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()
#-
เมื่อทดสอบว่าทำงานได้เหมือนเดิมแล้ว...
อย่าลืม commit งานของคุณ
การส่งงาน
เมื่อทำเสร็จ ให้ push งานที่ทำแล้ว กลับไปที่ github ยัง repository ที่เราสร้างขึ้นจากการ fork
ใน discord จะมีลิงก์ไปยัง google sheet สำหรับแปะ url ของ repository ดังกล่าว
push งานของคุณ และกรอก url ใน google sheet
Optional: เชื่อมต่อกับ mysql
สำหรับนิสิตที่มี mysql/mariadb server บนเครื่อง อาจจะอยากใช้ mysql แทน sqlite เราสามารถแก้ config ง่าย ๆ เพื่อทำให้โค้ดเราทำงานได้แทบจะทันที
ก่อนอื่น ต้องลงไลบรารีสำหรับเชื่อมต่อ mysql บน python ก่อน
pip install mysqlclient
จากนั้น ไปสร้าง database และ user บน mysql ด้วยเครื่องมือที่ถนัด หรือจะสั่งบน command line ก็ได้
จากนั้นไปแก้ config SQLALCHEMY_DATABASE_URI โดยใส่ในรูปแบบ
mysql://[username]:[password]@localhost/[database]
สมมติว่าเราสร้าง database ชื่อ todotest และกำหนดให้ user todotest ที่มีรหัสผ่าน helloworld สามารถเข้าถึงและแก้ไขได้ เราจะแก้บรรทัด config เป็นดังนี้
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://todotest:helloworld@localhost/todotest'
ถ้าแก้เรียบร้อย ลองสั่ง
flask db --help
ว่าโค้ดคอมไพล์ได้ จากนั้นเราสามารถสั่ง migrate database เพื่อสร้างตารางบน mysql ได้ทันที โดยสั่ง
flask db upgrade
ถ้าทำงานได้เรียบร้อย โค้ดคุณก็จะสามารถใช้งานได้ทันที โดยที่มี mysql เป็น database server
ข้อควรระวัง: เราไม่ควรใส่รหัสผ่านลงไปใน URI ตรง ๆ เพราะว่าถ้าเราพลาด commit ไฟล์ main.py ขึ้นไปที่ git รหัสผ่านเราอาจจะหลุดได้ โดยปกติเราจะเก็บในไฟล์แยกที่ไม่ถูกนำเข้า version control หรือส่งให้โค้ดผ่านทาง environment variable ซึ่งส่วนนี้เราอาจจะได้พูดถึงต่อไป
เราสามารถทดลองดูได้ว่ามีการสร้างตารางอย่างถูกต้องผ่านทางเครื่องมือ mysql ที่มี หรือผ่านทาง command line ก็ได้ ดังตัวอย่างด้านล่าง
mysql> use todotest; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> show tables; +--------------------+ | Tables_in_todotest | +--------------------+ | alembic_version | | comment | | todo_item | +--------------------+ 3 rows in set (0.00 sec) mysql> describe todo_item; +-------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------+--------------+------+-----+---------+----------------+ | id | int | NO | PRI | NULL | auto_increment | | title | varchar(100) | NO | | NULL | | | done | tinyint(1) | NO | | NULL | | +-------+--------------+------+-----+---------+----------------+ 3 rows in set (0.00 sec) mysql> describe comment; +---------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +---------+--------------+------+-----+---------+----------------+ | id | int | NO | PRI | NULL | auto_increment | | message | varchar(250) | NO | | NULL | | | todo_id | int | NO | MUL | NULL | | +---------+--------------+------+-----+---------+----------------+ 3 rows in set (0.01 sec) mysql> select * from todo_item; +----+-----------+------+ | id | title | done | +----+-----------+------+ | 1 | Test 1 | 0 | | 2 | Test test | 0 | +----+-----------+------+ 2 rows in set (0.00 sec)
ขั้นตอนต่อไป
โค้ด React เราค่อนข้างยุ่งเหยิงมาก เราควรจะแยกส่วน TodoItem ออกมาเป็น component การแยกส่วนดังกล่าวส่งผลต่อแนวทางการออกแบบที่สำคัญว่าเราจะเก็บ state ที่จุดใดใน application ด้วย

