Giới Thiệu Dự Án
Dự án này là một nền tảng cho thuê nhà đơn giản, nơi chủ nhà có thể đăng tin cho thuê và khách thuê có thể tìm kiếm, đặt phòng trong một khoảng thời gian nhất định. Các module chức năng chính bao gồm module người dùng (đăng ký/đăng nhập/thông tin cá nhân), trang chủ thuê nhà, trang danh sách nhà, trang chi tiết nhà, trang đặt phòng, và tích hợp thanh toán Alipay.
Dự án này sử dụng kiến trúc phân tách frontend/backend không hoàn toàn, với frontend được xây dựng bằng HTML+CSS+jQuery và backend sử dụng framework Flask. Backend chỉ cung cấp các giao diện API để tương tác dữ liệu, trong khi việc hiển thị trang và các chức năng tương tác đều do frontend đảm nhiệm. Định dạng trao đổi dữ liệu giữa frontend và backend là JSON.
Xây Dựng Cấu Trúc Thư Mục Dự Án Flask
Khác với Django, framework Flask không có một cấu trúc dự án mặc định. Điều này mang lại sự linh hoạt cao nhưng cũng có thể khiến người mới bắt đầu bối rối vì không có một hướng dẫn tiêu chuẩn. Do đó, cấu trúc thư mục của các dự án Flask có thể rất đa dạng. Dự án này sẽ áp dụng một cấu trúc tham khảo từ Django.
Về lý thuyết, một dự án Flask có thể đặt tất cả mọi thứ vào một tệp duy nhất, giống như ví dụ "Hello World" đơn giản, nơi khởi động, cấu hình, hiển thị trang và định tuyến đều nằm chung một chỗ. Ở đây, chúng ta sẽ bắt đầu bằng cách đặt tất cả các thành phần cơ bản vào một tệp đơn lẻ, sau đó dần dần tách chúng ra thành các tệp theo chức năng, giúp dễ hiểu hơn cách cấu trúc một dự án lớn.
Tạo Tệp Dự Án Đơn Lẻ
Đầu tiên, hãy tạo một thư mục dự án có tên RentalApp. Bên trong thư mục này, tạo một tệp khởi động dự án đơn lẻ có tên run.py (tương tự như manage.py của Django). Tệp này sẽ chịu trách nhiệm chính cho việc khởi động ứng dụng, còn các logic nghiệp vụ khác sẽ được phân tách. Chúng ta sẽ bắt đầu với một ứng dụng Flask tối thiểu cùng các cấu hình cơ bản:
# run.py
from flask import Flask
# Khởi tạo ứng dụng Flask
app = Flask(__name__)
class AppSettings:
"""Lớp cài đặt ứng dụng"""
DEBUG_MODE = True
SECRET_KEY = 'supersecretkey12345'
# Áp dụng cấu hình cho ứng dụng
app.config.from_object(AppSettings)
@app.route('/')
def home_page():
return 'Trang chủ ứng dụng'
Tiếp theo, chúng ta sẽ thêm các cấu hình cho các thành phần khác sẽ được sử dụng, như SQLAlchemy, Redis, Flask-Session, Flask-Migrate và CSRF:
# run.py
from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_session import Session
from flask_wtf import CSRFProtect
import redis
app = Flask(__name__)
class AppSettings:
"""Lớp cài đặt ứng dụng"""
DEBUG_MODE = True
SECRET_KEY = 'supersecretkey12345'
# Máy chủ từ xa (ví dụ)
REMOTE_HOST = 'your_remote_server.com'
# Cài đặt cơ sở dữ liệu
SQLALCHEMY_DATABASE_URI = f'mysql://root:root@{REMOTE_HOST}:3306/rental_db'
SQLALCHEMY_TRACK_MODIFICATIONS = False # Khuyến nghị đặt False để tiết kiệm tài nguyên
# Cấu hình Redis
REDIS_HOST = REMOTE_HOST
REDIS_PORT = 6379
REDIS_CACHE_DB_INDEX = 0 # Cơ sở dữ liệu cho cache
REDIS_SESSION_DB_INDEX = 1 # Cơ sở dữ liệu cho session
# Cấu hình Flask-Session
SESSION_TYPE = 'redis'
SESSION_REDIS = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_SESSION_DB_INDEX)
SESSION_USE_SIGNER = True
PERMANENT_SESSION_LIFETIME = 86400 # Thời gian sống của session, đơn vị: giây (1 ngày)
# Áp dụng cấu hình
app.config.from_object(AppSettings)
# Khởi tạo các tiện ích mở rộng
database_manager = SQLAlchemy(app)
redis_client = redis.StrictRedis(host=AppSettings.REDIS_HOST, port=AppSettings.REDIS_PORT, db=AppSettings.REDIS_CACHE_DB_INDEX)
Session(app)
migrate_manager = Migrate(app, database_manager)
csrf_protection = CSRFProtect(app)
@app.route('/')
def home_page():
return 'Trang chủ ứng dụng'
Lưu ý:
- Cơ sở dữ liệu MySQL và Redis được giả định chạy trên một máy chủ từ xa (
your_remote_server.com). - MySQL được cấu hình thông qua tiện ích mở rộng Flask-SQLAlchemy. Redis được kết nối bằng thư viện
redisgốc của Python. - Hai cơ sở dữ liệu Redis được sử dụng: 0 cho các thông tin cache nghiệp vụ và 1 dành riêng cho thông tin session.
Tách Tệp Dự Án Đơn Lẻ
Cấu Hình Ứng Dụng
Đầu tiên, chúng ta sẽ tách lớp cấu hình AppSettings. Trong thư mục gốc RentalApp, tạo một tệp config.py và di chuyển lớp AppSettings vào đó, đồng thời điều chỉnh để hỗ trợ nhiều môi trường:
# config.py
import redis
class BaseConfig:
"""Lớp cấu hình cơ bản cho ứng dụng"""
SECRET_KEY = 'supersecretkey12345'
REMOTE_HOST = 'your_remote_server.com'
SQLALCHEMY_DATABASE_URI = f'mysql://root:root@{REMOTE_HOST}:3306/rental_db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
REDIS_HOST = REMOTE_HOST
REDIS_PORT = 6379
REDIS_CACHE_DB_INDEX = 0
REDIS_SESSION_DB_INDEX = 1
SESSION_TYPE = 'redis'
SESSION_REDIS = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_SESSION_DB_INDEX)
SESSION_USE_SIGNER = True
PERMANENT_SESSION_LIFETIME = 86400
class DevelopmentConfig(BaseConfig):
"""Cấu hình cho môi trường phát triển"""
DEBUG_MODE = True
class ProductionConfig(BaseConfig):
"""Cấu hình cho môi trường sản xuất"""
DEBUG_MODE = False
config_environment_map = {
'development': DevelopmentConfig,
'production': ProductionConfig
}
Lưu ý:
- Chúng ta định nghĩa hai lớp cấu hình:
DevelopmentConfigcho môi trường phát triển vàProductionConfigcho môi trường sản xuất. - Các cài đặt chung được đặt trong lớp cơ sở
BaseConfig. - Một từ điển
config_environment_mapđược tạo để ánh xạ tên môi trường với lớp cấu hình tương ứng.
Tạo Hàm Khởi Tạo Ứng Dụng (Application Factory)
Tệp run.py cuối cùng chỉ nên dùng để khởi động dự án. Logic nghiệp vụ sẽ được đặt trong một package Python mới có tên rental_app. Như vậy, trong thư mục gốc RentalApp, chúng ta chỉ có ba tệp/thư mục chính: config.py (cấu hình), run.py (khởi động) và thư mục rental_app (logic nghiệp vụ).
Vì run.py không cần biết chi tiết cách ứng dụng được tạo và cấu hình, chúng ta sẽ giới thiệu một hàm factory. Hàm này, đặt tên là create_app, sẽ đóng gói logic tạo ứng dụng và được định nghĩa trong tệp __init__.py của package rental_app. Khi đó, run.py chỉ cần import và gọi hàm này để tạo đối tượng ứng dụng.
Trong tệp rental_app/__init__.py, hãy định nghĩa phương thức create_app và import lớp cấu hình:
# rental_app/__init__.py
from flask import Flask
from config import config_environment_map
# Tạo hàm factory ứng dụng
def create_app(environment_name):
"""
Hàm factory để tạo đối tượng ứng dụng Flask
:param environment_name: str - Tên môi trường ('development'/'production')
:return: app - Đối tượng ứng dụng Flask
"""
app = Flask(__name__)
config_class = config_environment_map.get(environment_name)
app.config.from_object(config_class)
return app
Giờ đây, run.py sẽ import và sử dụng hàm này:
# run.py
from rental_app import create_app
app = create_app('development')
@app.route('/')
def home_page():
return 'Trang chủ ứng dụng'
Di Chuyển Thông Tin Cấu Hình Thành Phần
Tiện ích mở rộng Flask-Migrate có thể giữ lại trong run.py vì nó liên quan đến việc quản lý cơ sở dữ liệu từ dòng lệnh. Các tiện ích mở rộng khác như SQLAlchemy, Redis, Session, CSRF cần được liên kết với đối tượng app. Chúng ta có thể liên kết chúng ngay khi ứng dụng được tạo, tức là di chuyển logic này vào hàm create_app. Điều này giúp run.py trở nên gọn gàng hơn, chỉ còn chức năng khởi động và migrate.
Cập nhật run.py:
# run.py
from flask_migrate import Migrate
from rental_app import create_app, db
# Tạo đối tượng ứng dụng
app = create_app('development')
# Tạo đối tượng Migrate (cần db instance từ rental_app)
migrate_manager = Migrate(app, db)
@app.route('/')
def home_page():
return 'Trang chủ ứng dụng'
Tuy nhiên, đối tượng db (SQLAlchemy) và redis_client (Redis) không thể chỉ được định nghĩa trong create_app vì chúng cần được sử dụng trong các tệp định nghĩa model và các hàm view. Do đó, chúng phải được tiếp cận từ bên ngoài hàm create_app.
Có hai cách để giải quyết vấn đề này:
- Với các tiện ích mở rộng Flask như Flask-SQLAlchemy: Thay vì liên kết ngay lập tức (
SQLAlchemy(app)), chúng ta có thể tạo đối tượng tiện ích mở rộng trước, sau đó gọi phương thứcinit_app(app)của nó khi đối tượngappđã được tạo. - Với các đối tượng không phải tiện ích mở rộng Flask (như kết nối Redis gốc): Khởi tạo một biến toàn cục là
None, sau đó gán giá trị cụ thể cho nó bên trongcreate_appbằng cách sử dụng từ khóaglobal.
Sau khi điều chỉnh, tệp rental_app/__init__.py sẽ như sau:
# rental_app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_session import Session
from flask_wtf import CSRFProtect
import redis
from config import config_environment_map
# Khởi tạo đối tượng database_manager mà không liên kết ngay với app
database_manager = SQLAlchemy()
# Khởi tạo đối tượng redis_client ban đầu là None
redis_client = None
# Đối tượng CSRFProtect cũng cần được khởi tạo ở đây để có thể sử dụng @csrf_protection.exempt
csrf_protection = CSRFProtect()
def create_app(environment_name):
"""
Hàm factory để tạo đối tượng ứng dụng Flask
:param environment_name: str - Tên môi trường ('development'/'production')
:return: app - Đối tượng ứng dụng Flask
"""
app = Flask(__name__)
config_class = config_environment_map.get(environment_name)
app.config.from_object(config_class)
# Liên kết các tiện ích mở rộng với ứng dụng
database_manager.init_app(app)
global redis_client
redis_client = redis.StrictRedis(
host=config_class.REDIS_HOST,
port=config_class.REDIS_PORT,
db=config_class.REDIS_CACHE_DB_INDEX
)
Session(app)
csrf_protection.init_app(app)
return app
Tóm tắt cấu hình các thành phần:
- Tệp
__init__.pynày sẽ công khai hai loại nội dung:create_app: Hàm factory tạo ứng dụng, đóng gói logic khởi tạo ứng dụng.- Các đối tượng tiện ích mở rộng khác cần được sử dụng ở các tệp khác, ví dụ
database_manager,redis_client,csrf_protection.
- Các tiện ích mở rộng Flask có thể được khởi tạo theo hai cách:
- Tạo đối tượng và truyền đối tượng
appngay lập tức:db = SQLAlchemy(app). - Tạo đối tượng trước mà không truyền
app, sau đó gọidb.init_app(app)khiappđã được tạo (lazy binding). Cách này phù hợp khi đối tượng tiện ích mở rộng cần được truy cập ở phạm vi toàn cục.
- Tạo đối tượng và truyền đối tượng
- Đối với kết nối Redis (không phải tiện ích mở rộng Flask), chúng ta sử dụng biến toàn cục khởi tạo là
Nonevà gán giá trị trongcreate_app. - Đối tượng
Sessionkhông cần công khai ra ngoài vì nó chỉ sửa đổi cơ chế session của ứng dụng.
Tạo Lớp Model (models.py)
Trong thư mục nghiệp vụ rental_app, tạo một tệp models.py. Vì dự án này không có quá nhiều bảng, chúng ta sẽ đặt tất cả các định nghĩa bảng trong một tệp. Nếu dự án lớn hơn, có thể chia thành nhiều tệp model theo từng module chức năng.
Cấu trúc model dự kiến bao gồm: User (người dùng), House (nhà), HouseImage (ảnh nhà), Area (khu vực), Facility (tiện nghi), HouseFacility (tiện nghi của nhà) và Order (đơn đặt phòng):
# rental_app/models.py
from rental_app import database_manager as db
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
class BaseModel(db.Model):
"""Lớp cơ sở trừu tượng cho các bảng"""
__abstract__ = True
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
class User(BaseModel):
"""Model người dùng"""
__tablename__ = 'users'
phone = db.Column(db.String(11), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
username = db.Column(db.String(240), unique=True, nullable=False)
avatar_url = db.Column(db.String(240))
real_name = db.Column(db.String(30))
id_card_number = db.Column(db.String(20), unique=True) # Thay Integer bằng String cho số CMND/CCCD
@property
def password(self):
raise AttributeError('Mật khẩu không được đọc trực tiếp')
@password.setter
def password(self, plain_password):
self.password_hash = generate_password_hash(plain_password)
def verify_password(self, plain_password):
return check_password_hash(self.password_hash, plain_password)
def __repr__(self):
return f'<User {self.username}>'
class Area(BaseModel):
"""Model khu vực"""
__tablename__ = 'areas'
name = db.Column(db.String(32), unique=True, nullable=False)
houses = db.relationship('House', backref='area', lazy='dynamic') # Thêm mối quan hệ ngược
def __repr__(self):
return f'<Area {self.name}>'
class House(BaseModel):
"""Model nhà cho thuê"""
__tablename__ = 'houses'
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
owner = db.relationship('User', backref='houses', lazy='dynamic')
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=False)
title = db.Column(db.String(240), nullable=False)
price_per_night = db.Column(db.Integer, default=0) # Giá mỗi đêm, đơn vị: xu
address = db.Column(db.String(512)) # Địa chỉ
room_count = db.Column(db.Integer, default=1) # Số phòng
area_sqm = db.Column(db.Integer, default=0) # Diện tích nhà (m2)
unit_type = db.Column(db.String(32)) # Loại hình (VD: 1PN 1WC)
max_guests = db.Column(db.Integer, default=1) # Sức chứa tối đa
beds_config = db.Column(db.String(64)) # Cấu hình giường
deposit_amount = db.Column(db.Integer, default=0) # Tiền đặt cọc
min_rental_days = db.Column(db.Integer, default=1) # Số ngày thuê tối thiểu
max_rental_days = db.Column(db.Integer, default=0) # Số ngày thuê tối đa (0 = không giới hạn)
booking_count = db.Column(db.Integer, default=0) # Số lần được đặt
main_image_url = db.Column(db.String(240)) # URL ảnh đại diện
images = db.relationship('HouseImage', backref='house', lazy='dynamic')
facilities = db.relationship('Facility', secondary='house_facilities_junction', backref='houses', lazy='dynamic') # Mối quan hệ nhiều-nhiều
def __repr__(self):
return f'<House {self.title}>'
class HouseImage(BaseModel):
"""Model ảnh nhà"""
__tablename__ = 'house_images'
house_id = db.Column(db.Integer, db.ForeignKey('houses.id'), nullable=False)
image_url = db.Column(db.String(240), nullable=False)
class Facility(BaseModel):
"""Model tiện nghi"""
__tablename__ = 'facilities'
name = db.Column(db.String(32), nullable=False)
def __repr__(self):
return f'<Facility {self.name}>'
# Bảng liên kết cho mối quan hệ nhiều-nhiều giữa House và Facility
house_facilities_junction = db.Table(
'house_facilities_junction',
db.Column('house_id', db.Integer, db.ForeignKey('houses.id'), primary_key=True),
db.Column('facility_id', db.Integer, db.ForeignKey('facilities.id'), primary_key=True)
)
class Order(BaseModel):
"""Model đơn đặt phòng"""
__tablename__ = 'orders'
order_reference = db.Column(db.String(30), unique=True, nullable=False, index=True)
guest_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
guest = db.relationship('User', backref='orders', lazy='dynamic')
house_id = db.Column(db.Integer, db.ForeignKey('houses.id'), nullable=False)
house = db.relationship('House', backref='orders', lazy='dynamic')
check_in_date = db.Column(db.Date, nullable=False)
check_out_date = db.Column(db.Date, nullable=False)
total_days = db.Column(db.Integer, nullable=False)
unit_price = db.Column(db.Integer, nullable=False)
total_amount = db.Column(db.Integer, nullable=False)
comment = db.Column(db.Text)
status = db.Column(db.Enum(
'WAITING_ACCEPTANCE', 'PENDING_PAYMENT', 'WAITING_REVIEW', 'COMPLETED', 'CANCELLED', 'REJECTED'
), default='WAITING_ACCEPTANCE', index=True)
def __repr__(self):
return f'<Order {self.order_reference}>'
Lưu ý:
- Cần import đối tượng
database_managerđã tạo trước đó. Tất cả các lớp model đều kế thừa từdb.Model. - Định nghĩa một lớp cơ sở trừu tượng
BaseModelđể chứa các trường chung, với__abstract__ = Trueđể không tạo bảng cho lớp này. - Mật khẩu người dùng được lưu trữ dưới dạng hash. Việc gán giá trị cho thuộc tính
passwordthông qua@password.settersẽ tự động băm mật khẩu. - Đối với các trường hình ảnh, chúng ta sẽ lưu trữ URL từ dịch vụ lưu trữ bên thứ ba.
- Mối quan hệ một-nhiều được định nghĩa bằng
db.ForeignKeyvàdb.relationship. - Mối quan hệ nhiều-nhiều được định nghĩa thông qua
db.Table()để tạo một bảng liên kết. - Sử dụng
db.Enum()cho các trường có giá trị cố định, như trạng thái đơn hàng.
Tạo Module Blueprint
Một dự án lớn thường được chia thành nhiều module chức năng (ví dụ: module người dùng, module nhà, module đơn hàng). Trong Flask, khái niệm này được gọi là Blueprint, tương tự như "app" trong Django.
Chúng ta sẽ tạo các thư mục blueprint trong thư mục nghiệp vụ chính (rental_app). Trong dự án này, để đơn giản, chúng ta sẽ sử dụng các phiên bản API để phân tách các blueprint. Ví dụ, tạo một package Python api_v1 (cho phiên bản 1.0). Thông thường, blueprint sẽ được định nghĩa trong tệp __init__.py của package này.
Tạo thư mục api_v1 trong rental_app, và thêm tệp __init__.py:
# rental_app/api_v1/__init__.py
from flask import Blueprint
# Tạo Blueprint
api_blueprint_v1 = Blueprint('api_v1_module', __name__, url_prefix='/api/v1.0')
# Import các view của blueprint
from . import user_views
from . import house_views # Giả định sẽ có thêm file views khác
Lưu ý:
- Blueprint có thể được coi là một subclass của ứng dụng Flask.
- Tiền tố URL cho blueprint có thể được đặt khi định nghĩa blueprint hoặc khi đăng ký nó với ứng dụng.
- Cần import các tệp view vào blueprint để Flask có thể tìm thấy các hàm view đã định nghĩa.
Đồng thời, trong thư mục api_v1, tạo tệp view cho module người dùng, ví dụ user_views.py. Lưu ý cần import đối tượng database_manager và các model từ rental_app.models để chúng được nhận diện khi migrate:
# rental_app/api_v1/user_views.py
from . import api_blueprint_v1
from rental_app import models, database_manager
@api_blueprint_v1.route('/users')
def get_users():
return 'Trang người dùng (API v1)'
Sau khi định nghĩa blueprint, chúng ta cần đăng ký nó với ứng dụng chính. Điều này được thực hiện trong hàm factory create_app của rental_app/__init__.py. Nên import blueprint ngay trước khi đăng ký để tránh lỗi import vòng tròn.
# rental_app/__init__.py
# ... (các import và định nghĩa khác) ...
def create_app(environment_name):
app = Flask(__name__)
# ... (cấu hình và liên kết tiện ích mở rộng) ...
# Đăng ký blueprint. Nên import ngay trước khi đăng ký.
from rental_app.api_v1 import api_blueprint_v1
app.register_blueprint(api_blueprint_v1)
return app
Lưu ý: Câu lệnh import blueprint from rental_app.api_v1 import api_blueprint_v1 nên được đặt bên trong hàm create_app hoặc ngay trước khi sử dụng nó, không đặt ở đầu tệp để tránh vấn đề import vòng tròn có thể xảy ra.
Thực Thi Lệnh Di Chuyển Cơ Sở Dữ Liệu
Sau khi định nghĩa các model, chúng ta cần tạo bảng trong cơ sở dữ liệu bằng cách sử dụng Flask-Migrate:
# Đặt biến môi trường FLASK_APP trỏ đến tệp khởi động của bạn
export FLASK_APP=run.py
# Khởi tạo thư mục migrate
flask db init
# Tạo script migrate ban đầu dựa trên các model
flask db migrate -m "Initial database schema"
# Áp dụng các thay đổi vào cơ sở dữ liệu
flask db upgrade
Quy Trình Khởi Động Ứng Dụng
Khi chạy lệnh flask run sau khi đặt biến FLASK_APP:
export FLASK_APP=run.py
flask run
Ứng dụng sẽ khởi động thông qua run.py. Hàm create_app được gọi để tạo đối tượng ứng dụng, trong đó các tiện ích mở rộng được liên kết và các blueprint được đăng ký. Blueprint api_v1, khi được import, sẽ import các tệp view (ví dụ: user_views.py). Các tệp view này lại import các model từ rental_app.models. Như vậy, toàn bộ cấu trúc (blueprint, view, model) được kết nối với nhau.
Bạn có thể truy cập http://127.0.0.1:5000/api/v1.0/users để xem kết quả.
Tạo Thư Mục cho Tệp Tĩnh, Tiện Ích và Thư Viện
Trong thư mục rental_app, tạo ba thư mục sau:
static: Thư mục chứa các tệp tĩnh frontend như HTML, CSS, JavaScript.utils: Thư mục chứa các package tiện ích dùng chung, các hàm trợ giúp toàn cục.libs: Thư mục chứa mã nguồn hoặc các tệp cấu hình của các thư viện bên thứ ba.
Cấu trúc dự án cuối cùng sẽ trông như sau:
.
├── config.py
├── rental_app
│ ├── __init__.py
│ ├── api_v1
│ │ ├── __init__.py
│ │ └── user_views.py
│ ├── libs
│ ├── models.py
│ ├── static
│ │ ├── css
│ │ ├── favicon.ico
│ │ ├── html
│ │ ├── images
│ │ ├── js
│ │ └── plugins
│ └── utils
├── migrations
│ └── ...
├── run.py
Thêm Blueprint để Truy Cập Tệp Tĩnh
Trong dự án này, mã frontend và backend nằm cùng một thư mục. Các tệp HTML/CSS/JS được đặt trong rental_app/static. Mặc định, người dùng phải nhập URL đầy đủ như http://127.0.0.1:5000/static/html/index.html để truy cập trang chủ. Chúng ta muốn đơn giản hóa việc này thành http://127.0.0.1:5000/ hoặc http://127.0.0.1:5000/index.
Để hỗ trợ điều này, chúng ta cần một blueprint đặc biệt để phân tích URL do người dùng nhập, tìm tệp HTML tương ứng trong thư mục static và trả về. Blueprint này sẽ xử lý việc chuyển đổi URL thành đường dẫn tệp tĩnh.
Bộ Chuyển Đổi Định Tuyến Tùy Chỉnh (Custom Route Converter)
Để phân tích các URL linh hoạt, chúng ta có thể sử dụng bộ chuyển đổi định tuyến. Mặc định, app.route('/ yêu cầu một giá trị sau /. Để cho phép URL trống (/), chúng ta sẽ tạo một bộ chuyển đổi định tuyến tùy chỉnh dựa trên biểu thức chính quy (regex).
Tạo Lớp Bộ Chuyển Đổi Định Tuyến
Vì bộ chuyển đổi này có thể được sử dụng ở nhiều nơi, chúng ta sẽ định nghĩa nó trong thư mục utils. Tạo tệp rental_app/utils/custom_converters.py:
# rental_app/utils/custom_converters.py
from werkzeug.routing import BaseConverter
class RegexConverter(BaseConverter):
"""Bộ chuyển đổi định tuyến tùy chỉnh sử dụng regex"""
def __init__(self, url_map, regex):
super().__init__(url_map)
self.regex = regex
Đăng Ký Bộ Chuyển Đổi Định Tuyến với Ứng Dụng
Trong hàm factory create_app, thêm logic đăng ký bộ chuyển đổi định tuyến. Sau khi đăng ký, chúng ta có thể sử dụng nó trong các hàm view của blueprint.
# rental_app/__init__.py
# ... (các import và định nghĩa khác) ...
from rental_app.utils.custom_converters import RegexConverter
def create_app(environment_name):
app = Flask(__name__)
# ... (cấu hình và liên kết tiện ích mở rộng) ...
# Đăng ký bộ chuyển đổi định tuyến tùy chỉnh
app.url_map.converters['re'] = RegexConverter
# ... (đăng ký blueprint api_v1) ...
return app
Tạo Blueprint
Trong thư mục rental_app, thêm một tệp mới html_renderer.py để xử lý việc trả về các tệp HTML. Lưu ý cách sử dụng bộ chuyển đổi re (không có khoảng trắng sau dấu hai chấm).
# rental_app/html_renderer.py
from flask import Blueprint, current_app, make_response
from flask_wtf import csrf # Để tạo CSRF token
html_blueprint = Blueprint('web_pages', __name__)
@html_blueprint.route('/')
def serve_html_file(file_path):
"""
Dựa vào file_path trong URL, trả về tệp HTML tương ứng từ thư mục static.
"""
# Nếu file_path trống (ví dụ: '/'), mặc định trả về index.html
if not file_path:
file_path = 'index.html'
# Nếu không kết thúc bằng .html, thêm .html vào cuối
if not file_path.endswith('.html') and file_path != 'favicon.ico':
file_path += '.html'
# Các tệp HTML thực tế nằm trong static/html/, Flask chỉ tìm kiếm đến static/
# Do đó, cần thêm tiền tố 'html/' vào đường dẫn
if file_path != 'favicon.ico': # favicon.ico thường nằm trực tiếp trong static/
file_path = 'html/' + file_path
# Sử dụng send_static_file để gửi tệp cho trình duyệt
response = make_response(current_app.send_static_file(file_path))
# Tạo và đặt CSRF token vào cookie
csrf_token = csrf.generate_csrf()
response.set_cookie('csrf_token', csrf_token)
return response
Đăng Ký Blueprint
Trong hàm factory create_app, thêm logic đăng ký blueprint html_blueprint:
# rental_app/__init__.py
# ... (các import và định nghĩa khác) ...
def create_app(environment_name):
app = Flask(__name__)
# ... (cấu hình và liên kết tiện ích mở rộng, đăng ký RegexConverter) ...
# Đăng ký blueprint HTML
from rental_app.html_renderer import html_blueprint
app.register_blueprint(html_blueprint)
return app
Bây giờ, bạn có thể truy cập http://127.0.0.1:5000/ hoặc http://127.0.0.1:5000/register để xem các tệp HTML tương ứng (giả định chúng tồn tại trong static/html/).
Tấn Công CSRF và Bảo Vệ
Tấn Công CSRF
CSRF (Cross-site request forgery - Giả mạo yêu cầu chéo trang), còn được gọi là XSRF, là một kiểu tấn công mà kẻ xấu lợi dụng đặc điểm của trình duyệt để thực hiện các yêu cầu độc hại (ví dụ: chuyển tiền, gửi email) dưới danh nghĩa của bạn mà bạn không hề hay biết.
Ví dụ Tấn Công
Giả sử bạn đăng nhập vào ngân hàng trực tuyến bank.com. Ngân hàng đặt một cookie xác thực trong trình duyệt của bạn. Kẻ tấn công gửi cho bạn một email lừa đảo chứa một liên kết đến evil.com. Khi bạn nhấp vào liên kết này, trang evil.com sẽ tự động gửi một yêu cầu HTTP đến bank.com/transfer?amount=1000&to=evil_account. Trình duyệt của bạn, vì bạn vẫn đang đăng nhập vào bank.com, sẽ tự động đính kèm cookie xác thực của bạn vào yêu cầu này. Ngân hàng nhận được yêu cầu và xử lý nó như một yêu cầu hợp lệ từ bạn, dẫn đến việc chuyển tiền cho kẻ tấn công.
Điều Kiện Cần Thiết cho Tấn Công CSRF
- Người dùng phải đăng nhập vào trang web bị tấn công trước đó và có cookie xác thực được lưu trữ cục bộ, và chưa đăng xuất. (Điều kiện này rất dễ đáp ứng, vì người dùng hiếm khi đăng xuất khỏi mọi trang web sau khi truy cập, và trạng thái đăng nhập có thể được lưu giữ ngay cả khi đóng trình duyệt).
- Người dùng phải truy cập trang web của kẻ tấn công (Điều kiện này là bắt buộc). Tuy nhiên, trang web của kẻ tấn công có thể trông giống như một trang hợp pháp, hoặc là một trang web hợp pháp nhưng có lỗ hổng bảo mật. Do đó, việc nhấp vào một liên kết không rõ nguồn gốc hoặc truy cập một trang web hợp pháp có lỗ hổng đều có thể dẫn đến nguy cơ bị tấn công.
Nguyên Lý Chính của Tấn Công CSRF
- Tấn công CSRF lợi dụng đặc điểm của trình duyệt là tự động đính kèm cookie xác thực vào các yêu cầu gửi đến cùng một domain mà cookie đó được thiết lập, ngay cả khi yêu cầu đó được khởi tạo từ một domain khác.
- Điều quan trọng cần lưu ý là trang web của kẻ tấn công chỉ có thể sử dụng cookie thông qua trình duyệt, nhưng không thể đọc giá trị cụ thể của cookie.
Bảo Vệ CSRF
Tận dụng điểm yếu là trang web của kẻ tấn công không thể đọc giá trị cụ thể của cookie, chúng ta có thể thêm cơ chế bảo vệ CSRF ở phía server. Cơ chế này yêu cầu mọi yêu cầu phải kèm theo một giá trị bí mật (gọi là csrf_token) trong URL hoặc trong body của yêu cầu. Server sau đó sẽ so sánh csrf_token này với giá trị được lưu trữ trong cookie của người dùng. Nếu không khớp, yêu cầu sẽ bị từ chối.
- Đối với yêu cầu hợp lệ từ người dùng: Frontend có thể lấy giá trị cookie
csrf_tokenvà thêm vào yêu cầu. Do đó, hai giá trị sẽ khớp và yêu cầu được chấp nhận. - Đối với yêu cầu từ trang web của kẻ tấn công: Do chính sách cùng nguồn gốc (same-origin policy) của trình duyệt, script từ một domain không thể truy cập tài nguyên (bao gồm cookie) được thiết lập bởi một domain khác. Vì vậy, trang web của kẻ tấn công không thể biết giá trị
csrf_tokenchính xác, và yêu cầu giả mạo sẽ không thể mang theo giá trị đúng, dẫn đến việc bị từ chối.
Thêm Cơ Chế Bảo Vệ CSRF trong Flask
Trong Flask, chúng ta có thể sử dụng tiện ích mở rộng flask-wtf để thêm cơ chế bảo vệ CSRF. Trong hàm factory create_app trước đó, chúng ta đã khởi tạo CSRFProtect:
# rental_app/__init__.py
from flask_wtf import CSRFProtect
csrf_protection = CSRFProtect() # Khởi tạo đối tượng toàn cục
def create_app(environment_name):
app = Flask(__name__)
# ...
csrf_protection.init_app(app) # Liên kết với ứng dụng
return app
Cơ chế này chỉ hoàn thành chức năng kiểm tra ở phía server. Việc thiết lập cookie cho trình duyệt và đưa csrf_token vào các yêu cầu vẫn cần được chúng ta thực hiện thủ công.
Trong các dự án sử dụng template của Django hoặc Flask, việc thêm {% csrf_token %} vào thẻ form thường đã đủ. Tuy nhiên, với kiến trúc frontend/backend tách rời, chúng ta không sử dụng template engine của framework. Do đó, chúng ta cần tự đặt cookie csrf_token.
Chúng ta sẽ đặt cookie csrf_token khi trả về các trang HTML. Chỉnh sửa tệp rental_app/html_renderer.py như sau:
# rental_app/html_renderer.py
from flask import Blueprint, current_app, make_response
from flask_wtf import csrf # Import csrf từ flask_wtf
html_blueprint = Blueprint('web_pages', __name__)
@html_blueprint.route('/')
def serve_html_file(file_path):
"""
Dựa vào file_path trong URL, trả về tệp HTML tương ứng từ thư mục static.
"""
if not file_path:
file_path = 'index.html'
if not file_path.endswith('.html') and file_path != 'favicon.ico':
file_path += '.html'
if file_path != 'favicon.ico':
file_path = 'html/' + file_path
# Tạo đối tượng response
response = make_response(current_app.send_static_file(file_path))
# Tạo csrf_token
csrf_token_value = csrf.generate_csrf()
# Đặt cookie csrf_token
response.set_cookie('csrf_token', csrf_token_value)
return response
Lưu ý:
- Sử dụng phương thức
csrf.generate_csrf()của tiện ích mở rộngflask-wtfđể tạocsrf_token. - Để đặt cookie, cần tạo một đối tượng phản hồi bằng
make_response, sau đó dùngresponse.set_cookie().
Khi truy cập http://127.0.0.1:5000/register.html, bạn sẽ thấy cookie csrf_token đã được thiết lập. Mỗi lần truy cập trang, một csrf_token mới sẽ được tạo và cookie sẽ được làm mới.
Gửi csrf_token từ Frontend
Trong các dự án frontend/backend tách rời, bạn cần thêm csrf_token vào các yêu cầu gửi đi. Cách phổ biến là thêm thuộc tính X-CSRFToken vào header của yêu cầu hoặc thêm thuộc tính csrf_token vào body của yêu cầu.
Nếu thêm csrf_token vào body, yêu cầu phải được gửi dưới định dạng form (application/x-www-form-urlencoded). Đối với các dự án sử dụng JSON để trao đổi dữ liệu, phương pháp tốt nhất là thêm X-CSRFToken vào header của yêu cầu.
Hủy Kích Hoạt Bảo Vệ CSRF trong Một View Cụ Thể
Đôi khi, bạn có thể cần hủy kích hoạt bảo vệ CSRF cho một số view cụ thể (ví dụ: các API chỉ đọc hoặc các API cần được truy cập bởi bên thứ ba không có CSRF token). Để làm điều này, bạn cần import đối tượng csrf_protection đã được liên kết với ứng dụng, sau đó sử dụng decorator @csrf_protection.exempt trên hàm view:
# rental_app/api_v1/user_views.py
from . import api_blueprint_v1
from rental_app import csrf_protection # Import đối tượng csrf_protection
# ...
@api_blueprint_v1.route('/users', methods=['POST'])
@csrf_protection.exempt # Hủy kích hoạt CSRF cho view này
def register_user():
# Xử lý đăng ký người dùng
pass
Chức Năng Ghi Log
Chúng ta sẽ sử dụng module ghi log gốc của Python. Thêm cấu hình ghi log vào tệp rental_app/__init__.py:
# rental_app/__init__.py
import logging
from logging.handlers import RotatingFileHandler
# ... (các import khác) ...
# Hàm cấu hình ghi log
def setup_logging(log_level):
"""Cấu hình ghi log cho ứng dụng"""
# Đặt cấp độ ghi log
logging.basicConfig(level=log_level)
# Tạo trình xử lý tệp ghi log xoay vòng
# Lưu log vào thư mục 'logs', mỗi tệp tối đa 100MB, giữ lại 10 tệp cũ
log_file_handler = RotatingFileHandler("logs/application.log", maxBytes=1024*1024*100, backupCount=10, encoding='utf-8')
# Định dạng bản ghi log: Thời gian, cấp độ, tên tệp, tên hàm, số dòng, thông điệp
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s'
)
# Đặt định dạng cho trình xử lý
log_file_handler.setFormatter(formatter)
# Thêm trình xử lý tệp vào logger gốc (logger mà Flask app sẽ sử dụng)
logging.getLogger().addHandler(log_file_handler)
# Gọi hàm cấu hình ghi log khi ứng dụng được tạo
def create_app(environment_name):
# Cấu hình log trước khi tạo app để app có thể sử dụng ngay
setup_logging(logging.INFO if environment_name == 'production' else logging.DEBUG)
app = Flask(__name__)
# ... (cấu hình và liên kết tiện ích mở rộng, đăng ký converters và blueprints) ...
return app
Các bản ghi log sẽ được lưu trong tệp logs/application.log trong thư mục gốc của dự án. Cần tạo thư mục logs thủ công nếu nó chưa tồn tại, tệp log sẽ được tạo tự động.
Để ghi log trong các hàm view hoặc các phần khác của ứng dụng:
from flask import current_app
# ...
current_app.logger.error('Đây là một thông báo lỗi.')
current_app.logger.info('Đây là một thông tin quan trọng.')