Nguyên lý hoạt động của EzyPlatform admin global search
Back To BlogsTính năng admin global search trong EzyPlatform được thiết kế theo hướng rất gọn, mở rộng tốt và phù hợp với kiến trúc plugin. Thay vì dồn toàn bộ logic tìm kiếm vào một service khổng lồ, hệ thống chia nhỏ trách nhiệm cho nhiều thành phần độc lập. Mỗi module hoặc plugin chỉ cần đăng ký một
AdminSearchService là có thể tự đóng góp kết quả vào ô tìm kiếm chung của trang quản trị.Cách làm này giúp global search không bị phụ thuộc cứng vào một domain cụ thể như sản phẩm, người dùng hay bài viết. Bất kỳ plugin nào cũng có thể tham gia vào luồng tìm kiếm miễn là triển khai đúng interface.

Tổng quan kiến trúc
Ở mức khái quát, luồng xử lý của admin global search như sau:
flowchart TD
A[Admin nhập keyword] --> B[GET /api/v1/search]
B --> C[AdminApiSearchController]
C --> D[Trim keyword và kiểm tra rỗng]
D --> E[Lấy danh sách AdminSearchService từ singletonFactory]
E --> F[Sắp xếp theo priority]
F --> G[Duyệt từng AdminSearchService]
G --> H[Gọi search adminId adminRoles keyword]
H --> I[Service trả về List SearchResultModel]
I --> J[Controller gộp toàn bộ kết quả]
J --> K[Trả response cho frontend]
Kiến trúc này có một số đặc điểm nổi bật:
- Controller đóng vai trò điều phối
- Mỗi plugin tự chịu trách nhiệm tìm kiếm dữ liệu của mình
- Kết quả được chuẩn hóa về chung một model là
SearchResultModel - Hệ thống hỗ trợ mở rộng mà không cần sửa controller trung tâm
Controller trung tâm của global search
Đây là entry point của tính năng tìm kiếm toàn cục trong admin:
@Api @Authenticated @Controller("/api/v1") public class AdminApiSearchController { private final EzyLazyInitializer<List<AdminSearchService>> searchServices; public AdminApiSearchController( EzySingletonFactory singletonFactory ) { searchServices = new EzyLazyInitializer<>(() -> { @SuppressWarnings("unchecked") List<AdminSearchService> searchServices = singletonFactory .getSingletonsOf(AdminSearchService.class); searchServices.sort( Comparator.comparingInt(AdminSearchService::priority) ); return searchServices; }); } @DoGet("/search") public List<SearchResultModel> searchGet( @AdminId long adminId, @AdminRoles AdminRolesProxy adminRoles, @RequestParam("keyword") String keyword ) { String trimmedKeyword = trimOrNull(keyword); if (trimmedKeyword == null) { return Collections.emptyList(); } List<SearchResultModel> result = new ArrayList<>(); for (AdminSearchService searchService : searchServices.get()) { try { result.addAll( searchService.search( adminId, adminRoles, trimmedKeyword ) ); } catch (Exception e) { // do nothing } } return result; } }
Nhìn vào controller này có thể thấy rõ tư tưởng thiết kế của hệ thống: controller không biết chi tiết từng domain cần tìm kiếm, nó chỉ biết rằng có nhiều
AdminSearchService, và mỗi service có thể trả về một nhóm kết quả phù hợp với keyword.Cách request được xử lý
Khi admin gọi endpoint:
GET /api/v1/search?keyword=...
controller thực hiện các bước sau:
Xác thực và lấy ngữ cảnh người dùng quản trị
Method
searchGet nhận vào:-
adminId -
adminRoles -
keyword
Điều này cho thấy global search không chỉ đơn thuần là tìm theo từ khóa, mà còn có khả năng tìm kiếm dựa trên ngữ cảnh phân quyền. Một plugin hoàn toàn có thể dựa vào
adminId hoặc adminRoles để quyết định:
- có cho phép trả kết quả hay không
- trả loại kết quả nào
- ẩn bớt dữ liệu nhạy cảm với một số nhóm quyền
Đây là điểm rất quan trọng trong môi trường admin, nơi mà cùng một keyword nhưng mỗi admin có thể nhìn thấy tập kết quả khác nhau.
Chuẩn hóa keyword đầu vào
Controller gọi:
String trimmedKeyword = trimOrNull(keyword); if (trimmedKeyword == null) { return Collections.emptyList(); }
Mục đích là:
- loại bỏ khoảng trắng thừa
- tránh xử lý keyword rỗng
- trả về rỗng sớm để giảm chi phí hệ thống
Nạp danh sách search service
Controller không hard-code danh sách service. Thay vào đó, nó lấy toàn bộ singleton triển khai
AdminSearchService từ EzySingletonFactory.
List<AdminSearchService> searchServices = singletonFactory
.getSingletonsOf(AdminSearchService.class);
Điều này có nghĩa là:
- plugin nào đăng ký một bean/service implement
AdminSearchService - thì bean đó sẽ tự động trở thành một phần của global search
Đây chính là nền tảng để global search hoạt động theo kiểu mở rộng động.
Sắp xếp service theo độ ưu tiên
Danh sách service được sort bằng:
searchServices.sort(
Comparator.comparingInt(AdminSearchService::priority)
);
Nhờ đó, hệ thống có thể kiểm soát thứ tự chạy của các nguồn tìm kiếm. Service nào có
priority() nhỏ hơn sẽ chạy trước.Cơ chế này hữu ích khi:
- muốn các kết quả quan trọng xuất hiện sớm hơn
- muốn một số plugin lõi chạy trước plugin mở rộng
- muốn kiểm soát trải nghiệm tìm kiếm mà không cần thay đổi controller
Duyệt toàn bộ service và gộp kết quả
Controller tạo một list kết quả chung:
List<SearchResultModel> result = new ArrayList<>();
Sau đó gọi lần lượt:
for (AdminSearchService searchService : searchServices.get()) { try { result.addAll( searchService.search( adminId, adminRoles, trimmedKeyword ) ); } catch (Exception e) { // do nothing } }
Từng service sẽ tự xử lý domain của nó, ví dụ:
- sản phẩm
- đơn hàng
- người dùng
- bài viết
- plugin cấu hình
- menu quản trị
Các kết quả được gộp lại thành một danh sách thống nhất để trả về cho frontend.
Cơ chế mở rộng qua interface AdminSearchService
Interface này là hợp đồng chung để các plugin tham gia vào global search:
public interface AdminSearchService { default List<SearchResultModel> search(long adminId, AdminRolesProxy rolesProxy, String keyword) { return this.search(adminId, keyword); } default List<SearchResultModel> search(long adminId, String keyword) { return this.search(keyword); } default List<SearchResultModel> search(String keyword) { return Collections.emptyList(); } default int priority() { return 0; } }
Thiết kế này rất đáng chú ý vì nó cho phép nhiều mức độ cài đặt khác nhau.
Một plugin đơn giản có thể chỉ cần override:
search(String keyword)
Trong khi plugin phức tạp hơn có thể override:
search(long adminId, AdminRolesProxy rolesProxy, String keyword)
để xử lý theo quyền hạn.
Các default method ở đây đóng vai trò như một lớp tương thích ngược và giảm chi phí triển khai. Plugin developer không bị bắt buộc phải quan tâm đến toàn bộ context nếu họ không cần.
Ví dụ một service tìm kiếm sản phẩm
Một cài đặt cụ thể của
AdminSearchService có thể trông như sau:@Service @AllArgsConstructor public class AdminEcommerceAdminSearchControllerService implements AdminSearchService { private final AdminProductService productService; @Override public List<SearchResultModel> search(String keyword) { if (isBlank(keyword)) { return Collections.emptyList(); } List<ProductModel> products = productService .getProductsByKeywordPrefix( keyword, LIMIT_30_RECORDS ); return newArrayList(products, it -> SearchResultModel.builder() .title(it.getProductCode()) .resultType("product") .content(it.getProductName()) .url("/ecommerce/products/" + it.getId()) .build() ); } }
Service này cho thấy rõ cách một plugin đóng góp kết quả vào global search.
Nó thực hiện ba việc chính:
- tìm sản phẩm theo keyword prefix
- ánh xạ
ProductModelsangSearchResultModel - trả về danh sách kết quả thống nhất cho controller
Mỗi kết quả gồm các thông tin như:
-
title -
resultType -
content -
url
Nhờ model chuẩn này, frontend không cần biết kết quả đến từ domain nào. Nó chỉ cần render theo một cấu trúc chung.
SearchResultModel là điểm chuẩn hóa của toàn hệ thống
Mặc dù đoạn code không đưa đầy đủ
SearchResultModel, nhưng nhìn từ builder có thể suy ra nó là model chuẩn của global search.Ví dụ trong trường hợp sản phẩm:
-
title: mã sản phẩm -
content: tên sản phẩm -
resultType: loại kết quả làproduct -
url: đường dẫn đến trang admin chi tiết
Chính việc chuẩn hóa về một model duy nhất giúp hệ thống đạt được hai mục tiêu:
- backend dễ gộp dữ liệu từ nhiều nguồn
- frontend dễ hiển thị kết quả hỗn hợp trong cùng một dropdown hoặc search page
Đây là một kiểu thiết kế rất thực dụng và hiệu quả.
Các design pattern đang được sử dụng
Kiến trúc của admin global search không chỉ đơn giản là gọi nhiều service. Bên dưới nó đang áp dụng khá nhiều pattern quen thuộc trong thiết kế hệ thống plugin.
Strategy Pattern
AdminSearchService chính là một họ strategy cho hành vi tìm kiếm.Mỗi implementation là một chiến lược tìm kiếm khác nhau:
- search sản phẩm
- search bài viết
- search user
- search order
Controller không cần biết chi tiết từng chiến lược, nó chỉ cần gọi chung một contract:
search(...)
Đây là biểu hiện rất rõ của Strategy Pattern.
classDiagram
class AdminApiSearchController
class AdminSearchService {
<<interface>>
+search(keyword)
+search(adminId, keyword)
+search(adminId, roles, keyword)
+priority()
}
class ProductSearchService
class UserSearchService
class OrderSearchService
AdminApiSearchController --> AdminSearchService
AdminSearchService <|.. ProductSearchService
AdminSearchService <|.. UserSearchService
AdminSearchService <|.. OrderSearchService
Plugin / Extension Point Pattern
Global search được thiết kế như một extension point.
Bất kỳ plugin nào cũng có thể mở rộng hệ thống bằng cách:
- implement
AdminSearchService - đăng ký thành bean/service
Sau đó controller sẽ tự phát hiện và dùng service đó.
Đây là pattern rất phổ biến trong các hệ thống modular và plugin-based.
Chain of Responsibility theo nghĩa mở rộng
Controller duyệt lần lượt qua từng
AdminSearchService và để mỗi service có cơ hội xử lý keyword. Dù kết quả không “pass tiếp” theo kiểu chặn luồng cổ điển, nhưng về bản chất vẫn có hơi hướng của Chain of Responsibility:- mỗi node trong chuỗi tự xử lý keyword theo domain của mình
- kết quả được cộng dồn lại
- lỗi ở một node không làm hỏng toàn bộ chuỗi
Kiểu chain này thiên về aggregation chain hơn là stop-on-first-match chain.
Lazy Initialization
Danh sách search service không được khởi tạo ngay trong constructor theo kiểu eager. Thay vào đó, nó được bọc bởi:
new EzyLazyInitializer<>(...)
Điều này cho thấy hệ thống trì hoãn việc load danh sách service cho đến khi thật sự cần dùng. Đây là một dạng Lazy Initialization.
Lợi ích:
- giảm chi phí khởi tạo ban đầu
- tránh load sớm khi feature chưa được dùng
- phù hợp với hệ thống có nhiều plugin
Template Method theo kiểu interface default methods
AdminSearchService có các default method gọi chồng lên nhau:search(adminId, roles, keyword) -> search(adminId, keyword) -> search(keyword)
Đây không phải Template Method theo hình thức class abstract cổ điển, nhưng về ý tưởng thì rất gần:
- interface định ra luồng fallback mặc định
- implementation chỉ cần override mức phù hợp nhất
Cách làm này giúp API mềm dẻo hơn cho developer của plugin.
Vì sao thiết kế này phù hợp với admin global search
Global search trong trang quản trị thường có một số yêu cầu đặc trưng:
- dữ liệu đến từ nhiều domain khác nhau
- mỗi domain có logic tìm kiếm riêng
- phân quyền có thể ảnh hưởng đến kết quả
- phải dễ mở rộng theo plugin
- một plugin lỗi không được làm chết toàn bộ search
Kiến trúc hiện tại đáp ứng khá tốt các yêu cầu đó.
Controller rất mỏng, gần như chỉ làm orchestration. Toàn bộ hiểu biết về domain được đẩy xuống các
AdminSearchService. Nhờ vậy:- code lõi gọn
- plugin mới dễ thêm
- rủi ro sửa một chỗ làm hỏng toàn hệ thống thấp hơn
- frontend chỉ cần làm việc với một format kết quả thống nhất
Cơ chế chống ảnh hưởng dây chuyền khi một service lỗi
Một điểm kỹ thuật rất thực tế trong controller là:
try { result.addAll(searchService.search(...)); } catch (Exception e) { // do nothing }
Nghĩa là nếu một plugin tìm kiếm gặp lỗi, hệ thống vẫn tiếp tục gọi các plugin khác.
Lợi ích là:
- global search không bị sập toàn bộ chỉ vì một nguồn dữ liệu lỗi
- trải nghiệm của admin vẫn được duy trì ở mức chấp nhận được
- hệ sinh thái plugin trở nên bền hơn
Ý nghĩa của priority() trong trải nghiệm tìm kiếm
priority() là một điểm nhỏ nhưng có giá trị thiết kế cao.Với hệ thống nhiều plugin, không phải kết quả nào cũng nên có vị trí ngang nhau. Ví dụ:
- kết quả lõi của hệ thống có thể ưu tiên cao hơn
- kết quả từ plugin phụ có thể xếp sau
- search service nhẹ có thể chạy trước service nặng
Nhờ
priority(), EzyPlatform có thể kiểm soát thứ tự đóng góp kết quả mà không làm controller phình to với quá nhiều if-else.
Đây là một giải pháp đơn giản nhưng rất “đúng chất framework”.
Minh họa quan hệ giữa controller và các plugin search
flowchart LR
A[AdminApiSearchController] --> B[Product Search Service]
A --> C[User Search Service]
A --> D[Order Search Service]
A --> E[Other Plugin Search Service]
B --> F[SearchResultModel]
C --> F
D --> F
E --> F
F --> G[Combined Search Response]
Sơ đồ này cho thấy controller hoạt động như một bộ điều phối trung tâm. Các plugin không cần biết nhau tồn tại, chỉ cần cùng phát ra một định dạng kết quả chung.
Ví dụ tối giản để thêm một nguồn tìm kiếm mới
Nếu một plugin muốn bổ sung kết quả tìm kiếm cho “coupon”, về cơ bản chỉ cần làm như sau:
@Service @AllArgsConstructor public class AdminCouponSearchService implements AdminSearchService { private final AdminCouponService couponService; @Override public List<SearchResultModel> search( long adminId, AdminRolesProxy rolesProxy, String keyword ) { if (keyword == null || keyword.isEmpty()) { return Collections.emptyList(); } return couponService.findByKeyword(keyword).stream() .map(it -> SearchResultModel.builder() .title(it.getCode()) .resultType("coupon") .content(it.getName()) .url("/coupons/" + it.getId()) .build()) .toList(); } @Override public int priority() { return 100; } }
Không cần sửa
AdminApiSearchController. Đây chính là ưu điểm lớn nhất của kiến trúc hiện tại.Kết luận
Nguyên lý hoạt động của EzyPlatform admin global search xoay quanh một tư tưởng rất rõ: controller trung tâm chỉ điều phối, còn từng plugin tự chịu trách nhiệm tìm kiếm dữ liệu của mình.
Kiến trúc này tạo ra một global search có các đặc điểm nổi bật:
- mở rộng dễ qua plugin
- ít phụ thuộc giữa các module
- có hỗ trợ ngữ cảnh phân quyền
- kết quả được chuẩn hóa qua
SearchResultModel - bền hơn khi một nguồn tìm kiếm gặp lỗi
Về mặt design pattern, đây là một tổ hợp rất đẹp giữa:
- Strategy Pattern
- Plugin / Extension Point Pattern
- Lazy Initialization
- Template Method theo kiểu default interface methods
- và một biến thể thực dụng của Chain of Responsibility
Đây là một thiết kế gọn, thực dụng và rất phù hợp với một nền tảng admin có kiến trúc module như EzyPlatform.