Một đơn hàng đi vào hệ thống phải qua nhiều lớp kiểm tra: khách đã đăng nhập chưa, kho còn hàng không, hạn mức thanh toán có vượt không. Nếu viết tất cả trong một phương thức dài với chuỗi if lồng nhau, mỗi lần thêm một luật mới bạn lại sửa chính phương thức đó. Chain of Responsibility (chuỗi trách nhiệm) giải quyết việc này bằng cách tách mỗi luật thành một bộ xử lý riêng và nối chúng thành chuỗi. Bài này trình bày Chain of Responsibility từ cài đặt thuần Java đến hai ví dụ nổi tiếng nhất trong hệ sinh thái Java: Servlet filter và Spring Security.

Bài nằm trong series Java & Design Pattern. Nếu bạn muốn xem một pattern hành vi khác, ngoài Chain of Responsibility, giải quyết bài toán "nhiều đối tượng cùng tham gia xử lý", hãy đọc Command pattern: hàng đợi lệnh, undo và redo và Template Method: khung thuật toán và điểm mở rộng. Toàn bộ code dưới đây đã chạy thật trên JDK 21 (bản OpenJDK 21.0.x) và Spring Boot 3.2.1 (kèm Spring Security 6.2.1), máy 16 GB RAM.

Vấn đề: chuỗi kiểm tra cứng trong một hàm

Hãy hình dung phương thức validate(Order) của một cửa hàng online. Bước đầu chỉ có kiểm tra khách hàng. Sau đó kinh doanh thêm kiểm tra tồn kho, rồi hạn mức thanh toán trước cho đơn lớn, rồi chống đơn trùng. Phương thức phình ra, thứ tự các bước bị chôn trong code, và không thể bật hoặc tắt một bước theo cấu hình mà không sửa nó. Bên gọi cũng bị buộc phải biết toàn bộ các bước.

Các vấn đề này có chung một gốc: người gửi yêu cầu biết quá nhiều về người xử lý. Chain of Responsibility gỡ ràng buộc đó. Theo mô tả của refactoring.guru, Chain of Responsibility là pattern hành vi cho phép truyền yêu cầu dọc theo một chuỗi bộ xử lý, trong đó mỗi bộ xử lý tự quyết định xử lý yêu cầu hay chuyển tiếp cho bộ kế tiếp. Sách GoF (1994) đặt mục tiêu tương tự cho Chain of Responsibility: tránh gắn chặt người gửi với người nhận.

Cấu trúc của Chain of Responsibility

Chain of Responsibility có bốn vai, và phần lớn cài đặt thực tế chỉ dùng ba trong số đó.

Vai tròNhiệm vụTrong ví dụ đơn hàng
HandlerKhai báo giao diện chung, thường một phương thức xử lýHandler.handle(Order)
Base handler (tuỳ chọn)Giữ tham chiếu tới bộ kế tiếp, cung cấp hành vi chuyển tiếp mặc địnhBaseHandler.pass()
Concrete handlerChứa logic thật, tự quyết định xử lý và có chuyển tiếp hay khôngAuthCheck, StockCheck, CreditLimit
ClientDựng chuỗi, gửi yêu cầu vào bộ xử lý đầumain

Điểm mấu chốt là quyền quyết định nằm ở từng bộ xử lý. Mỗi mắt xích có ba lựa chọn: xử lý xong rồi dừng, xử lý một phần rồi chuyển tiếp, hoặc không đụng tới và chuyển tiếp nguyên vẹn. Chính tính chất "có thể dừng bất cứ lúc nào" làm Chain of Responsibility khác với Decorator, dù cấu trúc hai pattern trông gần giống nhau.

Cài đặt Chain of Responsibility thuần Java

Ví dụ đầu tiên kiểm tra đơn hàng qua ba mắt xích. Mỗi bộ xử lý trả về Optional<String>: rỗng nghĩa là chưa ai từ chối, có giá trị là lý do từ chối. Lớp cơ sở BaseHandler giữ con trỏ next và phương thức pass để các lớp con không lặp lại đoạn chuyển tiếp.

    abstract static class BaseHandler implements Handler {
        private Handler next;
        BaseHandler then(BaseHandler n) { this.next = n; return n; }
        protected Optional<String> pass(Order o) {
            return next == null ? Optional.empty() : next.handle(o);
        }
    }

    static class AuthCheck extends BaseHandler {
        public Optional<String> handle(Order o) {
            System.out.println("  [Auth] kiểm tra khách " + o.customerId());
            if (o.customerId() == null || o.customerId().isBlank()) return Optional.of("401 thiếu khách hàng");
            return pass(o);
        }
    }

