[솔루션]
# jetty.http.port=9090
java -jar C:\Users\sua\Desktop\jetty-distribution-9.4.48.v20220622/start.jar
function updateLeaderBoard() {
$.ajax({
url: "http://localhost:8081/leaders"
}).then(function (data) {
$('#leaderboard-body').empty();
data.forEach(function (row) {
$('#leaderboard-body').append('<tr><td>' + row.userId + '</td>' +
'<td>' + row.totalScore + '</td>');
});
});
}
function updateStats(userId) {
$.ajax({
url: "http://localhost:8081/stats?userId=" + userId,
success: function (data) {
$('#stats-div').show();
$('#stats-user-id').empty().append(userId);
$('#stats-score').empty().append(data.score);
$('#stats-badges').empty().append(data.badges.join());
},
error: function (data) {
$('#stats-div').show();
$('#stats-user-id').empty().append(userId);
$('#stats-score').empty().append(0);
$('#stats-badges').empty();
}
});
}
$(document).ready(function () {
updateLeaderBoard();
$("#refresh-leaderboard").click(function (event) {
updateLeaderBoard();
});
});
package microservices.book.gamification.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class WebConfiguration extends WebMvcConfigurerAdapter {
/**
* CORS(Cross-Origin Resource Sharing) 설정
* 자세한 정보 : http://docs.spring.io/spring/docs/current/spring-framework-reference/html/cors.html
*
* @param registry
*/
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**");
}
}
package microservices.book.multiplication.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class WebConfiguration extends WebMvcConfigurerAdapter {
/**
* CORS(Cross-Origin Resource Sharing) 설정
* 자세한 정보 : http://docs.spring.io/spring/docs/current/spring-framework-reference/html/cors.html
*
* @param registry
*/
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**");
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Multiplication v1</title>
<link href="css/bootstrap.min.css" rel="stylesheet">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="multiplication-client.js"></script>
<script src="gamification-client.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h1 class="text-center">안녕하세요, 소셜 곱셈입니다!</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h3 class="text-center">오늘의 문제:</h3>
<h1 class="text-center">
<span class="multiplication-a"></span> x <span class="multiplication-b"></span>
</h1>
<p>
<form id="attempt-form">
<div class="form-group">
<label for="result-attempt">답은?</label>
<input type="text" name="result-attempt" id="result-attempt" class="form-control">
</div>
<div class="form-group">
<label for="user-alias">닉네임:</label>
<input type="text" name="user-alias" id="user-alias" class="form-control">
</div>
<input type="submit" value="확인" class="btn btn-default">
</form>
</p>
<div class="result-message"></div>
<div id="stats-div" style="display: none;">
<h2>통계</h2>
<table id="stats" class="table">
<tbody>
<tr>
<td class="info">사용자 ID:</td>
<td id="stats-user-id"></td>
</tr>
<tr>
<td class="info">점수:</td>
<td id="stats-score"></td>
</tr>
<tr>
<td class="info">배지:</td>
<td id="stats-badges"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-md-6">
<h3>리더보드</h3>
<table id="leaderboard" class="table">
<tr>
<th>사용자 ID</th>
<th>점수</th>
</tr>
<tbody id="leaderboard-body"></tbody>
</table>
<div class="text-right">
<button id="refresh-leaderboard" class="btn btn-default" type="submit">새로고침</button>
</div>
<div id="results-div" style="display: none;">
<h2>최근 답안</h2>
<table id="results" class="table">
<tr>
<th>답안 ID</th>
<th>곱셈</th>
<th>입력한 값</th>
<th>정답?</th>
</tr>
<tbody id="results-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<script src="js/bootstrap.min.js"></script>
</body>
</html>
function updateMultiplication() {
$.ajax({
url: "http://localhost:8080/multiplications/random"
}).then(function (data) {
// 폼 비우기
$("#attempt-form").find("input[name='result-attempt']").val("");
$("#attempt-form").find("input[name='user-alias']").val("");
// 무작위 문제를 API로 가져와서 추가하기
$('.multiplication-a').empty().append(data.factorA);
$('.multiplication-b').empty().append(data.factorB);
});
}
function updateResults(alias) {
var userId = -1;
$.ajax({
async: false,
url: "http://localhost:8080/results?alias=" + alias,
success: function (data) {
$('#results-div').show();
$('#results-body').empty();
data.forEach(function (row) {
$('#results-body').append('<tr><td>' + row.id + '</td>' +
'<td>' + row.multiplication.factorA + ' x ' + row.multiplication.factorB + '</td>' +
'<td>' + row.resultAttempt + '</td>' +
'<td>' + (row.correct === true ? 'YES' : 'NO') + '</td></tr>');
});
userId = data[0].user.id;
}
});
return userId;
}
$(document).ready(function () {
updateMultiplication();
$("#attempt-form").submit(function (event) {
// 폼 기본 제출 막기
event.preventDefault();
// 페이지에서 값 가져오기
var a = $('.multiplication-a').text();
var b = $('.multiplication-b').text();
var $form = $(this),
attempt = $form.find("input[name='result-attempt']").val(),
userAlias = $form.find("input[name='user-alias']").val();
// API 에 맞게 데이터를 조합하기
var data = {user: {alias: userAlias}, multiplication: {factorA: a, factorB: b}, resultAttempt: attempt};
// POST 로 데이터 보내기
$.ajax({
url: 'http://localhost:8080/results',
type: 'POST',
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
async: false,
success: function (result) {
if (result.correct) {
$('.result-message').empty()
.append("<p class='bg-success text-center'>정답입니다! 축하드려요!</p>");
} else {
$('.result-message').empty()
.append("<p class='bg-danger text-center'>오답입니다! 그래도 포기하지 마세요!</p>");
}
}
});
updateMultiplication();
setTimeout(function () {
var userId = updateResults(userAlias);
updateStats(userId);
updateLeaderBoard();
}, 300);
});
});
게임화 마이크로서비스가 클라이언트로써 http://multiplication/ 에 접속하려고 하면 유레카는 두 URL을 모두 반환하고 소비자가 어떤 인스턴스를 호출할지 결정
[자바가 아닌 언어로 게임화 마이크로서비스를 작성한 가상의 상황]
소비자가 마이크로서비스의 설계를 알고 있다는 문제
-> 내부 구조를 드러내지 않는 REST API를 만들어야 함
[API 게이트웨이 패턴 적용한 솔루션]
-> 이 패턴은 기존의 일체형을 마이크로서비스 아키텍처로 단계별로 분할하고 진화시키는 완벽한 방법
-> 장점 : 서비스 디스커버리나 로드 밸런싱을 필요로 하지 않음
[솔루션]
implementation("org.springframework.cloud:spring-cloud-starter-netflix-zuul:2.1.2.RELEASE")
package microservices.book.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
server:
port: 8000
zuul:
prefix: /api
routes:
multiplications:
path: /multiplications/**
url: http://localhost:8080/multiplications
results:
path: /results/**
url: http://localhost:8080/results
leaders:
path: /leaders/**
url: http://localhost:8081/leaders
stats:
path: /stats/**
url: http://localhost:8081/stats
endpoints:
trace:
sensitive: false
ribbon:
eureka:
enabled: false
[주울의 매핑 결과]
var SERVER_URL = "http://localhost:8000/api";
# REST 클라이언트 설정
multiplicationHost=http://localhost:8000/api
[시스템 실행 단계]
1. RabbitMQ 서버를 실행
2. 게이트웨이 마이크로서비스를 실행
3. 곱셈 마이크로서비스를 실행
4. 게임화 마이크로서비스를 실행
5. ui 루트 폴더에서 제티 웹 서버를 실행
[시스템 현재 상태]
plugins {
id 'org.springframework.boot' version '2.7.3'
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
id 'java'
}
group = 'microservices.book'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "2020.0.1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
package microservices.book.serviceregistry;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@EnableEurekaServer
@SpringBootApplication
public class ServiceRegistryApplication {
public static void main(String[] args) {
SpringApplication.run(ServiceRegistryApplication.class, args);
}
}
server.port=8761
implementation 'org.springframework.boot:spring-boot-starter-actuator'
package microservices.book.gamification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
@EnableEurekaClient
@SpringBootApplication
public class GamificationApplication {
public static void main(String[] args) {
SpringApplication.run(GamificationApplication.class, args);
}
}
# 서비스 디스커버리 설정
eureka.client.service-url.default-zone=http://localhost:8761/eureka/
spring.application.name=gamification
spring.application.name=service-registry
server:
port: 8000
zuul:
ignoredServices: '*'
prefix: /api
routes:
multiplications:
path: /multiplications/**
serviceId: multiplication
strip-prefix: false
results:
path: /results/**
serviceId: multiplication
strip-prefix: false
leaders:
path: /leaders/**
serviceId: gamification
strip-prefix: false
stats:
path: /stats/**
serviceId: gamification
strip-prefix: false
endpoints:
routes:
sensitive: false
trace:
sensitive: false
eureka:
client:
service-url:
default-zone: http://localhost:8761/eureka/
애플리케이션이 너무 복잡해지는 것을 피하려면 항상 상태를 저장하지 않는 마이크로서비스를 설계하는 것이 좋음
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MYSQL;AUTO_SERVER=TRUE;
RabbitMQ는 클러스터에서도 동작함
[여러 인스턴스로 동작하는 시스템]
package microservices.book.multiplication.controller;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.domain.Multiplication;
import microservices.book.multiplication.service.MultiplicationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 곱셈 애플리케이션의 REST API 를 구현한 클래스
*/
@Slf4j
@RestController
@RequestMapping("/multiplications")
final class MultiplicationController {
private final MultiplicationService multiplicationService;
private final int serverPort;
@Autowired
public MultiplicationController(final MultiplicationService multiplicationService, @Value("${server.port}") int serverPort) {
this.multiplicationService = multiplicationService;
this.serverPort = serverPort;
}
@GetMapping("/random")
Multiplication getRandomMultiplication() {
log.info("무작위 곱셈을 생성한 서버 @ {}", serverPort);
return multiplicationService.createRandomMultiplication();
}
}
package microservices.book.multiplication.controller;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.domain.MultiplicationResultAttempt;
import microservices.book.multiplication.service.MultiplicationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
/**
* 사용자가 POST 로 답안을 전송하도록 REST API 를 제공하는 클래스
*/
@Slf4j
@RestController
@RequestMapping("/results")
final class MultiplicationResultAttemptController {
private final MultiplicationService multiplicationService;
private final int serverPort;
@Autowired
MultiplicationResultAttemptController(
final MultiplicationService multiplicationService,
@Value("${server.port}") int serverPort) {
this.multiplicationService = multiplicationService;
this.serverPort = serverPort;
}
@PostMapping
ResponseEntity<MultiplicationResultAttempt> postResult(@RequestBody MultiplicationResultAttempt multiplicationResultAttempt) {
boolean isCorrect = multiplicationService.checkAttempt(multiplicationResultAttempt);
MultiplicationResultAttempt attemptCopy = new MultiplicationResultAttempt(
multiplicationResultAttempt.getUser(),
multiplicationResultAttempt.getMultiplication(),
multiplicationResultAttempt.getResultAttempt(),
isCorrect
);
return ResponseEntity.ok(attemptCopy);
}
@GetMapping
ResponseEntity<List<MultiplicationResultAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(
multiplicationService.getStatsForUser(alias)
);
}
@GetMapping("/{resultId}")
ResponseEntity<Optional<MultiplicationResultAttempt>> getResultById(final @PathVariable("resultId") Long resultId) {
log.info("조회 결과 {} 조회한 서버 @ {}", resultId, serverPort);
return ResponseEntity.ok(
multiplicationService.getResultById(resultId)
);
}
}
리본은 서비스에 핑을 보내고 결과에 따라 로드 밸런싱하는 기능이 있음
-> 스프링 빈 두 개를 설정해야 함 (기본 상태 체크 메커니즘을 변경하는 IPing과 기본 로드 밸런싱 전략을 수정하는 IRule)
-> 메인 클래스인 GatewayApplication이 애너테이션으로 설정 클래스를 가리키게 함
package microservices.book.gateway.configuration;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.*;
import org.springframework.context.annotation.Bean;
public class RibbonConfiguration {
@Bean
public IPing ribbonPing(final IClientConfig config) {
return new PingUrl(false, "/health");
}
@Bean
public IRule ribbonRule(final IClientConfig config) {
return new AvailabilityFilteringRule();
}
}
package microservices.book.gateway;
import microservices.book.gateway.configuration.RibbonConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.ribbon.RibbonClients;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
@EnableZuulProxy
@EnableEurekaClient
@RibbonClients(defaultConfiguration = RibbonConfiguration.class)
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
[서비스 디스커버리와 로드 밸런싱을 준비한 논리적인 관점]
주울이 요청을 리다이렉트하지 못하면 특정 서비스에 대한 폴백이 있는지 확인 -> 폴백이 있으면 기본 응답을 구성해서 반환
package microservices.book.gateway.configuration;
import org.springframework.cloud.netflix.zuul.filters.route.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@Configuration
public class HystrixFallbackConfiguration {
@Bean
public FallbackProvider zuulFallbackProvider() {
return new FallbackProvider() {
@Override
public String getRoute() {
// 헷갈릴 수 있는데 serviceId 프로퍼티이지 경로가 아님
return "multiplication";
}
@Override
public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
return new ClientHttpResponse() {
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.OK;
}
@Override
public int getRawStatusCode() throws IOException {
return HttpStatus.OK.value();
}
@Override
public String getStatusText() throws IOException {
return HttpStatus.OK.toString();
}
@Override
public void close() {
}
@Override
public InputStream getBody() throws IOException {
return new ByteArrayInputStream("{\"factorA\":\"죄송합니다, 서비스가 중단되었습니다!\",\"factorB\":\"?\",\"id\":null}".getBytes());
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccessControlAllowCredentials(true);
headers.setAccessControlAllowOrigin("*");
return headers;
}
};
}
};
}
}
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-hystrix'
package microservices.book.gamification.client;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import microservices.book.gamification.client.dto.MultiplicationResultAttempt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* Multiplication 마이크로서비스와 REST 로 연결하기 위한
* MultiplicationResultAttemptClient 인터페이스의 구현체
*/
@Component
class MultiplicationResultAttemptClientImpl implements MultiplicationResultAttemptClient {
private final RestTemplate restTemplate;
private final String multiplicationHost;
@Autowired
public MultiplicationResultAttemptClientImpl(final RestTemplate restTemplate,
@Value("${multiplicationHost}") final String multiplicationHost) {
this.restTemplate = restTemplate;
this.multiplicationHost = multiplicationHost;
}
@HystrixCommand(fallbackMethod = "defaultResult")
@Override
public MultiplicationResultAttempt retrieveMultiplicationResultAttemptbyId(final Long multiplicationResultAttemptId) {
return restTemplate.getForObject(
multiplicationHost + "/results/" + multiplicationResultAttemptId,
MultiplicationResultAttempt.class);
}
private MultiplicationResultAttempt defaultResult(final Long multiplicationResultAttemptId) {
return new MultiplicationResultAttempt("fakeAlias",
10, 10, 100, true);
}
@Override
public MultiplicationResultAttempt retrieveMultiplicationResultAttemptById(Long multiplicationId) {
return null;
}
}
package microservices.book.gamification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
@EnableEurekaClient
@EnableCircuitBreaker
@SpringBootApplication
public class GamificationApplication {
public static void main(String[] args) {
SpringApplication.run(GamificationApplication.class, args);
}
}