0

EzCode # Bài 9 — MVC và Front Controller

Học xong bài này bạn sẽ:

  • Hiểu mô hình MVC và biết mỗi tầng chịu trách nhiệm gì
  • Viết được index.php — bộ điều phối duy nhất của cả website EzCode
  • Hiểu vì sao địa chỉ ?ctrl=course&act=detail&id=1 lại gọi được đúng đoạn code cần chạy

Cần học trước: Bài 6, Bài 8


1. Vấn đề đặt ra

Hãy hình dung bạn xây EzCode theo cách ngây thơ nhất: mỗi trang một file.

trangchu.php
danhsachkhoahoc.php
chitietkhoahoc.php
dangnhap.php
dangky.php
hoso.php
doimatkhau.php
dashboard.php
taokhoahoc.php
suakhoahoc.php
... và 17 file nữa

Mở chitietkhoahoc.php ra, bạn sẽ thấy bên trong nó có đủ thứ trộn lẫn:

<?php
session_start();
$conn = new PDO(...);                          // kết nối database
$stmt = $conn->prepare("SELECT ...");          // truy vấn
$stmt->execute([$_GET['id']]);
$course = $stmt->fetch();
if (!$course) { header("Location: ..."); }     // logic nghiệp vụ
?>
<!DOCTYPE html>
<html>
<head>...</head>                                <!-- giao diện -->
<body>
    <header>...30 dòng menu...</header>
    <h1><?= $course['title'] ?></h1>
    ...
    <footer>...</footer>
</body>
</html>

Bốn vấn đề nghiêm trọng xuất hiện:

  1. Trộn lẫn ba loại code. Truy vấn database, logic nghiệp vụ và HTML nằm chung một file. Muốn sửa màu nút, bạn phải mò qua 40 dòng SQL.
  2. Lặp lại khắp nơi. Đoạn kết nối database và đoạn menu header bị chép sang cả 27 file. Đổi menu là sửa 27 chỗ.
  3. Không kiểm tra tập trung được. Muốn thêm quy tắc "trang này phải đăng nhập mới vào" thì phải nhớ thêm vào đúng từng file.
  4. Địa chỉ lộ cấu trúc thư mục. Người ngoài biết chính xác bạn có những file gì.

MVC giải quyết vấn đề 1 và 2. Front controller giải quyết vấn đề 3 và 4. Bài này làm cả hai.


2. Kiến thức mới

2.1. MVC là gì

MVC viết tắt của Model — View — Controller. Ý tưởng là chia code thành ba tầng, mỗi tầng một trách nhiệm duy nhất.

Tầng Chịu trách nhiệm Không được làm
Model Nói chuyện với database. Viết câu SQL, trả về mảng dữ liệu In HTML, đọc $_GET hay $_POST, chuyển hướng
View Hiển thị dữ liệu thành HTML Viết câu SQL, xử lý nghiệp vụ
Controller Nhận yêu cầu, gọi Model lấy dữ liệu, chọn View để hiện Viết câu SQL trực tiếp, viết HTML dài

Một ẩn dụ dễ nhớ — quán ăn:

  • Khách gọi món. Đó là request từ trình duyệt.
  • Nhân viên phục vụ nhận yêu cầu, xuống bếp đặt món, mang đồ ăn ra bàn. Đó là Controller.
  • Bếp lấy nguyên liệu từ kho và nấu. Đó là Model cùng database.
  • Cách bày biện món ăn trên đĩa là View.

Nhân viên phục vụ không tự nấu. Bếp không quyết định bày đĩa thế nào. Mỗi bên làm việc của mình.

Lợi ích cụ thể với EzCode:

  • Muốn đổi giao diện trang chủ? Chỉ sửa Views/page_home.php. Không đụng tới truy vấn.
  • Muốn đổi cách lấy danh sách khóa học? Chỉ sửa Models/Courses.php. Mọi trang dùng nó tự cập nhật.
  • Cùng một Model Courses phục vụ cả trang chủ, trang danh sách, trang chi tiết và dashboard giảng viên. Viết một lần dùng bốn chỗ.

2.2. Luồng chạy của một request