Hai lớp StockCheck và CreditLimit có cùng khuôn với AuthCheck, chỉ khác điều kiện từ chối (xem file đầy đủ trong thư mục code của bài).

Phương thức then trả về chính bộ xử lý vừa thêm nên có thể viết head.then(a).then(b) để dựng chuỗi theo kiểu fluent. Chạy bằng java OrderChain.java (JDK 21 chạy trực tiếp file nguồn) cho kết quả sau.

Đơn của KH001
  [Auth] kiểm tra khách KH001
  [Stock] kiểm tra tồn kho SP-01 x2
  [Credit] kiểm tra hạn mức 12000000
  => 200 hợp lệ
Đơn của (trống)
  [Auth] kiểm tra khách 
  => 401 thiếu khách hàng
Đơn của KH002
  [Auth] kiểm tra khách KH002
  [Stock] kiểm tra tồn kho SP-02 x9
  => 409 vượt tồn kho (tối đa 5)
Đơn của KH003
  [Auth] kiểm tra khách KH003
  [Stock] kiểm tra tồn kho SP-03 x1
  [Credit] kiểm tra hạn mức 80000000
  => 402 đơn trên 50.000.000 VND phải thanh toán trước

Quan sát đơn thứ hai và thứ ba: khi AuthCheck hoặc StockCheck từ chối, các mắt xích phía sau không chạy, vì không có dòng [Stock] hay [Credit] nào được in. Đó là cách một yêu cầu được "xử lý" ở giữa chuỗi. Muốn thêm luật chống đơn trùng vào Chain of Responsibility này, bạn viết một lớp mới và nối vào chuỗi mà không động tới ba lớp cũ, đúng tinh thần Open/Closed trong Nguyên lý SOLID trong Java qua ví dụ thực tế.

Servlet filter: Chain of Responsibility trong Java EE

Ví dụ kinh điển nhất của Chain of Responsibility trong Java nằm ở Servlet API. Theo Javadoc của Filter, một filter thực hiện việc lọc trên yêu cầu gửi tới tài nguyên, trên phản hồi trả về, hoặc cả hai (Javadoc này thuộc Java EE 7 với package javax.servlet). Hàm doFilter nhận thêm đối tượng FilterChain, và tài liệu nêu rõ filter phải chọn một trong hai: hoặc gọi chain.doFilter(request, response) để chuyển tiếp, hoặc không gọi để chặn xử lý tiếp, ví dụ filter xác thực từ chối người dùng chưa đăng nhập. Danh sách ví dụ trong Javadoc gồm xác thực, ghi log và kiểm toán, nén dữ liệu, mã hoá.

Lưu ý về tên package: từ Servlet 5.0 (Jakarta EE 9), package đổi từ javax.servlet sang jakarta.servlet, còn hợp đồng của Filter và FilterChain giữ nguyên. Code Spring Boot 3.2.1 trong bài dùng jakarta.servlet; bạn có thể đối chiếu với Javadoc Jakarta Servlet 6.0 của Filter.

Container (Tomcat, Jetty, Undertow) dựng sẵn chuỗi theo thứ tự khai báo, mỗi filter gọi chain.doFilter để đi tiếp, và khi hết filter thì tới servlet. Để thấy Chain of Responsibility ở đây không có gì bí ẩn, đoạn code dưới đây tự cài một Chain nhỏ có cùng hợp đồng. Mỗi filter là một lambda, con trỏ pos trong Chain đóng vai trò "bộ xử lý kế tiếp".

    record Request(String path, Map<String, String> headers) {}
    static class Response { int status = 200; String body = ""; }

    interface Filter { void doFilter(Request req, Response res, Chain chain); }

    static class Chain {
        private final List<Filter> filters; private final Runnable servlet; private int pos = 0;
        Chain(List<Filter> filters, Runnable servlet) { this.filters = filters; this.servlet = servlet; }
        void doFilter(Request req, Response res) {
            if (pos < filters.size()) filters.get(pos++).doFilter(req, res, this);
            else servlet.run();
        }
    }

Ba filter được khai báo bằng lambda trong main: filter logging gọi chain.doFilter ở giữa, filter xác thực trả 401 và return mà không gọi tiếp khi thiếu token, filter phân quyền trả 403 cho đường dẫn /admin nếu vai trò không phải admin.

Kết quả chạy với bốn yêu cầu khác nhau:

-> log vào /orders
   auth: ok
   authz: ok
   servlet xử lý
<- log ra /orders status=200
== kết quả 200

-> log vào /orders
   auth: từ chối, KHÔNG gọi chain.doFilter
<- log ra /orders status=401
== kết quả 401

-> log vào /admin/users
   auth: ok
   authz: cấm /admin
<- log ra /admin/users status=403
== kết quả 403

-> log vào /admin/users
   auth: ok
   authz: ok
   servlet xử lý
<- log ra /admin/users status=200
== kết quả 200

Kết quả này cho thấy ba điều về Chain of Responsibility. Thứ nhất, filter logging bọc quanh phần còn lại: dòng -> log vào in trước khi gọi chain.doFilter, dòng <- log ra in sau khi lời gọi trở về, nên nó thấy cả status do filter phía sau đặt. Thứ hai, ở yêu cầu bị từ chối, "servlet xử lý" không xuất hiện: filter xác thực đã dừng chuỗi bằng cách không gọi tiếp. Thứ ba, filter dừng chuỗi phải tự ghi phản hồi, vì không còn ai ghi hộ.

Spring Security: một SecurityFilterChain gồm nhiều filter

Spring Security dựng toàn bộ bảo mật servlet trên Chain of Responsibility của Servlet filter. Theo tài liệu kiến trúc Servlet của Spring Security, DelegatingFilterProxy là một Servlet filter làm cầu nối giữa container và ApplicationContext; nó ủy quyền cho FilterChainProxy, và FilterChainProxy chọn một SecurityFilterChain khớp với yêu cầu. Chỉ chuỗi khớp đầu tiên được chạy. Như vậy có hai tầng: tầng ngoài chọn chuỗi theo RequestMatcher, tầng trong là Chain of Responsibility thật sự giữa các filter bảo mật.

