Compare commits

43 Commits

Author SHA1 Message Date
assu10
11f0198222 Spring Cloud Sleuth, Open Zipkin 을 이용한 분산 추적 (4/4) - 로그 시각화를 위한 Open Zipkin 사용 2021-02-14 19:55:02 +09:00
assu10
3102f52c5d ELK 스택 2021-01-23 22:03:48 +09:00
assu10
2719d3b775 ... 2021-01-23 17:41:58 +09:00
assu10
df051147c3 zuul retry 2020-12-28 15:29:04 +09:00
assu10
873cb2f8bf zuul retry 2020-12-09 15:26:15 +09:00
assu10
83de70e741 유레카 상세 설정 (디폴트 값들로 명시적 셋팅) 2020-12-06 21:05:54 +09:00
assu10
b56e54696c 유레카 상세 설정 2020-12-06 00:25:45 +09:00
assu10
37b668d1b5 readme 2020-11-08 00:59:08 +09:00
assu10
e107340323 벌크 헤드 2020-11-08 00:56:10 +09:00
assu10
0d03290c17 히스트릭스 폴백전략 및 벌그헤드 테스트를 위한 bbs 모듈 추가 2020-11-07 22:43:57 +09:00
assu10
f96dba758c 개별 회로 차단기를 사용자 정의하여 호출별 타임아웃 설정 2020-11-01 23:59:03 +09:00
assu10
640ffc67f4 개별 회로 차단기를 사용자 정의하여 호출별 타임아웃 설정 2020-11-01 23:55:14 +09:00
assu10
0e73c70005 - 유레카 피어링 주석 처리
- 이벤트 서비스 컨피그 서버 제거
2020-11-01 23:22:08 +09:00
assu10
5e38188512 Hystrix 애너테이션을 사용하여 Circuit Breaker (회로 차단기) 패턴으로 원격 호출 실행 2020-11-01 22:02:00 +09:00
assu10
f9c3c0909b 회원서비스 히스트릭스 의존성 추가 2020-11-01 21:48:09 +09:00
assu10
66e067ea44 - 유레카 기본은 peer1 active
- 이벤트, 회원 서비스에 OAuth2 권한 재설정 (편의를 위해 모두 호출 가능하도록)
2020-11-01 21:47:43 +09:00
assu10
1c4a15599a Hystrix 설정을 위한 회원 서비스 애플리케이션 설정 2020-11-01 20:43:20 +09:00
assu10
c4746083c6 유레카 피어링 최종 2020-10-24 20:11:32 +09:00
assu10
c09b454c22 컨피스 서버 사용하지 않도록 수정 2020-10-24 20:04:23 +09:00
assu10
4786f791bf 컨피그 서버 사용하지 않도록 수정 2020-10-24 19:47:26 +09:00
assu10
ba2a7bb189 컨피그 서버 사용하지 않도록 수정 2020-10-24 19:47:18 +09:00
assu10
eb3596785e eureka peering 2020-10-24 19:42:31 +09:00
assu10
6911072862 eureka peering (여기서 하나씩 지우면서 확인해 볼 차례) 2020-10-24 19:18:27 +09:00
assu10
95c5794c3a 스프링 클라우드 스트림 설정 2020-10-03 22:23:31 +09:00
assu10
7bfdd48316 스프링 클라우드 스트림 설정 2020-10-03 22:11:33 +09:00
assu10
b569b62569 레디스에서 회원 데이터를 저장/조회 2020-10-03 18:38:55 +09:00
assu10
172667004a 분산캐싱 - 스프링 데이터 레포지토리 정의 2020-10-02 23:19:03 +09:00
assu10
b2b3cc9b9c 메시지 소비자 구현 (이벤트 서비스) 2020-10-02 20:42:56 +09:00
assu10
99a42c1159 메시지 발행자 구현 (회원 서비스) 2020-10-02 18:59:33 +09:00
assu10
73f8d8e786 readme 수정 2020-10-01 20:52:20 +09:00
assu10
75c1b7ad75 readme 수정 2020-10-01 20:51:35 +09:00
assu10
b28d45fc2f readme 수정 2020-10-01 20:50:31 +09:00
assu10
3c82498455 readme 수정 2020-10-01 20:50:01 +09:00
assu10
45552d0eca readme 수정 2020-10-01 20:49:37 +09:00
assu10
d2c919561c readme 수정 2020-10-01 20:46:03 +09:00
assu10
3c1cafaab8 Spring Cloud - OAuth2, Security(2/2) 2020-10-01 18:04:07 +09:00
assu10
9fe6262839 JWT 토큰에서 사용자 정의 필드 파싱 2020-10-01 17:35:50 +09:00
assu10
2aeadb71cb - 인증서버의 Custom 필터, 인터셉터 수정
- 회원서비스에서 JWT 토큰 사용하도록 수정
- 주울 서비스에서 Custom 필터, 인터셉터 삭제
2020-10-01 16:19:41 +09:00
assu10
ed0549575e 마이크로서비스(이벤트 서비스)에서 JWT 사용 2020-10-01 15:02:46 +09:00
assu10
a8c0f2933a JWT 에선 @RequestMapping(value = "/user") 필요없음 2020-09-30 21:55:51 +09:00
assu10
4093f6fbca JWT 발행을 위한 인증 서버 수정 2020-09-30 21:37:37 +09:00
assu10
8e6a18eb00 JWT 발행을 위한 인증 서버 수정 2020-09-30 21:37:03 +09:00
assu10
c6eeb07fd4 jwt 2020-09-30 16:36:04 +09:00
70 changed files with 1901 additions and 258 deletions

108
README.md
View File

