Triển khai Gateway pattern

Triển khai Gateway pattern

Gateway pattern với ý tưởng sử dụng một Service đứng trước tiếp nhận các request, thực hiện một số các nghiệp vụ như: rewrite-url, xác thực, rate limit, v.v… trước khi chuyển tiếp request vào các dịch vụ phía sau.

Ta có thể hình dung Nginx cũng là một dạng triển khai cho mô hình này, trong các dự án triển khai Microservice trên Spring Boot, nó có đưa ra một giải phải pháp là sử dụng Gateway Service nằm trong gói Spring Cloud.

Gateway Service trong Spring Cloud có 2 kiểu để bạn lựa chọn là

  • Gateway Service Web
  • Gateway Service Reactive (WebFlux)

Trong bài viết này mình sẽ giới thiệu về kiểu sử dụng Gateway Service Reactive (sau đây gọi tắt là GW),

GW sẽ đảm nhiệm các chức năng sau

  • xác thực JWT
  • ratelimit cho từng service
  • CircuitBreaker đoản mạch và fallback
  • rewrite path

Các thiết lập căn bản

Cấu hình cho Gateway service chạy ở chế độ reactive

implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux'

Sử dụng Spring Security, dùng cho xác thực JWT token

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.security:spring-security-oauth2-jose'

Sử dụng circuitbreaker ngắt kết nối và trả về giá trị mặc định

implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'

Sử dụng redis cho ratelimit, lưu ý khi Gateway Service sử dụng reactive thì database cũng cần chạy ở chế độ reactive

implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'

Sử dụng Gateway cho xác thực

Giả sử ta có authz-service dùng để đăng nhập và trả về JWT token nếu đăng nhập thành công, product-service là một domain service, khi truy cập vào service này người dùng cần xác thực.

Mô hình tổng quan

(1) Login: người dùng gọi API login với user/pass, gọi đến Authz service, API đi qua Gateway, đây là API Login do vậy không cần xác thực ta cần cấu hình cho bypass tại gateway

@Bean
public SecurityWebFilterChain securityWebFilterChain(
        ServerHttpSecurity http,
        ReactiveJwtDecoder jwtDecoder) {
    return http
            .cors(corsSpec -> {})
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(exchanges -> exchanges
                    .pathMatchers(
                            "/api/authz/token",
                            "/api/authz/signup",
                            "/api/authz/refresh-token",
                            "/api/authz/logout",
                            "/openapi/swagger-ui.html",
                            "/openapi/swagger-ui/**",
                            "/openapi/v3/api-docs/**",
                            "/actuator/health/liveness",
                            "/actuator/health/readiness",
                            "/.well-known/acme-challenge/**"
                    ).permitAll()
                    .pathMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() // Allow preflight
                    .anyExchange().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(
                    jwt -> jwt.jwtDecoder(jwtDecoder)))
            .addFilterAt(new JwtCookieAuthenticationFilter(jwtDecoder), SecurityWebFiltersOrder.AUTHENTICATION)
            .build();
}

(2) Xác thực, tại authz-service, sẽ tiến hành xác thực tài khoản, nếu đúng user/pass hoặc SSO qua OAuth2, nó sẽ trả về JWT token (3) và (4), thông thường sẽ trả về qua cookies với secured và http-only

Thông thường sẽ trả về cặp JWT token và refresh token qua secured cookies và HTTP-Only

(5) JWT token được gửi đi trong header (cookies), khi gọi đến API thuộc Product service, tại đây khi đi qua Gateway nó sẽ kiểm tra

Tại file cấu hình trên ta để ý đến các cấu hình sau

@Bean
public ReactiveJwtDecoder jwtDecoder(@Value("${app.jwt.public-key}") RSAPublicKey publicKey) {
    return NimbusReactiveJwtDecoder.withPublicKey(publicKey).build();
}

.oauth2ResourceServer(oauth2 -> oauth2.jwt(
                        jwt -> jwt.jwtDecoder(jwtDecoder)))

tại đây ta định nghĩa một Bean cho ReactiveJwtDecoder với publickey và cấu hình oauth2ResourceServer cho Gateway (khi này Gateway như một Resource Server cho OAuth2.0), public-key này phải giống với public-key tại authz-service (module cung cấp JWT token).

(6) đây là đoạn cấu hình của filter, cho phép xác thực thông qua cookie.

.addFilterAt(new JwtCookieAuthenticationFilter(jwtDecoder), SecurityWebFiltersOrder.AUTHENTICATION)