Tài liệu còn nhấn mạnh một hệ quả của Chain of Responsibility: vì một filter chỉ ảnh hưởng tới các filter phía sau nó và servlet, thứ tự gọi là cực kỳ quan trọng, và filter xác thực phải chạy trước filter phân quyền. Dưới đây là cấu hình thật của một API nhỏ gồm hai chuỗi. Chuỗi thứ nhất chỉ áp cho /api/**, thêm hai filter tự viết; chuỗi thứ hai mở cho phần còn lại.

    @Bean
    SecurityFilterChain api(HttpSecurity http, @Value("${chain.wrong:false}") boolean wrong) throws Exception {
        // wrong=true: cố ý đặt ApiKeyFilter SAU AuthorizationFilter để thấy thứ tự quan trọng thế nào
        if (wrong) http.addFilterAfter(new ApiKeyFilter(), AuthorizationFilter.class);
        else http.addFilterBefore(new ApiKeyFilter(), AnonymousAuthenticationFilter.class);
        http.securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .addFilterBefore(new RequestIdFilter(), SecurityContextHolderFilter.class)
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated());
        return http.build();
    }

Filter xác thực tự viết đọc header X-Api-Key. Key đúng thì đặt đối tượng Authentication vào SecurityContextHolder rồi gọi tiếp chain.doFilter. Key sai thì dừng chuỗi và tự ghi phản hồi 401. Không có key thì cứ chuyển tiếp để các filter sau quyết định. Đây là hai quyết định khác nhau của cùng một mắt xích.

    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String key = req.getHeader("X-Api-Key");
        if (key != null) {
            switch (key) {
                case "demo-user-key" -> SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken("ketoan01", null, List.of(new SimpleGrantedAuthority("ROLE_USER"))));
                case "demo-admin-key" -> SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken("quantri01", null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
                default -> {
                    // key sai: dừng chuỗi, tự ghi response (không gọi chain.doFilter)
                    res.setStatus(401);
                    res.setContentType("text/plain;charset=UTF-8");
                    res.getWriter().write("API key không hợp lệ");
                    return;
                }
            }
        }
        chain.doFilter(req, res);
    }
}

Lớp cha OncePerRequestFilter của Spring đảm bảo filter chỉ chạy một lần cho mỗi yêu cầu dù có forward nội bộ. Bài kiểm thử dùng MockMvc và FilterChainProxy để in danh sách filter thật của từng chuỗi, rồi gọi sáu yêu cầu. Danh sách filter của chuỗi /api/** (13 filter) như sau, hai filter tự viết nằm ở vị trí đã chỉ định. Các chú thích <- tự viết do tác giả thêm vào; output đầy đủ của test (cả hai chuỗi, kèm số filter) lưu ở code/s6-16/out/spring-filters.txt:

DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
RequestIdFilter                      <- tự viết, đặt trước SecurityContextHolderFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
LogoutFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
ApiKeyFilter                         <- tự viết, đặt trước AnonymousAuthenticationFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter

Chuỗi thứ hai có 11 filter, không có hai filter tự viết. Kết quả sáu yêu cầu với thứ tự đúng:

GET /api/orders        key=null           -> 403  X-Request-Id=có  body=
GET /api/orders        key=sai-key        -> 401  X-Request-Id=có  body=API key không hợp lệ
GET /api/orders        key=demo-user-key  -> 200  X-Request-Id=có  body=đơn hàng của ketoan01
GET /api/admin/report  key=demo-user-key  -> 403  X-Request-Id=có  body=
GET /api/admin/report  key=demo-admin-key -> 200  X-Request-Id=có  body=báo cáo cho quantri01
GET /public/ping       key=null           -> 200  X-Request-Id=không  body=pong

Yêu cầu không có key tới /api/orders nhận 403: filter của chúng ta chuyển tiếp, AnonymousAuthenticationFilter gán người dùng ẩn danh, và AuthorizationFilter từ chối. Mã 403 thay vì 401 là do cấu hình mặc định ở đây không khai báo form login hay HTTP Basic nên không có entry point nào yêu cầu thông tin xác thực. Yêu cầu tới /public/ping đi qua chuỗi thứ hai nên không có X-Request-Id, đúng như dự đoán.

Thứ tự quan trọng: thử đặt sai filter

Để kiểm chứng lời cảnh báo của tài liệu về Chain of Responsibility, ta cố ý đặt ApiKeyFilter sau AuthorizationFilter bằng biến môi trường CHAIN_WRONG=true. Lúc này việc phân quyền chạy khi chưa ai xác thực, nên mọi yêu cầu tới /api/** đều bị từ chối dù có key đúng.

GET /api/orders        key=null           -> 403  X-Request-Id=có  body=
GET /api/orders        key=sai-key        -> 403  X-Request-Id=có  body=
GET /api/orders        key=demo-user-key  -> 403  X-Request-Id=có  body=
GET /api/admin/report  key=demo-user-key  -> 403  X-Request-Id=có  body=
GET /api/admin/report  key=demo-admin-key -> 403  X-Request-Id=có  body=
GET /public/ping       key=null           -> 200  X-Request-Id=không  body=pong

Request có demo-admin-key cũng nhận 403. Filter xác thực nằm ở cuối chuỗi, sau khi quyết định đã được đưa ra, nên nó không bao giờ kịp đặt Authentication. Bảng hướng dẫn trong tài liệu Spring Security gợi ý quy tắc đặt filter tuỳ biến: filter xác thực đặt sau LogoutFilter, filter phân quyền đặt sau AnonymousAuthenticationFilter. Với HttpSecurity, bạn dùng addFilterBefore, addFilterAfter hoặc addFilterAt để chỉ vị trí.

Một chi tiết nữa liên quan tới Chain of Responsibility: ExceptionTranslationFilter gọi chain.doFilter trong khối try và bắt AccessDeniedException lẫn AuthenticationException từ các filter phía sau, rồi chuyển thành phản hồi HTTP. Nghĩa là một mắt xích có thể bọc toàn bộ phần còn lại của chuỗi để xử lý lỗi, y như khối catch bọc lời gọi hàm. Hệ quả: filter tự viết ném exception ở vị trí đứng trước ExceptionTranslationFilter sẽ không được dịch thành 403 mà để lọt ra ngoài.

Các biến thể của Chain of Responsibility trong thực tế

Có hai dạng cơ bản. Dạng thứ nhất là "một người xử lý": yêu cầu đi dọc chuỗi tới khi có bộ xử lý nhận, rồi dừng. Dạng thứ hai là "cả chuỗi cùng xử lý" như Servlet filter, mỗi mắt xích làm một phần và chuyển tiếp. Cả hai đều là Chain of Responsibility, chỉ khác điều kiện dừng. Một số nơi khác trong hệ sinh thái Java dùng Chain of Responsibility:

  • Spring MVC HandlerInterceptor: các interceptor được gọi lần lượt qua preHandle, và nếu một preHandle trả về false thì chuỗi dừng.
  • Khối try/catch: các mệnh đề catch kiểm tra lần lượt từ trên xuống, mệnh đề đầu tiên khớp kiểu ngoại lệ thì nhận xử lý.
  • Cơ chế uỷ quyền của ClassLoader: mỗi class loader hỏi loader cha trước, chỉ tự nạp khi cha không có. Đây là biến thể có hướng đi lên rồi mới xuống.

Khi so với các pattern gần gũi, refactoring.guru lưu ý Chain of Responsibility khác Decorator ở chỗ bộ xử lý có thể chạy thao tác tuỳ ý và dừng yêu cầu tại bất kỳ điểm nào, còn Decorator mở rộng hành vi mà không cắt dòng chảy. Với Observer pattern: từ listener tới event bus và Flow API, khác biệt là Observer thông báo tới mọi người đăng ký, còn Chain of Responsibility trao quyền theo thứ tự và có thể dừng.

Khi nào không nên dùng Chain of Responsibility

Chain of Responsibility không miễn phí. Nhược điểm lớn nhất, theo chính mô tả của refactoring.guru, là yêu cầu có thể tới cuối chuỗi mà không ai xử lý. Trong ví dụ đơn hàng, nếu không có mắt xích cuối "chấp nhận", bạn không phân biệt được "hợp lệ" với "bị bỏ sót". Vì vậy luôn quy định hành vi mặc định khi hết chuỗi: ném lỗi, trả về giá trị mặc định rõ ràng, hoặc ghi log cảnh báo.

Một số trường hợp không nên dùng Chain of Responsibility:

  • Chỉ có hai hoặc ba bước cố định, không đổi: một phương thức với vài câu if rõ ràng dễ đọc hơn một chuỗi lớp.
  • Cần biết chắc ai xử lý: chuỗi động làm việc gỡ lỗi khó, vì luồng chạy nằm rải rác ở nhiều lớp. Nếu quy tắc chọn người xử lý đơn giản (theo loại), một Map từ loại sang bộ xử lý hoặc Strategy pattern với lambda và functional interface thẳng hơn.

Lỗi thường gặp khi cài Chain of Responsibility

Các lỗi dưới đây xuất hiện lặp đi lặp lại khi viết filter hoặc bộ xử lý tự chế.

  • Quên gọi chain.doFilter: request treo hoặc trả về trang trắng, vì filter không chuyển tiếp mà cũng không ghi phản hồi.
  • Gọi chain.doFilter hai lần: servlet chạy hai lần. Chỉ gọi một lần trên mỗi nhánh thực thi.
  • Dừng chuỗi nhưng không ghi phản hồi: khách nhận mã 200 với thân rỗng. Khi chặn, hãy đặt status và body.
  • Đặt sai thứ tự: như thí nghiệm ở trên, phân quyền trước xác thực khiến mọi yêu cầu bị từ chối.
  • Trạng thái chung giữa các yêu cầu: filter là singleton dùng chung nhiều luồng, không lưu dữ liệu từng yêu cầu vào trường của lớp. Dùng thuộc tính của request hoặc ThreadLocal có dọn dẹp.

Checklist cho Chain of Responsibility

  • Mỗi mắt xích trong Chain of Responsibility làm đúng một việc và tự quyết định có chuyển tiếp hay không.
  • Quy định rõ hành vi khi yêu cầu chạm cuối chuỗi mà chưa ai xử lý.
  • Thứ tự là một phần của hợp đồng: ghi lại và viết test cho thứ tự, như bài kiểm thử in danh sách filter ở trên.
  • Mắt xích nên là đối tượng không giữ trạng thái theo yêu cầu, để dùng chung an toàn giữa nhiều luồng.
  • Mắt xích dừng chuỗi phải ghi kết quả hoàn chỉnh.

Để chạy lại toàn bộ ví dụ, dùng runall.sh trong thư mục code của bài: hai file Java chạy trực tiếp bằng java File.java, dự án Spring chạy bằng mvn test trên JDK 21.

Bài tiếp theo

Chain of Responsibility cho một yêu cầu đi qua nhiều bộ xử lý. Bài kế tiếp xét tình huống ngược lại: một đối tượng đổi hành vi theo trạng thái nội tại của nó. Mời bạn đọc State pattern và máy trạng thái cho đơn hàng.