Khi người dùng mở http://localhost/EZCode_khoahoc/?ctrl=course&act=detail&id=1, sáu bước sau xảy ra:

  1. Trình duyệt gửi yêu cầu tới Apache.
  2. Apache thấy đây là thư mục nên chạy file mặc định là index.php.
  3. index.php đọc $_GET['ctrl'] là course và $_GET['act'] là detail, rồi nạp Controllers/CourseController.php và gọi phương thức detail(1).
  4. Controller gọi Models/Courses.php bảo "cho tôi khóa học id 1", và gọi Models/Lession.php bảo "cho tôi bài học của khóa 1".
  5. Model gửi câu SQL sang MySQL, nhận mảng kết quả, trả về cho Controller.
  6. Controller nạp Views/course_detail.php, file này dùng biến mà Controller vừa tạo để in ra HTML.

Kết quả HTML đi ngược đường về trình duyệt.

Điểm cần nhớ: View không tự gọi Model. Model không tự chọn View. Controller đứng giữa điều phối cả hai.

2.3. Front controller — một cửa vào duy nhất

Front controller (bộ điều phối đầu vào) nghĩa là: mọi yêu cầu tới website đều đi qua đúng một file.

Với EzCode, file đó là index.php. Không có dangnhap.php, không có dashboard.php. Chỉ có index.php cùng các tham số trên URL.

Lợi ích:

  1. Đặt code chung đúng một chỗ. session_start() chỉ cần gọi một lần ở index.php là mọi trang đều dùng được session. Header và footer cũng vậy.
  2. Kiểm soát tập trung. Muốn ghi log mọi truy cập, hoặc chặn IP xấu, chỉ cần thêm vào index.php.
  3. Địa chỉ không lộ cấu trúc thư mục.
  4. Đổi địa chỉ dễ. Muốn ?ctrl=course thành /khoa-hoc/ chỉ cần sửa một chỗ.

Mọi framework PHP hiện đại — Laravel, Symfony, CodeIgniter — đều dùng mẫu này. EzCode làm thủ công đúng cái mà framework làm tự động.

2.4. ucfirst() — viết hoa chữ cái đầu

<?php
echo ucfirst("course");      // Course
echo ucfirst("user");        // User
echo ucfirst("teacher");     // Teacher
?>

Vì sao cần? Vì URL dùng chữ thường cho đẹp (?ctrl=course), còn tên class trong PHP quy ước viết hoa chữ đầu (CourseController).

ucfirst() là cầu nối giữa hai quy ước đó:

$tenFile  = ucfirst("course") . "Controller.php";   // CourseController.php
$tenClass = ucfirst("course") . "Controller";       // CourseController

2.5. Tạo object từ tên class nằm trong chuỗi

Ở Bài 6 mục 2.8 bạn đã gặp kỹ thuật này. Giờ là lúc dùng thật.

<?php
$tenClass = "CourseController";
$ctrl = new $tenClass();
?>

Nhưng nếu tên class là kết quả của một biểu thức chứ không phải một biến sẵn, bạn cần bọc ngoặc:

<?php
$ctrl = new (ucfirst($_GET['ctrl']) . "Controller")();
?>

Cú pháp new (biểu thức)() này chỉ có từ PHP 8.0. Chạy trên PHP 7 sẽ báo lỗi cú pháp. Đây là lý do Bài 1 yêu cầu bạn cài XAMPP có PHP 8 trở lên.

Cách viết tương đương chạy được trên cả PHP 7:

<?php
$tenClass = ucfirst($_GET['ctrl']) . "Controller";
$ctrl = new $tenClass();
?>

2.6. Gọi phương thức bằng tên nằm trong biến

<?php
$act = "detail";
$ctrl->$act();          // tương đương $ctrl->detail();
?>

Kết hợp với mục 2.5, bạn có một cơ chế cực kỳ mạnh: tên class và tên phương thức đều lấy từ URL. Một file 31 dòng điều phối được cả 27 chức năng của website.

⚠️ Đây cũng là một rủi ro bảo mật cần biết. Người dùng kiểm soát được $_GET['act'], nên họ gọi được bất kỳ phương thức public nào của Controller đó.

Với EzCode thì chưa nguy hiểm, vì mọi phương thức public đều là chức năng hợp lệ và các Controller nhạy cảm đã chặn quyền trong __construct(). Nhưng nếu sau này bạn thêm một phương thức nội bộ mà quên để private, nó sẽ gọi được từ URL.

Cách phòng đúng đắn là dùng danh sách trắng các cặp ctrl/act được phép, giống như cách Bài 12 dùng switch cho ORDER BY.

2.7. Toán tử trải ... khi gọi hàm

Ở Bài 8 mục 2.10 bạn đã học ... trong khai báo hàm — nó gom đối số thành mảng.

Bây giờ là vai trò ngược lại: ... trong lời gọi hàm sẽ trải mảng thành nhiều đối số riêng.

<?php
function chao($ten, $tuoi) {
    echo "$ten năm nay $tuoi tuổi";
}

$duLieu = ["Hoàng", 21];

chao(...$duLieu);        // giống hệt chao("Hoàng", 21)
?>

Vì sao index.php cần điều này? Vì các phương thức Controller có số tham số khác nhau:

Phương thức Số tham số
PageController::home() 0
CourseController::detail($id) 1
UserController::login() 0
TeacherController::editCourse($id) 1

index.php không biết trước sẽ gọi cái nào. Nó gom tham số vào mảng $args rồi trải ra khi gọi:

$args = [];
if (isset($_GET['id'])) {
    $args[] = $_GET['id'];
}
$ctrl->$act(...$args);
  • Không có id trên URL: $args rỗng, câu gọi thành $ctrl->home().
  • Có id=1: $args là ["1"], câu gọi thành $ctrl->detail("1").

3. Áp dụng vào EzCode

3.1. Viết index.php

Tạo file này ở gốc project, cùng cấp với các thư mục Models, Controllers, Views.

📁 index.php — tạo mới

<?php
// Bật error reporting để debug
error_reporting(E_ALL);
ini_set('display_errors', 1);

session_start();
include_once "./Views/layout_header.php";
//?ctrl=product&act=detail&id=1
if (isset($_GET['ctrl']) && isset($_GET['act'])) {
    // Nếu có yêu cầu điều -> chuyển đến controller theo yêu cầu
    // Tự động nhúng controller theo yêu cầu của đường dẫn
    include_once "./Controllers/" . ucfirst($_GET['ctrl']) . "Controller.php";

    // $ctrl = new PageController();
    $ctrl = new (ucfirst($_GET['ctrl']) . "Controller")();
    $act = $_GET['act'];
    
    // Chỉ truyền tham số không có tên (như id)
    $args = [];
    if (isset($_GET['id'])) {
        $args[] = $_GET['id'];
    }
    
    $ctrl->$act(...$args);
} else {
    include_once "./Controllers/PageController.php";
    $ctrl = new PageController();
    $ctrl->home();
}

include_once "./Views/layout_footer.php";
?>

Ba mươi mốt dòng này điều phối toàn bộ website. Ta đọc từng phần.

Dòng 2 đến 4 — bật hiện lỗi. Bạn đã học ở Bài 1. Vì mọi request đều qua đây nên đặt một lần là đủ cho cả project.

Dòng 6 — session_start();. Học ở Bài 5. Cũng chỉ cần một lần, và nhờ vậy mọi Controller lẫn View đều dùng được $_SESSION mà không phải gọi lại.

Dòng 7 — nạp header. In phần đầu trang HTML ra ngay.

Dòng 9 — kiểm tra tham số điều hướng. Học ở Bài 4.

Dòng 12 — nạp file Controller:

include_once "./Controllers/" . ucfirst($_GET['ctrl']) . "Controller.php";

Với ctrl=course, chuỗi ghép ra là ./Controllers/CourseController.php.

Dòng 15 — tạo object Controller:

$ctrl = new (ucfirst($_GET['ctrl']) . "Controller")();

Với ctrl=course, dòng này tương đương new CourseController().

Dòng ghi chú phía trên // $ctrl = new PageController(); là dấu vết tác giả để lại: ban đầu viết cứng một Controller, sau mới đổi sang cách động.

Dòng 16 — lấy tên phương thức:

$act = $_GET['act'];

Dòng 19 đến 22 — gom tham số:

    $args = [];
    if (isset($_GET['id'])) {
        $args[] = $_GET['id'];
    }

Dòng 24 — gọi phương thức:

$ctrl->$act(...$args);

Đây là dòng làm mọi việc. Kết hợp mục 2.6 và 2.7.

Dòng 25 đến 29 — nhánh mặc định. Không có ctrl và act thì mở trang chủ. Đó là lý do gõ địa chỉ trần http://localhost/EZCode_khoahoc/ vẫn ra trang chủ.