Như phần login API có trả về cookie so vậy khi điều kiện domain thỏa mãn, cookie sẽ được tự động gửi kèm qua API, nó sẽ được tách lấy ra, xác thực, tách được userId và gửi vào header, sau đó forward vào các service đằng sau gateway.

public class JwtCookieAuthenticationFilter implements WebFilter {

    private final ReactiveJwtDecoder jwtDecoder;

    public JwtCookieAuthenticationFilter(ReactiveJwtDecoder jwtDecoder) {
        this.jwtDecoder = jwtDecoder;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        return Mono.justOrEmpty(exchange.getRequest().getCookies().getFirst("access_token"))
                .map(HttpCookie::getValue)
                .flatMap(token ->
                        jwtDecoder.decode(token)
                                .map(jwt -> {
                                    Object sub = jwt.getClaims().get("sub");
                                    String userId = sub != null ? sub.toString() : "";
                                    return exchange.mutate()
                                            .request(request -> request.headers(headers -> {
                                                headers.setBearerAuth(token);
                                                headers.add("X-USER-ID", userId);
                                            }))
                                            .build();
                                })
                )
                .defaultIfEmpty(exchange)
                .flatMap(chain::filter);
    }

}

(7) khi hàm jwtDecoder.decode(token) xác định token còn hạn, tương đương với việc người dùng được xác thực, Gateway sẽ cho token đi tiếp vào các service bên trong như Product service

RateLimit

Khi bật kết nối rate-limit ta cần cầu hình Gateway app kết nối đến redis như đã giới thiệu trong phần đấu, với kết nối đến redis có dùng password và SSL thì cấu hình sẽ cần thêm tạo file cấu hình

@Bean
public LettuceConnectionFactory redisConnectionFactory() throws IOException {
    RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
    redisStandaloneConfiguration.setHostName(host);
    redisStandaloneConfiguration.setPort(port); // Or your SSL port, usually 6380 or custom
    redisStandaloneConfiguration.setPassword(password); // If your Redis requires authentication
    redisStandaloneConfiguration.setUsername(username);

    LettuceClientConfiguration.LettuceClientConfigurationBuilder lettuceClientConfigurationBuilder = LettuceClientConfiguration.builder();

    // Enable SSL
    lettuceClientConfigurationBuilder.useSsl();

     SslOptions sslOptions = SslOptions.builder()
             .trustManager(resourceLoader.getResource("classpath:redis.crt").getFile())
             .build();

     ClientOptions clientOptions = ClientOptions.builder()
             .sslOptions(sslOptions)
             .build();

     lettuceClientConfigurationBuilder.clientOptions(clientOptions);

    LettuceClientConfiguration lettuceClientConfiguration = lettuceClientConfigurationBuilder.build();

    return new LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration);
}

Với mỗi loại service khác nhau thì cần thông số rate-limit khác nhau, ta có thể cấu hình 1 thông số chung, và các thông số riêng cho từng dịch vụ. Thông số ratelimit ở đây là

@Bean
@Primary  // This will be the default bean
public RedisRateLimiter defaultRateLimiter() {
    return new RedisRateLimiter(10, 20);  // Default rate limiting
}

10 là số token được thêm vào 1 bucket trên 1 giây 20 là số token tối đa một bucket có thể chứa

Vậy 1 bucket ở đây được thể hiện cho một user hoặc một api key, vậy với những API không biết được userId thì ta cần xử lý như nào?

@Bean
public KeyResolver userKeyResolver() {
    return exchange -> {
        // Try to get user ID from header (set by your JwtCookieAuthenticationFilter)
        String userId = exchange.getRequest().getHeaders().getFirst("X-USER-ID");

        // If user ID is available, use it as the rate limit key
        if (userId != null && !userId.isEmpty()) {
            return Mono.just("user:" + userId);
        }

        // Fallback to IP address for unauthenticated requests
        String ip = exchange.getRequest().getRemoteAddress() != null ?
                exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() : "unknown";
        return Mono.just("ip:" + ip);
    };
}

Là cấu hình KeyResolver cho phép xác định nếu không tìm được userId thì sẽ chuyển sác định bởi IP request.

Sau khi cài đặt tham số cần thiết, ta cầu hình vào Gateway

