Chương 13. Phát triển module bằng AI

📚 Mục lục sách

Đến chương này, bạn đã biết cách tạo trang, bài viết, chức năng và tích hợp dịch vụ bên ngoài. Nhưng nếu nhu cầu của bạn vượt ra ngoài những gì EzyPlatform có sẵn, bạn có thể tự phát triển module (plugin) riêng — và đăng lên Marketplace để chia sẻ hoặc kinh doanh.

Khi nào cần tự phát triển module?

  • Bạn cần một tính năng đặc thù mà không plugin có sẵn nào đáp ứng được.
  • Bạn muốn đóng gói một bộ tính năng để tái sử dụng cho nhiều website khác nhau.
  • Bạn muốn phát triển một sản phẩm để bán trên Marketplace của EzyPlatform.

Plugin: đơn vị mở rộng cơ bản

Một Plugin là một gói mở rộng độc lập, có thể cài vào hoặc gỡ khỏi EzyPlatform mà không ảnh hưởng đến phần còn lại của hệ thống. Khi mô tả yêu cầu phát triển plugin cho AI, hãy làm rõ:

  • Plugin giải quyết vấn đề gì, dành cho ai sử dụng.
  • Nó cần những màn hình quản trị (Admin) nào để cấu hình.
  • Nó cần hiển thị gì ở phía Website.
  • Nó có cần lắng nghe Event nào từ hệ thống không.

Marketplace Item: đóng gói để chia sẻ hoặc bán

Sau khi plugin hoàn thiện, bạn có thể đóng gói thành một Marketplace Item — sản phẩm có thể được cài đặt bởi bất kỳ ai dùng EzyPlatform. Một Marketplace Item tốt cần có:

  • Mô tả rõ ràng plugin dùng để làm gì, giải quyết vấn đề gì.
  • Ảnh chụp minh họa (screenshot) giao diện thực tế.
  • Tài liệu hướng dẫn cài đặt và sử dụng cơ bản.

Theme: bộ giao diện có thể thay đổi toàn bộ

Theme (chủ đề giao diện) định hình toàn bộ diện mạo của một website — màu sắc, font chữ, bố cục tổng thể. Việc phát triển theme khác với việc chỉnh sửa một trang đơn lẻ: bạn cần đảm bảo tính nhất quán xuyên suốt tất cả các trang, bài viết, và khu vực khác nhau của website.

Khi phát triển theme, hãy yêu cầu AI thiết lập trước một bộ quy tắc chung: bảng màu, font chữ, khoảng cách, kiểu nút bấm — rồi mới áp dụng cho từng trang cụ thể. Điều này giúp toàn bộ website đồng bộ và chuyên nghiệp hơn.

Cài đặt môi trường cục bộ

Phát triển module khác với việc tạo trang hay bài viết qua giao diện Admin: bạn cần một môi trường lập trình Java chạy ngay trên máy để AI có thể sửa code, build và chạy thử trước khi cài vào hệ thống thật. Gồm 5 bước, mỗi bước bạn chỉ cần đưa prompt tương ứng cho AI Assistant (Claude Code, Codex...) — nó sẽ tự kiểm tra và cài đặt giúp bạn.

Bước 1: Cài đặt JDK 8

EzyPlatform chạy trên nền Java, nên cần đúng JDK 8 (không phải bản mới hơn). Sau khi cài, biến môi trường JAVA_HOME phải trỏ đúng đến thư mục cài đặt.

📋 Prompt mẫu — Cài JDK 8
Máy tôi đang chạy {Windows / macOS / Linux}. Hãy kiểm tra máy tôi đã cài JDK 8 chưa (lệnh java -version).
Nếu chưa có hoặc đang là bản khác, hướng dẫn tôi tải và cài JDK 8 từ Oracle hoặc Eclipse Adoptium, rồi khai báo biến môi trường JAVA_HOME trỏ đến đúng thư mục cài đặt.
Sau khi cài xong, đưa tôi lệnh để xác nhận java -version và JAVA_HOME đã đúng.

Bước 2: Cài đặt MySQL

MySQL là nơi EzyPlatform lưu trữ dữ liệu. Bạn cần cài MySQL, sau đó tạo sẵn một database rỗng dành riêng cho EzyPlatform.

📋 Prompt mẫu — Cài MySQL
Hãy kiểm tra máy tôi đã cài MySQL chưa. Nếu chưa, hướng dẫn tôi cài MySQL trên {Windows / macOS / Linux} và khởi động dịch vụ.
Sau đó hướng dẫn tôi tạo một database rỗng tên ezyplatform cùng một user có quyền truy cập database đó, và cho tôi biết cần ghi lại những thông tin gì (host, port, username, password) để dùng ở bước cấu hình sau.

Bước 3: Cài đặt Maven

Maven là công cụ build cho các plugin viết bằng Java — dùng để biên dịch và đóng gói module thành file jar trước khi cài vào EzyPlatform.

📋 Prompt mẫu — Cài Maven
Hãy kiểm tra máy tôi đã cài Maven chưa (lệnh mvn -version). Nếu chưa có, hướng dẫn tôi cài Maven trên {Windows / macOS / Linux} và đảm bảo nó dùng đúng JDK 8 vừa cài ở Bước 1.
Sau khi cài xong, đưa tôi lệnh để xác nhận mvn -version chạy đúng.

Bước 4: Tải và cấu hình EzyPlatform

Sau khi có đủ JDK, MySQL và Maven, đến lượt tải chính EzyPlatform về, giải nén, rồi khai báo biến môi trường EZYPLATFORM_HOME để chạy lệnh từ bất kỳ đâu.

📋 Prompt mẫu — Tải và cấu hình EzyPlatform
Hãy giúp tôi tải gói cài đặt EzyPlatform mới nhất, giải nén vào thư mục {đường dẫn bạn muốn}, rồi khai báo biến môi trường EZYPLATFORM_HOME trỏ đến thư mục đó.
Sau đó mở file settings/setup.properties giúp tôi điền thông tin kết nối tới database MySQL đã tạo ở Bước 2 (host, port, tên database, username, password).

Bước 5: Chạy thử

Khởi tạo ở chế độ console trước để kiểm tra cấu hình không lỗi, sau đó tạo tài khoản super admin và chuyển sang chạy nền.