@@ -2,14 +2,26 @@
## Development Environment
`Windows 10` `JDK 11.0.6` `SpringBoot 2.3.2.RELEASE` `Maven 3.6.3` `Git 2.22.0.windows.1` `intellij`
[`Spring Cloud Hoxton.SR6`](https://spring.io/projects/spring-cloud) [`RabbitMQ 3.8.6`](https://www.rabbitmq.com/download.html)
[`Erlang/OTP 23.0`](https://www.erlang.org/downloads)
[`Spring Cloud Hoxton.SR6`](https://spring.io/projects/spring-cloud) 에서 `SR8` 로 업그레이드 [`RabbitMQ 3.8.6`](https://www.rabbitmq.com/download.html)
[`Erlang/OTP 23.0`](https://www.erlang.org/downloads) [kafka_2.13-2.6.0](https://kafka.apache.org/downloads)
## Table of Contents
- ~~Config Server (환경설정 외부화)~~
- ~~Feign (REST Client & Circuit Breaker)~~
- ~~Eureka (Service Registry & Discovery)~~
- ~~Zuul (Proxy & API Gateway)~~
- ~~OAuth2, JWT (Security)~~
- ~~Spring Cloud Stream (EDA, 비동기 마이크로서비스 구성)~~
- Sleath, Papertrail, Zipkin (Logging Tracker)
- Hystrix + Turbine (Circuit Breaker & Dashboard, 여러 대의 WAS 한번에 모니터링)
---
***- Config Server (환경설정 외부화)<br />***
자세한 설명은 [여기](https://bravenamme.github.io/2020/08/16/spring-cloud-config-server/) 를 참고
자세한 설명은 [Spring Cloud - Spring Cloud Config Server](https://assu10.github.io/dev/2020/08/16/spring-cloud-config-server/) 를 참고
> [Messaging with RabbitMQ](https://spring.io/guides/gs/messaging-rabbitmq/) <br />
> [AMQP doc](https://docs.spring.io/spring-boot/docs/2.3.2.RELEASE/reference/htmlsingle/#boot-features-amqp) <br />
> [JCE jar download](https://www.oracle.com/java/technologies/javase-jce-all-downloads.html)
@@ -59,16 +71,13 @@ POST http://localhost:8889/decrypt
---
***- Feign (REST Client & Circuit Breaker)***<br />
자세한 설명은 [여기](https://assu10.github.io/dev/2020/06/18/spring-cloud-feign/) 를 참고
---
***- Ribbon (Load Balancer)***<br />
자세한 설명은 [Spring Cloud - Spring Cloud Feign](https://assu10.github.io/dev/2020/06/18/spring-cloud-feign/) 를 참고
---
***- Eureka (Service Registry & Discovery)***<br />
자세한 설명은 [여기](https://bravenamme.github.io/2020/08/26/spring-cloud-eureka/) 를 참고
자세한 설명은 [Spring Cloud - Spring Cloud Eureka](https://assu10.github.io/dev/2020/08/26/spring-cloud-eureka/)
[Spring Cloud - Spring Cloud Eureka (상세 설정편)](https://assu10.github.io/dev/2020/12/05/spring-cloud-eureka-configuration/)를 참고
```shell script
HOW TO RUN
@@ -127,7 +136,9 @@ GET http://localhost:8070/event/member/hyori
---
***- Zuul (Proxy & API Gateway)***<br />
자세한 설명은 [여기](https://assu10.github.io/dev/2020/08/26/netflix-zuul/)와 [여기](https://assu10.github.io/dev/2020/09/05/netflix-zuul2/) 를 참고
자세한 설명은 [Spring Cloud - Netflix Zuul(1/2)](https://assu10.github.io/dev/2020/08/26/netflix-zuul/)
[Spring Cloud - Netflix Zuul(2/2)](https://assu10.github.io/dev/2020/09/05/netflix-zuul2/),
[Spring Cloud - Netflix Zuul(Ribbon) Retry](https://assu10.github.io/dev/2020/12/06/netflix-zuul-retryable/)를 참고
```shell script
HOW TO RUN
@@ -164,7 +175,8 @@ http://localhost:5555/api/mb/member/name/hyori
---
***- OAuth2, JWT (Security)***<br />
자세한 설명은 [여기](https://assu10.github.io/dev/2020/09/12/spring-cloud-oauth2.0/) 를 참고
자세한 설명은 [Spring Cloud - OAuth2, Security(1/2)](https://assu10.github.io/dev/2020/09/12/spring-cloud-oauth2.0/)
[Spring Cloud - OAuth2, Security(2/2)](https://assu10.github.io/dev/2020/09/30/spring-cloud-oauth2.0-2/)를 참고
```shell script
HOW TO RUN
@@ -183,21 +195,79 @@ HOW TO RUN
-- oauth2 전파 (이벤트 서비스에서 회원서비스 호출)
[GET] http://localhost:5555/api/evt/event/userInfo/rinda
-- JWT 인증 서버 설정 후 액세스 토큰 획득
[POST] http://localhost:8901/auth/oauth/token
-- JWT 토큰 획득 후 인증 확인
[GET] http://localhost:8090/member/gift/manok
```
---
***- Spring Cloud Stream (EDA, 비동기 마이크로서비스 구성)***<br />
자세한 설명은 [Spring Cloud Stream, 분산 캐싱 (1/2)](https://assu10.github.io/dev/2020/10/01/spring-cloud-stream/) 와
[Spring Cloud Stream, 분산 캐싱 (2/2)](https://assu10.github.io/dev/2020/10/02/spring-cloud-stream-2/) 를 참고
```shell script
HOW TO RUN
-- 주키퍼 실행
C:\kafka_2.13-2.6.0\bin\windows>.\zookeeper-server-start.bat ..\..\config\zookeeper.properties
-- 카프카 실행
C:\kafka_2.13-2.6.0\bin\windows>.\kafka-server-start.bat ..\..\config\server.properties
-- 카프카 토픽 리스트 조회
C:\kafka_2.13-2.6.0\bin\windows>.\kafka-topics.bat --list --zookeeper localhost:2181
__consumer_offsets
mbChangeTopic
springCloudBus
-- 메시지 발행/수신 확인
[POST] http://localhost:8090/member/assu
-- 레디스 설치확인
C:\Users\ju>netstat -an|findstr 6379
TCP 127.0.0.1:6379 0.0.0.0:0 LISTENING
C:\Users\ju>redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> set key value
OK
127.0.0.1:6379> get key
"value"
127.0.0.1:6379>
-- 레디스 실행
redis-server.bat 실행
-- 모든 키 확인
127.0.0.1:6379> keys *
1) "member"
-- key-value 확인 (get 은 String 만 다루므로 여기선 에러로 표시)
127.0.0.1:6379> get member
(error) WRONGTYPE Operation against a key holding the wrong kind of value
-- 모든 키 삭제
127.0.0.1:6379> flushall
OK
-- 레디스 캐싱 데이터 무효화
[GET] http://localhost:8070/event/1234
[DELETE] http://localhost:8090/member/userInfo/1234
[GET] http://localhost:8070/event/1234
```
---
***- Sleath, Papertrail, Zipkin (Logging Tracker)***<br />
---
***- Travis CI (Build & Deploy)***<br />
---
***- Spring Cloud Messaging (비동기 마이크로서비스 구성)***<br />
---
***- Hystrix + Turbine (Circuit Breaker & Dashboard, 여러 대의 WAS 한번에 모니터링)***<br />
---

View File

@@ -31,10 +31,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
</dependency>-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
@@ -48,14 +48,20 @@
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-security</artifactId>
</dependency>
</dependency>-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -20,18 +20,18 @@ import java.util.Map;
@EnableAuthorizationServer // 이 서비스가 OAuth2 인증 서버가 될 것이라고 스프링 클라우드에 알림
public class AuthServiceApplication {
/**
* 사용자 정보 조회 시 사용
* 사용자 정보 조회 시 사용.
* OAuth2 로 보호되는 서비스에 접근하려고 할 때 사용
* 보호 서비스로 호출되어 OAuth2 액세스 토큰의 유효성을 검증하고 보호 서비스에 접근하는 사용자 역할 조회
*/
//@RequestMapping(value = { "/user" }, produces = "application/json") // /auth/user 로 매핑
@RequestMapping(value = "/user") // /auth/user 로 매핑
/*@RequestMapping(value = "/user") // /auth/user 로 매핑
public Map<String, Object> user(OAuth2Authentication user) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("user", user.getUserAuthentication().getPrincipal());
userInfo.put("authorities", AuthorityUtils.authorityListToSet(user.getUserAuthentication().getAuthorities()));
return userInfo;
}
}*/
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}

View File

@@ -0,0 +1,17 @@
package com.assu.cloud.authservice.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@Component
@Configuration
public class CustomConfig {
@Value("${signing.key}")
private String jwtSigningKey = "";
public String getJwtSigningKey() {
return jwtSigningKey;
}
}

View File

@@ -8,25 +8,61 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import java.util.Arrays;
/**
* JWTTokenStoreConfig 에서 서명하고 생성한 JWT 토큰을 OAuth2 인증 서버로 연결
*
* OAuth2 인증 서버에 등록될 애플리케이션 정의
* AuthorizationServerConfigurerAdapter: 스프링 시큐리티 핵심부, 핵심 인증 인가 기능 수행하는 기본 메커니즘 제공
*/
@Configuration
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
public class JWTOAuth2Config extends AuthorizationServerConfigurerAdapter {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final TokenStore tokenStore;
private final DefaultTokenServices defaultTokenServices;
private final JwtAccessTokenConverter jwtAccessTokenConverter;
private final JWTTokenEnhancer jwtTokenEnhancer;
public OAuth2Config(AuthenticationManager authenticationManager, @Qualifier("userDetailsServiceBean") UserDetailsService userDetailsService) {
public JWTOAuth2Config(AuthenticationManager authenticationManager, @Qualifier("userDetailsServiceBean") UserDetailsService userDetailsService,
TokenStore tokenStore, DefaultTokenServices defaultTokenServices,
JwtAccessTokenConverter jwtAccessTokenConverter, JWTTokenEnhancer jwtTokenEnhancer) {
this.authenticationManager = authenticationManager;
this.userDetailsService = userDetailsService;
this.tokenStore = tokenStore;
this.defaultTokenServices = defaultTokenServices;
this.jwtAccessTokenConverter = jwtAccessTokenConverter;
this.jwtTokenEnhancer = jwtTokenEnhancer;
}
/**
* AuthorizationServerConfigurerAdapter 안에서 사용될 여러 컴포넌트 정의
* 여기선 스프링에 토큰 스토어, 액세스 토큰 컨버터, 토큰 엔헨서, 기본 인증 관리자와 사용자 상세 서비스를 이용한다고 선언
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 스프링 OAuth TokenEnhancerChain 등록하면 여러 TokenEnhancer 후킹 가능
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
endpoints.tokenStore(tokenStore) // JWT, JWTTokenStoreConfig 에서 정의한 토큰 저장소
.accessTokenConverter(jwtAccessTokenConverter) // JWT, 스프링 시큐리티 OAuth2 JWT 사용하도록 연결
.tokenEnhancer(tokenEnhancerChain) // JWT, endpoints tokenEnhancerChain 연결
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
/**
* 인증 서버에 등록될 클라이언트 정의
* , OAuth2 서비스로 보호되는 서비스에 접근할 있는 클라이언트 애플리케이션 등록
* , OAuth2 서비스로 보호되는서비스에 접근할 있는 클라이언트 애플리케이션 등록
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
@@ -36,14 +72,4 @@ public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
.authorizedGrantTypes("refresh_token", "password", "client_credentials") // OAuth2 에서 지원하는 인가 그랜트 타입, 여기선 패스워드/클라이언트 자격증명 그랜트타입
.scopes("webclient", "mobileclient"); // 토큰 요청 애플리케이션의 수행 경계 정의
}
/**
* AuthorizationServerConfigurerAdapter 안에서 사용될 여러 컴포넌트 정의
* 여기선 스프링에 기본 인증 관리자와 사용자 상세 서비스를 이용한다고 선언
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
}

View File

@@ -0,0 +1,34 @@
package com.assu.cloud.authservice.security;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import java.util.HashMap;
import java.util.Map;
/**
* 액세스 토큰에 추가 정보 삽입
*/
@Configuration
public class JWTTokenEnhancer implements TokenEnhancer {
private String getUserId(String userName){
// DB 로 유저 아이디 조회
return "12345";
}
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> additionalInfo = new HashMap<>();
String userId = getUserId(authentication.getName());
additionalInfo.put("userId", userId);
// 모든 추가 속성은 HashMap 에 추가하고, 메서드에 전달된 accessToken 변수에 추가
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}

View File

@@ -0,0 +1,64 @@
package com.assu.cloud.authservice.security;
import com.assu.cloud.authservice.config.CustomConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* 인증 서버가 JWT 토큰을 생성, 서명, 해석하는 방법 지정
*/
@Configuration
public class JWTTokenStoreConfig {
private final CustomConfig customConfig;
public JWTTokenStoreConfig(CustomConfig customConfig) {
this.customConfig = customConfig;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 서비스에 전달된 토큰에서 데이터를 읽는데 사용
* @return
*/
@Bean
@Primary // 특정 타입의 빈이 둘 이상인 경우 (여기선 DefaultTokenServices) @Primary 로 지정된 타입을 자동 주입
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
/**
* JWT 와 OAuth2 인증 서버 사이의 변환기
* 토큰 서명에 사용되는 서명키 사용 (여기선 대칭 키)
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(customConfig.getJwtSigningKey()); // 토큰 서명에 사용되는 서명키 정의
return converter;
}
/**
* OAuth2 에 JWT 토큰 확장 클래스인 JWTTokenEnhancer 클래스를 사용한다고 알리기 위해 빈으로 노출
* 여기서 노출하면 JWTOAuth2Config 에서 사용 가능
* @return
*/
@Bean
public TokenEnhancer jwtTokenEnhancer() {
return new JWTTokenEnhancer();
}
}

View File

@@ -9,11 +9,14 @@ import org.springframework.stereotype.Component;
@Component
public class CustomContext {
public static final String CORRELATION_ID = "assu-correlation-id";
public static final String AUTH_TOKEN = "Authorization";
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
private static final ThreadLocal<String> authToken = new ThreadLocal<>();
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
public static String getCorrelationId() {
return correlationId.get();
}
@@ -21,4 +24,12 @@ public class CustomContext {
public static void setCorrelationId(String cid) {
correlationId.set(cid);
}
}
public static String getAuthToken() {
return authToken.get();
}
public static void setAuthToken(String aToken) {
authToken.set(aToken);
}
}

View File

@@ -10,8 +10,10 @@ import java.io.IOException;
/**
* 유입되는 HTTP 요청을 가로채서 필요한 헤더값을 CustomContext 에 매핑
*
*
* REST 서비스에 대한 모든 HTTP 요청을 가로채서 컨텍스트 정보(상관관계 ID 등)를 추출해 CustomContext 클래스에 매핑하는 HTTP 서블릿 필터
* (즉, HTTP 헤더에서 인증 토큰과 상관관계 ID 파싱)
*
* REST 서비스 호출 시 코드에서 CustomContext 액세스가 필요할 때마다 ThreadLocal 변수에서 검색해 읽어올 수 있음
*/
@Component
@@ -25,7 +27,9 @@ public class CustomContextFilter implements Filter {
// HTTP 호출 헤더에서 상관관계 ID 를 검색하여 CustomContextHolder 의 CustomContext 클래스에 설정
CustomContextHolder.getContext().setCorrelationId(httpServletRequest.getHeader(CustomContext.CORRELATION_ID));
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
CustomContextHolder.getContext().setAuthToken(httpServletRequest.getHeader(CustomContext.AUTH_TOKEN));
logger.debug("상관관계 ID {} 로 실행된 동적 라우팅", CustomContextHolder.getContext().getCorrelationId());
@@ -37,4 +41,4 @@ public class CustomContextFilter implements Filter {
@Override
public void destroy() {}
}
}

View File

@@ -27,7 +27,7 @@ public class CustomContextHolder {
}
public static final void setContext(CustomContext ctx) {
Assert.notNull(ctx, "customcontxt is null.");
Assert.notNull(ctx, "CustomContext is null.");
customContext.set(ctx);
}

View File

@@ -9,7 +9,7 @@ import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입 + 토큰
*/
public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
/**
@@ -20,7 +20,9 @@ public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
HttpHeaders headers = httpRequest.getHeaders();
headers.add(CustomContext.CORRELATION_ID, CustomContextHolder.getContext().getCorrelationId());
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
headers.add(CustomContext.AUTH_TOKEN, CustomContextHolder.getContext().getAuthToken()); // HTTP 헤더에 인증 토큰 추가
return clientHttpRequestExecution.execute(httpRequest, bytes);
}

View File

@@ -1,4 +1,40 @@
spring:
application:
name: auth-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
server:
port: 8901
servlet:
contextPath: /auth
contextPath: /auth
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
client:
register-with-eureka: true # 레지스트리에 자신을 등록할지에 대한 여부 (디폴트 true)
fetch-registry: true # 레지스트리에 있는 정보를 가져올지에 대한 여부 (디폴트 true)
registry-fetch-interval-seconds: 30 # 서비스 목록을 설정한 시간마다 캐싱 (디폴트 30초)
disable-delta: true # 캐싱 시 변경된 부분만 업데이트할 지 여부 (디폴트 false)
serviceUrl:
defaultZone: http://peer1:8762/eureka/
instance:
lease-renewal-interval-in-seconds: 30 # 유레카 서버로 설정된 시간(second)마다 하트비트 전송 (디폴트 30초)
# 디스커버리는 서비스 등록 해제 하기 전에 마지막 하트비트에서부터 설정된 시간(second) 동안 하트비트가 수신되지 않으면
# 서비스 등록 해제 (디폴트 90초)
lease-expiration-duration-in-seconds: 90
prefer-ip-address: true # 서비스의 호스트 이름이 아닌 IP 주소를 유레카 서버에 등록하도록 지정 (디폴트 false)
logging:
level:
com.netflix: WARN
org.springframework.web: WARN
com.assu.cloud: DEBUG
signing:
key: assusingkey

View File

@@ -1,8 +1,8 @@
spring:
application:
name: auth-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
config:
uri: http://localhost:8889 # 컨피그 서버 위치
#spring:
# application:
# name: auth-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
# profiles:
# active: default # 서비스가 실행할 기본 프로파일
# cloud:
# config:
# uri: http://localhost:8889 # 컨피그 서버 위치

View File

@@ -8,7 +8,7 @@ spring:
uri: https://github.com/assu10/config-repo.git
username: assu10
password: '{cipher}f38ff3546220bbac52d81c132916b1b1fd7c3cfdcfdf408760d1c4bf0b4ee97c'
search-paths: member-service, event-service, eurekaserver, zuulserver # 구성 파일을 찾을 폴더 경로
search-paths: member-service, event-service, eurekaserver, zuulserver, auth-service # 구성 파일을 찾을 폴더 경로
encrypt:
enabled: false

View File

@@ -27,14 +27,18 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
</dependency>-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>

View File

@@ -2,6 +2,8 @@ package com.assu.cloud.eurekaserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@EnableEurekaServer

View File

@@ -0,0 +1,41 @@
spring:
application:
name: eurekaserver-peer1 # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles: peer1
server:
port: 8762 # 유레카 서버가 수신 대기할 포트
your.name: "EUREKA peer-1"
#spring:
# rabbitmq:
# host: localhost
# port: 5672
# username: guest
# password: '{cipher}17b3128621cb4e71fbb5a85ef726b44951b62fac541e1de6c2728c6e9d3594ec'
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
server:
enable-self-preservation: true # 일시적인 네트워크 장애로 인한 서비스 해제 막기 위한 자기 보호 모드 (디폴트 true, 운영에선 반드시 true 로 설정 필요)
response-cache-update-interval-ms: 30000 # 유레카 서버의 캐싱 업데이트 주기 (디폴트 30,000ms)
eviction-interval-timer-in-ms: 60000 # 클라이언트로부터 하트비트가 계속 수신 되는지 점검 (디폴트 60,000)
wait-time-in-ms-when-sync-empty: 3000 # 유레카 서버가 시작되고 유레카 피어링 노드로부터 Instance 들을 가져올 수 없을 때 기다릴 시간 (디폴트 3000ms)
registry-sync-retries: 5 # 유레카 피어 노드로부터 registry 를 갱신할 수 없을 때 재시도 횟수 (디폴트 5)
client:
register-with-eureka: false
fetch-registry: false
serviceUrl:
# defaultZone: http://peer2:8763/eureka/ # 피어링 시 필요
defaultZone: http://peer1:8762/eureka/
logging:
level:
com.netflix: DEBUG
org.springframework.web: WARN
com.assu.cloud: DEBUG

View File

@@ -0,0 +1,40 @@
spring:
application:
name: eurekaserver-peer2 # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles: peer2
server:
port: 8763 # 유레카 서버가 수신 대기할 포트
your.name: "EUREKA peer-2"
#spring:
# rabbitmq:
# host: localhost
# port: 5672
# username: guest
# password: '{cipher}17b3128621cb4e71fbb5a85ef726b44951b62fac541e1de6c2728c6e9d3594ec'
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
server:
enable-self-preservation: false # 일시적인 네트워크 장애로 인한 서비스 해제 막기 위한 자기 보호 모드 (디폴트 true, 운영에선 반드시 true 로 설정 필요)
response-cache-update-interval-ms: 30000 # 유레카 서버의 캐싱 업데이트 주기 (디폴트 30,000ms)
eviction-interval-timer-in-ms: 15000 # 클라이언트로부터 하트비트가 계속 수신 되는지 점검 (디폴트 60,000)
wait-time-in-ms-when-sync-empty: 3000 # 유레카 서버가 시작되고 유레카 피어링 노드로부터 Instance 들을 가져올 수 없을 때 기다릴 시간 (디폴트 3000ms, 운영 환경에선 삭제 필요)
registry-sync-retries: 5 # 유레카 피어 노드로부터 registry 를 갱신할 수 없을 때 재시도 횟수 (디폴트 5)
client:
register-with-eureka: false
fetch-registry: false
serviceUrl:
defaultZone: http://peer1:8762/eureka/
logging:
level:
com.netflix: WARN
org.springframework.web: WARN
com.assu.cloud: DEBUG

View File

@@ -1,2 +1,7 @@
server:
port: 8761 # 유레카 서버가 수신 대기할 포트
spring:
profiles:
active: peer1
# cloud:
# inetutils:
# ignored-interfaces: eth1* # 해당 인터페이스 무시
# preferred-networks: 192.168 # 선호하는 IP 주소 설정

View File

@@ -1,8 +0,0 @@
spring:
application:
name: eurekaserver # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
config:
uri: http://localhost:8889 # 컨피그 서버 위치

View File

@@ -36,14 +36,14 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-bus</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
</dependency>-->
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
</dependency>-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
@@ -61,6 +61,56 @@
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
<!-- 스프링 클라우드 스트림 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<!-- 스프링 클라우드 카프카 (메시지 브로커) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-kafka</artifactId>
</dependency>
<!-- 분산 캐싱 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!-- log -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>6.6</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -1,14 +1,27 @@
package com.assu.cloud.eventservice;
import com.assu.cloud.eventservice.config.CustomConfig;
import com.assu.cloud.eventservice.utils.CustomContextInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoRestTemplateFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.List;
@@ -16,9 +29,75 @@ import java.util.List;
@EnableEurekaClient
@SpringBootApplication
@EnableFeignClients
@EnableResourceServer
@EnableResourceServer // 보호 자원으로 설정
//@EnableBinding(Sink.class) // 이 애플리케이션을 메시지 브로커와 바인딩하도록 스프링 클라우드 스트림 설정
// Sink.class 로 지정 시 해당 서비스가 Sink 클래스에 정의된 채널들을 이용해 메시지 브로커와 통신
public class EventServiceApplication {
private static final Logger logger = LoggerFactory.getLogger(EventServiceApplication.class);
private final CustomConfig customConfig;
public EventServiceApplication(CustomConfig customConfig) {
this.customConfig = customConfig;
}
/**
* 채널에서 받은 메시지를 MemberChangeModel 이라는 POJO 로 자동 역직렬화
* @param mbChange
*/
// CustomChannel 작업하면서 MemberChangeHandler 로 아래 메서드 옮김
/*@StreamListener(Sink.INPUT) // 메시지가 입력 채널에서 수신될 때마다 이 메서드 실행
public void loggerSink(MemberChangeModel mbChange) {
logger.info("======= Received an event for organization id {}", mbChange.getUserId());
}*/
/**
* 레디스 서버에 실제 DB 커넥션을 설정
* 레디스 인스턴스와 통신하려면 JedisConnectionFactory 를 빈으로 노출해야 함
* 이 커넥션을 사용해서 스프링 RedisTemplate 객체 생성
*/
@Bean
public JedisConnectionFactory jedisConnectionFactory() {
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
jedisConnectionFactory.setHostName(customConfig.getRedisServer());
jedisConnectionFactory.setPort(customConfig.getRedisPort());
return jedisConnectionFactory;
}
/**
* 레디스 서버에 작업 수행 시 사용할 RedisTemplate 객체 생성
*/
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(jedisConnectionFactory());
return redisTemplate;
}
/**
* 사용자 정의 RestTemplate 빈을 생성하여 토큰 삽입
* RestTemplate 기반 호출이 수행되기 전 후킹되는 메서드
*/
@Primary
@LoadBalanced
@Bean
public RestTemplate getCustomRestTemplate() {
RestTemplate template = new RestTemplate();
List interceptors = template.getInterceptors();
// CustomContextInterceptor 는 Authorization 헤더를 모든 REST 호출에 삽입함
if (interceptors == null) {
template.setInterceptors(Collections.singletonList(new CustomContextInterceptor()));
} else {
interceptors.add(new CustomContextInterceptor());
template.setInterceptors(interceptors);
}
return template;
}
/*
// OAuth2 RestTemplate -> JWT 기반 토큰을 전파하지 앟음
//@LoadBalanced
@Bean
public OAuth2RestTemplate restTemplate(UserInfoRestTemplateFactory factory) {
@@ -31,9 +110,11 @@ public class EventServiceApplication {
factory.getUserInfoRestTemplate().setInterceptors(interceptors);
}
return factory.getUserInfoRestTemplate();
}
}*/
/*@LoadBalanced // 스프링 클라우드가 리본이 지원하는 RestTemplate 클래스 생성하도록 지시
/*
기본 RestTemplate
@LoadBalanced // 스프링 클라우드가 리본이 지원하는 RestTemplate 클래스 생성하도록 지시
@Bean
public RestTemplate getRestTemplate() {
// return new RestTemplate();

View File

@@ -0,0 +1,89 @@
package com.assu.cloud.eventservice.client;
import com.assu.cloud.eventservice.config.CustomConfig;
import com.assu.cloud.eventservice.model.Member;
import com.assu.cloud.eventservice.repository.MemberRedisRepository;
import com.assu.cloud.eventservice.utils.CustomContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* 회원 데이터 필요 시 회원 서비스 호출 전 레디스 캐시 먼저 확인
*/
@Component
public class MemberCacheRestTemplateClient {
private static final Logger logger = LoggerFactory.getLogger(MemberCacheRestTemplateClient.class);
private final RestTemplate restTemplate;
private final MemberRedisRepository memberRedisRepository;
private final CustomConfig customConfig;
public MemberCacheRestTemplateClient(RestTemplate restTemplate, MemberRedisRepository memberRedisRepository, CustomConfig customConfig) {
this.restTemplate = restTemplate;
this.memberRedisRepository = memberRedisRepository;
this.customConfig = customConfig;
}
String URL_PREFIX = "/api/mb/member/"; // 회원 서비스의 주울 라우팅경로와 회원 클래스 주소
/**
* 회원 아이디로 레디스에 저장된 Member 클래스 조회
*/
private Member checkRedisCache(String userId) {
try {
return memberRedisRepository.findMember(userId);
} catch (Exception e) {
logger.error("======= Error encountered while trying to retrieve member {} check Redis Cache., Exception {}", userId, e);
return null;
}
}
/**
* 레디스 캐시에 데이터 저장
*/
private void cacheMemberObject(Member member) {
try {
memberRedisRepository.saveMember(member);
} catch (Exception e) {
e.printStackTrace();
logger.error("======= Unable to cache member {} in Redis. Exception {}", member.getId(), e);
}
}
public Member getMember(String userId) {
Member member = checkRedisCache(userId);
// 레디스에 데이터가 없다면 원본 데이터에서 데이터를 조회하기 위해 회원 서비스 호출
if (member != null) {
logger.debug("======= Successfully retrieved an Member {} from the redis cache: {}", userId, member);
return member;
}
logger.debug("======= Unable to locate member from the redis cache: {}", userId);
ResponseEntity<Member> restExchange =
restTemplate.exchange(
"http://" + customConfig.getServiceIdZuul() + URL_PREFIX + "{userId}", // http://localhost:5555/api/mb/member/userInfo/rinda
HttpMethod.GET,
null,
Member.class,
userId
);
// 캐시 레코드 저장
member = restExchange.getBody();
// 조회한 객체를 캐시에 저장
if (member != null) {
cacheMemberObject(member);
}
return member;
}
}

View File

@@ -12,7 +12,7 @@ import org.springframework.stereotype.Component;
@Component
public class MemberRestTemplateClient {
private final OAuth2RestTemplate restTemplate;
/*private final OAuth2RestTemplate restTemplate;
private final CustomConfig customConfig;
public MemberRestTemplateClient(OAuth2RestTemplate restTemplate, CustomConfig customConfig) {
@@ -36,5 +36,5 @@ public class MemberRestTemplateClient {
);
return restExchange.getBody();
}
}*/
}

View File

@@ -13,6 +13,15 @@ public class CustomConfig {
@Value("${service.id.zuul}")
private String serviceIdZuul;
@Value("${signing.key}")
private String jwtSigningKey;
@Value("${redis.server}")
private String redisServer;
@Value("${redis.port}")
private int redisPort;
public String getYourName() {
return yourName;
}
@@ -20,4 +29,16 @@ public class CustomConfig {
public String getServiceIdZuul() {
return serviceIdZuul;
}
public String getJwtSigningKey() {
return jwtSigningKey;
}
public String getRedisServer() {
return redisServer;
}
public int getRedisPort() {
return redisPort;
}
}

View File

@@ -1,36 +1,49 @@
package com.assu.cloud.eventservice.controller;
import com.assu.cloud.eventservice.client.MemberCacheRestTemplateClient;
import com.assu.cloud.eventservice.client.MemberRestTemplateClient;
import com.assu.cloud.eventservice.client.MemberFeignClient;
import com.assu.cloud.eventservice.config.CustomConfig;
import com.assu.cloud.eventservice.model.Member;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletRequest;
@RestController
@RequestMapping("/event")
public class EventController {
private static final Logger logger = LoggerFactory.getLogger(EventController.class);
private final CustomConfig customConfig;
private final MemberFeignClient memberFeignClient;
private final MemberRestTemplateClient memberRestTemplateClient;
private final MemberCacheRestTemplateClient memberCacheRestTemplateClient;
public EventController(CustomConfig customConfig, MemberFeignClient memberFeignClient, MemberRestTemplateClient memberRestTemplateClient) {
public EventController(CustomConfig customConfig, MemberFeignClient memberFeignClient, MemberRestTemplateClient memberRestTemplateClient,
MemberCacheRestTemplateClient memberCacheRestTemplateClient) {
this.customConfig = customConfig;
this.memberFeignClient = memberFeignClient;
this.memberRestTemplateClient = memberRestTemplateClient;
this.memberCacheRestTemplateClient = memberCacheRestTemplateClient;
}
@GetMapping(value = "name/{nick}")
public String getYourName(@PathVariable("nick") String nick) {
public String getYourName(ServletRequest req, @PathVariable("nick") String nick) {
// 히스트릭트 타임아웃을 테스트하기 위함
/*try {
Thread.sleep(4000);
} catch(InterruptedException e) {
e.printStackTrace();;
}*/
return "[EVENT] Your name is " + customConfig.getYourName() + ", nickname is " + nick;
logger.info("[EVENT] name/{nick} logging...nick is {}.", nick);
return "[EVENT] Your name is " + customConfig.getYourName() + ", nickname is " + nick + ", port is " + req.getServerPort();
}
/**
@@ -38,19 +51,57 @@ public class EventController {
*/
@GetMapping(value = "member/{nick}")
public String getMemberName(@PathVariable("nick") String nick) {
logger.info("[EVENT] Calling member's name/nick {}", nick);
return memberFeignClient.getYourName(nick);
}
/**
* 회원 서비스에서 호출할 메서드
* 회원 서비스에서 호출할 메서드 (fallback test)
*/
@GetMapping(value = "gift/{name}")
public String gift(@PathVariable("name") String gift) {
//sleep();
logger.info("[EVENT] Gift is {} logging...gift is {}.", gift);
memberFeignClient.getYourName(gift);
return "[EVENT] Gift is " + gift;
}
@GetMapping("userInfo/{name}")
/**
* 회원 서비스에서 호출할 메서드 (fallback test)
*/
@GetMapping(value = "gift2/{name}")
public String gift2(@PathVariable("name") String gift) {
return "[EVENT] Gift is " + gift;
}
@GetMapping(value = "timeout")
public String timeout() {
sleep();
return "[EVENT] good";
}
private void sleep() {
try {
Thread.sleep(10000); // 7,000 ms (7초), 기본적으로 히스트릭스는 1초 후에 호출을 타임아웃함
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 레디스 캐싱 데이터 사용
*/
@GetMapping(value = "{userId}")
public Member userInfo(@PathVariable("userId") String userId) {
return memberCacheRestTemplateClient.getMember(userId);
}
/*@GetMapping("userInfo/{name}")
public String userInfo(@PathVariable("name") String name) {
return "[EVENT-MEMBER] " + memberRestTemplateClient.userInfo(name);
}
}*/
}

View File

@@ -0,0 +1,13 @@
package com.assu.cloud.eventservice.event;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.messaging.SubscribableChannel;
/**
* 사용자 정의 input 채널 (SINK.INPUT 같은...), Consumer
*/
public interface CustomChannels {
@Input("inboundMemberChanges") // @Input 은 채널 이름을 정의하는 메서드 레벨 애너테이션
SubscribableChannel members(); // @Input 애너테이션으로 노출된 채널은 모두 SubscribableChannel 클래스를 반환해야 함
}

View File

@@ -0,0 +1,51 @@
package com.assu.cloud.eventservice.event.handlers;
import com.assu.cloud.eventservice.event.CustomChannels;
import com.assu.cloud.eventservice.event.model.MemberChangeModel;
import com.assu.cloud.eventservice.repository.MemberRedisRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
/**
* 사용자 정의 채널을 사용하여 메시지 수신
* 이 애플리케이션을 메시지 브로커와 바인딩하도록 스프링 클라우드 스트림 설정
*/
@EnableBinding(CustomChannels.class) // CustomChannels.class 로 지정 시 해당 서비스가 CustomChannels 클래스에 정의된 채널들을 이용해 메시지 브로커와 통신
public class MemberChangeHandler {
private static final Logger logger = LoggerFactory.getLogger(MemberChangeHandler.class);
private final MemberRedisRepository memberRedisRepository;
public MemberChangeHandler(MemberRedisRepository memberRedisRepository) {
this.memberRedisRepository = memberRedisRepository;
}
/**
* 메시지가 입력 채널에서 수신될 때마다 이 메서드 실행
*/
@StreamListener("inboundMemberChanges") // Sink.INPUT 대신 사용자 정의 채널명인 inboundMemberChanges 전달
public void loggerSink(MemberChangeModel mbChange) {
logger.info("======= Received a message of type {}", mbChange.getType());
switch (mbChange.getAction()) {
case "GET":
logger.debug("Received a GET event from the member service for userId {}", mbChange.getUserId());
break;
case "SAVE":
logger.debug("Received a SAVE event from the member service for userId {}", mbChange.getUserId());
break;
case "UPDATE":
logger.debug("Received a UPDATE event from the member service for userId {}", mbChange.getUserId());
memberRedisRepository.deleteMember(mbChange.getUserId()); // 캐시 무효화
break;
case "DELETE":
logger.debug("Received a DELETE event from the member service for userId {}", mbChange.getUserId());
memberRedisRepository.deleteMember(mbChange.getUserId());
break;
default:
logger.debug("Received an UNKNOWN event from the member service for userId {}", mbChange.getType());
break;
}
}
}

View File

@@ -0,0 +1,51 @@
package com.assu.cloud.eventservice.event.model;
/**
* 발행될 메시지를 표현하는 POJO
*/
public class MemberChangeModel {
private String type;
private String action;
private String userId;
private String correlationId;
public MemberChangeModel(String type, String action, String userId, String correlationId) {
// super();
this.type = type;
this.action = action;
this.userId = userId;
this.correlationId = correlationId;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCorrelationId() {
return correlationId;
}
public void setCorrelationId(String correlationId) {
this.correlationId = correlationId;
}
}

View File

@@ -0,0 +1,24 @@
package com.assu.cloud.eventservice.model;
import java.io.Serializable;
public class Member implements Serializable {
private String id;
private String name;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,13 @@
package com.assu.cloud.eventservice.repository;
import com.assu.cloud.eventservice.model.Member;
/**
* 레디스에 액세스해야 하는 클래스에 주입된 인터페이스
*/
public interface MemberRedisRepository {
void saveMember(Member member);
void updateMember(Member member);
void deleteMember(String userId);
Member findMember(String userId);
}

View File

@@ -0,0 +1,57 @@
package com.assu.cloud.eventservice.repository;
import com.assu.cloud.eventservice.model.Member;
import com.netflix.discovery.converters.Auto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Repository;
import javax.annotation.PostConstruct;
/**
* 부트스트랩 클래스에서 정의한 RedisTemplate 빈을 사용하여 레디스 서버와 통신
*/
@Repository
public class MemberRedisRepositoryImpl implements MemberRedisRepository {
private static final String HASH_NAME = "member"; // 회원 데이터가 저장되는 레디스 서버의 해시명
private final RedisTemplate<String, Member> redisTemplate;
private HashOperations hashOperations; // HashOperation 클래스는 레디스 서버에 데이터 작업을 수행하는 스프링 헬퍼 메서드의 집합
public MemberRedisRepositoryImpl(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@PostConstruct
public void init() {
hashOperations = redisTemplate.opsForHash();
// 키와 값을 명시적으로 직렬화해주지 않으면 default serializer 로 JdkSerializationRedisSerializer 를 사용하는데
// 그러면 \xac\xed\x00\x05t\x00\x06member 이런 식으로 저장됨
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
}
@Override
public void saveMember(Member member) {
hashOperations.put(HASH_NAME, member.getId(), member);
}
@Override
public void updateMember(Member member) {
hashOperations.put(HASH_NAME, member.getId(), member);
}
@Override
public void deleteMember(String userId) {
hashOperations.delete(HASH_NAME, userId);
}
@Override
public Member findMember(String userId) {
return (Member) hashOperations.get(HASH_NAME, userId);
}
}

View File

@@ -0,0 +1,53 @@
package com.assu.cloud.eventservice.security;
import com.assu.cloud.eventservice.config.CustomConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* 인증 서버가 JWT 토큰을 생성, 서명, 해석하는 방법 지정
*/
@Configuration
public class JWTTokenStoreConfig {
private final CustomConfig customConfig;
public JWTTokenStoreConfig(CustomConfig customConfig) {
this.customConfig = customConfig;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 서비스에 전달된 토큰에서 데이터를 읽는데 사용
* @return
*/
@Bean
@Primary // 특정 타입의 빈이 둘 이상인 경우 (여기선 DefaultTokenServices) @Primary 로 지정된 타입을 자동 주입
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
/**
* JWT 와 OAuth2 인증 서버 사이의 변환기
* 토큰 서명에 사용되는 서명키 사용 (여기선 대칭 키)
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(customConfig.getJwtSigningKey()); // 토큰 서명에 사용되는 서명키 정의
return converter;
}
}

View File

@@ -24,10 +24,16 @@ public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
// 회원 서비스의 모든 URL 에 대해 인증된 사용자만 접근하도록 제한
//http.authorizeRequests().anyRequest().authenticated();
http.authorizeRequests()
// 편의성을 위해 주석 처리
/*http.authorizeRequests()
.antMatchers(HttpMethod.PUT, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.hasRole("ADMIN") // ADMIN 권한을 가진 사용자만 PUT 호출 가능
.anyRequest() // 서비스의 모든 엔드포인트도 인증된 사용자만 접근 가능하도록 설정
.authenticated();
.authenticated();*/
// 편의성을 위해 DELETE 메서드만 인증된 사용자가 호출가능하도록 수정
http.authorizeRequests()
.antMatchers(HttpMethod.DELETE, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.hasRole("ADMIN"); // ADMIN 권한을 가진 사용자만 PUT 호출 가능
}
}

View File

@@ -9,11 +9,14 @@ import org.springframework.stereotype.Component;
@Component
public class CustomContext {
public static final String CORRELATION_ID = "assu-correlation-id";
public static final String AUTH_TOKEN = "Authorization";
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
private static final ThreadLocal<String> authToken = new ThreadLocal<>();
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
public static String getCorrelationId() {
return correlationId.get();
}
@@ -21,4 +24,12 @@ public class CustomContext {
public static void setCorrelationId(String cid) {
correlationId.set(cid);
}
public static String getAuthToken() {
return authToken.get();
}
public static void setAuthToken(String aToken) {
authToken.set(aToken);
}
}

View File

@@ -12,6 +12,8 @@ import java.io.IOException;
* 유입되는 HTTP 요청을 가로채서 필요한 헤더값을 CustomContext 에 매핑
*
* REST 서비스에 대한 모든 HTTP 요청을 가로채서 컨텍스트 정보(상관관계 ID 등)를 추출해 CustomContext 클래스에 매핑하는 HTTP 서블릿 필터
* (즉, HTTP 헤더에서 인증 토큰과 상관관계 ID 파싱)
*
* REST 서비스 호출 시 코드에서 CustomContext 액세스가 필요할 때마다 ThreadLocal 변수에서 검색해 읽어올 수 있음
*/
@Component
@@ -25,7 +27,9 @@ public class CustomContextFilter implements Filter {
// HTTP 호출 헤더에서 상관관계 ID 를 검색하여 CustomContextHolder 의 CustomContext 클래스에 설정
CustomContextHolder.getContext().setCorrelationId(httpServletRequest.getHeader(CustomContext.CORRELATION_ID));
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
CustomContextHolder.getContext().setAuthToken(httpServletRequest.getHeader(CustomContext.AUTH_TOKEN));
logger.debug("상관관계 ID {} 로 실행된 동적 라우팅", CustomContextHolder.getContext().getCorrelationId());

View File

@@ -27,7 +27,7 @@ public class CustomContextHolder {
}
public static final void setContext(CustomContext ctx) {
Assert.notNull(ctx, "customcontxt is null.");
Assert.notNull(ctx, "CustomContext is null.");
customContext.set(ctx);
}

View File

@@ -9,7 +9,7 @@ import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입 + 토큰
*/
public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
/**
@@ -20,7 +20,9 @@ public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
HttpHeaders headers = httpRequest.getHeaders();
headers.add(CustomContext.CORRELATION_ID, CustomContextHolder.getContext().getCorrelationId());
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
headers.add(CustomContext.AUTH_TOKEN, CustomContextHolder.getContext().getAuthToken()); // HTTP 헤더에 인증 토큰 추가
return clientHttpRequestExecution.execute(httpRequest, bytes);
}

View File

@@ -1,2 +1,90 @@
spring:
application:
name: event-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
stream:
bindings:
inboundMemberChanges: # inboundMemberChanges 은 채널명, EventServiceApplication 의 Sink.INPUT 채널에 매핑되고, input 채널을 mgChangeTopic 큐에 매핑함
destination: mbChangeTopic # 메시지를 넣은 메시지 큐(토픽) 이름
content-type: application/json
group: eventGroup # 메시지를 소비할 소비자 그룹의 이름
kafka: # stream.kafka 는 해당 서비스를 카프카에 바인딩
binder:
zkNodes: localhost # zkNodes, brokers 는 스트림에게 카프카와 주키퍼의 네트워크 위치 전달
brokers: localhost
zipkin:
enabled: true
base-url: http://localhost:9411 # 집킨 통신에 사용되는 URL
sleuth:
enabled: true
sampler:
probability: 1.0 # 집킨 으로 데이터를 전송하는 트랜잭션 샘플링 비율
server:
port: 8070
your.name: "EVENT DEFAULT."
#spring:
# rabbitmq:
# host: localhost
# port: 5672
# username: guest
# password: '{cipher}17b3128621cb4e71fbb5a85ef726b44951b62fac541e1de6c2728c6e9d3594ec'
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
client:
register-with-eureka: true # 레지스트리에 자신을 등록할지에 대한 여부 (디폴트 true)
fetch-registry: true # 레지스트리에 있는 정보를 가져올지에 대한 여부 (디폴트 true)
registry-fetch-interval-seconds: 30 # 서비스 목록을 설정한 시간마다 캐싱 (디폴트 30초)
disable-delta: true # 캐싱 시 변경된 부분만 업데이트할 지 여부 (디폴트 false)
serviceUrl:
defaultZone: http://peer1:8762/eureka/
instance:
lease-renewal-interval-in-seconds: 30 # 유레카 서버로 설정된 시간(second)마다 하트비트 전송 (디폴트 30초)
# 디스커버리는 서비스 등록 해제 하기 전에 마지막 하트비트에서부터 설정된 시간(second) 동안 하트비트가 수신되지 않으면
# 서비스 등록 해제 (디폴트 90초)
lease-expiration-duration-in-seconds: 90
prefer-ip-address: true # 서비스의 호스트 이름이 아닌 IP 주소를 유레카 서버에 등록하도록 지정 (디폴트 false)
logging:
level:
com.netflix: WARN
org.springframework.web: WARN
com.assu.cloud: DEBUG
service:
id:
member: member-service
zuul: zuulserver
security:
oauth2:
resource:
user-info-uri: http://localhost:8901/auth/user # OAuth2 콜백 URL
signing:
key: assusingkey
#spring:
# cloud:
# stream:
# bindings:
# input: # input 은 채널명, EventServiceApplication 의 Sink.INPUT 채널에 매핑되고, input 채널을 mgChangeTopic 큐에 매핑함
# destination: mbChangeTopic # 메시지를 넣은 메시지 큐(토픽) 이름
# content-type: application/json
# group: eventGroup # 메시지를 소비할 소비자 그룹의 이름
# kafka: # stream.kafka 는 해당 서비스를 카프카에 바인딩
# binder:
# zkNodes: localhost # zkNodes, brokers 는 스트림에게 카프카와 주키퍼의 네트워크 위치 전달
# brokers: localhost
#redis
redis:
server: localhost
port: 6379

View File

@@ -1,8 +1,8 @@
spring:
application:
name: event-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
config:
uri: http://localhost:8889 # 컨피그 서버 위치
#spring:
# application:
# name: event-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
# profiles:
# active: default # 서비스가 실행할 기본 프로파일
# cloud:
# config:
# uri: http://localhost:8889 # 컨피그 서버 위치

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- logback에 대한 기본적인 설정을 base.xml을 통해서 제공함.console,file appender 를 base.xml에 include 하고 있음-->
<include resource="org/springframework/boot/logging/logback/base.xml" />
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<!-- sleuth -->
<!--<property name="spring.application.name" value="event-service" />
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [${spring.application.name}] [trace=%X{X-Trace-Id:-},span=%X{X-Span-Id:-}] [%15.15t] %-40.40logger{39}: %m%n" />-->
<appender name="STASH" class="net.logstash.logback.appender.LogstashAccessTcpSocketAppender">
<destination>localhost:4560</destination>
<!-- encoder 필요 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="STASH" />
</root>
</configuration>

View File

@@ -31,14 +31,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
</dependency>-->
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
</dependency>-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
@@ -59,6 +59,47 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
<!-- 스프링 클라우드 스트림 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<!-- 스프링 클라우드 카프카 (메시지 브로커) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-kafka</artifactId>
</dependency>
<!-- 히스트릭스 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<!-- log -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>6.6</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -3,9 +3,13 @@ package com.assu.cloud.memberservice;
import com.assu.cloud.memberservice.utils.CustomContextInterceptor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.web.client.RestTemplate;
@@ -14,12 +18,38 @@ import java.util.List;
@SpringBootApplication
@EnableEurekaClient
@EnableResourceServer // 보호 자원으로 설정
@EnableResourceServer // 보호 자원으로 설정
@EnableBinding(Source.class) // 이 애플리케이션을 메시지 브로커와 바인딩하도록 스프링 클라우드 스트림 설정
// Source.class 로 지정 시 해당 서비스가 Source 클래스에 정의된 채널들을 이용해 메시지 브로커와 통신
@EnableCircuitBreaker // Hystrix
public class MemberServiceApplication {
public static void main(String[] args) {
SpringApplication.run(MemberServiceApplication.class, args);
}
/**
* 사용자 정의 RestTemplate 빈을 생성하여 토큰 삽입
* RestTemplate 기반 호출이 수행되기 전 후킹되는 메서드
*/
@Primary
@LoadBalanced
@Bean
public RestTemplate getCustomRestTemplate() {
RestTemplate template = new RestTemplate();
List interceptors = template.getInterceptors();
// CustomContextInterceptor 는 Authorization 헤더를 모든 REST 호출에 삽입함
if (interceptors == null) {
template.setInterceptors(Collections.singletonList(new CustomContextInterceptor()));
} else {
interceptors.add(new CustomContextInterceptor());
template.setInterceptors(interceptors);
}
return template;
}
/*
// 기본 RestTemplate
@LoadBalanced // 스프링 클라우드가 리본이 지원하는 RestTemplate 클래스 생성하도록 지시
@Bean
public RestTemplate getRestTemplate() {
@@ -34,5 +64,5 @@ public class MemberServiceApplication {
template.setInterceptors(interceptors);
}
return template;
}
}*/
}

View File

@@ -17,9 +17,10 @@ public class EventRestTemplateClient {
this.customConfig = customConfig;
}
String URL_PREFIX = "/api/evt/event/"; // 이벤트 서비스의 주울 라우팅경로와 이벤트 클래스 주소
String ZUUL_URL_PREFIX = "/api/evt/event/"; // 이벤트 서비스의 주울 라우팅경로와 이벤트 클래스 주소
public String gift(String name) {
System.out.println("----------http://" + customConfig.getServiceIdZuul() + ZUUL_URL_PREFIX + "gift/{name}");
/*ResponseEntity<EventGift> restExchange =
restTemplate.exchange(
"http://event-service/event/gift/{name}",
@@ -28,7 +29,24 @@ public class EventRestTemplateClient {
);*/
ResponseEntity<String> restExchange =
restTemplate.exchange(
"http://" + customConfig.getServiceIdZuul() + URL_PREFIX + "gift/{name}", // http://localhost:5555/api/mb/member/gift/flower
"http://" + customConfig.getServiceIdZuul() + ZUUL_URL_PREFIX + "gift/{name}", // http://localhost:5555/api/mb/member/gift/flower
HttpMethod.GET,
null, String.class, name
);
return restExchange.getBody();
}
public String gift2(String name) {
/*ResponseEntity<EventGift> restExchange =
restTemplate.exchange(
"http://event-service/event/gift/{name}",
HttpMethod.GET,
null, EventGift.class, name
);*/
ResponseEntity<String> restExchange =
restTemplate.exchange(
"http://" + customConfig.getServiceIdZuul() + ZUUL_URL_PREFIX + "gift2/{name}", // http://localhost:5555/api/mb/member/gift/flower
HttpMethod.GET,
null, String.class, name
);

View File

@@ -13,6 +13,9 @@ public class CustomConfig {
@Value("${service.id.zuul}")
private String serviceIdZuul;
@Value("${signing.key}")
private String jwtSigningKey = "";
public String getYourName() {
return yourName;
}
@@ -20,4 +23,8 @@ public class CustomConfig {
public String getServiceIdZuul() {
return serviceIdZuul;
}
public String getJwtSigningKey() {
return jwtSigningKey;
}
}

View File

@@ -2,25 +2,37 @@ package com.assu.cloud.memberservice.controller;
import com.assu.cloud.memberservice.client.EventRestTemplateClient;
import com.assu.cloud.memberservice.config.CustomConfig;
import org.springframework.http.HttpStatus;
import com.assu.cloud.memberservice.event.source.SimpleSourceBean;
import com.assu.cloud.memberservice.model.Member;
import com.assu.cloud.memberservice.utils.CustomContextHolder;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import javax.servlet.ServletRequest;
import java.util.Random;
@RestController
@RequestMapping("/member")
public class MemberController {
private static final Logger logger = LoggerFactory.getLogger(MemberController.class);
private final CustomConfig customConfig;
private final EventRestTemplateClient eventRestTemplateClient;
private final SimpleSourceBean simpleSourceBean;
public MemberController(CustomConfig customConfig, EventRestTemplateClient eventRestTemplateClient) {
public MemberController(CustomConfig customConfig, EventRestTemplateClient eventRestTemplateClient, SimpleSourceBean simpleSourceBean) {
this.customConfig = customConfig;
this.eventRestTemplateClient = eventRestTemplateClient;
this.simpleSourceBean = simpleSourceBean;
}
@GetMapping(value = "name/{nick}")
public String getYourName(ServletRequest req, @PathVariable("nick") String nick) {
logger.info("[MEMBER] ASSU name/{nick} logging...nick is {}.", nick);
return "[MEMBER] Your name is " + customConfig.getYourName() + " / nickname is " + nick + " / port is " + req.getServerPort();
}
@@ -29,6 +41,7 @@ public class MemberController {
*/
@GetMapping(value = "gift/{name}")
public String gift(ServletRequest req, @PathVariable("name") String name) {
logger.info("[MEMBER] gift/{name} logging...name is {}.", name);
return "[MEMBER] " + eventRestTemplateClient.gift(name) + " / port is " + req.getServerPort();
}
@@ -48,4 +61,158 @@ public class MemberController {
public String userInfo(@PathVariable("name") String name) {
return "[MEMBER] " + name;
}
/**
* 단순 메시지 발행
*/
@PostMapping("/{userId}")
public void saveUserId(@PathVariable("userId") String userId) {
// DB 에 save 작업..
simpleSourceBean.publishMemberChange("SAVE", userId);
}
/**
* 이벤트 서비스에서 캐시 용도로 회원 데이터 조회
*/
@GetMapping("{userId}")
public Member userInfoCache(@PathVariable("userId") String userId) {
logger.debug("====== 회원 저장 서비스 호출!");
// DB 를 조회하여 회원 데이터 조회 (간편성을 위해 아래와 같이 리턴함)
Member member = new Member();
member.setId(userId);
member.setName("rinda");
return member;
}
/**
* 이벤트 서비스에서 캐시 제거를 위한 메서드
*/
@DeleteMapping("userInfo/{userId}")
public void deleteUserInfoCache(@PathVariable("userId") String userId) {
logger.debug("====== 회원 삭제 후 DELETE 메시지 발생");
// DB 에 삭제 작업 (간편성을 위해 DB 작업은 생략)
simpleSourceBean.publishMemberChange("DELETE", userId);
}
/**
* Hystrix 기본 테스트 (RestTemplate 를 이용하여 이벤트 서비스의 REST API 호출)
*/
@HystrixCommand // 모두 기본값으로 셋팅한다는 의미
@GetMapping(value = "hys/{name}")
public String hys(ServletRequest req, @PathVariable("name") String name) {
logger.debug("LicenseService.getLicensesByOrg Correlation id: {}", CustomContextHolder.getContext().getCorrelationId());
//randomlyRunLong();
sleep();
return "[MEMBER] " + eventRestTemplateClient.gift(name) + " / port is " + req.getServerPort();
}
/**
* Circuit Breaker 타임아웃 설정 (RestTemplate 를 이용하여 이벤트 서비스의 REST API 호출)
*/
/*@HystrixCommand(
commandProperties = {
@HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds",
value="5000")}) // 회로차단기의 타임아웃 시간을 5초로 설정*/
@HystrixCommand(fallbackMethod = "timeoutFallback")
@GetMapping(value = "timeout/{name}")
public String timeout(ServletRequest req, @PathVariable("name") String name) {
return "[MEMBER] " + eventRestTemplateClient.gift(name) + " / port is " + req.getServerPort();
}
/**
* timeout 메서드의 폴백 메서드
*/
public String timeoutFallback(ServletRequest req, @PathVariable("name") String name) {
return "This is timeoutFallback test.";
}
@GetMapping(value = "bulkheadMain/{name}")
public String bulkheadMain(ServletRequest req, @PathVariable("name") String name) {
String eventApi = eventRestTemplateClient.gift(name);
return "[MEMBER] " + eventApi;
}
/**
* eventThreadPool 을 사용하면서 sleep() 이 있는 이벤트 서비스를 호출하는 함수
*/
@HystrixCommand( //fallbackMethod = "timeoutFallback")
threadPoolKey = "eventThreadPool",
threadPoolProperties =
{@HystrixProperty(name = "coreSize", value = "30"), // 스레드 풀의 스레드 갯수 (디폴트 10)
@HystrixProperty(name = "maxQueueSize", value = "10")}, // 스레드 풀 앞에 배치할 큐와 큐에 넣을 요청 수 (디폴트 -1)
commandProperties = {
// 히스트릭스가 호출 차단을 고려하는데 필요한 시간인 10초(metrics.rollingStats.timeInMilliseconds) 동안 연속 호출 횟수 (디폴트 20)
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "2"),
// 서킷 브레이커가 열린 후 requestVolumeThreshold 값만큼 호출한 후 타임아웃, 예외, HTTP 500 반환등으로 실패해야 하는 호출 비율 (디폴트 50)
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "10"),
// 서킷 브레이커가 열린 후 서비스의 회복 상태를 확인할 때까지 대기할 시간 간격. 즉, 서킷 브레이커가 열렸을 때 얼마나 지속될지...(디폴트 5000)
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "7000"),
// 서비스 호출 문제를 모니터할 시간 간격. 즉 서킷 브레이커가 열리기 위한 조건을 체크할 시간. (디폴트 10초)
@HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "15000"),
// 설정한 시간 간격동안 통계를 수집할 횟수 (이 버킷수는 모니터 시간 간격에 균등하게 분할되어야 함
// 여기선 15초 시간 간격을 사용하고, 3초 길이의 5개 버킷에 통계 데이터 수집
@HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "5")}
)
@GetMapping(value = "bulkheadEvtSleep/{name}")
public String bulkheadEvtSleep(@PathVariable("name") String name) {
String eventApi = eventRestTemplateClient.gift(name);
return "[MEMBER] " + eventApi;
}
/**
* eventThreadPool 을 사용하지만 sleep() 이 없는 이벤트 서비스를 호출하는 함수
* 바로 위 함수에서 서킷 브레이커가 열려도 아래 함수는 정상 동작함 (스레드 풀 키를 이런 식으로 공유해서 사용할 수 없는 것 같음)
*/
@HystrixCommand( //fallbackMethod = "timeoutFallback")
threadPoolKey = "eventThreadPool",
threadPoolProperties =
{@HystrixProperty(name = "coreSize", value = "30"), // 스레드 풀의 스레드 갯수 (디폴트 10)
@HystrixProperty(name = "maxQueueSize", value = "10")}, // 스레드 풀 앞에 배치할 큐와 큐에 넣을 요청 수 (디폴트 -1)
commandProperties = {
// 히스트릭스가 호출 차단을 고려하는데 필요한 시간인 10초(metrics.rollingStats.timeInMilliseconds) 동안 연속 호출 횟수 (디폴트 20)
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "2"),
// 서킷 브레이커가 열린 후 requestVolumeThreshold 값만큼 호출한 후 타임아웃, 예외, HTTP 500 반환등으로 실패해야 하는 호출 비율 (디폴트 50)
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "75"),
// 서킷 브레이커가 열린 후 서비스의 회복 상태를 확인할 때까지 대기할 시간 간격. 즉, 서킷 브레이커가 열렸을 때 얼마나 지속될지...(디폴트 5000)
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "7000"),
// 서비스 호출 문제를 모니터할 시간 간격. 즉 서킷 브레이커가 열리기 위한 조건을 체크할 시간. (디폴트 10초)
@HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "15000"),
// 설정한 시간 간격동안 통계를 수집할 횟수 (이 버킷수는 모니터 시간 간격에 균등하게 분할되어야 함
// 여기선 15초 시간 간격을 사용하고, 3초 길이의 5개 버킷에 통계 데이터 수집
@HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "5")}
)
@GetMapping(value = "bulkheadEvtNotSleepPool/{name}")
public String bulkheadEvtPool(ServletRequest req, @PathVariable("name") String name) {
String eventApi = eventRestTemplateClient.gift2(name);
return "[MEMBER] " + eventApi;
}
@GetMapping(value = "bulkheadEvtNotSleepNotPool/{name}")
public String bulkheadEvtNotPool(ServletRequest req, @PathVariable("name") String name) {
String eventApi = eventRestTemplateClient.gift2(name);
return "[MEMBER] " + eventApi;
}
/**
* 3번 중 1번은 sleep()
*/
private void randomlyRunLong() {
logger.debug("randomlyRunLong");
Random rand = new Random();
int randomNum = rand.nextInt((3-1)+1)+1;
if (randomNum == 3) {
sleep();
}
}
private void sleep() {
try {
Thread.sleep(7000); // 3,000 ms (3초), 기본적으로 히스트릭스는 1초 후에 호출을 타임아웃함
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,51 @@
package com.assu.cloud.memberservice.event.model;
/**
* 발행될 메시지를 표현하는 POJO
*/
public class MemberChangeModel {
private String type;
private String action;
private String userId;
private String correlationId;
public MemberChangeModel(String type, String action, String userId, String correlationId) {
// super();
this.type = type;
this.action = action;
this.userId = userId;
this.correlationId = correlationId;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCorrelationId() {
return correlationId;
}
public void setCorrelationId(String correlationId) {
this.correlationId = correlationId;
}
}

View File

@@ -0,0 +1,43 @@
package com.assu.cloud.memberservice.event.source;
import com.assu.cloud.memberservice.event.model.MemberChangeModel;
import com.assu.cloud.memberservice.utils.CustomContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
/**
* 메시지 브로커에 메시지 발행
*/
@Component
public class SimpleSourceBean {
private final Source source;
private static final Logger logger = LoggerFactory.getLogger(SimpleSourceBean.class);
/**
* 스프링 클라우드 스트림은 서비스가 사용할 소스 인터페이스 구현을 주입
*/
public SimpleSourceBean(Source source) {
this.source = source;
}
/**
* 메시지 발행
*/
public void publishMemberChange(String action, String userId) {
logger.debug("======= Sending kafka message {} for User Id : {}", action, userId);
// com.assu.cloud.memberservice.event.model.MemberChangeModel
logger.debug("======= MemberChangeModel.class.getTypeName() : {}", MemberChangeModel.class.getTypeName());
// 발행될 메시지는 자바 POJO
MemberChangeModel change = new MemberChangeModel(MemberChangeModel.class.getTypeName(),
action,
userId,
CustomContext.getCorrelationId());
// 메시지를 보낼 준비가 되면 Source 클래스에 정의된 채널에서 send() 메서드 사용
source.output().send(MessageBuilder.withPayload(change).build());
}
}

View File

@@ -0,0 +1,24 @@
package com.assu.cloud.memberservice.model;
import java.io.Serializable;
public class Member implements Serializable {
private String id;
private String name;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,53 @@
package com.assu.cloud.memberservice.security;
import com.assu.cloud.memberservice.config.CustomConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* 인증 서버가 JWT 토큰을 생성, 서명, 해석하는 방법 지정
*/
@Configuration
public class JWTTokenStoreConfig {
private final CustomConfig customConfig;
public JWTTokenStoreConfig(CustomConfig customConfig) {
this.customConfig = customConfig;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 서비스에 전달된 토큰에서 데이터를 읽는데 사용
* @return
*/
@Bean
@Primary // 특정 타입의 빈이 둘 이상인 경우 (여기선 DefaultTokenServices) @Primary 로 지정된 타입을 자동 주입
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
/**
* JWT 와 OAuth2 인증 서버 사이의 변환기
* 토큰 서명에 사용되는 서명키 사용 (여기선 대칭 키)
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(customConfig.getJwtSigningKey()); // 토큰 서명에 사용되는 서명키 정의
return converter;
}
}

View File

@@ -24,10 +24,18 @@ public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
// 회원 서비스의 모든 URL 에 대해 인증된 사용자만 접근하도록 제한
//http.authorizeRequests().anyRequest().authenticated();
http.authorizeRequests()
.antMatchers(HttpMethod.PUT, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
// 편의성을 위해 주석 처리
/*http.authorizeRequests()
//.antMatchers(HttpMethod.PUT, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.antMatchers(HttpMethod.DELETE, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.hasRole("ADMIN") // ADMIN 권한을 가진 사용자만 PUT 호출 가능
.anyRequest() // 서비스의 모든 엔드포인트도 인증된 사용자만 접근 가능하도록 설정
.authenticated();
.authenticated();*/
// 편의성을 위해 DELETE 메서드만 인증된 사용자가 호출가능하도록 수정
http.authorizeRequests()
//.antMatchers(HttpMethod.PUT, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.antMatchers(HttpMethod.DELETE, "/member/**") // 쉼표로 구분하여 엔드 포인트 목록 받음
.hasRole("ADMIN"); // ADMIN 권한을 가진 사용자만 PUT 호출 가능
}
}

View File

@@ -9,11 +9,14 @@ import org.springframework.stereotype.Component;
@Component
public class CustomContext {
public static final String CORRELATION_ID = "assu-correlation-id";
public static final String AUTH_TOKEN = "Authorization";
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
private static final ThreadLocal<String> authToken = new ThreadLocal<>();
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
public static String getCorrelationId() {
return correlationId.get();
}
@@ -21,4 +24,12 @@ public class CustomContext {
public static void setCorrelationId(String cid) {
correlationId.set(cid);
}
}
public static String getAuthToken() {
return authToken.get();
}
public static void setAuthToken(String aToken) {
authToken.set(aToken);
}
}

View File

@@ -10,8 +10,10 @@ import java.io.IOException;
/**
* 유입되는 HTTP 요청을 가로채서 필요한 헤더값을 CustomContext 에 매핑
*
*
* REST 서비스에 대한 모든 HTTP 요청을 가로채서 컨텍스트 정보(상관관계 ID 등)를 추출해 CustomContext 클래스에 매핑하는 HTTP 서블릿 필터
* (즉, HTTP 헤더에서 인증 토큰과 상관관계 ID 파싱)
*
* REST 서비스 호출 시 코드에서 CustomContext 액세스가 필요할 때마다 ThreadLocal 변수에서 검색해 읽어올 수 있음
*/
@Component
@@ -25,7 +27,9 @@ public class CustomContextFilter implements Filter {
// HTTP 호출 헤더에서 상관관계 ID 를 검색하여 CustomContextHolder 의 CustomContext 클래스에 설정
CustomContextHolder.getContext().setCorrelationId(httpServletRequest.getHeader(CustomContext.CORRELATION_ID));
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
CustomContextHolder.getContext().setAuthToken(httpServletRequest.getHeader(CustomContext.AUTH_TOKEN));
logger.debug("상관관계 ID {} 로 실행된 동적 라우팅", CustomContextHolder.getContext().getCorrelationId());

View File

@@ -27,7 +27,7 @@ public class CustomContextHolder {
}
public static final void setContext(CustomContext ctx) {
Assert.notNull(ctx, "customcontxt is null.");
Assert.notNull(ctx, "CustomContext is null.");
customContext.set(ctx);
}

View File

@@ -9,7 +9,7 @@ import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입 + 토큰
*/
public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
/**
@@ -20,7 +20,9 @@ public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
HttpHeaders headers = httpRequest.getHeaders();
headers.add(CustomContext.CORRELATION_ID, CustomContextHolder.getContext().getCorrelationId());
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
headers.add(CustomContext.AUTH_TOKEN, CustomContextHolder.getContext().getAuthToken()); // HTTP 헤더에 인증 토큰 추가
return clientHttpRequestExecution.execute(httpRequest, bytes);
}

View File

@@ -1,2 +1,78 @@
spring:
application:
name: member-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud: # 스프링 클라우드 스트림 설정
stream: # stream.bindings 는 스트림의 메시지 브로커에 발행하려는 구성의 시작점
bindings:
output: # output 은 채널명, SimpleSourceBean.publishMemberChange() 의 source.output() 채널에 매핑됨
destination: mbChangeTopic # 메시지를 넣은 메시지 큐(토픽) 이름
content-type: application/json # 스트림에 송수신할 메시지 타입의 정보 (JSON 으로 직렬화)
kafka: # stream.kafka 는 해당 서비스를 카프카에 바인딩
binder:
zkNodes: localhost # zkNodes, brokers 는 스트림에게 카프카와 주키퍼의 네트워크 위치 전달
brokers: localhost
zipkin:
enabled: true
base-url: http://localhost:9411 # 집킨 통신에 사용되는 URL
sleuth:
enabled: true
sampler:
probability: 1.0 # 집킨 으로 데이터를 전송하는 트랜잭션 샘플링 비율
server:
port: 8090
port: 8090
your.name: "MEMBER DEFAULT..."
#spring:
# rabbitmq:
# host: localhost
# port: 5672
# username: guest
# password: '{cipher}17b3128621cb4e71fbb5a85ef726b44951b62fac541e1de6c2728c6e9d3594ec'
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
client:
register-with-eureka: true # 레지스트리에 자신을 등록할지에 대한 여부 (디폴트 true)
fetch-registry: true # 레지스트리에 있는 정보를 가져올지에 대한 여부 (디폴트 true)
registry-fetch-interval-seconds: 30 # 서비스 목록을 설정한 시간마다 캐싱 (디폴트 30초)
disable-delta: true # 캐싱 시 변경된 부분만 업데이트할 지 여부 (디폴트 false)
serviceUrl:
defaultZone: http://peer1:8762/eureka/
instance:
lease-renewal-interval-in-seconds: 30 # 유레카 서버로 설정된 시간(second)마다 하트비트 전송 (디폴트 30초)
# 디스커버리는 서비스 등록 해제 하기 전에 마지막 하트비트에서부터 설정된 시간(second) 동안 하트비트가 수신되지 않으면
# 서비스 등록 해제 (디폴트 90초)
lease-expiration-duration-in-seconds: 90
prefer-ip-address: true # 서비스의 호스트 이름이 아닌 IP 주소를 유레카 서버에 등록하도록 지정 (디폴트 false)
logging:
level:
com.netflix: WARN
org.springframework.web: WARN
com.assu.cloud: DEBUG
service:
id:
zuul: zuulserver
security:
oauth2:
resource:
user-info-uri: http://localhost:8901/auth/user # OAuth2 콜백 URL
signing:
key: assusingkey
hystrix:
command:
default: # 유레카 서비스 ID
execution:
isolation:
thread:
timeoutInMilliseconds: 3000 # 히스트릭스 타임아웃 3초로 설정 (기본 1초, ribbon 의 타임아웃보다 커야 기대하는 대로 동작함)

View File

@@ -1,8 +0,0 @@
spring:
application:
name: member-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
config:
uri: http://localhost:8889 # 컨피그 서버 위치

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- logback에 대한 기본적인 설정을 base.xml을 통해서 제공함.console,file appender 를 base.xml에 include 하고 있음-->
<include resource="org/springframework/boot/logging/logback/base.xml" />
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<!-- sleuth -->
<!--<property name="spring.application.name" value="member-service"/>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [${spring.application.name}] [trace=%X{X-Trace-Id:-},span=%X{X-Span-Id:-}] [%15.15t] %-40.40logger{39}: %m%n"/>-->
<appender name="STASH" class="net.logstash.logback.appender.LogstashAccessTcpSocketAppender">
<destination>localhost:4560</destination>
<!-- encoder 필요 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="STASH" />
</root>
</configuration>

View File

@@ -23,6 +23,7 @@
<module>eurekaserver</module>
<module>zuulserver</module>
<module>auth-service</module>
</modules>
<build>

View File

@@ -27,9 +27,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>-->
<!-- 히스트릭스 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
@@ -48,6 +53,34 @@
<artifactId>spring-security-rsa</artifactId>
</dependency>
<!-- JWT Parser -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- parseClaimsJws 데이터 파싱 시 내부적으로 사용 -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<!-- 히스트릭스 -->
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -2,11 +2,14 @@ package com.assu.cloud.zuulserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@SpringBootApplication
@EnableZuulProxy // 주울 서버로 사용
@EnableCircuitBreaker
public class ZuulserverApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulserverApplication.class, args);
}

View File

@@ -0,0 +1,18 @@
package com.assu.cloud.zuulserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@Component
@Configuration
public class CustomConfig {
@Value("${signing.key}")
private String jwtSigningKey = "";
public String getJwtSigningKey() {
return jwtSigningKey;
}
}

View File

@@ -1,12 +1,13 @@
package com.assu.cloud.zuulserver.filters;
import com.assu.cloud.zuulserver.config.CustomConfig;
import com.assu.cloud.zuulserver.utils.FilterUtils;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
@@ -26,9 +27,11 @@ public class PreFilter extends ZuulFilter {
private static final Logger logger = LoggerFactory.getLogger(PreFilter.class);
private final FilterUtils filterUtils;
private final CustomConfig customConfig;
public PreFilter(FilterUtils filterUtils) {
public PreFilter(FilterUtils filterUtils, CustomConfig customConfig) {
this.filterUtils = filterUtils;
this.customConfig = customConfig;
}
/**
@@ -88,6 +91,38 @@ public class PreFilter extends ZuulFilter {
RequestContext ctx = RequestContext.getCurrentContext();
logger.debug("============ Processing incoming request for {}.", ctx.getRequest().getRequestURI());
logger.info("============ user id is {}.", getUserId());
return null;
}
private String getUserId() {
String result = "";
if (filterUtils.getAuthToken() != null) {
// HTTP Authorization 헤더에서 토큰 파싱
String authToken = filterUtils.getAuthToken().replace("Bearer ", "");
try {
// 토큰 서명에 사용된 서명 키를 전달해서 Jwts 클래스를 사용해 토큰 파싱
Claims claims = Jwts.parser()
.setSigningKey(customConfig.getJwtSigningKey().getBytes("UTF-8"))
.parseClaimsJws(authToken).getBody();
// JWT 토큰에서 userId 가져옴 (userId 는 인증 서버의 JWTTokenEnhancer 에서 추가했음)
result = (String) claims.get("userId");
// {user_name=assuAdmin, scope=[mobileclient], exp=1601582137, userId=12345, authorities=[ROLE_ADMIN, ROLE_USER], jti=595aa7f9-7887-4263-85b1-20aa3555ffd2, client_id=assuapp}
logger.info("claims: {}", claims);
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
} catch (Exception e) {
logger.error("Exception : {}", e.getMessage());
}
}
return result;
}
}

View File

@@ -1,24 +0,0 @@
package com.assu.cloud.zuulserver.utils;
import org.springframework.stereotype.Component;
/**
* 서비스가 쉽게 액세스할 수 있는 HTTP 헤더를 만들어 저장하는 클래스
* HTTP 요청에서 추출한 값을 보관하는 POJO
*/
@Component
public class CustomContext {
public static final String CORRELATION_ID = "assu-correlation-id";
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
public static String getCorrelationId() {
return correlationId.get();
}
public static void setCorrelationId(String cid) {
correlationId.set(cid);
}
}

View File

@@ -1,40 +0,0 @@
package com.assu.cloud.zuulserver.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 유입되는 HTTP 요청을 가로채서 필요한 헤더값을 CustomContext 에 매핑
*
* REST 서비스에 대한 모든 HTTP 요청을 가로채서 컨텍스트 정보(상관관계 ID 등)를 추출해 CustomContext 클래스에 매핑하는 HTTP 서블릿 필터
* REST 서비스 호출 시 코드에서 CustomContext 액세스가 필요할 때마다 ThreadLocal 변수에서 검색해 읽어올 수 있음
*/
@Component
public class CustomContextFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(CustomContextFilter.class);
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
// HTTP 호출 헤더에서 상관관계 ID 를 검색하여 CustomContextHolder 의 CustomContext 클래스에 설정
CustomContextHolder.getContext().setCorrelationId(httpServletRequest.getHeader(CustomContext.CORRELATION_ID));
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
logger.debug("상관관계 ID {} 로 실행된 동적 라우팅", CustomContextHolder.getContext().getCorrelationId());
filterChain.doFilter(httpServletRequest, servletResponse);
}
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void destroy() {}
}

View File

@@ -1,37 +0,0 @@
package com.assu.cloud.zuulserver.utils;
import org.springframework.util.Assert;
/**
* ThreadLocal 저장소에 CustomContext 를 저장하는 클래스
* * ThreadLocal 변수: 사용자 요청을 처리하는 해당 스레드에서 호출되는 모든 메서드에서 액세스 가능한 변수
*
* CustomContext 가 ThreadLocal 저장소에 저장되면 요청으로 실행된 모든 코드에서 CustomContextHolder 의 CustomContext 객체 사용 가능
*/
public class CustomContextHolder {
/** 정적 ThreadLocal 변수에 저장되는 CustomContext */
private static final ThreadLocal<CustomContext> customContext = new ThreadLocal<>();
/**
* CustomContext 객체를 사용하기 위해 조회해오는 메서드
*/
public static final CustomContext getContext() {
CustomContext ctx = customContext.get();
if (ctx == null) {
ctx = createEmptyContext();
customContext.set(ctx);
}
return customContext.get();
}
public static final void setContext(CustomContext ctx) {
Assert.notNull(ctx, "customcontxt is null.");
customContext.set(ctx);
}
public static final CustomContext createEmptyContext() {
return new CustomContext();
}
}

View File

@@ -1,27 +0,0 @@
package com.assu.cloud.zuulserver.utils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/**
* RestTemplate 인스턴스에서 실행되는 모든 HTTP 기반 서비스 발신 요청에 상관관계 ID 삽입
*/
public class CustomContextInterceptor implements ClientHttpRequestInterceptor {
/**
* RestTemplate 로 실제 HTTP 서비스 호출 전 intercept 메서드 호출
*/
@Override
public ClientHttpResponse intercept(HttpRequest httpRequest, byte[] bytes, ClientHttpRequestExecution clientHttpRequestExecution) throws IOException {
HttpHeaders headers = httpRequest.getHeaders();
headers.add(CustomContext.CORRELATION_ID, CustomContextHolder.getContext().getCorrelationId());
// 그 외 필요한 항목 넣을 수 있음 (인증 토큰 등...)
return clientHttpRequestExecution.execute(httpRequest, bytes);
}
}

View File

@@ -14,6 +14,9 @@ public class FilterUtils {
public static final String PRE_FILTER_TYPE = "pre";
public static final String POST_FILTER_TYPE = "post";
public static final String ROUTING_FILTER_TYPE = "route";
public static final String AUTH_TOKEN = "Authorization";
private static final Logger logger = LoggerFactory.getLogger(FilterUtils.class);
/**
@@ -44,4 +47,9 @@ public class FilterUtils {
RequestContext ctx = RequestContext.getCurrentContext();
ctx.addZuulRequestHeader(CORRELATION_ID, correlationId);
}
public final String getAuthToken() {
RequestContext ctx = RequestContext.getCurrentContext();
return ctx.getRequest().getHeader(AUTH_TOKEN);
}
}

View File

@@ -1,2 +1,85 @@
spring:
application:
name: zuulserver # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
zipkin:
base-url: http://localhost:9411 # 집킨 통신에 사용되는 URL
server:
port: 5555
your.name: "ZUUL DEFAULT"
#spring:
# rabbitmq:
# host: localhost
# port: 5672
# username: guest
# password: '{cipher}17b3128621cb4e71fbb5a85ef726b44951b62fac541e1de6c2728c6e9d3594ec'
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
shutdown:
enabled: true
eureka:
client:
register-with-eureka: true # 레지스트리에 자신을 등록할지에 대한 여부 (디폴트 true)
fetch-registry: true # 레지스트리에 있는 정보를 가져올지에 대한 여부 (디폴트 true)
registry-fetch-interval-seconds: 30 # 서비스 목록을 설정한 시간마다 캐싱 (디폴트 30초)
disable-delta: true # 캐싱 시 변경된 부분만 업데이트할 지 여부 (디폴트 false)
serviceUrl:
defaultZone: http://peer1:8762/eureka/
instance:
lease-renewal-interval-in-seconds: 30 # 유레카 서버로 설정된 시간(second)마다 하트비트 전송 (디폴트 30초)
# 디스커버리는 서비스 등록 해제 하기 전에 마지막 하트비트에서부터 설정된 시간(second) 동안 하트비트가 수신되지 않으면
# 서비스 등록 해제 (디폴트 90초)
lease-expiration-duration-in-seconds: 90
prefer-ip-address: true # 서비스의 호스트 이름이 아닌 IP 주소를 유레카 서버에 등록하도록 지정 (디폴트 false)
zuul:
ignored-services: '*' # 유레카 기반 모든 경로 제외
prefix: /api # 정의한 모든 서비스에 /api 접두어
routes:
event-service: /evt/**
member-service: /mb/**
sensitive-headers: Cookie,Set-Cookie # 주울이 하위 서비스에 전파하지 않는 헤더 차단 목록 (디폴트는 Cookie, Set-Cookie, Authorization)
retryable: true # 디폴트 false
event-service: # 이 부분이 없으면 전체적으로 설정 적용
ribbon:
MaxAutoRetries: 0 # 첫 시도 실패시 같은 서버로 재시도 하는 수 (첫번째 전송은 제외)
MaxAutoRetriesNextServer: 1 # 첫 시도 실패시 다음 서버로 재시도 하는 수 (첫번째 전송은 제외)
# ReadTimeout: 1000 # HttpClient 의 Read Timeout (데이터를 읽어오는 과정의 Timeout 시간)
# ConnectTimeout: 2000 # HttpClient 의 Connection timeout (연결과정의 Timeout 시간)
ReadTimeout: 3000 # HttpClient 의 Read Timeout (디폴트 1,000 ms, 데이터를 읽어오는 과정의 Timeout 시간)
ConnectTimeout: 1000 # HttpClient 의 Connection timeout (디폴트 1,000 ms, 연결과정의 Timeout 시간)
hystrix:
command:
default:
circuitBreaker:
sleepWindowInMilliseconds: 5000 # 서킷 브레이커가 열린 후 서비스의 회복 상태를 확인할 때까지 대기할 시간 간격. 즉, 서킷 브레이커가 열렸을 때 얼마나 지속될지...(디폴트 5000)
errorThresholdPercentage: 50 # 서킷 브레이커가 열리기 위헤 requestVolumeThreshold 값만큼 호출한 후 타임아웃, 예외, HTTP 500 반환등으로 실패해야 하는 호출 비율 (디폴트 50)
requestVolumeThreshold: 10 # 히스트릭스가 호출 차단을 고려하는데 필요한 시간인 10초(metrics.rollingStats.timeInMilliseconds) 동안 연속 호출 횟수 (디폴트 20)
metrics:
rollingStats:
timeInMilliseconds: 10000 # 서비스 호출 문제를 모니터할 시간 간격. 즉 서킷 브레이커가 열리기 위한 조건을 체크할 시간. (디폴트 10초)
# 설정한 시간 간격동안 통계를 수집할 횟수 (디폴트 10, 이 버킷수는 모니터 시간 간격에 균등하게 분할되어야 함)
# 여기선 10초 시간 간격을 사용하고, 3초 길이의 5개 버킷에 통계 데이터 수집
numBuckets: 5
execution:
isolation:
thread:
timeoutInMilliseconds: 8100 # 히스트릭스 타임아웃 5초로 설정 (기본 1초, ribbon 의 타임아웃보다 커야 기대하는 대로 동작함)
logging:
level:
com.netflix: DEBUG
org.springframework.web: WARN
com.assu.cloud: DEBUG
signing:
key: assusingkey

View File

@@ -1,8 +1,8 @@
spring:
application:
name: zuulserver # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
profiles:
active: default # 서비스가 실행할 기본 프로파일
cloud:
config:
uri: http://localhost:8889 # 컨피그 서버 위치..
#spring:
# application:
# name: zuulserver # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
# profiles:
# active: default # 서비스가 실행할 기본 프로파일
# cloud:
# config:
# uri: http://localhost:8889 # 컨피그 서버 위치..