总概
A、技术栈
- 开发语言:Java 1.8
- 数据库:MySQL、Redis、MongoDB、Elasticsearch
- 微服务框架:Spring Cloud Alibaba
- 微服务网关:Spring Cloud Gateway
- 服务注册和配置中心:Nacos
- 分布式事务:Seata
- 链路追踪框架:Sleuth
- 服务降级与熔断:Sentinel
- ORM框架:MyBatis-Plus
- 分布式任务调度平台:XXL-JOB
- 消息中间件:RocketMQ
- 分布式锁:Redisson
- 权限:OAuth2
- DevOps:Jenkins、Docker、K8S
B、本节实现目标
- 新建mall-gateway服务,所有请求通过Gateway转发
- Gateway鉴权token
- Gateway配置白名单
- 所有服务swagger通过gateway访问,并提供下列列表选择服务
@RestControllerAdvice
拦截Controller返回统一格式数据@ControllerAdvice
拦截返回统一格式Exception
一、API Gateway
API 网关出现的原因是微服务架构的出现,不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信,会有以下的问题:
-
客户端会多次请求不同的微服务,增加了客户端的复杂性。
-
存在跨域请求,在一定场景下处理相对复杂。
-
认证复杂,每个服务都需要独立认证。
-
难以重构,随着项目的迭代,可能需要重新划分微服务。例如,可能将多个服务合并成一个或者将一个服务拆分成多个。如果客户端直接与微服务通信,那么重构将会很难实施。
-
某些微服务可能使用了防火墙 / 浏览器不友好的协议,直接访问会有一定的困难。
以上这些问题可以借助 API 网关解决。API 网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过 API 网关这一层。也就是说,API 的实现方面更多的考虑业务逻辑,而安全、性能、监控可以交由 API 网关来做,这样既提高业务灵活性又不缺安全性,典型的架构图如图所示:
API 网关
二、Spring Cloud Gateway简介
Spring Cloud Gateway 是 Spring Cloud 的一个全新项目,该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等技术开发的网关,它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。
Spring Cloud Gateway作为Spring Cloud生态系统中的网关,目标是替代Netflix Zuul,其不仅提供统一的路由方式,并且基于Filter链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。
由于Spring 5.0支持 Netty,Http2,而Spring Boot 2.0支持Spring 5.0,因此Spring Cloud Gateway支持 Netty和Http2。
补充:
1、Zuul(1.x) 基于 Servlet,使用阻塞 API,它不支持任何长连接 ,如 WebSockets。
2、Zuul(2.x) 基于Netty。
3、Spring Cloud GateWay天⽣就是异步⾮阻塞的,基于Reactor模型,支持 WebSockets,支持限流等新特性。
4、Spring Cloud 已经不再集成 Zuul 2.x 。
三、架构说明
认证服务(mall-auth)负责认证授权,网关服务(mall-gateway)负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。
具体服务:
- [mall-auth]:认证服务,负责对登录用户进行认证授权颁发token。
- [mall-gateway]:网关服务,负责请求转发和校验认证和鉴权。
- [mall-member]:受保护的API服务,用户鉴权通过后可以访问该服务,该类服务还有[mall-product]、[mall-search]等等。
四、代码实现
4.1 新建mall-gateway服务
新建mall-gateway服务用户token鉴权、API请求转发
4.2 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>mall-pom</artifactId>
<groupId>com.ac</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<groupId>com.ac</groupId>
<artifactId>mall-gateway</artifactId>
<version>${mall.version}</version>
<name>mall-gateway</name>
<description>网关服务</description>
<dependencies>
<dependency>
<groupId>com.ac</groupId>
<artifactId>mall-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.ac</groupId>
<artifactId>mall-oauth2-module</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- Spring Cloud Gateway 是使用 netty+webflux 实现因此不需要再引入 web 模块 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>4.0.4</version>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.3 配置路由Route(路由)、白名单
bootstrap-dev.yml
server:
port: 6001
spring:
application:
name: mall-gateway
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
namespace: dev_id
file-extension: yml
shared-configs:
- data-id: common.yml
group: DEFAULT_GROUP
refresh: true
discovery:
namespace: dev_id
gateway:
routes:
- id: mall-member-route # 当前路由的标识, 要求唯一
uri: lb://mall-member # lb指的是从nacos中按照名称获取微服务,并遵循负载均衡策略
predicates:
- Path=/mall-member/** # 当请求路径满足Path指定的规则时,才进行路由转发
filters:
- StripPrefix=1 # 转发之前去掉1层路径
- id: mall-search-route
uri: lb://mall-search
predicates:
- Path=/mall-search/**
filters:
- StripPrefix=1
- id: mall-product-route
uri: lb://mall-product
predicates:
- Path=/mall-product/**
filters:
- StripPrefix=1
- id: mall-order-route
uri: lb://mall-order
predicates:
- Path=/mall-order/**
filters:
- StripPrefix=1
#gateway swagger开关
swagger:
enable: true
#配置白名单路径
mall:
security:
ignore:
urls:
- "/**/member/list"
- "/**/redis/**"
重点说明一下配置,- StripPrefix=1
转发之前去掉1层路径,如:127.0.0.1:6001/mall-member/member/264260572479489
,去掉第一层路径mall-member
,就变成了127.0.0.1:6001/member/264260572479489
,会被转发到mall-member服务。
4.4 Application配置@ComponentScan
mall-core服务config包里的WebMvcConfigurer配置类,和mall-gateway服务里排除的spring-webmvc
有冲突,因此排除该目录下的配置类
@ComponentScan(
value = "com.ac.*",
excludeFilters = {@ComponentScan.Filter(type = FilterType.REGEX, pattern = "com.ac.core.config.*")})
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
4.5 swagger配置
4.5.1 配置类
package com.ac.gateway.config;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.support.NameUtils;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import springfox.documentation.swagger.web.SwaggerResource;
import springfox.documentation.swagger.web.SwaggerResourcesProvider;
import java.util.ArrayList;
import java.util.List;
@Configuration
@Primary
@ConditionalOnProperty(name = "swagger.enable", havingValue = "true")
public class GateWaySwaggerConfig implements SwaggerResourcesProvider {
public static final String API_URI = "/v2/api-docs";
private final RouteLocator routeLocator;
private final GatewayProperties gatewayProperties;
public GateWaySwaggerConfig(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<>();
//取出gateway的route
routeLocator.getRoutes().subscribe(route -> routes.add(route.getId()));
//结合配置的route-路径(Path),和route过滤,只获取有效的route节点
gatewayProperties.getRoutes().stream().filter(routeDefinition -> routes.contains(routeDefinition.getId()))
.forEach(routeDefinition -> routeDefinition.getPredicates().stream()
.filter(predicateDefinition -> ("Path").equalsIgnoreCase(predicateDefinition.getName()))
.forEach(predicateDefinition -> resources.add(swaggerResource(routeDefinition.getId(),
predicateDefinition.getArgs().get(NameUtils.GENERATED_NAME_PREFIX + "0")
.replace("/**", API_URI)))));
return resources;
}
private SwaggerResource swaggerResource(String name, String location) {
SwaggerResource swaggerResource = new SwaggerResource();
swaggerResource.setName(name);
swaggerResource.setLocation(location);
swaggerResource.setSwaggerVersion("1.0");
return swaggerResource;
}
}
4.5.2 controller类
package com.ac.gateway.controller;
import com.ac.gateway.config.GateWaySwaggerConfig;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import springfox.documentation.swagger.web.SecurityConfiguration;
import springfox.documentation.swagger.web.SecurityConfigurationBuilder;
import springfox.documentation.swagger.web.UiConfiguration;
import springfox.documentation.swagger.web.UiConfigurationBuilder;
import javax.annotation.Resource;
/**
* @author Alan Chen
* @description 在浏览器中打开gateway的swagger地址时,会将请求自动打到下面API
* http://127.0.0.1:6001/swagger-ui.html
* @date 2023/02/22
*/
@ConditionalOnProperty(name = "swagger.enable", havingValue = "true")
@RestController
public class SwaggerController {
@Resource
private GateWaySwaggerConfig gateWaySwaggerConfig;
@GetMapping("/swagger-resources/configuration/security")
public Mono<ResponseEntity<SecurityConfiguration>> securityConfiguration() {
return Mono.just(new ResponseEntity<>(SecurityConfigurationBuilder.builder().build(), HttpStatus.OK));
}
@GetMapping("/swagger-resources/configuration/ui")
public Mono<ResponseEntity<UiConfiguration>> uiConfiguration() {
return Mono.just(new ResponseEntity<>(UiConfigurationBuilder.builder().build(), HttpStatus.OK));
}
@GetMapping("/swagger-resources")
public Mono<ResponseEntity> swaggerResources() {
return Mono.just((new ResponseEntity<>(gateWaySwaggerConfig.get(), HttpStatus.OK)));
}
@GetMapping("/")
public Mono<ResponseEntity> swaggerResourcesN() {
return Mono.just((new ResponseEntity<>(gateWaySwaggerConfig.get(), HttpStatus.OK)));
}
@GetMapping("/csrf")
public Mono<ResponseEntity> swaggerResourcesCsrf() {
return Mono.just((new ResponseEntity<>(gateWaySwaggerConfig.get(), HttpStatus.OK)));
}
}
在GateWaySwaggerConfig、SwaggerController类上都加上了@ConditionalOnProperty(name = "swagger.enable", havingValue = "true")
注解,该注解表示当swagger.enable配置值为true时,则将当前类初始化为bean。该开关用户关闭生产环境swagger,保证服务安全性。
下拉选择服务
4.6 @RestControllerAdvice
拦截Controller返回统一格式数据
该配置类放在mall-core模块
package com.ac.core.config;
import com.ac.core.response.RepResult;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.ArrayList;
import java.util.List;
/**
* @author Alan Chen
* @description Controller返回参数全局包装成ResponseResult对象
* 使用是一般需要指定basePackages,@RestControllerAdvice(basePackages = {"com.netx.web.controller"})
* 只拦截controller包下的类;否则swagger也会拦截影响swagger正常使用
* @date 2023/04/15
*/
@EnableWebMvc
@Configuration
@RestControllerAdvice
public class GlobalReturnConfig implements ResponseBodyAdvice<Object>, WebMvcConfigurer {
/**
* 支持返回 text/plan 格式 字符串不会带双引号
*
* @return
*/
public boolean supportTextPlan() {
return false;
}
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
//排除swagger的请求 springfox.documentation.swagger2.web.Swagger2Controller
if (methodParameter.getDeclaringClass().getName().contains("swagger")) {
return false;
}
return true;
}
@Override
public Object beforeBodyWrite(Object returnObj, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
// 返回值为void
if (returnObj == null) {
return RepResult.success();
}
//全局异常会拦截统一封装成ResponseResult对象,因此不需要再包装了
if (returnObj instanceof RepResult) {
return returnObj;
}
return RepResult.success(returnObj);
}
/**
* 解决不能返回单个字符的问题
*
* @param converters
*/
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
if (supportTextPlan()) {
converters.add(stringHttpMessageConverter());
}
//创建fastJson消息转换器
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
// 解决Content-Type cannot contain wildcard type '*'问题
List<MediaType> supportedMediaTypes = new ArrayList<>();
supportedMediaTypes.add(MediaType.APPLICATION_JSON);
supportedMediaTypes.add(MediaType.APPLICATION_ATOM_XML);
supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
supportedMediaTypes.add(MediaType.APPLICATION_OCTET_STREAM);
supportedMediaTypes.add(MediaType.APPLICATION_PDF);
supportedMediaTypes.add(MediaType.APPLICATION_RSS_XML);
supportedMediaTypes.add(MediaType.APPLICATION_XHTML_XML);
supportedMediaTypes.add(MediaType.APPLICATION_XML);
supportedMediaTypes.add(MediaType.IMAGE_GIF);
supportedMediaTypes.add(MediaType.IMAGE_JPEG);
supportedMediaTypes.add(MediaType.IMAGE_PNG);
supportedMediaTypes.add(MediaType.TEXT_EVENT_STREAM);
supportedMediaTypes.add(MediaType.TEXT_HTML);
supportedMediaTypes.add(MediaType.TEXT_MARKDOWN);
supportedMediaTypes.add(MediaType.TEXT_PLAIN);
supportedMediaTypes.add(MediaType.TEXT_XML);
converter.setSupportedMediaTypes(supportedMediaTypes);
FastJsonConfig fastJsonConfig = new FastJsonConfig();
// 字段为null时依然返回到前端,而不是省略该字段
fastJsonConfig.setSerializerFeatures(SerializerFeature.WriteMapNullValue);
converter.setFastJsonConfig(fastJsonConfig);
converters.add(converter);
}
@Bean
public StringHttpMessageConverter stringHttpMessageConverter() {
return new StringHttpMessageConverter();
}
}
查询用户接口
虽然查询用户接口,返回的是一个用户对象,但返回到前端时,统一返回的是RepResult格式,将用户数据放在了data里。
统一返回RepResult格式
4.7 @ControllerAdvice
拦截返回统一格式Exception
该配置放在mall-core里
package com.ac.core.exception;
import com.ac.core.i18n.I18nResource;
import com.ac.core.response.RepResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* @author Alan Chen
* @description 全局异常处理
* @date 2023/4/27
*/
@Slf4j
@ControllerAdvice
@Component
public class GlobalExceptionHandler {
private I18nResource validationI18nSource;
private I18nResource responseMessageI18nSource;
/**
* 是否开启Validator国际化功能
* @return
*/
protected boolean enableValidationI18n(){
return false;
}
/**
* 国际化文件地址
* @return
*/
protected String validationI18nSourcePath(){
return "i18n/validation";
}
/**
* 是否开启消息国际化
* @return
*/
protected boolean enableResponseMessageI18n(){
return false;
}
/**
* 消息国际化文件地址
* @return
*/
protected String responseMessageI18nSourcePath(){
return "i18n/messages";
}
/**
* 全局异常捕捉处理
* @param ex
* @return
*/
@ResponseBody
@ExceptionHandler(value = Exception.class)
public RepResult errorHandler(Exception ex) {
ex.printStackTrace();
log.error("Exception:"+ex.getMessage());
return RepResult.fail(ex.getMessage());
}
/**
* validator校验失败信息处理
* @param exception
* @return
*/
@ResponseBody
@ExceptionHandler(value = BindException.class)
public RepResult bindExceptionHandler(BindException exception) {
exception.printStackTrace();
return doValidationException(exception.getBindingResult());
}
/**
* validator校验失败信息处理
* @param exception
* @return
*/
@ResponseBody
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public RepResult validationHandler(MethodArgumentNotValidException exception) {
exception.printStackTrace();
log.error("MethodArgumentNotValidException:"+exception.getMessage());
return doValidationException(exception.getBindingResult());
}
/**
* 拦截捕捉业务异常 ServiceException.class
* @param ex
* @return
*/
@ResponseBody
@ExceptionHandler(value = ServerException.class)
public RepResult commonExceptionHandler(ServerException ex) {
ex.printStackTrace();
log.error("ServiceException:"+ex.getMessage());
if(enableResponseMessageI18n()){
if(responseMessageI18nSource == null){
responseMessageI18nSource = new I18nResource(responseMessageI18nSourcePath());
}
String messageKey = ex.getMessage();
try{
String message = responseMessageI18nSource.getValue(messageKey);
String[] placeholder = ex.getPlaceholder();
if(placeholder!=null && placeholder.length>0){
for(int i =0;i<placeholder.length;i++){
message = message.replace("#{"+(i+1)+"}",placeholder[i]);
}
}
return RepResult.info(message);
}catch (Exception e){
return RepResult.info(ex.getMessage());
}
}
return RepResult.info(ex.getMessage());
}
private RepResult doValidationException(BindingResult bindingResult){
StringBuffer stringBuffer = new StringBuffer();
if(enableValidationI18n()){
if(validationI18nSource == null){
validationI18nSource = new I18nResource(validationI18nSourcePath());
}
for (FieldError error : bindingResult.getFieldErrors()) {
String messageKey = error.getDefaultMessage();
try{
String message = validationI18nSource.getValue(messageKey);
stringBuffer.append(message).append(";");
}catch (Exception e){
stringBuffer.append(messageKey).append(";");
}
}
}else{
for (FieldError error : bindingResult.getFieldErrors()) {
stringBuffer.append(error.getDefaultMessage()).append(";");
}
}
log.error("BindException:"+stringBuffer.toString());
return RepResult.info(stringBuffer.toString());
}
}
统一异常格式
五、token鉴权测试
5.1 鉴权拦截成功
请求gateway访问mall-member服务接口,不携带token,请求被拦截
鉴权拦截成功
5.2 鉴权成功转发请求
请求gateway访问mall-member服务接口,携带合法token,请求被正确转发
鉴权成功转发请求
5.3 白名单
在bootstrap-dev.yml里配置了白名单:
#配置白名单路径
mall:
security:
ignore:
urls:
- "/**/member/list"
- "/**/redis/**"
请求gateway访问mall-member服务白名单接口,不携带token,请求被正确转发
访问白名单接口