📋 Prompt mẫu — Chạy thử EzyPlatform
Hãy chạy giúp tôi EzyPlatform ở chế độ console (trên Linux/macOS dùng cli.sh, trên Windows dùng cli.bat) để kiểm tra cấu hình không có lỗi.
Nếu chạy thành công, hướng dẫn tôi truy cập http://localhost:9090/setup-admin để tạo tài khoản super admin đầu tiên.
Sau khi tạo xong, giúp tôi dừng tiến trình console và khởi động lại ở chế độ nền kèm theo dõi log.

Khi cần dừng toàn bộ hệ thống, chỉ cần nhờ AI chạy lệnh bash cli.sh stop (hoặc cli.bat stop trên Windows).

Muốn thử nhanh EzyPlatform mà chưa cần build plugin, bạn có thể chạy bằng Docker (docker pull youngmonkeys/ezyplatform) — nhưng cách này chỉ phù hợp để trải nghiệm, không thay thế được môi trường JDK, Maven, MySQL cài trực tiếp khi thực sự phát triển module.

Xem hướng dẫn cài đặt chi tiết từng bước tại trang hướng dẫn chính thức của EzyPlatform.

Cài đặt bộ công cụ hỗ trợ phát triển (EzyPlatform SDK)

EzyPlatform cài xong ở phần trên là nơi chạy website/ứng dụng của bạn. Còn EzyPlatform SDK là bộ công cụ dòng lệnh (ezy.sh) dành riêng cho việc phát triển plugin — sinh khung project, build và đóng gói module. Gồm 3 bước:

Bước 1: Tải và giải nén SDK

Tải EzyPlatform SDK về máy, rồi giải nén vào một thư mục cố định, ví dụ app/ezyplatform-sdk.

📋 Prompt mẫu — Tải EzyPlatform SDK
Hãy giúp tôi tải EzyPlatform SDK từ https://ezyplatform.com/ và giải nén vào thư mục app/ezyplatform-sdk trên máy {Windows / macOS / Linux} của tôi.

Bước 2: Khai báo biến môi trường

Khai báo biến EZYPLATFORM_SDK trỏ đến thư mục vừa giải nén, rồi thêm $EZYPLATFORM_SDK/bin vào PATH để có thể gọi lệnh ezy.sh từ bất kỳ đâu.

📋 Prompt mẫu — Khai báo biến môi trường SDK
Hãy giúp tôi khai báo biến môi trường EZYPLATFORM_SDK trỏ đến thư mục app/ezyplatform-sdk vừa giải nén, và thêm $EZYPLATFORM_SDK/bin vào biến PATH.
Sau đó đưa tôi lệnh để xác nhận ezy.sh đã gọi được từ Terminal.

Bước 3: Build các thư viện cần thiết

SDK cần một số thư viện được build sẵn bằng Maven từ kho mã nguồn ezyplatform-development trước khi dùng được. Bước này yêu cầu đã cài Maven ở phần trước.

📋 Prompt mẫu — Build thư viện cho SDK
Hãy giúp tôi clone kho mã nguồn ezyplatform-development (địa chỉ ghi trong hướng dẫn cài đặt EzyPlatform SDK chính thức), vào thư mục dự án của tôi, sau đó chạy bash build.sh để build các thư viện cần thiết bằng Maven.
Nếu có lỗi trong quá trình build, hãy đọc log và giải thích cho tôi nguyên nhân.

Sau khi hoàn tất cả 3 bước, bạn đã có đủ công cụ để bắt đầu nhờ AI sinh khung plugin mới bằng ezy.sh. Xem chi tiết tại trang hướng dẫn cài đặt EzyPlatform SDK chính thức.

Cài đặt IntelliJ IDEA

Ngoài VS Code để trò chuyện với AI, khi phát triển module bạn còn cần một IDE Java chuyên dụng để debug, xem cấu trúc project Maven và điều hướng mã nguồn Java thuận tiện hơn — IntelliJ IDEA là lựa chọn phổ biến nhất. Bản Community Edition miễn phí và mã nguồn mở là đủ dùng cho việc phát triển plugin EzyPlatform.

Lưu ý: kho github.com/JetBrains/intellij-community/releases chỉ đánh dấu (tag) các phiên bản mã nguồn mở của IntelliJ, không có sẵn file cài đặt (.exe, .dmg, .tar.gz). Để tải bộ cài đặt chạy được ngay, hãy dùng trang tải về chính thức của JetBrains.

📋 Prompt mẫu — Cài IntelliJ IDEA Community Edition
Hãy hướng dẫn tôi tải và cài IntelliJ IDEA Community Edition (bản miễn phí) từ trang tải về chính thức của JetBrains cho máy {Windows / macOS / Linux} của tôi.
Sau khi cài xong, đưa tôi cách kiểm tra IntelliJ đã nhận diện đúng JDK 8 tôi đã cài ở phần trước hay chưa.

Nếu muốn tìm hiểu sâu hơn về chính mã nguồn của IntelliJ (không bắt buộc để phát triển plugin), bạn có thể tham khảo thêm kho intellij-community trên GitHub.

Hướng dẫn khởi tạo dự án mô đun

Sau khi đã có đủ JDK, MySQL, Maven, EzyPlatform, EzyPlatform SDK và IntelliJ IDEA, bạn đã sẵn sàng để sinh khung (scaffold) cho plugin đầu tiên bằng lệnh ezy.sh. Gồm 3 bước:

Bước 1: Sinh khung project bằng ezy.sh

Lệnh ezy.sh cp (create project) sinh sẵn cấu trúc project Maven cho plugin, gồm tên project, nhóm (group), phiên bản (version), và danh sách module cần có — chọn trong 5 loại: admin-plugin (màn hình quản trị), web-plugin (hiển thị phía website), theme (giao diện), socket-pluginsocket-app (xử lý realtime qua socket). Ví dụ tạo một project tên sweet-bakery, nhóm org.youngmonkeys, phiên bản 1.0.0, gồm 3 module admin-plugin, web-plugin và theme:

💻 Ví dụ lệnh tạo project
ezy.sh cp sweet-bakery -g org.youngmonkeys -v 1.0.0 -i admin-plugin,web-plugin,theme
📋 Prompt mẫu — Sinh khung project plugin
Hãy giúp tôi tạo một plugin project EzyPlatform mới tên {tên-plugin-của-bạn} bằng ezy.sh, với group là {com.tencongty}, gồm các module {admin-plugin,theme,web-plugin} (chọn module phù hợp với plugin tôi muốn làm: {mô tả ngắn plugin của bạn}).
Giải thích cho tôi ý nghĩa của từng tham số trong lệnh trước khi chạy, và cấu trúc thư mục vừa được sinh ra gồm những gì.