.route("authz-service", r -> r
                        .path("/api/authz/token", "/api/authz/logout", "/api/authz/refresh-token", "/api/authz/signup")
                        .filters(f -> f
                                .rewritePath("/api/authz/(?<segment>token|logout|refresh-token|signup)", "/api/v1/${segment}")
                                .requestRateLimiter(config -> config
                                        .setRateLimiter(authServiceLimiter)
                                        .setKeyResolver(userKeyResolver)
                                        .setStatusCode(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS)
                                )
                        )
                        .uri(authzEndpoint)
                )

ví dụ như trên, cho các API liên quan đến đăng nhập ta cấu hình authServiceLimiteruserKeyResolver và mã lỗi trả về nếu có limit

CircuitBreaker đoản mạch và fallback

Khi triển khai dưới dạng microservice, trong trường hợp các service bên trong bị lỗi ta cần triển khai cơ chế đoản mạch để ngắt kết nối tạm thời vào service đó đồng thời đưa ra giá trị trả về khi service bị lỗi.

Cấu hình các thư viện cần thiết trong phần đâu, ngoài ra ta cần cấu hình các tham số hoạt động trong file thuộc tính của Spring Boot (application.yaml/application.properties)

# Circuit breaker configuration
resilience4j.circuitbreaker.instances.productServiceCircuitBreaker.failure-rate-threshold: 50
resilience4j.circuitbreaker.instances.productServiceCircuitBreaker.wait-duration-in-open-state: 10000

resilience4j:
  timelimiter:
    instances:
      productServiceCircuitBreaker:
        timeoutDuration: 10s # Increase as needed
        cancelRunningFuture: true

Đây là cấu hình cho product service, ta đặt tên là productServiceCircuitBreaker lưu ý tên này, là Id để xác định trong cấu hình của Gateway.

Trong gateway cấu hình ta thiết lập thuộc tính

.circuitBreaker(c -> c.setName("productServiceCircuitBreaker")
        .setFallbackUri("forward:/fallback/products")
)

với fallback (giá trị trả về khi gặp lỗi) ta có thể thiết lập

@Bean
public RouterFunction<ServerResponse> fallbackRoutes() {
    return RouterFunctions.route()
            .GET("/fallback/products", request -> {
                Throwable error = request.attribute("org.springframework.cloud.gateway.support.ServerWebExchangeUtils.gatewayExecutionException")
                        .filter(Throwable.class::isInstance)
                        .map(Throwable.class::cast)
                        .orElse(null);

                String message = "Product service is currently unavailable";
                if (error != null) {
                    if (error instanceof org.springframework.web.server.ResponseStatusException statusEx) {
                        if (statusEx.getStatusCode().is5xxServerError()) {
                            message = "Product service failed with 5xx error";
                        } else if (statusEx.getStatusCode().is4xxClientError()) {
                            message = "Product service returned 4xx error";
                        }
                    }
                }
                return ServerResponse.ok().bodyValue(new FallbackResponse(message));
            })
            .build();
}

rewrite path APIs

Để thực hiện việc rewrite path, trong cấu hình Gateway ta thiết lập như sau

cho authz-service

.route("authz-service", r -> r
    .path("/api/authz/token", "/api/authz/logout", "/api/authz/refresh-token", "/api/authz/signup")
    .filters(f -> f
            .rewritePath("/api/authz/(?<segment>token|logout|refresh-token|signup)", "/api/v1/${segment}")
    )
    .uri(authzEndpoint)
)

với cấu hình trên khi gặp 4 paths "/api/authz/token", "/api/authz/logout", "/api/authz/refresh-token", "/api/authz/signup" nó sẽ rewrite thành /api/v1/ trước khi gửi vào authz service

hay như cho product ta có cấu hình cấu hình này thêm v1 vào endpoint

.rewritePath("/api/products(/.*)?", "/api/v1/products$1") 

Tổng kết

Gateway pattern là một pattern quan trọng khi triển khai ứng dụng theo mô hình microservices.

Tại Gateway ta có thể xử lý tập trung các vấn đề như(ratelimit, auth, rewrite,..) trước khi đưa request đến các service tương ứng, để mỗi service đứng sau gateway chỉ cần tập trung vào xử lý nghiệp vụ của riêng mình.

Bài viết này mình mới trình bày một số khía cạnh của Gateway trong Spring Boot, còn nhiều vấn đề nữa các bạn có thế tìm hiểu về bổ xung thêm cho ứng dụng của mình.

Written by :

user

Java Developer, System Architect, Learner and becoming Youtuber

View All Posts

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *