Compare commits
43 Commits
master
...
master_jwt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f0198222 | ||
|
|
3102f52c5d | ||
|
|
2719d3b775 | ||
|
|
df051147c3 | ||
|
|
873cb2f8bf | ||
|
|
83de70e741 | ||
|
|
b56e54696c | ||
|
|
37b668d1b5 | ||
|
|
e107340323 | ||
|
|
0d03290c17 | ||
|
|
f96dba758c | ||
|
|
640ffc67f4 | ||
|
|
0e73c70005 | ||
|
|
5e38188512 | ||
|
|
f9c3c0909b | ||
|
|
66e067ea44 | ||
|
|
1c4a15599a | ||
|
|
c4746083c6 | ||
|
|
c09b454c22 | ||
|
|
4786f791bf | ||
|
|
ba2a7bb189 | ||
|
|
eb3596785e | ||
|
|
6911072862 | ||
|
|
95c5794c3a | ||
|
|
7bfdd48316 | ||
|
|
b569b62569 | ||
|
|
172667004a | ||
|
|
b2b3cc9b9c | ||
|
|
99a42c1159 | ||
|
|
73f8d8e786 | ||
|
|
75c1b7ad75 | ||
|
|
b28d45fc2f | ||
|
|
3c82498455 | ||
|
|
45552d0eca | ||
|
|
d2c919561c | ||
|
|
3c1cafaab8 | ||
|
|
9fe6262839 | ||
|
|
2aeadb71cb | ||
|
|
ed0549575e | ||
|
|
a8c0f2933a | ||
|
|
4093f6fbca | ||
|
|
8e6a18eb00 | ||
|
|
c6eeb07fd4 |
108
README.md
108
README.md
@@ -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 />
|
||||
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 # 컨피그 서버 위치
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
41
eurekaserver/src/main/resources/application-peer1.yaml
Normal file
41
eurekaserver/src/main/resources/application-peer1.yaml
Normal 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
|
||||
40
eurekaserver/src/main/resources/application-peer2.yaml
Normal file
40
eurekaserver/src/main/resources/application-peer2.yaml
Normal 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
|
||||
@@ -1,2 +1,7 @@
|
||||
server:
|
||||
port: 8761 # 유레카 서버가 수신 대기할 포트
|
||||
spring:
|
||||
profiles:
|
||||
active: peer1
|
||||
# cloud:
|
||||
# inetutils:
|
||||
# ignored-interfaces: eth1* # 해당 인터페이스 무시
|
||||
# preferred-networks: 192.168 # 선호하는 IP 주소 설정
|
||||
@@ -1,8 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: eurekaserver # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
|
||||
profiles:
|
||||
active: default # 서비스가 실행할 기본 프로파일
|
||||
cloud:
|
||||
config:
|
||||
uri: http://localhost:8889 # 컨피그 서버 위치
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -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 클래스를 반환해야 함
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 호출 가능
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 # 컨피그 서버 위치
|
||||
|
||||
22
event-service/src/main/resources/logback.xml
Normal file
22
event-service/src/main/resources/logback.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 호출 가능
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 의 타임아웃보다 커야 기대하는 대로 동작함)
|
||||
@@ -1,8 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: member-service # 서비스 ID (컨피그 클라이언트가 어떤 서비스를 조회하는지 매핑)
|
||||
profiles:
|
||||
active: default # 서비스가 실행할 기본 프로파일
|
||||
cloud:
|
||||
config:
|
||||
uri: http://localhost:8889 # 컨피그 서버 위치
|
||||
22
member-service/src/main/resources/logback.xml
Normal file
22
member-service/src/main/resources/logback.xml
Normal 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>
|
||||
1
pom.xml
1
pom.xml
@@ -23,6 +23,7 @@
|
||||
<module>eurekaserver</module>
|
||||
<module>zuulserver</module>
|
||||
<module>auth-service</module>
|
||||
|
||||
</modules>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 # 컨피그 서버 위치..
|
||||
|
||||
Reference in New Issue
Block a user