Bước 2: Mở project và chạy thử bằng IntelliJ IDEA

Import project vừa sinh vào IntelliJ IDEA (đã cài ở phần trên), cấu hình JDK 8 làm Project SDK nếu IntelliJ chưa tự nhận diện đúng, chạy bash export.sh (hoặc export.bat trên Windows) để export các thành phần cần thiết, rồi tìm file StartupTest tương ứng trong thư mục src/test/ của từng module (ví dụ HelloWorldAdminPluginStartupTest cho module admin-plugin) để chạy thử ngay trong IDE.

📋 Prompt mẫu — Chạy thử plugin trong IntelliJ IDEA
Tôi vừa mở project plugin {tên-plugin-của-bạn} bằng IntelliJ IDEA. Hãy hướng dẫn tôi:
1. Kiểm tra và cấu hình JDK 8 làm Project SDK nếu IntelliJ chưa tự nhận đúng.
2. Chạy bash export.sh (hoặc export.bat) để export thành phần cần thiết.
3. Tìm đúng file StartupTest trong thư mục src/test/ của module admin-plugin.
4. Chạy file đó và truy cập http://localhost:9090 để kiểm tra plugin đã chạy đúng chưa.

Bước 3: Export plugin thành file cài đặt

Khi plugin đã chạy đúng như mong muốn, export lại thành một file zip hoàn chỉnh để cài vào EzyPlatform thật hoặc đóng gói lên Marketplace ở phần trước.

📋 Prompt mẫu — Export plugin
Plugin {tên-plugin-của-bạn} đã chạy thử ổn định. Hãy giúp tôi chạy bash export.sh để export plugin thành file zip trong thư mục target/project/, rồi hướng dẫn tôi cách cài file zip đó vào một EzyPlatform khác để kiểm tra lần cuối trước khi đăng Marketplace.

Xem hướng dẫn đầy đủ tại trang hướng dẫn khởi tạo plugin chính thức của EzyPlatform.

Cấu trúc dự án mô đun

Sau khi ezy.sh cp sinh project (ví dụ tên book-store), mỗi loại module bạn chọn ở Bước 1 trở thành một sub-module Maven riêng, đặt tên theo mẫu {project}-{loại-module}. Hiểu rõ vai trò từng module giúp bạn mô tả yêu cầu cho AI đúng chỗ hơn, thay vì để AI tự đoán nên đặt code ở đâu.

  • {project}-sdk — chứa các lớp dùng chung (model, service, config...) cho toàn bộ module còn lại.
  • {project}-admin-plugin — chứa model, controller và file tĩnh cho màn hình quản trị (Admin).
  • {project}-web-plugin — chứa model, controller và file tĩnh hiển thị phía Website.
  • {project}-theme — module giao diện (theme) cho phần Website.
  • {project}-socket-plugin{project}-socket-app — xử lý các tác vụ realtime qua socket.
Cấu trúc project sweet-bakery trong IntelliJ IDEA với các module sweet-bakery-admin-plugin, sweet-bakery-sdk, sweet-bakery-theme, sweet-bakery-web-plugin
Project sweet-bakery sau khi sinh bằng ezy.sh cp, mở trong IntelliJ IDEA — mỗi module nằm trong một thư mục Maven riêng.

Bên trong mỗi module, cấu trúc thư mục theo chuẩn Maven:

  • src/main/java — mã nguồn Java: model, controller, service, class cấu hình...
  • src/main/resources/static — file tĩnh: html, css, javascript, media.
  • src/main/resources/templates — template Thymeleaf.
  • src/test/java — mã kiểm thử, gồm cả file StartupTest dùng để chạy thử module ngay trong IDE (đã nhắc ở phần trước).

Ở gốc mỗi module còn có pom.xml (cấu hình Maven), module.properties (định nghĩa tên, version, group của plugin), menus.properties (định nghĩa menu Admin, chỉ có ở module admin-plugin) và assembly.xml (cấu hình đóng gói). Ở gốc project còn có script export.sh/export.bat dùng để xuất bản plugin vào EzyPlatform cục bộ.

Khi export, mỗi module được sao chép vào đúng vị trí tương ứng trong EzyPlatform:

  • {project}-admin-pluginezyplatform/admin/plugins/{project}
  • {project}-web-pluginezyplatform/web/plugins/{project}
  • {project}-themeezyplatform/admin/themes/{project}
  • {project}-socket-pluginezyplatform/socket/plugins/{project}
  • {project}-socket-appezyplatform/socket/apps/{project}
  • {project}-sdk → thư mục lib của tất cả module trên (admin, socket, web) — vì đây là thư viện dùng chung cho cả plugin.
📋 Prompt mẫu — Nhờ AI định vị đúng module
Tôi đang phát triển plugin {tên-plugin-của-bạn} với các module {admin-plugin,theme,web-plugin}.
Tôi muốn thêm tính năng: {mô tả tính năng}.
Hãy cho tôi biết phần này nên đặt code ở module nào (sdk, admin-plugin, web-plugin, theme...), theo đúng cấu trúc chuẩn của một plugin project EzyPlatform, trước khi bắt đầu viết code.

Xem chi tiết đầy đủ tại trang hướng dẫn cấu trúc plugin project chính thức.

Hướng dẫn khắc phục lỗi khi import dự án vào IntelliJ (nếu có)

Một số máy, đặc biệt trên Windows, IntelliJ đôi khi không nhận diện được các biến môi trường như EZYPLATFORM_HOME hay EZYPLATFORM_SDK dù đã khai báo đúng ở cấp hệ điều hành, khiến project báo lỗi build ngay khi import. Nếu gặp tình huống này, hãy thử lần lượt 4 bước sau — dừng lại ngay khi bước nào đó đã khắc phục được lỗi, không cần làm hết cả 4.

Bước 1: Kiểm tra lại biến môi trường ở cấp hệ điều hành

Đảm bảo biến (ví dụ EZYPLATFORM_HOME) đã khai báo đúng, trỏ đến thư mục chứa cli.sh/cli.bat, sau đó khởi động lại IntelliJ hoàn toàn (không chỉ đóng project) để IDE đọc lại biến môi trường mới.