Dòng 31 — nạp footer. Đóng phần cuối trang HTML.

Chú ý: nếu Controller gọi exit() — mà hầu hết đều gọi sau khi chuyển hướng — thì dòng 31 không bao giờ chạy. Trang chuyển hướng không có footer. Không sao, vì người dùng đâu có nhìn thấy trang đó.

⚠️ Lưu ý — hai vấn đề trong file này.

Thứ nhất, dòng 19 đến 22 chỉ gom duy nhất $_GET['id'] vào $args. Mọi tham số mang tên khác đều bị bỏ qua.

Hậu quả cụ thể: Views/teacher_lessons.php sinh liên kết ?ctrl=teacher&act=createLesson&course_id=20, nhưng TeacherController::createLesson($course_id) bắt buộc có tham số. Vì course_id không được gom vào $args, PHP báo ArgumentCountError và trang chết.

Bạn sẽ gặp lỗi này thật ở Bài 19. Đây là lỗi số 1 trong Bài 22.

Thứ hai, dòng 7 nạp header trước khi Controller chạy. Nghĩa là HTML đã được in ra trước khi bất kỳ lệnh header("Location: ...") nào được gọi. Theo lý thuyết ở Bài 4 mục 2.9, mọi chuyển hướng phải thất bại.

Nó chạy được chỉ nhờ output_buffering mà XAMPP bật sẵn. Tắt tùy chọn đó là chức năng đăng nhập, đăng ký và thanh toán ngừng hoạt động toàn bộ. Đây là lỗi số 13 trong Bài 22.

3.2. Viết Controller đầu tiên

Tạo thư mục Controllers rồi tạo file bên trong. Đây là Controller đơn giản nhất của project — chỉ 16 dòng và một phương thức.

📁 Controllers/PageController.php — tạo mới

<?php

class PageController
{
    public function home()
    {
        // Logic for displaying the home page
        // echo "Welcome to the home page!";
        include_once "./Models/Courses.php"; // Include the Courses model
        $coursesModel = new Courses(); // Create an instance of the Courses model
        $coursesList = $coursesModel->getAll(); // Fetch all courses


        include_once "./Views/page_home.php";
    }
}

Phương thức home() thể hiện đúng khuôn mẫu chung của cả 27 phương thức Controller trong EzCode:

  1. Nạp Model — include_once "./Models/Courses.php";
  2. Tạo object Model — $coursesModel = new Courses();
  3. Gọi Model lấy dữ liệu — $coursesList = $coursesModel->getAll();
  4. Nạp View — include_once "./Views/page_home.php";

Không có câu SQL nào ở đây. Không có thẻ HTML nào. Controller chỉ điều phối, đúng như mục 2.1 quy định.

Cơ chế truyền biến từ Controller sang View. Đây là điểm khiến nhiều người mới bối rối.

Biến $coursesList được tạo ở dòng 11, còn Views/page_home.php dùng nó ở dòng 14. Hai file khác nhau, sao View thấy được biến của Controller?

Câu trả lời: include không phải là gọi hàm. Nó chèn nội dung file vào đúng vị trí đó, và code được chèn chạy trong cùng phạm vi biến với nơi gọi.

Nói cách khác, PHP xử lý như thể bạn đã chép toàn bộ nội dung page_home.php dán thẳng vào chỗ dòng 14. Vì $coursesList được khai báo phía trên trong cùng một hàm, View nhìn thấy nó bình thường.

Đây là cách truyền dữ liệu đơn giản nhất, và cũng là điểm yếu: không có gì ràng buộc rõ ràng rằng View cần biến nào. Nếu bạn đổi tên $coursesList thành $khoaHocList trong Controller mà quên sửa View, View sẽ báo Warning: Undefined variable — và trang vẫn hiện ra, chỉ là trống trơn.

Framework hiện đại giải quyết bằng cách truyền dữ liệu qua một mảng tường minh, ví dụ view('home', ['courses' => $list]).


4. Chạy thử

Bạn chưa có Models/Courses.php và Views/page_home.php — Bài 10 mới viết chúng. Nên bây giờ ta kiểm tra riêng phần định tuyến.

Bước 1 — Sửa tạm PageController. Mở Controllers/PageController.php, tạm thời thay thân phương thức home():

<?php

class PageController
{
    public function home()
    {
        echo "<h1>PageController::home() da chay!</h1>";
    }
}

Đây chỉ là sửa tạm để kiểm tra. Bài 10 sẽ trả lại nguyên bản. Hãy nhớ điều này.

Bước 2 — Tạo hai file layout rỗng tạm thời, vì index.php cần chúng:

📄 Views/layout_header.php

<!DOCTYPE html>
<html lang="vi">
<head><meta charset="UTF-8"><title>EzCode</title></head>
<body>

📄 Views/layout_footer.php

</body>
</html>

Bước 3 — Thử các địa chỉ sau:

Địa chỉ Kết quả mong đợi
http://localhost/EZCode_khoahoc/ Hiện dòng chữ. Đi vào nhánh else
http://localhost/EZCode_khoahoc/?ctrl=page&act=home Hiện dòng chữ. Đi vào nhánh if
http://localhost/EZCode_khoahoc/?ctrl=abc&act=home Lỗi Failed opening './Controllers/AbcController.php'
http://localhost/EZCode_khoahoc/?ctrl=page&act=xyz Lỗi Call to undefined method PageController::xyz()

Hai địa chỉ cuối cố tình gây lỗi. Hãy đọc kỹ thông báo và đối chiếu với code:

  • Trường hợp ctrl=abc: dòng 12 ghép ra ./Controllers/AbcController.php, file không tồn tại.
  • Trường hợp act=xyz: dòng 24 gọi $ctrl->xyz(), phương thức không tồn tại.

Hai thông báo này cho bạn thấy tận mắt cách index.php ghép tên file và tên phương thức từ URL.


5. Bài tập

Bài tập 1

Tạo Controllers/TestController.php với một phương thức chao() in ra dòng chữ bất kỳ.

Gọi nó bằng địa chỉ ?ctrl=test&act=chao và xác nhận nó chạy.

Sau khi làm xong, hãy trả lời: index.php đã ghép ra tên file nào và tên class nào từ chuỗi test?

Bài tập 2

Thêm vào TestController phương thức thứ hai:

public function chaoTen($id)
{
    echo "<h2>Tham số nhận được: $id</h2>";
}

Gọi bằng ?ctrl=test&act=chaoTen&id=5. Xác nhận nó in ra số 5.

Sau đó chỉ ra chính xác dòng nào trong index.php khiến số 5 đi được từ URL vào tham số $id.

Bài tập 3

Vẫn với chaoTen, gọi bằng ?ctrl=test&act=chaoTen — không có tham số id.

  1. Chép lại nguyên văn thông báo lỗi.
  2. Giải thích vì sao lỗi xảy ra, dựa trên mục 2.7.
  3. Sửa khai báo thành chaoTen($id = 0) rồi gọi lại. Giải thích vì sao hết lỗi.
  4. Bây giờ thử gọi ?ctrl=test&act=chaoTen&ma_so=5 — dùng tên tham số là ma_so thay vì id. Chuyện gì xảy ra, và điều này liên quan thế nào tới lỗi số 1 trong Bài 22?

6. Đáp án

Đáp án bài tập 1

📄 Controllers/TestController.php

<?php

class TestController
{
    public function chao()
    {
        echo "<h2>Xin chào từ TestController!</h2>";
    }
}

Gọi ?ctrl=test&act=chao hiện đúng dòng chữ.

index.php đã ghép ra:

  • Tên file: ucfirst("test") . "Controller.php" → ./Controllers/TestController.php
  • Tên class: ucfirst("test") . "Controller" → TestController

Hai chuỗi này phải khớp với thực tế, nếu không sẽ lỗi. Đây là một quy ước bắt buộc của project: tên file và tên class phải trùng nhau và phải viết hoa chữ đầu.

Hãy kiểm chứng bằng cách đổi tên class thành testcontroller viết thường mà giữ nguyên tên file. Bạn sẽ nhận:

Fatal error: Uncaught Error: Class "TestController" not found

PHP phân biệt chữ hoa thường với tên class trong ngữ cảnh này.

Đáp án bài tập 2

Thêm vào TestController:

    public function chaoTen($id)
    {
        echo "<h2>Tham số nhận được: $id</h2>";
    }

Gọi ?ctrl=test&act=chaoTen&id=5 in ra Tham số nhận được: 5.

Ba dòng liên quan trong index.php:

    $args = [];              // dòng 19 — tạo mảng rỗng
    if (isset($_GET['id'])) {// dòng 20 — kiểm tra URL có id không
        $args[] = $_GET['id'];// dòng 21 — cho id vào mảng: $args = ["5"]
    }

    $ctrl->$act(...$args);   // dòng 24 — trải mảng: $ctrl->chaoTen("5")

Dòng 21 là nơi giá trị được lấy từ URL. Dòng 24 là nơi nó được truyền vào tham số $id.

Chú ý giá trị nhận được là chuỗi "5" chứ không phải số 5, đúng như Bài 4 mục 2.3 đã nói. Trong ví dụ này không quan trọng vì echo in cả hai như nhau, nhưng khi so sánh bằng === thì có khác biệt.

Đáp án bài tập 3

Câu 1 — Thông báo lỗi:

Fatal error: Uncaught ArgumentCountError: Too few arguments to function TestController::chaoTen(), 0 passed in C:\xampp\htdocs\EZCode_khoahoc\index.php on line 24 and exactly 1 expected in C:\xampp\htdocs\EZCode_khoahoc\Controllers\TestController.php:10

Câu 2 — Vì sao lỗi:

Không có id trên URL nên isset($_GET['id']) trả về false, dòng 21 không chạy, và $args vẫn là mảng rỗng.

Dòng 24 $ctrl->$act(...$args) trải một mảng rỗng ra thành không có đối số nào, tức là gọi $ctrl->chaoTen().

Nhưng chaoTen($id) khai báo một tham số bắt buộc. PHP 8 coi việc gọi hàm thiếu đối số bắt buộc là lỗi nghiêm trọng và dừng hẳn.

Thông báo nói rất rõ: 0 passed ... and exactly 1 expected — truyền 0, cần 1.

Câu 3 — Vì sao tham số mặc định sửa được lỗi:

    public function chaoTen($id = 0)

Với giá trị mặc định, tham số trở thành không bắt buộc. Gọi mà không truyền gì thì $id tự nhận giá trị 0. PHP không còn lý do để báo lỗi.

Kỹ thuật này bạn đã học ở Bài 2 mục 2.9, và EzCode dùng nó thật ở TeacherController::students($course_id = null) — một phương thức phục vụ hai trang khác nhau tùy có tham số hay không. Bài 19 sẽ gặp.

Câu 4 — Liên hệ với lỗi số 1 của Bài 22:

Gọi ?ctrl=test&act=chaoTen&ma_so=5 sẽ cho ra đúng lỗi ArgumentCountError như câu 1 (nếu bạn đã khôi phục chaoTen($id) không có giá trị mặc định).

Lý do: dòng 20 của index.php chỉ kiểm tra isset($_GET['id']). Tham số tên ma_so có mặt trong $_GET nhưng index.php hoàn toàn phớt lờ nó. Mảng $args vẫn rỗng.

Đây chính xác là lỗi số 1 trong Bài 22.

Trong project thật, Views/teacher_lessons.php sinh ra liên kết:

<a href="?ctrl=teacher&act=createLesson&course_id=20">+ Thêm bài học mới</a>

Và TeacherController khai báo:

public function createLesson($course_id)

Tham số tên course_id không phải id, nên nó không bao giờ tới nơi. Bấm nút "Thêm bài học mới" trong EzCode sẽ làm trang chết ngay.

Cách sửa sẽ được trình bày đầy đủ ở Bài 22, nhưng ý tưởng thì bạn đã đủ sức nghĩ ra ngay bây giờ: thay vì chỉ lấy id, hãy gom mọi tham số GET không phải ctrl và act vào $args:

$args = [];
foreach ($_GET as $khoa => $giaTri) {
    if ($khoa !== 'ctrl' && $khoa !== 'act') {
        $args[] = $giaTri;
    }
}

Hãy thử áp dụng vào index.php của bạn rồi gọi lại ?ctrl=test&act=chaoTen&ma_so=5. Lần này nó chạy.

Nhưng đừng giữ bản sửa này. Hãy khôi phục index.php về đúng nguyên bản ở mục 3.1, vì 13 bài tiếp theo đều giả định bạn đang dùng code khớp với repo. Bài 22 sẽ là lúc áp dụng bản vá chính thức.


➡️ Bài tiếp theo: Bài 10 — Layout và trang chủ chạy được

⬅️ Về mục lục · Bài trước


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí