普通视图

发现新文章,点击刷新页面。
昨天以前winter's Blog

SpringCloud系列教程(六)之SpringCloud 使用sentinel作为熔断器

作者 winter chen
2021年8月5日 10:05

阅读提醒:

  1. 本文面向的是有一定springboot基础者
  2. 本次教程使用的Spring Cloud Hoxton RELEASE版本
  3. 由于knife4j比swagger更加友好,所以本文集成knife4j
  4. 本文依赖上一篇的工程,请查看上一篇文章以做到无缝衔接,或者直接下载源码:https://github.com/WinterChenS/spring-cloud-hoxton-study

前情概要

本文概览

  • 什么是熔断器
  • 什么是sentinel
  • spring cloud 整合sentinel
  • 实际的应用场景

什么是熔断器

想必大家都知道一个生活中常见的物件,保险丝,其实它就是一种熔断器,当电器出现短路故障导致瞬间电流超过瞬间值会触发熔断,导致断开电路,从而保护了整个电路和电器的安全。

熔断器(fuse)是指当电流超过规定值时,以本身产生的热量使熔体熔断,断开电路的一种电器。熔断器是根据电流超过规定值一段时间后,以其自身产生的热量使熔体熔化,从而使电路断开;运用这种原理制成的一种电流保护器。熔断器广泛应用于高低压配电系统和控制系统以及用电设备中,作为短路和过电流的保护器,是应用最普遍的保护器件之一。

可能就有同学要问了,这个跟我们说的服务的熔断器有什么关系呢?其实很多原理性的事物都跟生活息息相关的,电路的熔断,保护电路和电器。在代码的世界里,可以对服务起到流量控制和降级熔断的能力,可以做到部分服务的宕机不会导致整个服务集群的雪崩。

什么是sentinel

sentinel是阿里巴巴开源的服务治理的框架,sentinel中文译名为哨兵,是为微服务提供流量控制、熔断降级的功能,它和Hystrix提供的功能一样,可以有效的解决微服务调用产生的“雪崩”效应,为微服务系统提供了稳定性的解决方案。

随着Hytrxi进入了维护期,不再提供新功能,Sentinel是一个不错的替代方案。通常情况,Hystrix采用线程池对服务的调用进行隔离,Sentinel才用了用户线程对接口进行隔离,二者相比,Hystrxi是服务级别的隔离,Sentinel提供了接口级别的隔离,Sentinel隔离级别更加精细,另外Sentinel直接使用用户线程进行限制,相比Hystrix的线程池隔离,减少了线程切换的开销。另外Sentinel的DashBoard提供了在线更改限流规则的配置,也更加的优化。

通过官方文档的介绍,sentinel有以下特征:

  • 丰富的应用场景: Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、实时熔断下游不可用应用等。
  • 完备的实时监控: Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
  • 广泛的开源生态: Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Dubbo、gRPC 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。
  • 完善的 SPI 扩展点: Sentinel 提供简单易用、完善的 SPI 扩展点。您可以通过实现扩展点,快速的定制逻辑。例如定制规则管理、适配数据源等。

Sentinel的主要特性:

Sentinel 功能和设计理念

流量控制

流量控制在网络传输中是一个常用的概念,它用于调整网络包的发送数据。然而,从系统稳定性角度考虑,在处理请求的速度上,也有非常多的讲究。任意时间到来的请求往往是随机不可控的,而系统的处理能力是有限的。我们需要根据系统的处理能力对流量进行控制。Sentinel 作为一个调配器,可以根据需要把随机的请求调整成合适的形状,如下图所示:

流量控制有以下几个角度:

  • 资源的调用关系,例如资源的调用链路,资源和资源之间的关系;
  • 运行指标,例如 QPS、线程池、系统负载等;
  • 控制的效果,例如直接限流、冷启动、排队等。

Sentinel 的设计理念是让您自由选择控制的角度,并进行灵活组合,从而达到想要的效果。

熔断降级

除了流量控制以外,降低调用链路中的不稳定资源也是 Sentinel 的使命之一。由于调用关系的复杂性,如果调用链路中的某个资源出现了不稳定,最终会导致请求发生堆积。这个问题和 Hystrix 里面描述的问题是一样的。

Sentinel 和 Hystrix 的原则是一致的: 当调用链路中某个资源出现不稳定,例如,表现为 timeout,异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终产生雪崩的效果。

Sentinel 是如何工作的

Sentinel 的主要工作机制如下:

  • 对主流框架提供适配或者显示的 API,来定义需要保护的资源,并提供设施对资源进行实时统计和调用链路分析。
  • 根据预设的规则,结合对资源的实时统计信息,对流量进行控制。同时,Sentinel 提供开放的接口,方便您定义及改变规则。
  • Sentinel 提供实时的监控系统,方便您快速了解目前系统的状态。

Spring Cloud 整合 Sentinel

注意:本文整合的工程是基于前几篇文章提供的,所以需要根据前几篇的内容一步步的搭建

下载安装sentinel dashboard

从官方的github仓库下载最新的release版本:https://github.com/alibaba/Sentinel/releases

下载完之后启动服务,端口为8748,启动命令如下:

1
java -Dserver.port=8748 -Dcsp.sentinel.dashboard.server=localhost:8748 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.2.jar

启动完之后登录控制台:http://localhost:8748

账号:sentinel 密码: sentinel

改造工程consumer

增加maven依赖:

1
2
3
4
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

修改配置application.yml(只显示部分需要修改的配置):

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
sentinel:
transport:
port: 18763 #1
dashboard: 127.0.0.1:8748

feign:
sentinel:
enabled: true #2
  1. 这里的 spring.cloud.sentinel.transport.port 端口配置会在应用对应的机器上启动一个 Http Server,该 Server 会与 Sentinel 控制台做交互。比如 Sentinel 控制台添加了一个限流规则,会把规则数据 push 给这个 Http Server 接收,Http Server 再将规则注册到 Sentinel 中。
  2. 通过feign.sentinel.enable开启Feign和sentinel的自动适配。

测试

分别启动provider/consumer/gateway服务,然后多次请求: http://localhost:16011/nacos/feign-test/hello

注意:需要请求接口之后才可以在控制台看到对应的服务和接口。

打开控制台可以看到:

实时监控:

簇点链路:

增加流控规则

可以通过修改后面的一些控制来限制接口的一些功能,大家可以改一改尝试一下,这里就不赘述了。

可以给其他的服务:provider和auth也按照上面的步骤进行配置。

Spring Cloud Gateway使用Sentinel

在工程 gateway中修改

增加maven依赖:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>

修改application.yml配置文件:

1
2
3
4
5
6
spring:
cloud:
sentinel:
transport:
port: 15000
dashboard: localhost:8748

创建一个网关分组和网关的限流规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@Configuration
public class GatewayConfiguration {

private final List<ViewResolver> viewResolvers;
private final ServerCodecConfigurer serverCodecConfigurer;

public GatewayConfiguration(ObjectProvider<List<ViewResolver>> viewResolversProvider,
ServerCodecConfigurer serverCodecConfigurer) {
this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
this.serverCodecConfigurer = serverCodecConfigurer;
}

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SentinelGatewayBlockExceptionHandler sentinelGatewayBlockExceptionHandler() {
// Register the block exception handler for Spring Cloud Gateway.
return new SentinelGatewayBlockExceptionHandler(viewResolvers, serverCodecConfigurer);
}

@PostConstruct
public void doInit() {
initCustomizedApis();
initGatewayRules();
}

private void initCustomizedApis() {
Set<ApiDefinition> definitions = new HashSet<>();
ApiDefinition api1 = new ApiDefinition("consumer")
.setPredicateItems(new HashSet<ApiPredicateItem>() {{

add(new ApiPathPredicateItem().setPattern("/consumer/**")
.setMatchStrategy(SentinelGatewayConstants.URL_MATCH_STRATEGY_PREFIX));
}});
ApiDefinition api2 = new ApiDefinition("provider")
.setPredicateItems(new HashSet<ApiPredicateItem>() {{
add(new ApiPathPredicateItem().setPattern("/provider/**")
.setMatchStrategy(SentinelGatewayConstants.URL_MATCH_STRATEGY_PREFIX));
}});
definitions.add(api1);
definitions.add(api2);
GatewayApiDefinitionManager.loadApiDefinitions(definitions);
}

private void initGatewayRules() {
Set<GatewayFlowRule> rules = new HashSet<>();
rules.add(new GatewayFlowRule("consumer")
.setCount(10)
.setIntervalSec(1)
);
rules.add(new GatewayFlowRule("consumer")
.setCount(2)
.setIntervalSec(2)
.setBurst(2)
.setParamItem(new GatewayParamFlowItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_CLIENT_IP)
)
);
rules.add(new GatewayFlowRule("provider")
.setCount(10)
.setIntervalSec(1)
.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER)
.setMaxQueueingTimeoutMs(600)
.setParamItem(new GatewayParamFlowItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_HEADER)
.setFieldName("X-Sentinel-Flag")
)
);
rules.add(new GatewayFlowRule("provider")
.setCount(1)
.setIntervalSec(1)
.setParamItem(new GatewayParamFlowItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM)
.setFieldName("pa")
)
);
rules.add(new GatewayFlowRule("provider")
.setCount(2)
.setIntervalSec(30)
.setParamItem(new GatewayParamFlowItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM)
.setFieldName("type")
.setPattern("warn")
.setMatchStrategy(SentinelGatewayConstants.PARAM_MATCH_STRATEGY_CONTAINS)
)
);

rules.add(new GatewayFlowRule("provider")
.setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_CUSTOM_API_NAME)
.setCount(5)
.setIntervalSec(1)
.setParamItem(new GatewayParamFlowItem()
.setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM)
.setFieldName("pn")
)
);
GatewayRuleManager.loadRules(rules);
}
}

测试

通过上面的配置gateway已经成功整合了Sentinel,然后我们可以通过请求接口:http://127.0.0.1:15010/consumer/nacos

正常的结果:

1
2
3
4
{
"code": 401,
"message": "未登录"
}

这里返回的结果是因为之前gateway做了登录权限的校验,可以通过auth服务登录之后,将header中的token值作为请求的header中的token,就可以得到正确的返回值,对于当前的测试这个都是无关紧要的,可以通过查看前面的教程知道前因后果。

通过修改限流:

多次点击可以得到异常信息:

1
Blocked by Sentinel: FlowException

这样就可以通过网关层进行统一的接口流量控制,当然Sentinel的作用不止于此,大家可以通过其他的功能掌握对于接口的控制。

应用场景

通过接口的限流和熔断可以让微服务中的模块可以更加的稳定,当大量流量来袭的时候也丝毫不慌。

除了一些比较极端的比如秒杀抢购抢券等功能,在流量比较大的应用中也是广泛的使用。

源码地址

https://github.com/WinterChenS/spring-cloud-hoxton-study

参考文献

introduction

SpringCloud 2020版本教程3:使用sentinel作为熔断器_方志朋的专栏-CSDN博客

SpringCloud系列教程(五)之SpringCloud Gateway 网关聚合开发文档 swagger knife4j 和登录权限统一验证

作者 winter chen
2021年8月4日 12:21

阅读提醒:

  1. 本文面向的是有一定springboot基础者
  2. 本次教程使用的Spring Cloud Hoxton RELEASE版本
  3. 由于knife4j比swagger更加友好,所以本文集成knife4j
  4. 本文依赖上一篇的工程,请查看上一篇文章以做到无缝衔接,或者直接下载源码:https://github.com/WinterChenS/spring-cloud-hoxton-study

前情概要

本文概览

  • Spring Cloud Gateway集成Knife4j
  • Spring Cloud Gateway集成登录权限统一校验

开始

上篇文章介绍了Spring Cloud Gateway的使用,本文将介绍如何在网关层聚合swagger文档,聚合之后可以非常方便的对开发文档进行管理,也是业界比较常用的方式。

首先在父pom文件dependencyManagement 节点中增加knife4j的依赖:

1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>

配置 spring-cloud-nacos-provider和spring-cloud-nacos-consumer

注意,这里标题中两个工程为前几篇文章中构建的,如果不想看前几篇文章,就创建两个模块然后分别集成nacos,knife4j即可。

在两个模块中分别增加maven依赖:

1
2
3
4
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>

在两个模块中分别新增配置类:Knife4jConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class Knife4jConfiguration {

@Value("${swagger.enable:true}")
private boolean enableSwagger;

@Bean(value = "defaultApi2")
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(new ApiInfoBuilder()
.title("provider服务")
.version("1.0")
.build())
.enable(enableSwagger)
.select()
.apis(RequestHandlerSelectors.basePackage("com.winterchen.nacos.rest"))
.paths(PathSelectors.any())
.build();
}

}

注意有些相关的信息需要修改。

新增配置:

1
2
swagger:
enable: true

配置 spring-cloud-gateway

接下来是重头戏,如何在Spring Cloud Gateway中聚合swagger文档。

首先在pom中增加maven依赖:

1
2
3
4
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>

新增Swagger的配置类:SwaggerResourceConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Component
@Primary
public class SwaggerResourceConfig implements SwaggerResourcesProvider {

private final RouteLocator routeLocator;
private final GatewayProperties gatewayProperties;

public SwaggerResourceConfig(RouteLocator routeLocator, GatewayProperties gatewayProperties) {
this.routeLocator = routeLocator;
this.gatewayProperties = gatewayProperties;
}

@Override
public List<SwaggerResource> get() {
List<SwaggerResource> resources = new ArrayList<>();
List<String> routes = new ArrayList<>();
//获取所有路由的ID
routeLocator.getRoutes().subscribe(route -> routes.add(route.getId()));
//过滤出配置文件中定义的路由->过滤出Path Route Predicate->根据路径拼接成api-docs路径->生成SwaggerResource
gatewayProperties.getRoutes().stream().filter(routeDefinition -> routes.contains(routeDefinition.getId())).forEach(route -> {
route.getPredicates().stream()
.filter(predicateDefinition -> ("Path").equalsIgnoreCase(predicateDefinition.getName()))
.forEach(predicateDefinition -> resources.add(swaggerResource(route.getId(),
predicateDefinition.getArgs().get(NameUtils.GENERATED_NAME_PREFIX + "0")
.replace("**", "v2/api-docs"))));
});

return resources;
}

private SwaggerResource swaggerResource(String name, String location) {
SwaggerResource swaggerResource = new SwaggerResource();
swaggerResource.setName(name);
swaggerResource.setLocation(location);
swaggerResource.setSwaggerVersion("2.0");
return swaggerResource;
}
}

主要的配置的作用已经在代码中进行注释。

新增一个控制器:SwaggerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@RestController
public class SwaggerHandler {

@Autowired(required = false)
private SecurityConfiguration securityConfiguration;

@Autowired(required = false)
private UiConfiguration uiConfiguration;

private final SwaggerResourcesProvider swaggerResources;

@Autowired
public SwaggerHandler(SwaggerResourcesProvider swaggerResources) {
this.swaggerResources = swaggerResources;
}

/**
* Swagger安全配置,支持oauth和apiKey设置
*/
@GetMapping("/swagger-resources/configuration/security")
public Mono<ResponseEntity<SecurityConfiguration>> securityConfiguration() {
return Mono.just(new ResponseEntity<>(
Optional.ofNullable(securityConfiguration).orElse(SecurityConfigurationBuilder.builder().build()), HttpStatus.OK));
}

/**
* Swagger UI配置
*/
@GetMapping("/swagger-resources/configuration/ui")
public Mono<ResponseEntity<UiConfiguration>> uiConfiguration() {
return Mono.just(new ResponseEntity<>(
Optional.ofNullable(uiConfiguration).orElse(UiConfigurationBuilder.builder().build()), HttpStatus.OK));
}

/**
* Swagger资源配置,微服务中这各个服务的api-docs信息
*/
@GetMapping("/swagger-resources")
public Mono<ResponseEntity> swaggerResources() {
return Mono.just((new ResponseEntity<>(swaggerResources.get(), HttpStatus.OK)));
}
}-----------------

测试

分别运行:spring-cloud-nacos-provider,spring-cloud-nacos-consumer,spring-cloud-gateway 三个服务。

打开swagger的地址: http://127.0.0.1:15010/doc.html

验证结果:

看到上图中的结果说明聚合成功。

集成登录权限的统一校验

为了直达主题,本次集成登录权限会尽量的简单,其中忽略一些细节,比如登录服务模块,可以查看demo的源码:https://github.com/WinterChenS/spring-cloud-hoxton-study/tree/main/spring-cloud-auth
并且我会在关键的点上明确讲解。

在开始之前需要明白一个原理,如果要实现统一鉴权,那么需要对所有的请求进行统一的拦截,要实现统一的拦截,在Spring Cloud Gateway中有一个filter接口为:GlobalFilter

所以我们可以通过实现GlobalFilter 接口来实现请求的拦截。

实现

新建一个类实现GlobalFilter 接口,并且实现filter 方法,在此基础上我们还需要实现Ordered 接口,控制拦截的优先级,鉴权拦截优先级是最高的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@Slf4j
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {

@Autowired
UserRedisCollection userRedisCollection;

// (1)
private boolean checkWhiteList(String uri) {
boolean access = false;
if (uri.contains("/login") || uri.contains("/v2/api-docs")) {
access = true;
if (uri.contains("logout")) {
access = false;
}
}
if (uri.contains("/open-api")) {
access = true;
}
return access;
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// (2)
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String uri = request.getURI().getPath();

// (3)
//前端访问不到header问题
response.getHeaders().add("Access-Control-Allow-Headers","X-PINGOTHER, Origin, X-Requested-With, Content-Type, Accept, token");
response.getHeaders().add("Access-Control-Expose-Headers", "token");
ServerHttpRequest mutableReq = request.mutate()
.header(DefaultConstants.IP_ADDRESS, getIpAddress(request))
.build();
ServerWebExchange mutableExchange = exchange.mutate().request(mutableReq).build();
// (4)
//检查白名单
if (checkWhiteList(uri)) {
return chain.filter(mutableExchange);
}

// (5)
//从request获取token
String accessToken = request.getHeaders().getFirst(DefaultConstants.TOKEN);
log.info("AccessToken: [{}]", accessToken);

// (6)
if (StringUtils.isBlank(accessToken)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(response, ResultCodeEnum.UNAUTHORIZED, "未登录");
}
Claims claims = null;
try {
// (7)
claims = JwtUtil.parseJWT(accessToken, DefaultConstants.SECRET_KEY);

if (claims == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(response, ResultCodeEnum.UNAUTHORIZED, "未登录");
}
log.info("claims is:{}", claims);
if (claims.getSubject().equals(DefaultConstants.USER)){
if(claims.get(DefaultConstants.USERID)!=null) {
Long userId = Long.parseLong(claims.get(DefaultConstants.USERID).toString());

log.info("userId:{}", userId);
Map<String, Object> map = Maps.newHashMapWithExpectedSize(1);
map.put(DefaultConstants.USERID,userId.toString());

// (8)
UserInfoEntity userInfo = userRedisCollection.getAuthUserInfoAndCache(userId);
//判断是否
if (userInfo == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(response, ResultCodeEnum.UNAUTHORIZED, "未登录");
}

// (9)
String token = JwtUtil.createToken(DefaultConstants.USER, map, DefaultConstants.SECRET_KEY);
response.getHeaders().add(DefaultConstants.TOKEN, token);

mutableReq = request.mutate().header(DefaultConstants.USER_ID, String.valueOf(userId))
.header(DefaultConstants.IP_ADDRESS, getIpAddress(request))
.build();
mutableExchange = exchange.mutate().request(mutableReq).build();
return chain.filter(mutableExchange);

}

}
} catch (Exception e) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(response, ResultCodeEnum.UNAUTHORIZED, "未登录");
}

return getVoidMono(response, ResultCodeEnum.UNAUTHORIZED, "未登录");
}

private Mono<Void> getVoidMono(ServerHttpResponse serverHttpResponse, ResultCodeEnum resultCode, String responseText) {
serverHttpResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
CommonResult<?> result = CommonResult.failed(resultCode.getCode(), responseText);
DataBuffer dataBuffer = serverHttpResponse.bufferFactory().wrap(JSON.toJSONString(result).getBytes());
return serverHttpResponse.writeWith(Flux.just(dataBuffer));
}

public String getIpAddress(ServerHttpRequest request) {
String ip = request.getHeaders().getFirst("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeaders().getFirst("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeaders().getFirst("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeaders().getFirst("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeaders().getFirst("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddress().getHostString();
}
return ip;
}

@Override
public int getOrder() {
return -100;
}
}
  • (1) 这个方法可以将白名单加入,当请求到这些url的时候不进行权限的校验
  • (2) 我们在使用Spring Cloud Gateway的时候,注意到过滤器(包括GatewayFilter、GlobalFilter和过滤器链GatewayFilterChain),都依赖到ServerWebExchange,这里filter的设计跟Servlet中的的filter相似,也就是当前过滤器决定是否执行下一个过滤器的逻辑,而ServerWebExchange就是当前请求和响应的上下文,不仅包含了request和response,还包含了一些扩展方法,如代码中可以获取到request和response。
  • (3) 这一步,主要是解决前端无法中header中获取到需要获取的参数。比如token
  • (4) 这里对应了第一步白名单的判断,如果当前请求在白名单就跳过后面的一些权限的判断,直接执行下一个过滤器。
  • (5) 这里很简单,就是从request中获取到token,方便jwt转换成对应的用户信息。
  • (6) 如果请求没有携带token就不予通过。
  • (7) jwt将token转换成用户信息,用于后面的判断以及用户的基本信息。
  • (8) 上面获取到用户的信息之后,根据用户的userId查询redis中是否存在该用户数据,如果不存在表示该用户的用户信息已经过期了,而且从redis查询的时候会重置超时时间(也就保证了只要经常的在线就不需要重新登录,超过设定的时间没有在线,那么就需要重新登录)。注意:这里是需要跟登录服务进行联动,也就是登录成功之后将用户的信息存入redis,然后gateway这边能取到该用户信息。所以需要保证这一点。
  • (9) 这里会重新颁发token,主要是防止token会过期,token的过期时间在jwtUtils中可以设置,所以,前端需要每次请求之后都将新的token作为下一次请求的token。
  • 注意:本文的token是放在header中,前端小伙伴需要从header中取token。

代码中的方法:UserRedisCollection.getAuthUserInfoAndCache(Long userId)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Autowired
private RedisTemplate redisTemplate;

public UserInfoEntity getAuthUserInfoAndCache(Long userId) {
CommonAssert.meetCondition(userId == null, "未获取到userId");
String key = DefaultConstants.USER_INFO_REDIS + userId;
UserInfoEntity entity = (UserInfoEntity) redisTemplate.opsForValue().get(key);
if (null != entity) {
redisTemplate.opsForValue().set(key, entity, 60 * 24 * 60 * 60 * 1000, TimeUnit.MILLISECONDS);
return entity;
}
CommonAssert.meetCondition(true, "当前用户未登陆,未获取到登陆信息");
return null;
}

该方法简单,就是根据userId获取到用户信息的,成功获取之后会重置超时时间,时长可以根据需要进行修改。

关于本文的登录服务,可以从demo源码中获取:

spring-cloud-hoxton-study/spring-cloud-auth at main · WinterChenS/spring-cloud-hoxton-study

测试

分别启动gateway,provider,auth服务。

首先,测试未登录的情况:

然后进入auth服务,打开登录接口:

打开Headers,复制token

再次切换到provider服务,设置文档的全局参数:

然后刷新一下页面再请求就可以发现,请求成功:

到这里就成功集成了gateway鉴权了。

拓展

  • 至于登出的逻辑,思路是这样的,登出只需要在auth服务中将用户信息从redis清除即可,这样在gateway中查询redis就可以查到用户登录信息已经失效了。
  • 如何在服务中获取当前登录用户信息呢?这个其实挺简单的,一般的做法就是写一个工具类,该工具类从请求的header中获取token,或者在请求阶段就讲userId设置到token中,如果只有token就讲token转换成用户信息,然后根据id从redis中获取用户详细信息,不过一般只需要userId就可以了,这边给个示例:
1
2
3
4
5
6
7
8
9
10
11
public static String getUserIdByCurrent() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
return request.getHeader(ConstantsUtil.USER_ID);
}else{
return "";
}

}

总结

本章介绍了如何在gateway中聚合swagger文档以及统一鉴权的集成,内容其实并不多,原本打算分开两篇进行介绍的,swagger部分没有什么需要注意的原理,索性就放在一起,真正重要的点就是Spring Cloud Gateway中的filter的应用,这部分可以通过查找资料详细的了解一下,下一篇将介绍如何使用限流组件Sentinel,这个组件由alibaba提供。

源码地址

https://github.com/WinterChenS/spring-cloud-hoxton-study

参考文献

Spring Cloud Gateway-ServerWebExchange核心方法与请求或者响应内容的修改

SpringCloud系列教程(四)之SpringCloud Gateway

作者 winter chen
2021年8月3日 09:35

**阅读提醒:

  1. 本文面向的是有一定springboot基础者
  2. 本次教程使用的Spring Cloud Hoxton RELEASE版本
  3. 本文依赖上一篇的工程,请查看上一篇文章以做到无缝衔接,或者直接下载源码:**https://github.com/WinterChenS/spring-cloud-hoxton-study

前情概要

本文概览

  • Spring Cloud Gateway的简介
  • gateway在微服务架构中的应用场景
  • Spring Cloud Gateway使用
  • 相关配置详解
  • 实战中的使用

简介

Spring Cloud 作为新一代的微服务网关,该项目基于Spring webflux技术开发的网关,作为Spring Cloud生态系统中的网关,目标是替代zuul,为什么使用webflux?webflux是Reactor模式的响应式编程框架,底层使用了netty通信框架,非Reactor模式的zuul采用的是Tomcat容器,使用的还是传统的Servlet IO处理模型。想了解webflux和netty的同学可以搜索相关知识,这也是当前比较热门的技术。

本文中用到的demo源码地址:https://github.com/WinterChenS/spring-cloud-hoxton-study

Gateway的特征

引用官方的文档:

Spring Cloud Gateway features:

  • Built on Spring Framework 5, Project Reactor and Spring Boot 2.0

  • Able to match routes on any request attribute.

  • Predicates and filters are specific to routes.

  • Circuit Breaker integration.

  • Spring Cloud DiscoveryClient integration

  • Easy to write Predicates and Filters

  • Request Rate Limiting

  • Path Rewriting

  • Spring Cloud Gateway 基于 Spring Framework 5, Project Reactor 和 Spring Boot 2.0

  • 能够匹配任何请求属性上的路由。

  • Predicates和filters特定于路由,易于编写的Predicates和filters

  • Hystrix断路器的继承

  • 集成了DiscoveryClient

  • 具备了一些高级功:动态路由、限流、路径重写等

和zuul的功能相差不大,最主要的区别是底层通信框架的实现上。

具体的通信框架这里就暂时不展开了。

微服务网关的应用场景

在微服务架构中,网关起到了下层服务的路由、限流和路径重写等作用,下面用一张简单的架构图来描述一下微服务网关的作用:

上图中很直观的展示了Spring Cloud Gateway在整个架构中的作用。

Spring Cloud Gateway使用

新建模块

新建一个springboot工程,maven依赖如下,详细的配置可以查看demo源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

配置文件

修改配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
server:
port: 15010

spring:
cloud:
nacos:
discovery:
server-addr: 118.25.36.41:8848
gateway:
discovery:
locator:
enabled: false
lowerCaseServiceId: true
routes:
- id: provider
uri: lb://winter-nacos-provider
predicates:
- Path=/provider/**
filters:
- StripPrefix=1
- id: consumer
uri: lb://winter-nacos-consumer
predicates:
- Path=/consumer/**
filters:
- StripPrefix=1

application:
name: winter-gateway

具体配置的解释后面会介绍。

接下来就在启动类中加入注解: @EnableDiscoveryClient

1
2
3
4
5
6
7
8
9
@EnableDiscoveryClient
@SpringBootApplication
public class SpringCloudGatewayApplication {

public static void main(String[] args) {
SpringApplication.run(SpringCloudGatewayApplication.class, args);
}

}

启动服务:spring-cloud-nacos-consumerspring-cloud-nacos-providerspring-cloud-gateway

测试

浏览器输入:http://127.0.0.1:15010/consumer/nacos/echo/hello

得到返回值:Hello Nacos Discovery hello

拓展:解决跨域问题

Spring Cloud Gateway如何解决跨域问题?我们可以在配置文件中加入:

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE

Spring Cloud Gateway 配置详解

我们看一下上面我们用到的配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
server:
port: 15010

spring:
cloud:
nacos:
discovery:
server-addr: 118.25.36.41:8848
gateway:
discovery:
locator:
enabled: false
lowerCaseServiceId: true
routes:
- id: provider
uri: lb://winter-nacos-provider
predicates:
- Path=/provider/**
filters:
- StripPrefix=1
- id: consumer
uri: lb://winter-nacos-consumer
predicates:
- Path=/consumer/**
filters:
- StripPrefix=1

application:
name: winter-gateway

id:我们自定义的路由 ID,保持唯一

uri:目标服务地址

predicates:路由条件,Predicate 接受一个输入参数,返回一个布尔值结果。该接口包含多种默认方法来将 Predicate 组合成其他复杂的逻辑(比如:与,或,非)。

上面这段配置的意思是,配置了一个 id 为 url-proxy-1的URI代理规则,路由的规则为:

当访问地址http://localhost:8080/provider/nacos/echo/hello时,

会路由到上游地址http://winter-nacos-provider/nacos/echo/hello

filters: 过滤器是路由转发请求时所经过的过滤逻辑,可用于修改请求、响应内容,StripPrefix=1就代表截取路径的个数为1,比如前端过来请求/provider/nacos/echo/hello,匹配成功后,路由到后端的请求路径就会变成http://127.0.0.1:16012/nacos/echo/hello

关于gateway的详细配置,可以参考:

SpringCloud gateway (史上最全)

总结

关于spring cloud gateway的介绍先讲到这里,后一篇会讲解如何在网关层集成swagger文档以及如何在网关层进行登录权限的验证。并且会介绍相关的原理。

源码地址

https://github.com/WinterChenS/spring-cloud-hoxton-study

参考文献:

Spring Cloud Gateway

SpringCloud gateway (史上最全)

SpringCloud系列教程(三)之Open Feign

作者 winter chen
2021年8月2日 10:33

阅读提醒:

  1. 本文面向的是有一定springboot基础者
  2. 本次教程使用的Spring Cloud Hoxton RELEASE版本
  3. 本文依赖上一篇的工程,请查看上一篇文章以做到无缝衔接,或者直接下载源码:https://github.com/WinterChenS/spring-cloud-hoxton-study

前情概要

本文概览

  • RPC是什么?
  • Spring Cloud如何整合openfeign
  • 如何使用ribbon和Hystrix 进行服务的负载均衡和服务熔断
  • 实际的应用场景

上篇文章介绍了Spring Cloud如何整合Nacos作为配置中心和注册中心,接下来将介绍如何结合
Open Feign进行远程服务调用。在讲解OpenFeign之前我们需要了解一下RPC的基础概念。

本文中用到的demo源码地址:https://github.com/WinterChenS/spring-cloud-hoxton-study

什么是RPC?

在分布式计算,远程过程调用(英语:Remote Procedure Call,缩写为 RPC)是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一个地址空间(通常为一个开放网络的一台计算机)的子程序,而程序员就像调用本地程序一样,无需额外地为这个交互作用编程(无需关注细节)。RPC是一种服务器-客户端(Client/Server)模式,经典实现是一个通过发送请求-接受回应进行信息交互的系统。
如果涉及的软件采用面向对象编程,那么远程过程调用亦可称作远程调用或远程方法调用,例:Java RMI。
RPC是一种进程间通信的模式,程序分布在不同的地址空间里。如果在同一主机里,RPC可以通过不同的虚拟地址空间(即便使用相同的物理地址)进行通讯,而在不同的主机间,则通过不同的物理地址进行交互。许多技术(常常是不兼容)都是基于这种概念而实现的。
— 引用自维基百科

Feign与RPC的关系是什么?为什么有人觉得Feign是伪RPC?

其实Feign实现了可以通过像调用本地方法去调用远程服务的话,就是RPC,RPC最关键的两个协议是:

  1. 通讯协议
  2. 序列化协议

常用的rpc框架有:open feign 和 dubbo,feign基于http协议,dubbo基于tcp协议。

feign的原理:

feign集成了两个重要的模块:ribbonHystrix 来实现负载均衡服务熔断,ribbon内置了RestTemplate, RestTemplate基于HTTP,所以说feign基于HTTP。

Feign通过处理注解,将请求模板化,当实际调用的时候,传入参数,根据参数再应用到请求上,进而转化成真正的 Request 请求。通过Feign以及JAVA的动态代理机制,使得Java 开发人员,可以不用通过HTTP框架去封装HTTP请求报文的方式,完成远程服务的HTTP调用。

Spring Cloud集成feign

根据上篇的文章中我们使用的Nacos作为代码基础,在此之上集成Feign,所以请查看上一篇文章以做到无缝衔接。

spring-cloud-nacos-provider 修改:

在工程spring-cloud-nacos-provider 中增加依赖:

1
2
3
4
5
<!-- springCloud-feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

新建一个类:NacosProviderClient 并增加注解:@FeignClient(value = "winter-nacos-provider")

1
2
3
4
5
6
7
@FeignClient(value = "winter-nacos-provider")
public interface NacosProviderClient {

@GetMapping("/nacos/feign-test/{string}")
String echo2(@PathVariable String string);

}

类:NacosController 增加方法:

1
2
3
4
@GetMapping("feign-test/{string}")
public String feignTest(@PathVariable String string) {
return "Hello feign " + string;
}

spring-cloud-nacos-consumer 修改:

在工程spring-cloud-nacos-consumer 中增加依赖:

1
2
3
4
5
6
7
8
9
10
11
<!-- springCloud-feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 对于服务provider的依赖 -->
<dependency>
<groupId>com.winterchen</groupId>
<artifactId>spring-cloud-nacos-provider</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>

在启动类NacosConsumerApplication 中增加注解:@EnableFeignClients

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication
public class NacosConsumerApplication {

@LoadBalanced
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

public static void main(String[] args) {
SpringApplication.run(NacosConsumerApplication.class, args);
}

}

在类:NacosController 中增加方法:

1
2
3
4
5
6
7
8

@Autowired
private NacosProviderClient nacosProviderClient;

@GetMapping("/feign-test/{str}")
public String feignTest(@PathVariable String str) {
return nacosProviderClient.echo2(str);
}

测试:

  1. 依次启动两个服务;
  2. 浏览器输入:http://127.0.0.1:16011/nacos/feign-test/hello
  3. 返回:Hello feign hello 就表示成功了

使用Ribbon

Feign内置了ribbon用于服务的负载均衡,所以只需要引入feign的依赖就会自动使用负载均衡,接下来我们试一下服务的负载均衡:

spring-cloud-nacos-provider 修改:

NacosController 新增方法和参数:

1
2
3
4
5
6
7
@Value("${server.port}")
String port;

@GetMapping("/ribbon-test")
public String ribbonTest() {
return "Hello ribbon , my port: " + port;
}

NacosProviderClient 新增接口:

1
2
@GetMapping("/nacos/ribbon-test")
String ribbonTest();

为了测试服务的负载,所以provider服务不使用配置中心的配置,删除配置中心的配置,然后新建application.yml 配置文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
server:
port: 16012

spring:
cloud:
nacos:
discovery:
server-addr: 118.25.36.41:8848

test:
config:
refresh: false

spring-cloud-nacos-consumer 修改

NacosController 新增方法:

1
2
3
4
@GetMapping("/ribbon-test")
public String ribbonTest1() {
return nacosProviderClient.ribbonTest();
}

测试:

在测试之前需要修改idea的配置,红色方框处勾选之后服务可以启动多个节点

依次启动两个服务,然后修改spring-cloud-nacos-provider 的端口配置

1
2
server:
port: 16013

然后再次启动spring-cloud-nacos-provider 服务,启动后该服务存在两个节点

然后调用:http://127.0.0.1:16011/nacos/ribbon-test

可以发现请求会轮流调用:

1
2
Hello ribbon , my port: 16012
Hello ribbon , my port: 16013

这样就实现了服务的负载均衡。

Ribbon的配置

可以配置Ribbon的一些参数实现更多的控制,简单的介绍一下Ribbon的配置,我们可以在consumer工程的配置文件中增加:

1
2
3
4
5
6
7
8
9
10
feign:
client:
config:
winter-nacos-consumer:
connectTimeout: 12000000
readTimeout: 12000000
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
OkToRetryOnAllOperations: true
MaxAutoRetriesNextServer: 2
MaxAutoRetries: 1
1
2
3
4
5
6
    ConnectTimeout: #单位ms,请求连接超时时间
  ReadTimeout: #单位ms,请求处理的超时时间
  OkToRetryOnAllOperations: #对所有操作请求都进行重试
  MaxAutoRetriesNextServer: #切换实例的重试次数
  MaxAutoRetries: #对当前实例的重试次数
NFLoadBalancerRuleClassName #配置Ribbon负载均衡规则:IRule

Hystrix 使用

除了上面提到的RibbonFeign还集成了Hystrix 作为服务熔断组件,服务为什么需要熔断呢?是因为一旦下层服务超时或异常导致不可用,没有熔断机制会导致整个服务集群宕机,所以在微服务架构中,服务的熔断是非常重要的。

spring-cloud-nacos-provider 修改:

NacosController 新增方法:

1
2
3
4
@GetMapping("/hystrix-test")
public String hystrixTest() {
throw new RuntimeException("ex");
}

新增类NacosProviderClientFallback 并实现接口NacosProviderClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class NacosProviderClientFallback implements NacosProviderClient{
@Override
public String echo2(String string) {
return "error";
}

@Override
public String ribbonTest() {
return "error";
}

@GetMapping("/hystrix-test")
public String hystrixTest() {
return "hystrix error";
}
}

修改NacosProviderClient ,新增方法并且@FeignClient 注解增加fallback 参数,该参数就是服务降级的类,当服务不可用会调用此类的实现方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
@FeignClient(value = "winter-nacos-provider", fallback = NacosProviderClientFallback.class)
public interface NacosProviderClient {

@GetMapping("/nacos/feign-test/{string}")
String echo2(@PathVariable String string);

@GetMapping("/nacos/ribbon-test")
String ribbonTest();

@GetMapping("/nacos/hystrix-test")
String hystrixTest();

}

spring-cloud-nacos-consumer 修改

增加配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
feign:
client:
config:
winter-nacos-consumer:
connectTimeout: 12000000
readTimeout: 12000000
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
OkToRetryOnAllOperations: true
MaxAutoRetriesNextServer: 2
MaxAutoRetries: 1
hystrix:
enabled: true #启用hystrix

hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 20000 #默认的超时时间
winter-nacos-consumer:
execution:
isolation:
thread:
timeoutInMilliseconds: 20000 #当前服务的超时时间,可以不写

注释的注解都是新增的,如果要特定某个服务的配置,就不写default,直接指定服务名就可以,如上面的配置。

测试

分别启动两个服务,然后调用:http://127.0.0.1:16011/nacos/hystrix-test

返回的响应:hystrix error

拓展:hystrix的配置

线程池配置
| Name | 备注 | 默认值 |
|——————————————————————|—————————————————|——-|
| hystrix.threadpool.default.coreSize | 线程池大小 | 10 |
| hystrix.threadpool.default.maximumSize | 线程池最大大小 | 10 |
| hystrix.threadpool.default.allowMaximumSizeToDivergeFromCoreSize | 是否允许动态调整线程数量,默认false,只有设置为true了,上面的maximumSize才有效 | FALSE |
| hystrix.threadpool.default.keepAliveTimeMinutes | 超出coreSize的线程,空闲1分钟后释放掉 | 1 |
| hystrix.threadpool.default.maxQueueSize | 不能动态修改 | -1 |
| hystrix.threadpool.default.queueSizeRejectionThreshold | 可以动态修改,默认是5,先进入请求队列,然后再由线程池执行 | 5 |

如何计算线程池数量?

高峰期每秒的请求数量 / 1000毫秒 / TP99请求延时 + buffer空间

比如说处理一个请求,要50ms,那么TP99,也就是99%的请求里处理一个请求耗时最长是50ms。

我们给一点缓冲空间10ms,那就是处理请求接口耗时60ms。

所以一秒钟一个线程可以处理:1000 / 60 = 16,一个线程一秒钟可以处理16个请求。

假设高峰期,每秒最多1200个请求,一个线程每秒可以处理16个请求,需要多少个线程才能处理每秒1200个请求呢?1200 / 16 = 75,最多需要75个线程,每个线程每秒处理16个请求,75个线程每秒才可以处理1200个请求。

最多需要多少个线程数量,就是这样子算出来

如果是服务B -> 服务A的话,服务B线程数量怎么设置?

服务B调用服务A的线程池需要多少个线程呢?

高峰期,服务B最多要调用服务A每秒钟1200次,服务A处理一个请求是60ms,服务B每次调用服务A的时候,用一个线程发起一次请求,那么这个服务B的这个线程,要60ms才能返回。

服务B而言,一个线程对服务A发起一次请求需要60ms,一个线程每秒钟可以请求服务A达到16次,但是现在服务B每秒钟需要请求服务A达到1200次,那么服务B就需要75个线程,在高峰期并发请求服务A,才可以完成每秒1200次的调用。

服务B,部署多台机器,每台机器调用服务A的线程池有10个线程,比如说搞个10个线程,一共部署10台机器,那么服务B调用服务A的线程数量,一共有100个线程,轻轻松松可以支撑高峰期调用服务A的1200次的场景

每个线程调用服务A一次,耗时60ms,每个线程每秒可以调用服务A一共是16次,100个线程,每秒最多可以调用服务A是1600次,高峰的时候只要支持调用服务A的1200次就可以了,所以这个机器部署就绰绰有余了

执行配置
| Name | 备注 | 默认值 |
|—————————————————————————–|——————————–|——–|
| hystrix.command.default.execution.isolation.strategy | 隔离策略,默认Thread,可以选择Semaphore信号量 | Thread |
| hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds | 超时时间 | 1000ms |
| hystrix.command.default.execution.timeout.enabled | 是否启用超时 | TRUE |
| hystrix.command.default.execution.isolation.thread.interruptOnTimeout | 超时的时候是否中断执行 | TRUE |
| hystrix.command.default.execution.isolation.semaphore.maxConcurrentRequests | 信号量隔离策略下,允许的最大并发请求数量 | 10 |

降级配置
| Name | 备注 | 默认值 |
|——————————————|——–|——|
| hystrix.command.default.fallback.enabled | 是否启用降级 | TRUE |

熔断配置
| Name | 备注 | 默认值 |
|——————————————————————|————————————————-|——|
| hystrix.command.default.circuitBreaker.enabled | 是否启用熔断器 | TRUE |
| hystrix.command.default.circuitBreaker.requestVolumeThreshold | 10秒钟内,请求数量达到多少才能去尝试触发熔断 | 20 |
| hystrix.command.default.circuitBreaker.errorThresholdPercentage | 10秒钟内,请求数量达到20,同时异常比例达到50%,就会触发熔断 | 50 |
| hystrix.command.default.circuitBreaker.sleepWindowInMilliseconds | 触发熔断之后,5s内直接拒绝请求,走降级逻辑,5s后尝试half-open放过少量流量试着恢复 | 5000 |
| hystrix.command.default.circuitBreaker.forceOpen | 强制打开熔断器 | |
| hystrix.command.default.circuitBreaker.forceClosed | 强制关闭熔断器 | |

监控配置
| Name | 备注 | 默认值 |
|———————————————————————–|———————————————————————————————————————————————————–|—————|
| hystrix.threadpool.default.metrics.rollingStats.timeInMillisecond | 线程池统计指标的时间 | 默认10000,就是10s |
| hystrix.threadpool.default.metrics.rollingStats.numBuckets | 将rolling window划分为n个buckets | 10 |
| hystrix.command.default.metrics.rollingStats.timeInMilliseconds | command的统计时间,熔断器是否打开会根据1个rolling window的统计来计算。若rolling window被设为10000毫秒,则rolling window会被分成n个buckets,每个bucket包含success,failure,timeout,rejection的次数的统计信息。 | 10000 |
| hystrix.command.default.metrics.rollingStats.numBuckets | 设置一个rolling window被划分的数量,若numBuckets=10,rolling window=10000,那么一个bucket的时间即1秒。必须符合rolling window % numberBuckets == 0 | 10 |
| hystrix.command.default.metrics.rollingPercentile.enabled | 执行时是否enable指标的计算和跟踪 | TRUE |
| hystrix.command.default.metrics.rollingPercentile.timeInMilliseconds | 设置rolling percentile window的时间 | 60000 |
| hystrix.command.default.metrics.rollingPercentile.numBuckets | 设置rolling percentile window的numberBuckets。逻辑同上。 | 6 |
| hystrix.command.default.metrics.rollingPercentile.bucketSize | 如果bucket size=100,window=10s,若这10s里有500次执行,只有最后100次执行会被统计到bucket里去。增加该值会增加内存开销以及排序的开销。 | 100 |
| hystrix.command.default.metrics.healthSnapshot.intervalInMilliseconds | 记录health 快照(用来统计成功和错误绿)的间隔 | 500ms |

高阶特性配置
| Name | 备注 | 默认值 |
|—————————————————-|——————————————————————|——————-|
| hystrix.command.default.requestCache.enabled | 是否启用请求缓存 | TRUE |
| hystrix.command.default.requestLog.enabled | 记录日志到HystrixRequestLog | TRUE |
| hystrix.collapser.default.maxRequestsInBatch | 单次批处理的最大请求数,达到该数量触发批处理 | Integer.MAX_VALUE |
| hystrix.collapser.default.timerDelayInMilliseconds | 触发批处理的延迟,也可以为创建批处理的时间+该值 | 10 |
| hystrix.collapser.default.requestCache.enabled | 是否对HystrixCollapser.execute() and HystrixCollapser.queue()的cache | TRUE |

Feign和Hystrix结合的原理

Feign在和Hystrix整合的时候,feign动态代理里面有一些Hystrix相关的代码,请求走feign动态代理的时候,就会基于Hystrix Command发送请求,实现服务间调用的隔离、限流、超时、降级、熔断、统计等。

http://www.saily.top/img/spring-cloud/Feign%E5%92%8CHystrix%E7%9A%84%E6%A0%B8%E5%BF%83%E5%8E%9F%E7%90%86.jpg

总结

本文学习了springcloud整合feign实现远程服务调用,并且使用了feign集成的Ribbon和Hystrix实现的负载均衡和服务熔断,以及对服务负载均衡和熔断进行了深度的了解和实际使用当中的一些配置,下一篇将会讲讲微服务网关SpringCloud Gateway。

源码地址

https://github.com/WinterChenS/spring-cloud-hoxton-study

参考文档:

Spring Cloud OpenFeign

Feign和Hystrix的结合使用

mongodb多数据源之mongotemplate和事务的配置

作者 winter chen
2020年12月28日 15:55

题图:我的家乡,拍摄于2020年初疫情期间

maven坐标

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
<version>2.1.13.RELEASE</version>
</dependency>

多数据源配置

配置文件:

1
2
3
4
5
6
7
8
spring:
data:
mongodb:
uri: mongodb://192.168.150.154:17017
database: ewell-label
mongodb-target:
uri: mongodb://192.168.150.154:17017
database: ewell-label-target

java配置:

主数据源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package com.winterchen.label.service.configuration;

import com.mongodb.MongoClient;
import com.mongodb.MongoClientURI;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.mongodb.MongoDbFactory;
import org.springframework.data.mongodb.MongoTransactionManager;
import org.springframework.data.mongodb.config.AbstractMongoConfiguration;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.SimpleMongoDbFactory;
import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver;
import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;

/**
* @author winterchen
* @version 1.0
* @date 2020/12/2 3:51 下午
* @description 业务mongo数据源
**/
@Configuration
public class BusinessMongoConfig extends AbstractMongoConfiguration {

@Value("${spring.data.mongodb.uri}")
private String uri;

@Value("${spring.data.mongodb.database}")
private String database;

@Override
protected String getDatabaseName() {
return database;
}

@Override
public MongoClient mongoClient() {
MongoClientURI mongoClientURI = new MongoClientURI(uri);
return new MongoClient(mongoClientURI);
}

@Primary
@Bean("mongoMappingContext")
public MongoMappingContext mongoMappingContext() {
MongoMappingContext mappingContext = new MongoMappingContext();
return mappingContext;
}

@Primary
@Bean
public MongoTransactionManager transactionManager(@Qualifier("mongoDbFactory") MongoDbFactory mongoDbFactory) throws Exception {
return new MongoTransactionManager(mongoDbFactory);
}

@Primary
@Bean("mongoDbFactory")
public MongoDbFactory mongoDbFactory() {
return new SimpleMongoDbFactory(mongoClient(), getDatabaseName());
}

@Primary
@Bean("mappingMongoConverter") //使用自定义的typeMapper去除写入mongodb时的“_class”字段
public MappingMongoConverter mappingMongoConverter(@Qualifier("mongoDbFactory") MongoDbFactory mongoDbFactory,
@Qualifier("mongoMappingContext") MongoMappingContext mongoMappingContext) throws Exception {
DefaultDbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDbFactory);
MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext);
converter.setTypeMapper(new DefaultMongoTypeMapper(null));
return converter;
}

@Primary
@Bean(name = "mongoTemplate")
public MongoTemplate getMongoTemplate(@Qualifier("mongoDbFactory") MongoDbFactory mongoDbFactory,
@Qualifier("mappingMongoConverter") MappingMongoConverter mappingMongoConverter) throws Exception {
return new MongoTemplate(mongoDbFactory, mappingMongoConverter);
}
}

第二数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.winterchen.label.service.configuration;

import com.mongodb.MongoClient;
import com.mongodb.MongoClientURI;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDbFactory;
import org.springframework.data.mongodb.MongoTransactionManager;
import org.springframework.data.mongodb.config.AbstractMongoConfiguration;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.SimpleMongoDbFactory;
import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver;
import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;

/**
* @author winterchen
* @version 1.0
* @date 2020/12/2 3:53 下午
* @description TODO
**/
@Configuration
public class TargetMongoConfig extends AbstractMongoConfiguration {

@Value("${spring.data.mongodb-target.uri}")
private String uri;

@Value("${spring.data.mongodb-target.database}")
private String database;

@Override
protected String getDatabaseName() {
return database;
}

@Override
public MongoClient mongoClient() {
MongoClientURI mongoClientURI = new MongoClientURI(uri);
return new MongoClient(mongoClientURI);
}

@Bean("targetMongoMappingContext")
public MongoMappingContext mongoMappingContext() {
MongoMappingContext mappingContext = new MongoMappingContext();
return mappingContext;
}

@Bean("TARGET_MONGO_TRANSACTION_MANAGER")
public MongoTransactionManager transactionManager(@Qualifier("targetMongoDbFactory") MongoDbFactory mongoDbFactory) throws Exception {
return new MongoTransactionManager(mongoDbFactory);
}

@Bean("targetMongoDbFactory")
public MongoDbFactory mongoDbFactory() {
return new SimpleMongoDbFactory(mongoClient(), getDatabaseName());
}

@Bean("targetMappingMongoConverter") //使用自定义的typeMapper去除写入mongodb时的“_class”字段
public MappingMongoConverter mappingMongoConverter(@Qualifier("targetMongoDbFactory") MongoDbFactory mongoDbFactory,
@Qualifier("targetMongoMappingContext") MongoMappingContext mongoMappingContext) throws Exception {
DefaultDbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDbFactory);
MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext);
converter.setTypeMapper(new DefaultMongoTypeMapper(null));
return converter;
}