📋 Prompt mẫu — Kiểm tra biến môi trường
Project plugin của tôi báo lỗi build khi import vào IntelliJ, nghi ngờ do không nhận được biến môi trường {EZYPLATFORM_HOME hoặc EZYPLATFORM_SDK}.
Hãy kiểm tra giúp tôi biến này đã khai báo đúng ở cấp hệ điều hành chưa, rồi hướng dẫn tôi khởi động lại IntelliJ đúng cách để nó nhận biến mới.

Bước 2: Khai báo Path Variables trong IntelliJ

Nếu vẫn lỗi, vào Settings → Appearance & Behavior → Path Variables, thêm thủ công biến (ví dụ EZYPLATFORM_HOME) trỏ đúng đường dẫn, rồi build lại project.

📋 Prompt mẫu — Khai báo Path Variables
Bước kiểm tra biến môi trường cấp hệ điều hành chưa khắc phục được lỗi build. Hãy hướng dẫn tôi vào Settings → Path Variables của IntelliJ để khai báo thủ công biến {EZYPLATFORM_HOME} trỏ đến {đường dẫn thư mục EzyPlatform của bạn}, rồi build lại project để kiểm tra.

Bước 3: Thêm VM option cho Maven

Nếu vẫn chưa hết lỗi, vào Settings → Build, Execution, Deployment → Build Tools → Maven, thêm VM option -Denv.EZYPLATFORM_HOME=/đường/dẫn/của/bạn ở cả mục ImportingRunner — riêng mục Runner cần thêm cả biến môi trường tương ứng.

📋 Prompt mẫu — Cấu hình Maven VM options
Hai bước trước vẫn chưa hết lỗi build. Hãy hướng dẫn tôi vào Settings → Build, Execution, Deployment → Build Tools → Maven của IntelliJ, thêm VM option -Denv.EZYPLATFORM_HOME=/đường/dẫn/của/bạn vào cả mục Importing và Runner, đồng thời thêm biến môi trường EZYPLATFORM_HOME ở mục Runner.

Bước 4: Cấu hình ở cấp Run Configuration

Nếu lỗi vẫn còn khi chạy thử (ví dụ chạy file StartupTest), thêm cùng VM option và biến môi trường vào chính Run Configuration của class khởi động đó.

📋 Prompt mẫu — Cấu hình Run Configuration
Tôi vẫn gặp lỗi khi chạy file StartupTest trong IntelliJ. Hãy hướng dẫn tôi mở Run Configuration của file này, thêm VM option -Denv.EZYPLATFORM_HOME=/đường/dẫn/của/bạn và biến môi trường EZYPLATFORM_HOME tương ứng, rồi chạy lại để kiểm tra.

Xem hướng dẫn đầy đủ tại trang khắc phục lỗi PATH variable trong IntelliJ chính thức.

Sử dụng các phần trang (page fragment) để cài đặt theme

Mỗi khách hàng lại muốn một giao diện khác nhau cho website của mình — nếu mọi thay đổi nhỏ đều phải nhờ developer sửa code, chi phí và thời gian phát triển theme sẽ rất tốn kém. Page fragment giải quyết vấn đề này bằng cách "phân mảnh" giao diện thành từng phần (styles, scripts, header, footer, nội dung trang chủ...), để người dùng cuối tự chỉnh sửa ngay trong Admin mà không cần đụng vào code. Gồm 3 bước để khai báo:

Bước 1: Đăng ký danh sách page fragment

Trong module SDK dùng chung của plugin/theme, tạo một lớp cấu hình implement EzyBeanConfig để đăng ký tên các fragment, ví dụ nhóm common (styles, scripts, header, footer dùng chung mọi trang) hoặc home, blog_details (container HTML riêng cho từng loại trang).

📋 Prompt mẫu — Đăng ký page fragment
Trong module {tên-sdk-module} của theme, hãy tạo một lớp cấu hình implement EzyBeanConfig để đăng ký các page fragment sau bằng pageFragmentManager.registerFragmentNames:
- Nhóm "common": styles, scripts, header, footer (dùng chung mọi trang).
- Nhóm "home": container (HTML trang chủ).
Giải thích cho tôi ý nghĩa của từng fragment trước khi tạo.

Bước 2: Mở rộng cấu hình cho module Web và Admin

Tại module web-pluginadmin-plugin, tạo lớp kế thừa từ config gốc ở Bước 1, đánh dấu @EzyConfigurationAfter để đảm bảo chạy sau cấu hình chung.

📋 Prompt mẫu — Mở rộng cấu hình fragment
Hãy tạo giúp tôi lớp cấu hình kế thừa từ {tên-lớp-config-gốc-ở-bước-1} trong module web-plugin và admin-plugin, đánh dấu @EzyConfigurationAfter để nó chạy sau cấu hình gốc.

Bước 3: Hiển thị page fragment trong template

Cập nhật ViewDecorator để nạp fragment vào biến của view (ví dụ commonFragments), rồi trong page.html, render có điều kiện: nếu fragment đã được người dùng chỉnh sửa qua Admin thì lấy nội dung đó, nếu chưa thì giữ nguyên mặc định.

📋 Prompt mẫu — Render page fragment trong template
Hãy giúp tôi:
1. Cập nhật ViewDecorator để nạp các page fragment nhóm "common" vào biến commonFragments của view.
2. Sửa page.html: nếu commonFragments.get('styles') khác null và nội dung khác giá trị mặc định "styles" thì render nội dung đó bằng ezy:utext, ngược lại giữ nguyên style mặc định của theme.
Áp dụng tương tự cho scripts, header, footer.

Sau khi hoàn tất, người dùng có thể tự chỉnh sửa các phần trang này tại {đường-dẫn-admin-của-bạn}/ezyarticle/pages/fragments mà không cần nhờ developer. Xem chi tiết và ví dụ đầy đủ tại bài viết hướng dẫn page fragment chính thức.

Tạo giao diện cho theme

Nếu bạn (hoặc AI) đã có sẵn một giao diện HTML/CSS tĩnh — tải từ một template có sẵn, xuất từ Figma, hay do AI tạo riêng — bước tiếp theo là biến nó thành theme chạy được trên EzyPlatform bằng Thymeleaf, engine template mà EzyPlatform sử dụng. Gồm 4 bước.

Bước 1: Đưa file tĩnh vào đúng thư mục

