Netflix Zuul 대신 Spring 제단에서 권장하는 gateway 서비스로 현재 새로운 부트 버전으로 프로젝트 구성시 사용하면 된다. 또한 Zuul 1.x 에서는 비동기 방식을 지원하지 않는 단점을 보안하기 위해 2.x부터는 비동기 방식을 지원하지만 Spring Cloud의 다른 라이브러리의 호환성 문제로 인해 Spring Gateway를 사용하는 것을 권장한다.
그후에 yml 파일을 세팅해줘야한다.
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates: #조건절
- Path=/first-service/**
- id: first-service
uri: http://localhost:8082/
predicates: #조건절
- Path=/second-service/**
다음과 같이 세팅하면 gateway 설정이 끝났다. 이후 실행을 시켜보자.
위 그림에서 주의 깊게 봐야할 점은 우리가 평소 사용하던 was tomcat이 아닌 netty로 실행된 모습을 확인할 수 있다. netty는 비동기 was로 gateway 서버가 비동기 방식으로 작동할 수 있다는 걸 확인이 가능하다.
하지만 이렇게 서버를 실행하여 url을 호출하면 404 오류가 뜰 것이다. 그 이유는 gateway에서 client에 요청한 url을 host부분을 제외하고 그대로 넘어가기 때문이다.
ex)http://localhost:8000/first-service/welcome -> http://localhost:8081/first-service/welcome
그렇기 때문에 추후에 api filter를 적용하여 사용자 요청정보를 gateway에서 통일할 수 있도록 변경하는 과정을 거쳐야한다. 이 상태로 그대로 사용하자고 한다면 service들의 url을 변경시켜주면 된다.
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates: #조건절
- Path=/first-service/**
filters:
- AddRequestHeader=first-request, first-request-header2
- AddResponseHeader=first-response, first-response-header2
- id: first-service
uri: http://localhost:8082/
predicates: #조건절
- Path=/second-service/**
filters:
- AddRequestHeader=second-request, second-request-header2
- AddResponseHeader=second-response, second-response-header2
다음과 같이 filter를 추가해주면 request header와 response header에 값을 추가할 수 있고 실제 실행했을 때 확인해볼 수 있다.
위에서 설정했던 내용을 java code로 설정하기 위해 설정된 정보를 먼저 주석처리해준다.
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public RouteLocator gatewayRoutes(RouteLocatorBuilder builder){
return builder.routes()
.route(r -> r.path("/first-service/**") //라우터 등록
.filters( //필터 등록
f->f.addRequestHeader("first-request","first-request-header") //ReqeustHeader 추가
.addResponseHeader("first-response","first-response-header")) //ResponseHeader 추가
.uri("http://localhost:8081")
).route(r -> r.path("/second-service/**")
.filters(
f->f.addRequestHeader("second-request","second-request-header")
.addResponseHeader("second-response","second-response-header"))
.uri("http://localhost:8082")
)
.build();
}
}
그 후 FilterConfig class를 생성하여 다음과 같이 코드를 추가해준다. 간단한 설명은 주석으로 대체했다.
그 후 first service와 second service에 message라는 메서드를 추가하여 header에 추가된 값들을 확인하는 로그를 찍어보자.
@GetMapping("message")
public String message(@RequestHeader("first-request") String header){
log.info("header > "+ header);
return "ok-first";
}
@GetMapping("message")
public String message(@RequestHeader("second-request") String header){
log.info("header > "+ header);
return "ok-second";
}
다음과 같이 정상적으로 header의 정보를 가져옴을 알수 있다. 만약 여기서 요청이 404로 반환된다면 우리가 앞서 설정하지 않았던 url앞에 first-service/와 second-service문제이니 RequestMapping에 url을 추가해줘서 테스트 해보자
위에서 필터를 적용하여 request와 response의 헤더를 추가해봤다. 하지만 헤더 추가만 할수 있다면 우리가 학습할 이유가 없다. filter를 개발자의 의도에 따라 수정하여 사용해 볼수 있도록 custom filter를 만들어보자.
Custom filter class 생성
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {
public CustomFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
//filter에서 하고 싶은 내용을 재정의
//pre filter 동작
return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Custom PRE filter : request id -> {}", request.getId());
//post filter 동작
return chain.filter(exchange).then(Mono.fromRunnable(()->{ //스프링5에서 지원하는 기능으로 비동기로 값을 전달할때 사용되는 객체
log.info("Custom POST filter : response id -> {}", response.getStatusCode());
}));
});
}
public static class Config{
// configuration 정보 입력
}
}
다음과 같이 CustomFilter class를 생성해준다. 여기서 중요한건 AbstractGatewayFilterFactory를 상속받아 제너릭 안에는 현재 class의 내부 class로 정의할 Config class를 넣어주는데 Config class에서는 설정할 값들을 넣어주면 된다. 그리고 상속받은 메서드중 apply 메서드를 구현해야하는데 return 해주는 값에 pre filter가 동작하는 부분이며 pre filter 안에는 chain으로 연결되어서 post filter의 동작까지 return 해주는 코드를 입력하면 된다. 대체로 사용자 인증 기능을 여기서 처리한다.
yml 파일 설정
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates: #조건절
- Path=/first-service/**
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
- id: first-service
uri: http://localhost:8082/
predicates: #조건절
- Path=/second-service/**
filters:
# - AddRequestHeader=second-request, second-request-header2
# - AddResponseHeader=second-response, second-response-header2
- CustomFilter
원래 우리가 지정했던 filter의 설정 내용을 주석처리하고 새로 java 파일로 지정한 CustomFilter class를 넣어준다.
service check url 추가
@GetMapping("/check")
public String check(){
return "hi check first";
}
다음과 같이 check라는 url로 요청할 수 있도록 first service와 second service에 추가해준 뒤 postman으로 해당 url로 요청해보면
다음과 같은 결과를 확인해 볼수 있다.
이전 필터는 라우터에 서비스마다 필터를 적용했었는데 Global Filter는 모든 라우터가 실행되어질 때 실행되어진다. Global Filter는 이전 구현과 동일하게 구현하면 되는데 코드가 조금 다르다.
Global Filter 생성
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
//filter에서 하고 싶은 내용을 재정의
//pre filter 동작
return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Global filter message : {}", config.getMessage());
if(config.isPreLogger()){
log.info("Global filter Start : request id -> {}", request.getId());
}
//post filter 동작
return chain.filter(exchange).then(Mono.fromRunnable(()->{ //스프링5에서 지원하는 기능으로 비동기로 값을 전달할때 사용되는 객체
if(config.isPostLogger()){
log.info("Global filter End : response code -> {}", response.getStatusCode());
}
}));
});
}
@Data
public static class Config{
// configuration 정보 입력
private String Message;
private boolean preLogger;
private boolean postLogger;
}
}
위 코드와 거의 비슷하지만 조금 다른 부분은 우선 Config의 인스턴스들이 생성되었고 해당 객체들의 값을 가져오는 코드가 생겼다. 그런데 여기서 궁금한 부분은 객체의 값들을 어디서 세팅하고 가져오는걸까? 이게 궁금해진다. 객체의 값은 yml 파일에서 세팅이 된다.
yml 세팅
server:
port: 8000
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates: #조건절
- Path=/first-service/**
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
- id: first-service
uri: http://localhost:8082/
predicates: #조건절
- Path=/second-service/**
filters:
# - AddRequestHeader=second-request, second-request-header2
# - AddResponseHeader=second-response, second-response-header2
- CustomFilter
default-filters:
- name: GlobalFilter #Global filter class 파일 명
args: #파라미터로 전달될 값 정의
message: hi global gateway
preLogger: true
postLogger: true
맨 아래 default-filters에 정보를 입력해줘야한다. 하지만 우리가 앞서 학습한 내용에서는 이렇게 설정되는 내용들은 service 내장 파일로 지정하지 않고 외부 파일을 변경함으로 변경한다고 배웠었다. 우선은 이렇게 내부로 실행되어진다는걸 알고 추후에 외부에서 변경하여 적용하는 방법을 배울 것이다. 그리고 다음과 같은 파라미터를 전달하는 세팅은 custom filter에도 적용시킬 수 있다.
실행
gateway 서버를 실행한 뒤 이전과 동일하게 요청을 하면
다음과 같은 결과를 확인할 수 있다. 여기서 확인할 수 있는 것은 url 요청이 들어왔을때
1. Global Filter pre filter 실행
2. Custom Filter pre filter 실행
3. Custom Filter post filter 실행
4. Glbal Filter post filter 실행
순서로 진행된다는 것을 확인할 수 있었다.
mport lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {
public CustomFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
//filter에서 하고 싶은 내용을 재정의
//pre filter 동작
/*return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Custom PRE filter : request id -> {}", request.getId());
//post filter 동작
return chain.filter(exchange).then(Mono.fromRunnable(()->{ //스프링5에서 지원하는 기능으로 비동기로 값을 전달할때 사용되는 객체
log.info("Custom POST filter : response id -> {}", response.getStatusCode());
}));
});*/
GatewayFilter filter = new OrderedGatewayFilter((exchange, chain)->{ //WebFlux를 활용하여 비동기 처리에서 request와 response를 가져올 수 있다.
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Custom PRE filter : request id -> {}", request.getId());
//post filter 동작
return chain.filter(exchange).then(Mono.fromRunnable(()->{ //스프링5에서 지원하는 기능으로 비동기로 값을 전달할때 사용되는 객체
log.info("Custom POST filter : response id -> {}", response.getStatusCode());
}));
}, Ordered.HIGHEST_PRECEDENCE); //필터의 우선순위
return filter;
}
public static class Config{
// configuration 정보 입력
}
}
위의 예제 코드와 다르게 위와 같이 구현할 수 있다. 람다로 구현하는게 더 편할 수 있지만 filter 객체를 직접 new 예약어를 통해 OrderedGatewayFilter로 생성하는 과정을 보여주고 싶어서 다른 예제도 적어봤다. 그리고 여기서 중요한건 exchange를 getRequest와 getResponse로 request와 response 객체를 가져오는데 우리가 지금까지 사용했던 HttpServletRequest, Response가 아닌 ServerHttpRequest와 ServerHttpResponse를 받아서 사용할 수 있는데 이는 Spring5부터 지원하는 WebFlux를 활용하여 동기 처리가 아닌 비동기 처리에서 Request와 Response를 가져오기 위함이다. 또한 OrderedGatewayFilter를 통해 필터의 우선순위를 지정해줄 수 있는데 Ordered.HIGHEST_PRECEDENCE
를 매개변수로 전달했기 때문에 해당 log는 Global Filter보다 우선적으로 적용되어진다.
GATEWAY 설정 변경
server:
port: 8000
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
# uri: http://localhost:8081/
uri: lb://MY-FIRST-SERVICE
predicates: #조건절
- Path=/first-service/**
filters:
# - AddRequestHeader=first-request, first-request-header2
# - AddResponseHeader=first-response, first-response-header2
- CustomFilter
- id: first-service
# uri: http://localhost:8082/
uri: lb://MY-SECOND-SERVICE
predicates: #조건절
- Path=/second-service/**
filters:
# - AddRequestHeader=second-request, second-request-header2
# - AddResponseHeader=second-response, second-response-header2
- CustomFilter
default-filters:
- name: GlobalFilter #Global filter class 파일 명
args: #파라미터로 전달될 값 정의
message: hi global gateway
preLogger: true
postLogger: true
service 설정 변경
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
Eureka server에 gateway와 서비스들을 등록시켜주시 위해 gateway eureka 설정과 first-service, second-service의 eureka설정을 다음과 같이 해준다.
그 후 세개의 서버를 각각 실행시켜준다.
실행 확인
다음과 같이 Eureka에서 정상적으로 정보를 가져오는 것을 확인할 수 있다. 또한 포스트맨으로 요청했을 때 전과 똑같이 uri 설정이 되어있는 것도 확인이 가능하다.
동일한 서비스를 여러개 실행시키기
동일한 서비스를 여러개 실행시키는건 이전 시간에 실습했었다. 하지만 혹시 까먹었다면 터미널을 이용해서 실행시키는 방법으로 실행시킬 수 있다.
mvn clean compile package
터미널에서 프로젝트 경로로 이동후 maven으로 패키징을 해준 뒤
java -jar -Dserver.port=9092 ./target/second-service-0.0.1-SNAPSHOT.jar
다음과 같이 명령어를 통해서도 바로 실행시킬 수 있다. 그 외 방법으로 실행시켜도 무관하다.
다음과 같이 서버가 2개씩 더 생성된것도 확인할 수 있다!
랜덤 포트로 설정하기
server:
port: 0
spring:
application:
name: my-fisrt-service
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
instance:
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} #인스턴스를 구별하기 위한 설정
서비스의 설정을 다음과 같이 변경하여 Eureka에 등록되는 서비스의 인스턴스를 구별할 수 있도록 설정해준다. 이렇게 설정하지 않으면 Eureka에서 동일한 0번 포트로 인식하여 인스턴스를 구별할 수 없다.
또한 서버를 실행할 때 터미널에서
mvn spring-boot:run
명령어만 입력해주면 된다. 포트번호를 따로 설정할 필요가 없기때문에 run으로 끝나는 것이다.
위 그림처럼 포트번호 대신 random id값이 들어간것을 확인할 수 있다.
포트 번호를 찍어보면서 확인
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/first-service")
@Slf4j
public class FirstServiceController {
Environment env;
public FirstServiceController(Environment env) {
this.env = env;
}
@GetMapping("/check")
public String check(HttpServletRequest request){
log.info("server port = {}", request.getServerPort());
return String.format("hi check first on PORT : %s", env.getProperty("local.server.port"));
}
}
하지만 정말로 서비스를 하나로 호출했을때 다른 서비스로 호출해주는지 확인할 수가 없다. 그래서 실제 log와 반환되는 값에 포트번호를 찍어서 확인해보도록 서비스의 코드를 다음과 같이 변경했다. 여기서 Environment는 yml의 환경설정 값을 가져올 수 있도록 해주는 기능인데 더 자세히 알고 싶은 분들은 검색해서 확인해보면 좋을것 같다. yml에 api key등을 숨겨서 사용하거나 할때 유용할거 같다.
포스트맨으로 확인
두 그림을 통해 각각의 다른 포트로 요청되어지는 것을 확인할 수 있다.
만약 서버 하나를 끄게되면 Eureka에서 자동으로 나머지 서버로만 전송하게 된다. 만약 테스트하는데 종료된 서버로 전송이 된다면 Eureka에서 아직 서버가 종료된걸 인식하지 못 해서이다. 최대 30초 정도 기다렸다가 다시 테스트 해보면 LB가 정상적으로 Health check를 통해 전송하는 것을 확인할 수 있다!