Chuyển tới nội dung chính

🐍 Ngày 45 Python 365 ngày | Xây dựng ứng dụng web đầu tiên với Flask

🗓 Ngày 45: Chuyển Python từ terminal lên web​

Từ ngày 1 đến 44, mọi thứ bạn viết đều chạy trong terminal. Nhưng để chia sẻ với người khác — cho đồng nghiệp xem dashboard, cho khách hàng tra cứu dữ liệu — bạn cần web. Hôm nay chúng ta bắt đầu với Flask, framework web Python nhỏ gọn và dễ học nhất.


🎯 Mục tiêu bài học​

  • Hiểu web framework là gì và Flask nằm ở đâu.
  • Tạo route, trả HTML và JSON.
  • Dùng template Jinja2 để tách giao diện khỏi logic.
  • Nhận dữ liệu từ form và query string.
  • Dùng session để nhớ trạng thái người dùng.
  • Xây một app hoàn chỉnh: Tra cứu tỷ giá crypto.

🧠 Flask là gì và khi nào nên dùng?​

Flask là micro-framework: nó cung cấp đúng những gì cần thiết (routing, request/response, template) và để bạn tự chọn phần còn lại.

FrameworkTriết lýPhù hợp
FlaskNhỏ, linh hoạt, tự chọn thư việnAPI nhỏ, prototype, microservice, học web
DjangoPin kèm mọi thứ (ORM, admin, auth)Ứng dụng lớn, nhiều CRUD
FastAPIAsync, tự sinh tài liệu APIAPI hiệu năng cao, ML serving

💡 Quy tắc chọn nhanh: Cần API → FastAPI. Cần web có giao diện + admin → Django. Cần thứ gì đó nhỏ, nhanh, dễ hiểu → Flask. Flask cũng là nền tảng tốt nhất để hiểu web hoạt động ra sao trước khi học hai cái còn lại.


🧰 Cài đặt và tạo dự án​

# Tạo môi trường ảo — luôn luôn làm bước này
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux

pip install flask

Cấu trúc dự án:

crypto_app/
├── app.py
├── requirements.txt
├── templates/
│ ├── base.html
│ ├── index.html
│ └── ket_qua.html
└── static/
└── style.css

🚀 Bước 1: App Flask nhỏ nhất​

# app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def trang_chu():
return "<h1>Xin chào từ Flask! 🐍</h1>"

if __name__ == "__main__":
app.run(debug=True)

Chạy:

python app.py

Mở http://127.0.0.1:5000 — bạn vừa có một web server. Chỉ 7 dòng code.

⚙️ debug=True giúp tự reload khi sửa code và hiện traceback chi tiết. Tuyệt đối tắt khi deploy — nó cho phép chạy code tùy ý trên server.


🛣️ Bước 2: Route — bản đồ URL của ứng dụng​

from flask import Flask, jsonify, request

app = Flask(__name__)

# Route tĩnh
@app.route("/gioi-thieu")
def gioi_thieu():
return "<p>Đây là trang giới thiệu.</p>"

# Route động: <ten> là biến nhận từ URL
@app.route("/chao/<ten>")
def chao(ten):
return f"<h1>Chào {ten}, chúc bạn học Python vui vẻ!</h1>"

# Route nhận số, có ràng buộc kiểu dữ liệu
@app.route("/binh-phuong/<int:so>")
def binh_phuong(so):
return f"{so}² = {so ** 2}"

# Route trả JSON (dùng cho API)
@app.route("/api/trang-thai")
def trang_thai():
return jsonify({"trang_thai": "ok", "phien_ban": "1.0"})

# Nhiều method trên cùng một URL
@app.route("/api/du-lieu", methods=["GET", "POST"])
def du_lieu():
if request.method == "POST":
return jsonify({"da_nhan": request.get_json()})
return jsonify({"method": "GET"})

Quy tắc vàng: tên hàm phải duy nhất trong toàn app, vì Flask dùng tên hàm để sinh URL.


🎨 Bước 3: Template Jinja2 — tách giao diện khỏi logic​

Trả HTML bằng chuỗi Python sẽ nhanh chóng biến thành ác mộng. Jinja2 giải quyết việc đó.

templates/base.html — bộ khung dùng chung:

<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crypto App{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('trang_chu') }}">Trang chủ</a> |
<a href="{{ url_for('gioi_thieu') }}">Giới thiệu</a>
</nav>

<main>
{% block noi_dung %}{% endblock %}
</main>

<footer>© 2026 Hướng Nghiệp Python</footer>
</body>
</html>

templates/index.html — kế thừa khung trên:

{% extends "base.html" %}

{% block title %}Tra cứu tỷ giá{% endblock %}

{% block noi_dung %}
<h1>Tra cứu tỷ giá Crypto</h1>

<form method="POST" action="{{ url_for('tra_cuu') }}">
<label for="symbol">Cặp giao dịch:</label>
<input type="text" id="symbol" name="symbol" value="BTCUSDT" required>
<button type="submit">Tra cứu</button>
</form>

{% if loi %}
<p class="loi">⚠️ {{ loi }}</p>
{% endif %}
{% endblock %}

Cập nhật Python:

from flask import Flask, render_template

@app.route("/")
def trang_chu():
return render_template("index.html")

Cú pháp Jinja2 cần nhớ:

Cú phápÝ nghĩa
{{ bien }}In giá trị
{% if %} / {% else %}Rẽ nhánh
{% for x in ds %}Vòng lặp
{% extends "base.html" %}Kế thừa template
{% block ten %}Vùng có thể ghi đè
{{ url_for('ten_ham') }}Sinh URL (không hard-code)

📝 Bước 4: Nhận dữ liệu từ form​

from flask import Flask, render_template, request, jsonify

@app.route("/tra-cuu", methods=["GET", "POST"])
def tra_cuu():
if request.method == "POST":
# Dữ liệu từ <form> nằm trong request.form
symbol = request.form.get("symbol", "").strip().upper()

if not symbol:
return render_template("index.html", loi="Bạn chưa nhập cặp giao dịch.")

# Dữ liệu từ query string ?symbol=BTCUSDT nằm trong request.args
return render_template("ket_qua.html", symbol=symbol, gia=lay_gia(symbol))

return render_template("index.html")

Ba nguồn dữ liệu đầu vào — phân biệt rõ:

NguồnThuộc tínhVí dụ
Form (application/x-www-form-urlencoded)request.form<input name="symbol">
Query string trên URLrequest.args/tra-cuu?symbol=BTCUSDT
JSON bodyrequest.get_json()fetch() từ JavaScript

🔌 Bước 5: Gọi API bên ngoài (CoinGecko)​

import urllib.request
import json

def lay_gia(symbol: str) -> dict:
"""Lấy giá crypto từ CoinGecko (không cần API key)."""
bang_doi = {"BTCUSDT": "bitcoin", "ETHUSDT": "ethereum", "SOLUSDT": "solana"}
coin_id = bang_doi.get(symbol)

if not coin_id:
return {"loi": f"Chưa hỗ trợ cặp {symbol}"}

url = (
"https://api.coingecko.com/api/v3/simple/price"
f"?ids={coin_id}&vs_currencies=usd&include_24hr_change=true"
)

try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read().decode())["bitcoin" if coin_id == "bitcoin" else coin_id]
return {
"gia_usd": data["usd"],
"thay_doi_24h": round(data.get("usd_24h_change", 0), 2),
}
except Exception as e:
return {"loi": f"Không lấy được dữ liệu: {e}"}

templates/ket_qua.html:

{% extends "base.html" %}
{% block title %}{{ symbol }} — Kết quả{% endblock %}

{% block noi_dung %}
<h1>Kết quả: {{ symbol }}</h1>

{% if gia.loi %}
<p class="loi">⚠️ {{ gia.loi }}</p>
{% else %}
<table>
<tr><th>Giá (USD)</th><td>${{ "{:,.2f}".format(gia.gia_usd) }}</td></tr>
<tr><th>Thay đổi 24h</th>
<td class="{{ 'xanh' if gia.thay_doi_24h >= 0 else 'do' }}">
{{ "{:+.2f}".format(gia.thay_doi_24h) }}%
</td></tr>
</table>
{% endif %}

<p><a href="{{ url_for('trang_chu') }}">← Tra cứu cặp khác</a></p>
{% endblock %}

🔐 Bước 6: Session — nhớ trạng thái người dùng​

HTTP là stateless: mỗi request là độc lập. session là cách Flask lưu dữ liệu nhỏ qua nhiều request.

from flask import session, redirect, url_for

# BẮT BUỘC: khóa bí mật để ký session
app.secret_key = "doi-khoa-nay-trong-thuc-te-va-luu-trong-env"

@app.route("/luu-lich-su", methods=["POST"])
def luu_lich_su():
symbol = request.form.get("symbol", "").upper()
lich_su = session.get("lich_su", [])
lich_su.append(symbol)
session["lich_su"] = lich_su[-5:] # chỉ giữ 5 lần gần nhất
return redirect(url_for("tra_cuu"))

@app.route("/lich-su")
def xem_lich_su():
return jsonify({"lich_su": session.get("lich_su", [])})

⚠️ Session mặc định lưu ở cookie phía client — người dùng có thể đọc nhưng không sửa được (nhờ chữ ký). Không bao giờ lưu mật khẩu hay API key vào session. Với dữ liệu lớn, hãy dùng Redis hoặc database.


🏗️ Bước 7: File hoàn chỉnh​

# app.py
import json
import urllib.request
from flask import Flask, render_template, request, jsonify, session, redirect, url_for

app = Flask(__name__)
app.secret_key = "thay-bang-os.environ.get('SECRET_KEY')"

BANG_DOI = {"BTCUSDT": "bitcoin", "ETHUSDT": "ethereum", "SOLUSDT": "solana"}


def lay_gia(symbol: str) -> dict:
coin_id = BANG_DOI.get(symbol)
if not coin_id:
return {"loi": f"Chưa hỗ trợ cặp {symbol}. Thử: {', '.join(BANG_DOI)}"}
url = (
"https://api.coingecko.com/api/v3/simple/price"
f"?ids={coin_id}&vs_currencies=usd&include_24hr_change=true"
)
try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read().decode())[coin_id]
return {"gia_usd": data["usd"],
"thay_doi_24h": round(data.get("usd_24h_change", 0), 2)}
except Exception as e:
return {"loi": f"Không lấy được dữ liệu: {e}"}


@app.route("/")
def trang_chu():
return render_template("index.html", lich_su=session.get("lich_su", []))


@app.route("/tra-cuu", methods=["POST"])
def tra_cuu():
symbol = request.form.get("symbol", "").strip().upper()
if not symbol:
return render_template("index.html", loi="Bạn chưa nhập cặp giao dịch.")

session["lich_su"] = (session.get("lich_su", []) + [symbol])[-5:]
return render_template("ket_qua.html", symbol=symbol, gia=lay_gia(symbol))


@app.route("/api/gia/<symbol>")
def api_gia(symbol):
"""Endpoint JSON để JavaScript hoặc app khác gọi."""
return jsonify({"symbol": symbol.upper(), **lay_gia(symbol.upper())})


@app.errorhandler(404)
def khong_tim_thay(e):
return render_template("404.html"), 404


if __name__ == "__main__":
app.run(debug=True)

🌐 Bước 8: Deploy lên internet​

pip install gunicorn
pip freeze > requirements.txt

Trên server Linux:

gunicorn -w 4 -b 0.0.0.0:8000 app:app
Nền tảngGhi chú
VercelCần adapter; phù hợp app nhỏ, serverless
Render / RailwayĐơn giản nhất: push Git → tự deploy
VPS (Ubuntu)gunicorn + nginx reverse proxy — kiểm soát hoàn toàn

Checklist trước khi deploy:

  • debug=False
  • secret_key lấy từ biến môi trường
  • Có file requirements.txt
  • Đã bật HTTPS
  • Không có key/secret nào trong code

🧪 Bài tập thực hành​

  1. Thêm route /tin-tuc hiển thị danh sách 5 bài viết từ một list Python.
  2. Tạo trang /so-sanh cho phép nhập nhiều cặp cùng lúc và hiển thị bảng so sánh.
  3. Viết endpoint /api/lich-su trả JSON lịch sử tra cứu.
  4. Dùng @app.template_filter tạo filter vnd để hiển thị 1234.5 thành 1.234,50 ₫.
  5. Thêm trang 404 tùy chỉnh.

⚠️ Lỗi thường gặp​

LỗiNguyên nhânCách sửa
TemplateNotFoundSai thư mụcTemplate phải nằm trong templates/
BuildError: Could not build url for endpointTên hàm trong url_for saiKhớp đúng tên hàm Python
RuntimeError: Working outside of request contextDùng request ngoài routeTruyền dữ liệu qua tham số
session không lưuThiếu secret_keyĐặt app.secret_key
Address already in usePort 5000 bị chiếmapp.run(port=5001)
Trang trắng, không lỗiTemplate render nhưng block rỗngKiểm tra {% block %} khớp tên

📝 Ghi chú​

  • Flask không có sẵn ORM. Khi cần database, bạn thêm SQLAlchemy (hoặc Flask-SQLAlchemy).
  • url_for luôn tốt hơn hard-code URL. Đổi route chỉ cần sửa một chỗ.
  • Tách logic khỏi route. Hàm lay_gia() ở trên nên nằm trong module riêng khi app lớn lên.
  • Blueprint là cách Flask chia app lớn thành nhiều module — học ở bài sau khi bạn đã quen.

🎯 Tóm tắt ngày 45​

✅ Hiểu Flask và biết khi nào chọn Flask/Django/FastAPI ✅ Tạo route tĩnh, động, có ràng buộc kiểu ✅ Dùng Jinja2 với extends/block để tái sử dụng giao diện ✅ Nhận dữ liệu từ form, query string, JSON ✅ Lưu trạng thái bằng session ✅ Deploy với gunicorn


📚 Học tiếp​