Copy toàn bộ css, js, ảnh (thường nằm trong thư mục assets/ của giao diện gốc) vào src/main/resources/static/ của module theme, rồi sửa lại các đường dẫn tham chiếu trong HTML — ví dụ ./assets/css/style.css đổi thành /css/style.css — vì khi chạy, EzyPlatform tự ánh xạ thư mục static về gốc /.

📋 Prompt mẫu — Đưa giao diện tĩnh vào theme
Tôi có sẵn một giao diện HTML/CSS tĩnh tại thư mục {đường-dẫn-giao-diện-gốc}, gồm các file css/js/ảnh trong assets/.
Hãy giúp tôi copy toàn bộ các file tĩnh đó vào src/main/resources/static/ của module theme {tên-module-theme}, rồi sửa lại mọi đường dẫn tham chiếu trong HTML từ dạng ./assets/... sang /... cho đúng với cách EzyPlatform ánh xạ thư mục static.

Bước 2: Tách phần dùng chung thành fragment và layout

Header, footer thường lặp lại ở mọi trang nên tách thành fragment riêng (ví dụ fragments/header.html, fragments/footer.html) bằng thuộc tính th:fragment. Phần khung chung — đầu trang, cuối trang, và chỗ chèn nội dung riêng của từng trang — gom vào một file layout.html, dùng thư viện thymeleaf-layout-dialect với layout:fragment="content" đánh dấu vị trí nội dung sẽ được thay thế.

📋 Prompt mẫu — Tách fragment và layout
Từ file index.html tĩnh tôi vừa đưa vào theme, hãy giúp tôi:
1. Tách phần <header>...</header> thành fragments/header.html với th:fragment="header".
2. Tách phần <footer>...</footer> thành fragments/footer.html với th:fragment="footer".
3. Tạo layout.html dùng thymeleaf-layout-dialect, nhúng lại hai fragment trên bằng th:replace, và đánh dấu layout:fragment="content" ở chỗ nội dung chính sẽ được chèn vào.

Bước 3: Tạo template riêng cho từng trang

Mỗi trang cụ thể (trang chủ, trang chi tiết bài viết...) chỉ cần viết phần nội dung riêng (ví dụ phần <main>...</main> cũ) rồi khai báo layout:decorate để "mặc" lại layout.html, tránh phải lặp lại code header/footer ở mọi trang.

📋 Prompt mẫu — Tạo template trang chủ kế thừa layout
Hãy giúp tôi tạo home.html từ phần nội dung chính (thẻ main) trong index.html gốc, khai báo layout:decorate="~{layout}" để kế thừa layout.html vừa tạo ở Bước 2, và đặt nội dung vào đúng layout:fragment="content".

Bước 4: Chạy thử và kiểm tra giao diện

Chạy thử theme trong EzyPlatform (xem lại Bước 2 ở phần "Hướng dẫn khởi tạo dự án mô đun"), sau đó truy cập website để kiểm tra giao diện đã hiển thị đúng như bản gốc chưa — đặc biệt là css, js và ảnh có load đúng không.

Nếu giao diện gốc lấy từ một template miễn phí có sẵn, hãy giữ lại credit cho tác giả gốc (trong README hoặc footer theme) — đây vừa là phép lịch sự, vừa thường là điều kiện bắt buộc trong giấy phép sử dụng của template đó.

Xem hướng dẫn đầy đủ kèm ví dụ mã nguồn tại bài viết tạo giao diện cho theme blog đơn giản.

Ghép dữ liệu động cho theme

Giao diện dựng ở phần trước mới chỉ có dữ liệu giả (hardcode trong HTML). Bước tiếp theo là thay bằng dữ liệu thật lấy từ EzyPlatform — ví dụ danh sách bài viết blog đã xuất bản — kèm phân trang và định dạng ngày tháng dễ đọc. Gồm 4 bước.

Bước 1: Liên kết plugin EzyArticle vào theme

Theme cần truy vấn dữ liệu bài viết từ plugin EzyArticle, nên trước tiên phải liên kết (link) plugin đó vào project bằng ezy.sh — lệnh này tự thêm dependency SDK cần thiết vào pom.xml và cập nhật @ComponentsScan để quét đúng package của EzyArticle.

📋 Prompt mẫu — Liên kết plugin EzyArticle
Hãy giúp tôi chạy lệnh ezy.sh link ezyarticle trong project theme {tên-module-theme} của tôi, rồi kiểm tra pom.xml và @ComponentsScan đã được cập nhật đúng để dùng được các service của EzyArticle chưa.

Bước 2: Tạo dữ liệu mẫu để kiểm thử

Trước khi đụng vào code, tạo sẵn vài bài viết mẫu qua trang Admin — tiêu đề, ảnh đại diện, nội dung, loại bài viết là "blog", trạng thái "đã xuất bản" — để có dữ liệu thật dùng kiểm tra kết quả ở các bước sau.

Bước 3: Lấy danh sách bài viết trong Controller

Sửa HomeController: tiêm (inject) WebPostControllerService, tạo bộ lọc DefaultPostFilter với postType = PostType.BLOGpostStatus = PostStatus.PUBLISHED, gọi getPostItemPagination() kèm ngôn ngữ, thứ tự sắp xếp và token phân trang, rồi đưa kết quả vào view qua biến pagination.

📋 Prompt mẫu — Lấy danh sách bài viết cho trang chủ
Hãy sửa HomeController của theme để lấy danh sách bài viết loại "blog" đã xuất bản, có phân trang, hiển thị ở trang chủ:
1. Tiêm WebPostControllerService vào controller.
2. Tạo DefaultPostFilter lọc postType = PostType.BLOG, postStatus = PostStatus.PUBLISHED.
3. Gọi getPostItemPagination() kèm ngôn ngữ hiện tại, thứ tự sắp xếp, nextPageToken/prevPageToken và limit (mặc định 12).
4. Đưa kết quả vào view qua biến "pagination".

Bước 4: Lặp và hiển thị danh sách trong template

Trong home.html, dùng th:each lặp qua pagination.items, mỗi phần tử render qua một fragment riêng (ví dụ fragments/blog-item) để tái sử dụng HTML của từng thẻ bài viết thay vì lặp code. Vì dữ liệu trả về là timestamp dạng Unix, thêm thư viện moment.js để chuyển thành ngày tháng dễ đọc ngay trong template.

📋 Prompt mẫu — Hiển thị danh sách bài viết
Trong home.html, hãy giúp tôi:
1. Dùng th:each lặp qua pagination.items, mỗi bài viết render qua fragment fragments/blog-item (truyền biến blog vào fragment).
2. Tạo fragment blog-item hiển thị ảnh đại diện, tiêu đề, ngày đăng của bài viết.
3. Thêm thư viện moment.js và dùng nó để chuyển timestamp Unix của bài viết thành định dạng ngày tháng dễ đọc.
4. Thêm nút chuyển trang trước/sau dựa trên nextPageToken và prevPageToken của pagination.

Xem hướng dẫn đầy đủ kèm ví dụ mã nguồn tại bài viết ghép dữ liệu động cho theme blog đơn giản.

Lập trình đa ngôn ngữ cho theme

Nếu website của bạn phục vụ nhiều thị trường, theme cần hỗ trợ đa ngôn ngữ (i18n) ngay từ đầu, thay vì hardcode text trực tiếp trong HTML. EzyPlatform dùng cơ chế thay thế theo key: mỗi đoạn text trong giao diện ứng với một key, giá trị thật được lấy từ file properties riêng cho từng ngôn ngữ. Gồm 4 bước.

Bước 1: Thay text cứng bằng key

Trong template, thay các đoạn text tĩnh bằng cú pháp Thymeleaf [[#{key}]] — ví dụ <a>Liên hệ</a> đổi thành <a>[[#{contact}]]</a>. Đặt tên key theo quy ước chữ thường, các từ cách nhau bằng dấu gạch dưới, ví dụ contact, load_more, topic_title.

📋 Prompt mẫu — Thay text cứng bằng key đa ngôn ngữ
Trong các template của theme {tên-module-theme}, hãy giúp tôi thay toàn bộ text tĩnh (tiêu đề, nhãn nút, thông báo...) bằng cú pháp Thymeleaf [[#{key}]], đặt tên key theo quy ước chữ thường và gạch dưới.
Liệt kê cho tôi danh sách đầy đủ các key vừa tạo kèm text gốc tương ứng, để tôi dùng cho bước tạo file bản dịch tiếp theo.

Bước 2: Tạo file bản dịch cho từng ngôn ngữ

Trong src/main/resources/messages, tạo messages.properties (giá trị mặc định) và các file riêng theo ngôn ngữ như messages_vi.properties, messages_zh.properties, mỗi dòng theo dạng key=giá trị, ví dụ about_me=Về tôi.

📋 Prompt mẫu — Tạo file bản dịch
Dựa trên danh sách key ở bước trước, hãy tạo giúp tôi các file trong src/main/resources/messages:
- messages.properties: giá trị mặc định bằng tiếng {ngôn ngữ mặc định của bạn}.
- messages_vi.properties: bản dịch tiếng Việt.
- messages_zh.properties: bản dịch tiếng Trung.
Mỗi file theo định dạng key=giá trị.

Bước 3: Cấu hình encoding trong IntelliJ để tránh lỗi font

Vì tiếng Việt và nhiều ngôn ngữ khác dùng ký tự có dấu, cần bật tùy chọn Transparent native-to-ascii conversion tại Settings → Editor → File Encodings của IntelliJ, để tránh chữ có dấu bị lưu sai thành ký tự lỗi trong file .properties.

📋 Prompt mẫu — Cấu hình encoding cho file properties
Hãy hướng dẫn tôi bật "Transparent native-to-ascii conversion" trong Settings → Editor → File Encodings của IntelliJ, để các file messages_vi.properties, messages_zh.properties lưu đúng ký tự có dấu, không bị lỗi font.

Bước 4: Chạy thử và chuyển đổi ngôn ngữ

Khởi động lại project (chạy lại file StartupTest tương ứng), rồi truy cập website kèm tham số lang trên URL để kiểm tra từng ngôn ngữ, ví dụ http://localhost:8080/?lang=vi.

📋 Prompt mẫu — Chạy thử đa ngôn ngữ
Hãy giúp tôi khởi động lại theme {tên-module-theme} (chạy lại StartupTest), rồi cho tôi biết cách truy cập trang chủ với các tham số ?lang=vi và ?lang=zh trên URL để kiểm tra bản dịch từng ngôn ngữ đã hiển thị đúng chưa.
Bài hướng dẫn gốc chỉ dừng ở việc chuyển ngôn ngữ qua tham số ?lang= trên URL, chưa có sẵn nút chọn ngôn ngữ trên giao diện. Nếu muốn người dùng tự đổi ngôn ngữ bằng một menu, bạn cần tự thiết kế thêm — có thể nhờ AI tạo một dropdown chuyển hướng sang URL kèm đúng tham số lang tương ứng.

Xem hướng dẫn đầy đủ tại bài viết lập trình đa ngôn ngữ với EzyPlatform.

Hướng dẫn cài đặt menu cho admin plugin

Mặc định, menu của plugin bạn tạo sẽ nằm trong nhóm "Phần mở rộng" (Extension) ở khu vực quản trị — nhưng bạn có thể tùy chỉnh vị trí và icon để menu nổi bật hơn, phù hợp mức độ quan trọng của plugin. Gồm 2 bước.

Bước 1: Hiểu 4 khu vực đặt menu

EzyPlatform chia menu Admin thành 4 khu vực:

  • Top — dành cho plugin quan trọng, hiển thị ngay khu vực đầu của sidebar.
  • System — dành cho các menu quản lý hệ thống.
  • Admin — dành cho plugin phục vụ công việc quản trị.
  • Extension — nhóm "Phần mở rộng", vị trí mặc định của mọi plugin mới tạo.

Bước 2: Khai báo và tùy chỉnh menu trong menu.properties

Menu được khai báo trong file src/main/resources/menu.properties của module admin-plugin, theo cú pháp {khu-vực}.{tên-plugin}=đường-dẫn cho mục cha và {khu-vực}.{tên-plugin}.{tên-mục-con}=đường-dẫn; icon cho mục con — icon dùng class Font Awesome hoặc Ionic, ví dụ:

📄 menu.properties (mặc định, nhóm Extension)
extensions.personal=/
extensions.personal.dashboard=/dashboard; fas fa-tachometer-alt

Muốn chuyển menu lên nhóm Top và đổi icon, chỉ cần đổi tiền tố extensions thành top:

📄 menu.properties (chuyển sang Top, đổi icon)
top.personal=/; fas fa-blog
top.personal.dashboard=/dashboard; fas fa-tachometer-alt
📋 Prompt mẫu — Tùy chỉnh menu admin plugin
Hãy mở file menu.properties trong module {tên-module-admin-plugin} của tôi, cho tôi xem cấu hình menu hiện tại.
Sau đó chuyển menu từ nhóm extensions sang nhóm {top / system / admin}, đổi icon thành {tên icon Font Awesome hoặc Ionic bạn muốn}, giữ nguyên các mục con và đường dẫn.
Cuối cùng hướng dẫn tôi export lại và chạy lại StartupTest để xem thay đổi có hiển thị đúng trên sidebar Admin không.
Hướng dẫn gốc chỉ dừng ở việc đổi vị trí và icon menu, chưa đề cập cách khai báo menu con nhiều cấp, nhãn đa ngôn ngữ hay phân quyền hiển thị theo role. Nếu plugin cần những tính năng menu nâng cao hơn, hãy nhờ AI đọc thêm mã nguồn các plugin có sẵn (ví dụ EzyArticle, EzySupport) để tham khảo cách họ khai báo, thay vì đoán cú pháp.

Xem hướng dẫn đầy đủ tại bài viết lập trình menu cho EzyPlatform admin plugin.

Tạo bảng mới

Khi plugin cần lưu trữ dữ liệu riêng ngoài các bảng có sẵn của EzyPlatform, bạn tự định nghĩa bảng mới ngay trong plugin. EzyPlatform dùng JPA/Hibernate với các annotation quen thuộc (@Entity, @Table, @Id, @Column) để ánh xạ entity Java với bảng MySQL. Gồm 3 bước.

Bước 1: Viết script SQL tạo bảng

Đặt file .sql trong thư mục src/main/resources/scripts của module admin-plugin — EzyPlatform tự quét và chạy một lần duy nhất khi khởi động. Luôn dùng CREATE TABLE IF NOT EXISTS để tránh lỗi khi chạy lại, và đặt tiền tố tên plugin trước tên bảng để tránh trùng với bảng của plugin khác.

📋 Prompt mẫu — Viết script tạo bảng
Plugin {tên-plugin-của-bạn} cần lưu {mô tả dữ liệu cần lưu, ví dụ: số lượng từ trong mỗi bài viết}.
Hãy giúp tôi viết file SQL trong src/main/resources/scripts của module admin-plugin, dùng CREATE TABLE IF NOT EXISTS, đặt tên bảng có tiền tố {tên-plugin}_, khai báo đúng các cột, khóa chính và index cần thiết cho dữ liệu trên.

Bước 2: Khai báo Entity ánh xạ với bảng

Tạo class Java tương ứng, đánh dấu @Entity, @Table(name = ...), khai báo các cột bằng @Column và khóa chính bằng @Id, khớp đúng với bảng vừa tạo ở Bước 1.

📋 Prompt mẫu — Tạo Entity cho bảng
Hãy tạo giúp tôi một class Entity Java ánh xạ với bảng vừa tạo ở Bước 1, dùng @Entity, @Table, @Id và @Column, đặt tên các trường tương ứng đúng với tên cột trong bảng.

Bước 3: Sinh Repository để CRUD dữ liệu

Thay vì viết tay, EzyPlatform cung cấp công cụ RepositoryClassesGenerator — chạy như một class Java bình thường trong IntelliJ — để tự sinh ra lớp repository tương ứng (ví dụ AdminPersonalPostWordCountRepository), có sẵn các phương thức CRUD như save(), findById(), delete().

📋 Prompt mẫu — Sinh Repository từ Entity
Hãy giúp tôi tạo và chạy class RepositoryClassesGenerator để sinh repository cho Entity vừa tạo ở Bước 2, rồi viết cho tôi một đoạn code ví dụ dùng repository đó để save, findById và delete một bản ghi.

Sau này nếu cần thay đổi cấu trúc bảng đã tồn tại, tạo thêm một file riêng có chữ "alter" trong tên (ví dụ alter_add_column.sql) — EzyPlatform sẽ chạy các file này và bỏ qua lỗi nếu thay đổi đã được áp dụng trước đó, tránh việc phải sửa lại file tạo bảng gốc. Xem hướng dẫn đầy đủ tại bài viết tạo bảng trong EzyPlatform plugin.

Cài đặt batch (data appender)

Data Appender (Batch) là cơ chế xử lý dữ liệu hàng loạt chạy nền, phù hợp cho các nghiệp vụ tổng hợp dữ liệu nặng — ví dụ quét toàn bộ lịch sử bài viết để đếm số từ và lưu vào bảng thống kê riêng, thay vì tính toán ngay mỗi khi người dùng thao tác. Gồm 3 bước.

Bước 1: Chuẩn bị Repository đọc dữ liệu nguồn

Tạo repository đọc dữ liệu nguồn theo trang (page), ví dụ AdminPersonalPostHistoryRepository với phương thức findByIdGt(id) lấy các bản ghi có id lớn hơn id đã xử lý lần trước — đảm bảo duyệt tuần tự và không xử lý trùng.

📋 Prompt mẫu — Tạo Repository đọc dữ liệu nguồn
Hãy tạo giúp tôi repository {tên-repository-nguồn}, có phương thức findByIdGt(id) lấy danh sách bản ghi {tên-entity-nguồn} có id lớn hơn id truyền vào, sắp xếp tăng dần theo id, giới hạn số bản ghi mỗi lần lấy.

Bước 2: Tạo lớp Data Appender

Tạo class kế thừa AdminDataAppender<Nguồn, Đích, KiểuPageToken> (ví dụ AdminDataAppender<PostHistory, PersonalPostWordCount, Long>), đánh dấu @EzySingleton để EzyPlatform tự nhận diện và quản lý vòng đời, rồi implement các phương thức chính: getValueList (lấy danh sách bản ghi cần xử lý), toDataRecord (chuyển entity nguồn sang entity đích), addDataRecord (lưu vào bảng đích), extractNewLastPageToken (lấy id bản ghi cuối để dùng cho lần chạy tiếp theo), và defaultStarted (trả về true để chạy ngay khi EzyPlatform khởi động).

📋 Prompt mẫu — Tạo Data Appender
Hãy tạo giúp tôi class {TênPluginPostWordCountDataAppender} kế thừa AdminDataAppender<PostHistory, {EntityĐích}, Long>, đánh dấu @EzySingleton, dùng repository {tên-repository-nguồn} ở Bước 1 và repository của {EntityĐích} ở phần Tạo bảng mới.
Implement getValueList, toDataRecord (đếm số từ trong nội dung bài viết), addDataRecord, extractNewLastPageToken (lấy id bản ghi cuối cùng) và defaultStarted trả về true.

Bước 3: Quản lý Data Appender qua Admin

Sau khi export và cài vào EzyPlatform, vào Cài đặt → Thông thường để thấy appender xuất hiện trong danh sách (ví dụ personal_post_word_count) với ba tùy chọn: Stop (dừng), Restart (chạy tiếp từ token hiện tại) và Reload (xóa token, quét lại toàn bộ từ đầu) — hữu ích khi cần chạy lại sau khi sửa logic tính toán.

Xem hướng dẫn đầy đủ tại bài viết tạo Batch Data Appender trong EzyPlatform plugin.

Tạo và xử lý sự kiện nội bộ

Để plugin dễ mở rộng, các phần bên trong nó không nên gọi trực tiếp lẫn nhau — thay vào đó, EzyPlatform cung cấp cơ chế sự kiện nội bộ (Internal Event): một nơi bắn sự kiện, nhiều nơi khác nhau có thể tự đăng ký lắng nghe và xử lý mà không cần biết đến nhau. Tiếp nối ví dụ Data Appender ở phần trên: thay vì tính luôn "thời gian đọc" ngay trong lúc đếm số từ, ta bắn một sự kiện sau khi đếm xong — một handler riêng sẽ lắng nghe và tự tính thời gian đọc. Gồm 3 bước.

Bước 1: Định nghĩa tên sự kiện

Khai báo một hằng số String đặt tên sự kiện, thường gom vào một class hằng số chung của plugin (ví dụ PersonalConstants), đặt tiền tố tên plugin để tránh trùng với sự kiện của plugin khác — ví dụ INTERNAL_EVENT_NAME_POST_WORD_COUNT = "personal_post_word_count".

📋 Prompt mẫu — Định nghĩa tên sự kiện
Hãy thêm vào class {TênPlugin}Constants của tôi một hằng số String đặt tên sự kiện nội bộ cho việc {mô tả hành động cần thông báo, ví dụ: đã đếm xong số từ trong bài viết}, đặt tiền tố tên plugin để tránh trùng với plugin khác.

Bước 2: Bắn sự kiện tại nơi xảy ra hành động

Tại nơi cần thông báo (ví dụ ngay sau khi addDataRecord của Data Appender lưu xong bản ghi số từ), tiêm EventHandlerManager phù hợp (AdminEventHandlerManager cho phía admin, WebEventHandlerManager cho phía web) qua constructor hoặc @EzyAutoBind, rồi gọi handleEvent(tên-sự-kiện, dữ-liệu) kèm dữ liệu cần truyền, đóng gói bằng EzyMapBuilder.

📋 Prompt mẫu — Bắn sự kiện
Trong {tên-class-nơi-xảy-ra-hành-động}, hãy giúp tôi tiêm AdminEventHandlerManager, rồi ngay sau khi {mô tả hành động, ví dụ: lưu xong bản ghi số từ}, gọi eventHandlerManager.handleEvent với tên sự kiện đã định nghĩa ở Bước 1, dữ liệu đóng gói bằng EzyMapBuilder gồm các trường {liệt kê dữ liệu cần truyền, ví dụ: postId, wordCount}.

Bước 3: Đăng ký handler lắng nghe sự kiện

Tạo class implement EventHandler<D, R> (hoặc kế thừa AbstractEventHandler<D, R> nếu không cần trả kết quả), đánh dấu @EzySingleton để EzyPlatform tự nhận diện, override getEventName() trả về đúng tên sự kiện ở Bước 1 và processEventData()/doHandleEventData() để xử lý logic. Một sự kiện có thể có nhiều handler cùng lắng nghe, mỗi handler làm một việc độc lập, không ảnh hưởng lẫn nhau.

📋 Prompt mẫu — Tạo handler xử lý sự kiện
Hãy tạo giúp tôi class {TênPluginTimeToReadEventHandler} kế thừa AbstractEventHandler<Map<String, Object>, Void>, đánh dấu @EzySingleton.
Override getEventName() trả về hằng số sự kiện đã tạo ở Bước 1.
Override processEventData() để {mô tả logic xử lý, ví dụ: tính thời gian đọc dựa trên wordCount nhận được và lưu vào bảng tương ứng}.
Sự kiện nội bộ chỉ xử lý được trong cùng một thành phần (admin, web hoặc socket) và trên cùng một máy chủ — không thể dùng để giao tiếp giữa các thành phần khác nhau hoặc giữa nhiều máy chủ. Nếu cần điều đó, bạn sẽ cần một cơ chế khác (ví dụ message queue), nằm ngoài phạm vi của Internal Event.

Xem hướng dẫn đầy đủ tại bài viết tạo và xử lý sự kiện nội bộ trong EzyPlatform plugin.

📌 Tóm tắt chương

  • Plugin là đơn vị mở rộng độc lập; Marketplace Item là plugin đã đóng gói để chia sẻ hoặc bán; Theme định hình toàn bộ diện mạo website.
  • Môi trường phát triển cục bộ cần JDK 8, MySQL, Maven, EzyPlatform, EzyPlatform SDK và IntelliJ IDEA — mỗi bước đều có thể nhờ AI kiểm tra và cài đặt giúp.
  • Dùng ezy.sh để sinh khung project mới; mỗi loại module (sdk, admin-plugin, web-plugin, theme, socket-plugin, socket-app) là một sub-module Maven riêng với vai trò rõ ràng.
  • Xây theme gồm: dựng giao diện tĩnh bằng Thymeleaf, ghép dữ liệu động, lập trình đa ngôn ngữ, và cho phép người dùng tự chỉnh sửa qua page fragment.
  • Xây plugin gồm: cấu hình menu Admin, tạo bảng dữ liệu riêng, xử lý dữ liệu hàng loạt bằng Data Appender, và giao tiếp nội bộ giữa các thành phần bằng Internal Event.
  • Luôn kiểm thử kỹ với dữ liệu giả trước khi đưa module vào vận hành thật.
Chương trước: kết nối sản phẩm của bạn với các dịch vụ bên ngoài như Google, Meta, cổng thanh toán. ← Đọc Chương 12
Chương tiếp theo: đảm bảo sản phẩm của bạn chạy nhanh và an toàn trước người dùng thật. Đọc Chương 14 →