/**
* MongoTemplate实现
*/
@Bean(name = "targetMongoTemplate")
public MongoTemplate getMongoTemplate(@Qualifier("targetMongoDbFactory") MongoDbFactory mongoDbFactory,
@Qualifier("mappingMongoConverter") MappingMongoConverter mappingMongoConverter) throws Exception {
return new MongoTemplate(mongoDbFactory, mappingMongoConverter);
}

}

使用

默认的使用:

1
2
@Autowired
private MongoTemplate mongoTemplate;

使用另一个数据源,只需要指定名称即可:

1
2
3
@Autowired
@Qualifier("targetMongoTemplate")
private MongoTemplate targetMongoTemplate;

事务

普通的:

1
@Transactional(rollbackFor = Throwable.class)

其他数据源指定事务管理器即可

1
@Transactional(rollbackFor = Throwable.class, transactionManager = "TARGET_MONGO_TRANSACTION_MANAGER")

注意:两种事务不能混合使用

mapstruct 高级用法之userid转换为username

作者 winter chen
2020年11月30日 18:30

mapstruct的简单用法就不讲了,看完这篇文章能获得什么呢?

  • 1.普通用法:将userId转换为userName?
  • 2.高级用法:一劳永逸的将userId转换为userName?

很多时候在数据库里面只有userid而没有username的冗余信息,在entity转换为dto,vo等模型的时候需要额外的设值,mapstruct可以很方便的进行对象之间的转换,那么接下来我们就开始吧

前提

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @author winterchen
* @version 1.0
* @date 2020/11/27 4:52 下午
* @description 项目信息
**/
@Data
@Builder
@ToString
@ApiModel("项目信息")
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper=false)
public class ProjectDTO implements Serializable {

private static final long serialVersionUID = -2601073448289463936L;

// ... 省略部分字段

@ApiModelProperty("创建人")
private String createUserId;

// 这里一定不能使用String类型,必须要自己包装一个简单的类型,因为mapstruct是根据类型进行转换的
@ApiModelProperty("创建人名称")
private SimpleUserDTO createUserName;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("基本用户信息")
@EqualsAndHashCode(callSuper=false)
public class SimpleUserDTO implements Serializable {

private static final long serialVersionUID = 6889842645997918707L;

@ApiModelProperty("用户名")
private String userName;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author winterchen
* @version 1.0
* @date 2020/11/27 4:52 下午
* @description 项目信息
**/
@Data
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "project")
public class Project {

// ... 省略部分字段

/**
* 创建人
*/
@CreatedBy
private String createUserId;


}

普通用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* @author winterchen
* @version 1.0
* @date 2020/11/28 1:48 下午
* @description
**/
@Mapper(componentModel = "spring")
public abstract class ProjectMapping{

// 博主这里使用的是mongodb,这里可以换成你对应的查询用户信息的类
@Autowired
protected MongoTemplate mongoTemplate;
/**
* 这里的注释主要是指定需要转换的source到target的信息,mapstruct会根据类型进行相应的转换
* 比如 String-> SimpleUserDTO
* 所以我们需要指定属性名称,然后mapstruct会根据属性类型调用方法 SimpleUserDTO toConvertToUserName(String userId)
**/
@Mappings({
@Mapping(target = "createUserName", source = "createUserId")
})
public abstract ProjectDTO toConvertToDto(Project project);

public abstract List<ProjectDTO> toConvertToDtos(List<Project> projects);

protected SimpleUserDTO toConvertToUserName(String userId) {
Query query = new Query(Criteria.where("id").is(userId));
User user = mongoTemplate.findOne(query, User.class);
if (null != user) {
SimpleUserDTO result = SimpleUserDTO.builder()
.userName(user.getUserName())
.build();
return result;
}
return null;
}
}
  • 注意点:没有使用interface而是使用abstract抽象类,主要原因是因为需要有自己的实现方法来转换userid到username

我们看看maven编译之后的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2020-11-28T16:43:17+0800",
comments = "version: 1.3.1.Final, compiler: javac, environment: Java 1.8.0_251 (Oracle Corporation)"
)
@Component
public class ProjectMappingImpl extends ProjectMapping {

@Override
public ProjectDTO toConvertToDto(Project project) {
if ( project == null ) {
return null;
}
ProjectDTOBuilder projectDTO = ProjectDTO.builder();
// 重点看这里,mapstruct生成的实现类会自动调用我们定义的方法
projectDTO.createUserName( toConvertToUserName( project.getCreateUserId() ) );
projectDTO.createUserId( project.getCreateUserId() );
return projectDTO.build();
}

@Override
public List<ProjectDTO> toConvertToDtos(List<Project> projects) {
if ( projects == null ) {
return null;
}

List<ProjectDTO> list = new ArrayList<ProjectDTO>( projects.size() );
for ( Project project : projects ) {
list.add( toConvertToDto( project ) );
}

return list;
}

protected SimpleUserDTO toConvertToUserName(String userId) {
Query query = new Query(Criteria.where("id").is(userId));
User user = mongoTemplate.findOne(query, User.class);
if (null != user) {
SimpleUserDTO result = SimpleUserDTO.builder()
.userName(user.getUserName())
.build();
return result;
}
return null;
}
}

返回的结果:

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"data": {
"createUserId": "5fb476444dfa732e47790966",
"createUserName": {
"userName": "winter"
}
},
"message": "操作成功"
}

高级用法:一劳永逸型用法

所谓的一劳永逸主要是解决每次都要写实现就很烦了,所以就要实现写一次后面都不用实现了,思路是这样的,ProjectMapping抽象类继续往上抽象一层,将上述的转换方法抽到上一层,以后有需要转换userid到username的需求只需要继承那个抽象类(BaseMapping)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author winterchen
* @version 1.0
* @date 2020/11/28 4:31 下午
* @description 基本的mapping
**/
public abstract class BaseMapping {

@Autowired
protected MongoTemplate mongoTemplate;

protected SimpleUserDTO toConvertToUserName(String userId) {
Query query = new Query(Criteria.where("id").is(userId));
User user = mongoTemplate.findOne(query, User.class);
if (null != user) {
SimpleUserDTO result = SimpleUserDTO.builder()
.userName(user.getUserName())
.build();
return result;
}
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author winterchen
* @version 1.0
* @date 2020/11/28 1:48 下午
* @description
**/
@Mapper(componentModel = "spring")
public abstract class ProjectMapping extends BaseMapping{

@Mappings({
@Mapping(target = "createUserName", source = "createUserId")
})
public abstract ProjectDTO toConvertToDto(Project project);

public abstract List<ProjectDTO> toConvertToDtos(List<Project> projects);

}

以上就可以使用了,只需要继承这个抽象类就可以,前提是DTO,VO中的属性类型是SimpleUserDTO

拓展

按照这种方式其实可以举一反三,以后遇到需要获取源对象内子对象的某个属性到DTO、VO的属性字段也可以使用这种方式

Java导出Excel文档(poi),并上传到腾讯云对象存储服务器

作者 winter chen
2017年10月21日 20:00

需求

后台生成周报月报季报年报Excel,将文件下载链接推送给对应客户

开发思路:

1.根据选定日期生成周报,月报,季报,年报数据
2.将这些数据报告生成Excel表格
3.把生成的文件上传到腾讯云对象存储服务器
4.将服务器返回的url存储到数据库

工具

poi-3.14-20160307.jar(点击可下载)

数据

获取数据部分省略了

代码

主方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean addReportExcelToCloud(ReportResult rr) {

OutputStream out = new ByteArrayOutputStream();
ExcelProjectUtils eu = new ExcelProjectUtils();
eu.exportExcel(rr, out); //<1>
ConvertUtil cu = new ConvertUtil();
try {
ByteArrayInputStream byteInput = cu.parse(out);
String rs = PicUploadToYun.uploadExcel(SysContent.getFileRename("案场数据报.xls"), byteInput); //<2>
addReportExcelToDB(rr, rs); //<3>
return true;
} catch (Exception e) {
e.printStackTrace();
}

return false;
}

<1> 将数据生成二进制Excel文件 (方法详细见下面代码)
<2> 将生成的二进制文件上传到腾讯云对象存储服务器 (方法详细见下面代码)
<3> 将服务器返回的url存储到数据库 (方法详细见下面代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
/**
* 周报年报生成excel
*
* @param report
* @param out
*/
public void exportExcel(ReportResult report, OutputStream out) {

// 判断传入的时间间隔
String dateStr = "";
String reportName = "";
List<String> dateCount = DateUtil.getTwoDateEveryDay(report.getStartTime(), report.getEndTime());
if (dateCount.size() <= 7) {
dateStr += "本周";
reportName += "案场周报";
} else if (dateCount.size() >= 28 && dateCount.size() <= 31) {
dateStr += "本月";
reportName += "案场月报";
} else if (dateCount.size() >= 85 && dateCount.size() <= 100) {
dateStr += "本季度";
reportName += "案场季报";
} else if (dateCount.size() >= 180 && dateCount.size() <= 185) {
dateStr += "本半年度";
reportName += "案场半年报";
} else if (dateCount.size() >= 360 && dateCount.size() <= 367) {
dateStr += "本年度";
reportName += "案场年报";
} else {
dateStr += "时间段内";
reportName += "案场阶段报";
}
report.setReportName(reportName);
// 声明一个工作薄
HSSFWorkbook workbook = new HSSFWorkbook();
// 生成一个表格
HSSFSheet sheet = workbook.createSheet(report.getReportName() + report.getStartTime() + " - " + report.getEndTime());
// 设置表格默认列宽度为100个字节
sheet.setDefaultColumnWidth((short) 100);
/** ----------样式一:标题 ------------ **/
HSSFCellStyle style = workbook.createCellStyle();
// 设置这些样式
style.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style.setBorderRight(HSSFCellStyle.BORDER_THIN);
//style.setBorderTop(HSSFCellStyle.BORDER_THIN);
style.setAlignment(HSSFCellStyle.ALIGN_CENTER);
// 生成一个字体
HSSFFont font = workbook.createFont();
font.setFontName("宋体");
//font.setColor(HSSFColor.VIOLET.index);
font.setFontHeightInPoints((short) 14);
font.setBoldweight(HSSFFont.BOLDWEIGHT_BOLD);
// 把字体应用到当前的样式
style.setFont(font);
/***---------样式二:小标题---------***/
HSSFCellStyle style2 = workbook.createCellStyle();
style2.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style2.setBorderRight(HSSFCellStyle.BORDER_THIN);
//style2.setBorderTop(HSSFCellStyle.BORDER_THIN);
style2.setAlignment(HSSFCellStyle.ALIGN_LEFT);

//style2.setVerticalAlignment(HSSFCellStyle.VERTICAL_CENTER);
// 生成另一个字体
HSSFFont font2 = workbook.createFont();
//font2.setBoldweight(HSSFFont.BOLDWEIGHT_NORMAL);
font2.setFontName("宋体");
font2.setFontHeightInPoints((short) 11);
font2.setBoldweight(HSSFFont.BOLDWEIGHT_BOLD);
// 把字体应用到当前的样式
style2.setFont(font2);

/*** 样式三:右侧日期 ***/
HSSFCellStyle style3 = workbook.createCellStyle();
//样式
style3.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style3.setBorderRight(HSSFCellStyle.BORDER_THIN);
style3.setAlignment(HSSFCellStyle.ALIGN_RIGHT);
style3.setBorderBottom(HSSFCellStyle.BORDER_THIN);
//字体
HSSFFont font3 = workbook.createFont();
font3.setFontName("宋体");
font3.setFontHeightInPoints((short) 11);
style3.setFont(font3);

/** 样式四:主内容 ***/
HSSFCellStyle style4 = workbook.createCellStyle();
//样式
style4.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style4.setBorderRight(HSSFCellStyle.BORDER_THIN);
style4.setAlignment(HSSFCellStyle.ALIGN_LEFT);
//字体
HSSFFont font4 = workbook.createFont();
font4.setFontName("宋体");
font4.setFontHeightInPoints((short) 11);
style4.setFont(font4);

/** 样式五:底侧空内容 ***/
HSSFCellStyle style5 = workbook.createCellStyle();
//样式
style5.setBorderLeft(HSSFCellStyle.BORDER_THIN);
style5.setBorderRight(HSSFCellStyle.BORDER_THIN);
style5.setAlignment(HSSFCellStyle.ALIGN_LEFT);
style5.setBorderBottom(HSSFCellStyle.BORDER_THIN);
//字体
HSSFFont font5 = workbook.createFont();
font5.setFontName("宋体");
font5.setFontHeightInPoints((short) 11);
style5.setFont(font5);


// 声明一个画图的顶级管理器
HSSFPatriarch patriarch = sheet.createDrawingPatriarch();
// 定义注释的大小和位置,详见文档
HSSFComment comment = patriarch.createComment(new HSSFClientAnchor(0, 0, 0, 0, (short) 4, 2, (short) 6, 5));
// 设置注释内容
comment.setString(new HSSFRichTextString("数据报"));
// 设置注释作者,当鼠标移动到单元格上是可以在状态栏中看到该内容.
comment.setAuthor("saas");

// 产生表格标题行 -- 项目名称
HSSFRow row = sheet.createRow(0);
createCellAndRow(style4, report.getProjectName(), row);

// 产生表格标题行 -- 周报名称
row = sheet.createRow(1);
createCellAndRow(style, report.getReportName(), row);

// 产生表格标题行 -- 起始时间-终止时间
row = sheet.createRow(2);
String startTime = DateUtil.format(DateUtil.parse(report.getStartTime(), DateUtil.PATTERN_CLASSICAL_SIMPLE),
DateUtil.PATTERN_CLASSICAL_SIMPLE_YMD);
String endTime = DateUtil.format(DateUtil.parse(report.getEndTime(), DateUtil.PATTERN_CLASSICAL_SIMPLE),
DateUtil.PATTERN_CLASSICAL_SIMPLE_YMD);
String date = "日期:" + startTime + " - " + endTime;
createCellAndRow(style3, date, row);

// 接访情况标题
row = sheet.createRow(3);
createCellAndRow(style2, "·接访情况", row);

// 接访客户组数
row = sheet.createRow(4);
Integer visitCount = report.getVisitCount();
String visitNum = "1、" + dateStr + "共计接访客户" + visitCount + "组,来访量";
if (visitCount < 40) {
visitNum += "较少,有待提升";
} else if (visitCount >= 41 && visitCount <= 99) {
visitNum += "尚可,还有提高空间";
} else if (visitCount >= 100 && visitCount <= 139) {
visitNum += "很多";
} else if (visitCount > 140) {
visitNum += "火爆";
}
createCellAndRow(style4, visitNum, row);

// 有效接访率
row = sheet.createRow(5);
Double visitRate = new Double(report.getValidVisitRate());
String visitRateStr = "2、有效接访率为" + visitRate + "%,接访成效";
if (visitRate < 50) {
visitRateStr += "较低,有待提升";
} else if (visitRate >= 50 && visitRate <= 65) {
visitRateStr += "尚可,还有提高空间";
} else if (visitRate >= 65 && visitRate <= 80) {
visitRateStr += "很高";
} else if (visitRate > 80) {
visitRateStr += "极高";
}
createCellAndRow(style4, visitRateStr, row);

// 首访有效率
row = sheet.createRow(6);
Double newVisitRate = new Double(report.getValidNewCuVisitRate());
String newVisitStr = "3、首访有效率为" + newVisitRate + "%,来访转储客的概率";
if (newVisitRate < 40) {
newVisitStr += "较差,有待提升";
} else if (newVisitRate >= 40 && newVisitRate <= 60) {
newVisitStr += "尚可,还有提高空间";
} else if (newVisitRate >= 60 && newVisitRate <= 75) {
newVisitStr += "很高";
} else if (newVisitRate > 75) {
newVisitStr += "极高";
}
createCellAndRow(style4, newVisitStr, row);

// 老客户接访占比
row = sheet.createRow(7);
Double oldVisitRate = new Double(report.getOldCuVisitRate());
String oldVisitStr = "4、老客户接访比为" + oldVisitRate + "%,老客户接访的占比";
if (oldVisitRate < 20) {
oldVisitStr += "较低";
} else if (oldVisitRate >= 20 && oldVisitRate <= 40) {
oldVisitStr += "尚可";
} else if (oldVisitRate >= 40 && oldVisitRate <= 60) {
oldVisitStr += "很高";
} else if (oldVisitRate > 60) {
oldVisitStr += "极高";
}
createCellAndRow(style4, oldVisitStr, row);

//空行
row = sheet.createRow(8);
createCellAndRow(style4, "", row);

// 储客情况
row = sheet.createRow(9);
createCellAndRow(style2, "·储客情况", row);

// 新增储客
row = sheet.createRow(10);
Integer newCuCount = report.getNewCuCount();
String newCuStr = "1、" + dateStr + "新增储客" + newCuCount + "组,新增量";
if (newCuCount < 30) {
newCuStr += "较少,有待提升";
} else if (newCuCount >= 31 && newCuCount <= 60) {
newCuStr += "尚可,还有提高空间";
} else if (newCuCount >= 61 && newCuCount <= 79) {
newCuStr += "很多";
} else if (newCuCount > 80) {
newCuStr += "爆满";
}
createCellAndRow(style4, newCuStr, row);

// 累计老客户
row = sheet.createRow(11);
Integer oldCuCount = report.getTotalOldCuCount();
Integer totalCuCount = report.getTotalCuCount();
Double oldCuRate = new Double(SysContent.getTwoNumberForValue(oldCuCount, totalCuCount));
String oldCuStr = "2、累计老客户总量为" + oldCuCount + "组,老客户占比为" + oldCuRate + "%,显示老客户关注度";
if (oldCuRate < 15) {
oldCuStr += "较低,有待提升";
} else if (oldCuRate >= 15 && oldCuRate <= 25) {
oldCuStr += "尚可,还有提高空间";
} else if (oldCuRate >= 25 && oldCuRate <= 40) {
oldCuStr += "很高";
} else if (oldCuRate > 40) {
oldCuStr += "极高";
}
createCellAndRow(style4, oldCuStr, row);

// 累计总储客
row = sheet.createRow(12);
String totalOldCuStr = "3、累计总储客" + totalCuCount + "组";
createCellAndRow(style4, totalOldCuStr, row);

// 成交情况(周报没有,其他有)
if (report.getSubscribeHouseCount() != null) {

//空行
row = sheet.createRow(13);
createCellAndRow(style4, "", row);

row = sheet.createRow(14);
createCellAndRow(style2, "·成交情况", row);

// 新增认购套数
row = sheet.createRow(15);
Integer subscribeHouseCount = report.getSubscribeHouseCount();
Double subscribeHouseRate = new Double(report.getSubscribeHouseRate());
String subscribeHouseStr = "1、" + dateStr + "新增认购套数" + subscribeHouseCount + "套,较" + dateStr + "同期";
if (subscribeHouseRate < 0) {
subscribeHouseStr += "减少";
} else {
subscribeHouseStr += "增长";
}
subscribeHouseStr += Math.abs(subscribeHouseRate) + "%";
createCellAndRow(style4, subscribeHouseStr, row);

// 新增认购金额
row = sheet.createRow(16);
Long subscribeMoney = report.getSubscribeMoney();
Double subscribeMoneyRate = new Double(report.getSubscribeMoneyRate());
String subscribeMoneyStr = " 新增认购金额" + subscribeMoney + "万元,较" + dateStr + "同期";
if (subscribeHouseRate < 0) {
subscribeMoneyStr += "减少";
} else {
subscribeMoneyStr += "增长";
}
subscribeMoneyStr += Math.abs(subscribeMoneyRate) + "%";
createCellAndRow(style4, subscribeMoneyStr, row);

// 新增签约套数
row = sheet.createRow(17);
Integer signCount = report.getSignCount();
Double signRate = new Double(report.getSignRate());
String signStr = "2、新增签约套数" + signCount + "套,较" + dateStr + "同期";
if (signRate < 0) {
signStr += "减少";
} else {
signStr += "增长";
}
signStr += Math.abs(signRate) + "%";
createCellAndRow(style4, signStr, row);

// 新增签约金额
row = sheet.createRow(18);
Long signHouseMoney = report.getSignHouseMoney();
Double signHouseMoneyRate = new Double(report.getSignHouseMoneyRate());
String signHouseMoneyStr = " 新增签约金额" + signHouseMoney + "万元,较" + dateStr + "同期";
if (signHouseMoneyRate < 0) {
signHouseMoneyStr += "减少";
} else {
signHouseMoneyStr += "增长";
}
signHouseMoneyStr += Math.abs(signHouseMoneyRate) + "%";
createCellAndRow(style4, signHouseMoneyStr, row);

// 新接访签约率
row = sheet.createRow(19);
Double newCustomerSignedRate = new Double(report.getNewCustomerSignedRate());
String newCustomerSignedStr = "3、" + dateStr + "新客户接访签约率" + newCustomerSignedRate + "%,接访签约概率";
if (newCustomerSignedRate < 4) {
newCustomerSignedStr += "较低,与理想值差距大";
} else if (newCustomerSignedRate >= 4 && newCustomerSignedRate <= 6) {
newCustomerSignedStr += "尚可,还有提高空间";
} else if (newCustomerSignedRate >= 6 && newCustomerSignedRate <= 7) {
newCustomerSignedStr += "很高";
} else if (newCustomerSignedRate > 7) {
newCustomerSignedStr += "非常高";
}
createCellAndRow(style4, newCustomerSignedStr, row);

// 储客签约率
row = sheet.createRow(20);
Double momeryCustomerSignedRate = new Double(report.getMomeryCustomerSignedRate());
String momeryCustomerSignedStr = "4、储客签约率" + momeryCustomerSignedRate + "%,储备客户签约概率";
if (momeryCustomerSignedRate < 7) {
momeryCustomerSignedStr += "较低,与理想值差距大";
} else if (momeryCustomerSignedRate >= 7 && momeryCustomerSignedRate <= 12) {
momeryCustomerSignedStr += "尚可,还有提高空间";
} else if (momeryCustomerSignedRate >= 12 && momeryCustomerSignedRate <= 15) {
momeryCustomerSignedStr += "很高";
} else if (momeryCustomerSignedRate > 15) {
momeryCustomerSignedStr += "非常高";
}
createCellAndRow(style4, momeryCustomerSignedStr, row);

// 老客户签约率
row = sheet.createRow(21);
Double oldCustomerSignedRate = new Double(report.getOldCustomerSignedRate());
String oldCustomerSignedStr = "5、老客户签约率为23.2%,高意向客户签约概率";
if (oldCustomerSignedRate < 25) {
oldCustomerSignedStr += "较低,与理想值差距大";
} else if (oldCustomerSignedRate >= 25 && oldCustomerSignedRate <= 35) {
oldCustomerSignedStr += "尚可,还有提高空间";
} else if (oldCustomerSignedRate >= 35 && oldCustomerSignedRate <= 50) {
oldCustomerSignedStr += "很高";
} else if (oldCustomerSignedRate > 50) {
oldCustomerSignedStr += "非常高";
}
createCellAndRow(style4, oldCustomerSignedStr, row);

// 认购客户签约率
row = sheet.createRow(22);
Double contratCuSignedRate = new Double(report.getContratCuSignedRate());
String contratCuSignedStr = "6、认购客户签约率为92%,已认购客户签约率";
if (contratCuSignedRate < 95) {
contratCuSignedStr += "不高,较多退订或拒签";
} else if (contratCuSignedRate >= 95 && contratCuSignedRate <= 97) {
contratCuSignedStr += "尚可,一定数量退订或拒签";
} else if (contratCuSignedRate >= 97 && contratCuSignedRate <= 99) {
contratCuSignedStr += "很高";
} else if (contratCuSignedRate > 99) {
contratCuSignedStr += "非常高";
}
createCellAndRow(style4, contratCuSignedStr, row);

//空行
row = sheet.createRow(23);
createCellAndRow(style4, "", row);

//底侧
row = sheet.createRow(24);
createCellAndRow(style5, "", row);

}else{
row = sheet.createRow(13);
createCellAndRow(style5, "", row);
}

try {
workbook.write(out);
} catch (IOException e) {
e.printStackTrace();
}
}

private void createCellAndRow(HSSFCellStyle style, String text, HSSFRow row) {
HSSFCell cell = row.createCell(0);
cell.setCellStyle(style);
HSSFRichTextString rs = new HSSFRichTextString(text);
cell.setCellValue(rs);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 上传Excel
* @param fileNewName
* @param uploadFile
* @return
*/
public static String uploadExcel(String fileNewName,ByteArrayInputStream uploadFile){
// 设置用户属性, 包括appid, secretId和SecretKey
// 这些属性可以通过cos控制台获取(https://console.qcloud.com/cos)
String version = PropertiesUtil.getValue("version");
long appId = "你的appId";
String secretId = "你的secretId ";
String secretKey = "你的secretKey ";

// 设置要操作的bucket
String bucketName = "root";
// 初始化客户端配置
ClientConfig clientConfig = new ClientConfig();
// 设置bucket所在的区域,比如广州(gz), 天津(tj)
clientConfig.setRegion("sh");
// 初始化秘钥信息
Credentials cred = new Credentials(appId, secretId, secretKey);
// 初始化cosClient
COSClient cosClient = new COSClient(clientConfig, cred);
// 文件操作 //
// 1. 上传文件(默认不覆盖)
// 将本地的local_file_1.txt上传到bucket下的根分区下,并命名为sample_file.txt
// 默认不覆盖, 如果cos上已有文件, 则返回错误
String cosFilePath = "/report/" + fileNewName;

byte[] localFilePath1 = null;
try {
localFilePath1 = ConvertUtil.toByteArray(uploadFile);
} catch (IOException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}

UploadFileRequest uploadFileRequest = new UploadFileRequest(bucketName, cosFilePath, localFilePath1);
uploadFileRequest.setEnableShaDigest(false);
String uploadFileRet = cosClient.uploadFile(uploadFileRequest);
System.out.println("upload file ret:" + uploadFileRet);
//获取保存路径
ObjectMapper om = new ObjectMapper();
HashMap map = new HashMap<>();
try {
map = om.readValue(uploadFileRet, HashMap.class);
} catch (JsonParseException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (JsonMappingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
HashMap<String, String> value = (HashMap<String, String>) map.get("data");
return value.get("source_url");

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public boolean addReportExcelToDB(ReportResult rr, String url) {

if(StringUtils.isEmpty(url)){
return false;
}
if(rr == null){
return false;
}

ProjectReportRecord prr = new ProjectReportRecord();
prr.setCreateTime(DateUtil.format(new Date()));
prr.setProjectId(rr.getProjectId());
prr.setProjectName(rr.getProjectName());
prr.setStartTime(rr.getStartTime());
prr.setEndTime(rr.getEndTime());
prr.setUrl(url);
String report = "";
if("案场周报".equals(rr.getReportName())){
report = "week";
}else if("案场月报".equals(rr.getReportName())){
report = "month";
}else if("案场季报".equals(rr.getReportName())){
report = "quarter";
}else if("案场半年报".equals(rr.getReportName())){
report = "half";
}else if("案场年报".equals(rr.getReportName())){
report = "year";
}else{
report = "other";
}
prr.setReportName(report);

baseDao.save(prr);

return true;
}

生成的文件示例

周报或者其他报告都是后台自动根据时间进行判断的

周报
这里写图片描述

季报

这里写图片描述

以上

❌
❌