前言
今天开始,我和大家一起,从0开始,基于Spring Cloud Alibaba,搭建一套基本的微服务架构的项目。
主要用到下面的知识内容
- JDK8/IDEA/Maven
- Spring/SpringBoot/Spring Cloud Alibaba
- Dubbo/openfeign:服务间调用
- 这里之所以引入了两种调用方式,是为了方便根据接口的实际情况,选择合适的调用方式
- Seata:分布式事务
- MySQL
- Redis
- RabbitMQ/RocketMQ
- RBAC:这里,我不打算使用常见的 Spring Security/Spring Security OAuth2.0/Shiro等常见的认证授权技术,我打算自己手动撸一套简易的认证授权体系出来
之所以这么干,我还是考虑到一个定制化和灵活性的问题,在真正的大企业中,其认证授权体系是非常个性化的,如果用了某项技术,往往会制约这种个性化的实现。好吧,我这里主要想吐槽的就是 Spring Security太复杂了,做一个简单的功能,要理解的概念太多了。既然RBAC理论和OAuth2.0协议本身已经非常成熟了,我们根据自己的需要,手撸一个也不是什么大问题
理论:微服务与Spring Cloud
什么是微服务
首先,微服务本身,是一种软件设计,软件架构的思想,并不是具体的某一种技术。可千万别把微服务和Spring Cloud给划上等号。
- 单体应用
我们先来说说传统的单体应用,也就是在一个项目中,把所有的代码都写在一个应用中。
这么做其实在后续功能不断迭代之后,在软件管理和设计上,是会带来种种问题的。
1、应用整体挂机的概率增强
我们写代码的时候要有一个底线思维,就是要把情况往糟糕的情况下去考虑,然后给出解决方案。
只有这样严格要求自己,我们的代码才会更加健壮。
但即使是这样,只要是人,就会犯错,并且,一个项目中的代码,迭代几轮之后,不知道经了多少人的手。
保不齐就有个小伙伴埋了个雷,一旦这个雷爆发,那么我们整个系统也就挂了。
2、技术受限制
当我们在已有的应用中添加功能代码的时候,是需要受当前应用的限制的。
比如说,我举个极端的例子,这个项目比较老了,使用的是Spring2.5,而现在我们做开发一般至少都用JDK8.
那么很不幸,Spring2.5和JDK8是不兼容的。于是我们就没法使用JDK1.8提供的各种实用的特性。
3、部署复杂
这个部署复杂,是针对代码量急剧增加之后来说的。
随着代码量越来越庞大,不管是启动还是测试,上线部署,都将变得越来越慢。
有时候明明改的是一个很小的功能,但是没办法,我们就是需要整个项目都部署一遍
- 分布式
与单体应用相对的,就是分布式系统。
所谓分布式,就是说我们把一个项目,按照一定的划分原则,将各个功能的实现拆开来,部署的时候分开部署。
应用之间通过约定的方式调用。一般是http协议或者rpc协议(如 gRPC、thrift、Dubbo等)。
当我们按照功能拆开后进行部署和开发,带来的好处是很明显的,至少把上面讲的,单体应用的几个缺点,是都给解决掉了。
但是同时,也引入了新的问题
1、事务怎么办,在单体应用中,一个进程中操作一个数据库,回滚是非常方便的,直接使用数据库的特性就可以实现回滚
2、基础功能重复开发怎么办?即如何复用?各个模块中有些功能可能是公用的,一旦拆开后,怎么复用这些代码?
3、测试困难,原先只要启动一个应用就够了,现在如果一个功能的测试,依赖于多个模块,那么相关模块就都要运行起来
但是,后端开发进入现在这个阶段,我们对大数据量高并发的要求,相对于分布式系统的一点点缺点,其带来的好处是不言而喻的。
所以,现在基本上一家企业如果已经过了市场验证阶段,没理由继续使用单体应用,肯定都是要改造成分布式系统的。
- 微服务
那么什么是微服务呢?
微服务肯定是分布式的。那么微服务和分布式系统又有什么区别呢?
以我自己不成熟的看法来说的话,所谓微服务,除了解决分布式系统领域的问题之外,其特点,重在一个微。
怎么样的一个服务,可以称之为微服务呢?这个才是难点。一个系统中,我们怎么拆分,不管是从业务的角度还是技术的角度,各个应用单元之间如何协作,这个才是微服务设计中最最复杂的部分,技术上的难点,其实互联网上的大厂,基本上都已经有解决方案了。
微服务设计6大原则
- 微服务和单体应用的选择
既然微服务有这么多优点,单体应用有那么多缺点,我们在实际项目中到底选择使用哪种开发模式呢?
这个其实和公司当前的实际情况是挂钩的,我们做开发的,一定要理清楚一点,技术,是为业务服务的,或者说是为了需求服务的。
千万不要为了技术而技术。如果一家公司,刚起步,人手不足、业务量也不大、技术实力也不够雄厚,这个时候贸贸然跟风上微服务,这不就是自己给自己找事儿做吗?
当公司业务上来了,团队规模也起来了,人员配备足够覆盖微服务本身的规模,能够驾驭了,这个时候,才能够切实感受到微服务对整个系统带来的好处。
Spring Cloud和微服务是什么关系
Spring Cloud,就是针对微服务的一套实现。
比较著名的所有 Spring Cloud Netflix和Spring Cloud Alibaba
小结
以上讲的都是一些概念性质的东西,具体怎么开发已经详细的理论,我会在后续实战部分,逐个进行讲解。
理论:微服务软件架构设计
我们先来对这一整个微服务体系,进行整体设计。
第一张图,是我在网上找的一个关于微服务架构的社交
第二张图,我重新精简了一下,基本上就划分为网关、认证中心、其他业务模块这几种类型。
实战:搭建项目骨架
- chan
- chan-dep:全局版本依赖管理
- chan-common:通用工具类
- chan-auth:认证授权模块
- chan-user:用户管理模块
- chan-log:日志模块
- chan-gateway:网关
- …:其他业务模块
我们创建了一个项目来进行依赖管理,其他大多数pom关系不大,
不过,对于Spring Cloud、Spring Cloud Alibaba、SpringBoot,这三者之间的版本,是有关联的。
Spring Cloud Alibaba的版本与Spring Boot一致
参见:https://start.spring.io/actuator/info
https://gitee.com/test-qqqq/spring-cloud-alibaba
Spring Cloud的版本与Spring Boot 一致
参考:https://spring.io/projects/spring-cloud/#overview
Spring Cloud Alibaba及其组件(Dubbo、Seata、Sentinel、Nacos),他们之间的版本也是有关联的。
我们在配置这几个组件的版本的时候,最好先去网上查一下,他们是怎么搭配的。
一般这种配置,除非是大版本的更新,否则一旦配置好后,就不要去修改了,避免引起不必要麻烦
理论:注册中心之Nacos
多个服务之间在相互调用的时候,是需要建立连接的。
那么他们怎么知道对方部署时的ip和port是多少呢?
- 代码中写死?
- 重启怎么办?
- 扩容怎么办?
这个时候,就要用到注册中心。
- 在微服务中,注册中心是个什么概念
注册中心主要是用来服务注册和发现的
实战:服务间简单调用
Nacos环境搭建
git clone https://gitee.com/test-qqqq/nacos-docker.git
我这里就直接把nacos-docker clone到chan项目了,但是,我把nacos-docker加入了git忽略列表,不提交。
之所以将nacos-docker放到chan中,只是为了将所有与项目有关的物料,都放在此。
cd nacos-docker
# 启动单机版,用于开发测试足矣,生产上如果是部署在阿里云环境,可以购买服务,也不需要自己构建
docker-compose -f example/standalone-mysql-5.7.yaml up -d
# 停止
docker-compose -f example/standalone-mysql-5.7.yaml down
不过,这个standalone-mysql-5.7.yaml,里面的镜像速度超有点慢,我把他们都上传到自己的镜像仓库了,然后修改yaml文件如下
version: "2"
services:
nacos:
image: registry.cn-hangzhou.aliyuncs.com/sherry/nacos-server:latest
container_name: nacos-standalone-mysql
env_file:
- ../env/nacos-standlone-mysql.env
volumes:
- ./standalone-logs/:/home/nacos/logs
- ./init.d/custom.properties:/home/nacos/init.d/custom.properties
ports:
- "8848:8848"
- "9555:9555"
depends_on:
- mysql
restart: on-failure
mysql:
container_name: mysql
image: registry.cn-hangzhou.aliyuncs.com/sherry/nacos-mysql:5.7
env_file:
- ../env/mysql.env
volumes:
- ./mysql:/var/lib/mysql
ports:
- "3306:3306"
prometheus:
container_name: prometheus
image: registry.cn-hangzhou.aliyuncs.com/sherry/prometheus:latest
volumes:
- ./prometheus/prometheus-standalone.yaml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
depends_on:
- nacos
restart: on-failure
grafana:
container_name: grafana
image: registry.cn-hangzhou.aliyuncs.com/sherry/grafana:latest
ports:
- 3000:3000
restart: on-failure
- 验证
http://localhost:8848/nacos
默认账号密码均为nacos
OpenFeign
- 创建chan-auth和chan-user,通过OpenFeign,chan-auth调用chan-user
- chan-user模块
1、编写一个正常的接口
@RequestMapping("/user")
@Slf4j
@RestController
public class UserController {
@GetMapping(params = "userId")
public R userInfo(@RequestParam String userId) {
return R.ok("返回id为" + userId + "的用户信息");
}
}
2、在api模块中暴露此接口
@FeignClient(name = "chan-user-svc")
public interface UserServiceApi {
/**
* 查询
*
* @param userId
* @return
*/
@GetMapping("/user")
R user(@RequestParam(name = "userId") String userId);
}
- chan-auth
1、引入user-api模块
<dependency>
<groupId>com.zhangln</groupId>
<artifactId>chan-user-api</artifactId>
<version>${project.parent.version}</version>
</dependency>
2、auth模块调用user
3、auth模块中打开Feign支持
测试
- 依次启动ChanAuthApplication和ChanUserApplication
- 调用接口
实战:配置中心之Nacos
实战:网关
跨域问题
package com.zhangln.chan.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.util.pattern.PathPatternParser;
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
路由规则配置
spring:
application:
name: chan-gateway-svc
main:
allow-bean-definition-overriding: true
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
discovery:
locator:
enabled: true
routes:
- id: auth_route
uri: lb://chan-auth-svc
predicates:
- Path=/auth/**
- id: user_route
uri: lb://chan-user-svc
predicates:
- Path=/user/**
全局日志
/**
* 日志记录
*
* @author sherry
* @description
* @date Create in 2020/8/22
* @modified By:
*/
@Component
@Slf4j
public class LogFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
GatewayLogDto gatewayLogDto = new GatewayLogDto();
gatewayLogDto.setId(UUID.randomUUID().toString().replace("-", ""));
gatewayLogDto.setReqTime(LocalDateTime.now());
log.info("{},日志过滤器--start", gatewayLogDto.getId());
ServerHttpRequest serverHttpRequest = exchange.getRequest();
ServerHttpResponse serverHttpResponse = exchange.getResponse();
gatewayLogDto.setUri(serverHttpRequest.getURI().getRawPath());
HttpHeaders headers = serverHttpRequest.getHeaders();
gatewayLogDto.setHeaderMap(headers);
gatewayLogDto.setHost(headers.getHost().getHostName());
MultiValueMap<String, String> queryParams = serverHttpRequest.getQueryParams();
gatewayLogDto.setParamMap(queryParams);
String method = serverHttpRequest.getMethodValue();
gatewayLogDto.setMethod(method);
String contentType = serverHttpRequest.getHeaders().getFirst("Content-Type");
//向headers中放文件,记得build
ServerHttpRequest host = null;
try {
URI newUri = null;
// 对URL参数进行URL编码
if (!Objects.isNull(queryParams)) {
StringBuilder query = new StringBuilder();
Set<String> keySet = queryParams.keySet();
for (Iterator<String> iterator = keySet.iterator(); iterator.hasNext(); ) {
String k = iterator.next();
String v = java.net.URLEncoder.encode(queryParams.getFirst(k), "UTF-8");
query.append(k + "=" + v + "&");
}
// 添加额外的param参数
newUri = UriComponentsBuilder.fromUri(serverHttpRequest.getURI())
.replaceQuery(query.toString())
.build(true)
.toUri();
// 添加额外的请求头
host = exchange.getRequest().mutate()
.uri(newUri)
.header("x-forwarded-for", Objects.isNull(gatewayLogDto.getUri()) ? "" : URLEncoder.encode(gatewayLogDto.getUri(), "UTF-8"))
.build();
} else {
host = exchange.getRequest().mutate()
.header("x-forwarded-for", Objects.isNull(gatewayLogDto.getUri()) ? "" : URLEncoder.encode(gatewayLogDto.getUri(), "UTF-8"))
.build();
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
//将现在的request 变成 change对象
ServerWebExchange.Builder builder = exchange.mutate().request(host);
ServerHttpResponseDecorator decoratedResponse = getServerHttpResponseDecorator(gatewayLogDto, serverHttpResponse);
ServerWebExchange build = builder.response(decoratedResponse).build();
log.info("{},日志过滤器--end", gatewayLogDto.getId());
return chain.filter(build);
}
/**
* 处理返回
*
* @param gatewayLogDto
* @param serverHttpResponse
* @return
*/
private ServerHttpResponseDecorator getServerHttpResponseDecorator(GatewayLogDto gatewayLogDto, ServerHttpResponse serverHttpResponse) {
DataBufferFactory bufferFactory = serverHttpResponse.bufferFactory();
return new ServerHttpResponseDecorator(serverHttpResponse) {
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux) {
Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
return super.writeWith(fluxBody.map(dataBuffer -> {
// probably should reuse buffers
byte[] content = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(content);
//释放掉内存
DataBufferUtils.release(dataBuffer);
// 返回二进制流 二进制与普通json要分开处理
if (Objects.equals("/auth/v1/auth/get_validate_code", gatewayLogDto.getUri())) {
printGatewayLog(gatewayLogDto);
return bufferFactory.wrap(content);
} else {
// 返回普通json
String resStr = new String(content, Charset.forName("UTF-8"));
gatewayLogDto.setResBody(resStr);
gatewayLogDto.setResTime(LocalDateTime.now());
byte[] uppedContent = resStr.getBytes();
printGatewayLog(gatewayLogDto);
return bufferFactory.wrap(uppedContent);
}
}));
}
// if body is not a flux. never got there.
return super.writeWith(body);
}
};
}
/**
* 处理网关日志,一般简单打印或发送MQ
*
* @param gatewayLogDto
*/
private void printGatewayLog(GatewayLogDto gatewayLogDto) {
log.info("{},{},{},网关日志:{},耗时约:{} 毫秒", gatewayLogDto.getId(), gatewayLogDto.getMethod(), gatewayLogDto.getUri(), gatewayLogDto, (Duration.between(gatewayLogDto.getReqTime(), gatewayLogDto.getResTime())).toMillis());
}
@Override
public int getOrder() {
// 注意order要小于-1,就能查看服务端响应的值了
return -99;
}
}
实战:Sentinel:熔断与流控
参考:https://github.com/alibaba/spring-cloud-alibaba/wiki/Sentinel
Sentinel控制台环境搭建
1、下载jar
https://github.com/alibaba/Sentinel/releases
2、找到下载地址
https://github.com/alibaba/Sentinel/releases/download/v1.8.0/sentinel-dashboard-1.8.0.jar
3、运行jar
#!/usr/bin/env bash
nohup java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.0.jar > Sentilel.log &
4、登陆
http://localhost:8888
登陆的用户名密码均为:sentinel
- 其他启动参数
-Dsentinel.dashboard.auth.username=sentinel
-Dsentinel.dashboard.auth.password=sentinel
-Dsentinel.servlet.session.timeout=7200
指定SpringBoot服务端session的过期时间,单位秒
60m,则表示60分钟
默认值为30分钟
- 扩展
如果使用jar启动麻烦,我就把他做成镜像供大家使用
FROM registry.cn-hangzhou.aliyuncs.com/sherry/java:8-jdk232-with-skywaling-agent
COPY ./sentinel-dashboard-1.8.0.jar /usr/app/sentinel-dashboard.jar
WORKDIR /usr/app
ENV TZ='Asia/Shanghai'
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' > /etc/timezone
ENV JAVA_OPTS='-Xms1024m -Xmx1024m'
# Skywalking相关配置
ENV SW_AGENT_NAME='sentinel-dashboard'
ENV SAMPLE_N_PER_3_SECS=1500
ENV SW_AGENT_OPEN_DEBUG=false
ENV SW_LOGGING_MAX_HISTORY_FILES=1
ENV JAVA_AGENT_OPTS='-javaagent:/skywalking/skywalking-agent.jar'
ENTRYPOINT java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar
# 打包
docker build -t registry.cn-hangzhou.aliyuncs.com/sherry/sentinel-dashboard:1.8.0 .
# 启动
docker run --restart=always -d -p 8888:8888 --name sentinel-dasbboard registry.cn-hangzhou.aliyuncs.com/sherry/sentinel-dashboard:1.8.0
Feign调用超时与降级
- pom
在auth和user模块都加入pom
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
其实在微服务体系中,基本上所有应用都是Sentinel客户端,都加入依赖即可
- yaml
spring:
application:
name: chan-auth-svc
main:
allow-bean-definition-overriding: true
cloud:
nacos:
discovery:
server-addr: localhost:8848
sentinel:
transport:
dashboard: localhost:8888
server:
port: 10001
management:
# 端点检查(健康检查)
endpoints:
web:
exposure:
exclude: "*"
feign:
sentinel:
enabled: true
- fallback
package com.zhangln.chan.user.api.fallback;
import com.zhangln.chan.common.vo.R;
import com.zhangln.chan.user.api.UserServiceApi;
import org.springframework.stereotype.Component;
/**
* @author sherry
* @description
* @date Create in 2020/8/23
* @modified By:
*/
@Component
public class UserServiceFallback implements UserServiceApi {
@Override
public R user(String userId) {
return R.error("调用/user失败,参数 userId:" + userId);
}
@Override
public R testSentinel(Integer timeout) {
return R.error("调用/user/sentinel失败,参数 timeout:" + timeout);
}
}
package com.zhangln.chan.user.api;
import com.zhangln.chan.common.vo.R;
import com.zhangln.chan.user.api.fallback.UserServiceFallback;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @author sherry
* @description
* @date Create in 2020/8/22
* @modified By:
*/
@FeignClient(name = "chan-user-svc", fallback = UserServiceFallback.class)
public interface UserServiceApi {
/**
* 查询
*
* @param userId
* @return
*/
@GetMapping("/user")
R user(@RequestParam(name = "userId") String userId);
/**
* 测试Sentinel
*
* @param timeout
* @return
*/
@GetMapping("/user/sentinel")
R testSentinel(@RequestParam(name = "timeout") Integer timeout);
}
注意:UserServiceFallback编写后,要配置到spring.factories中,否则auth模块无法找到
- 调用
我们找一个Feign调用的接口进行调用,然后就能在sentinel控制台看到我们的Feign客户端注册上来了
注意:一定要多调用几次接口,才能在控制台看到我们的应用
- 验证熔断
这个是正常的
这个由于超时时间过长,就触发了熔断。
并且从user模块的日志来看,Feign在调用的时候应该是发起了超时重试的。
这里我猜测有一个默认配置,就是Feign调用默认超时时间为1秒,超时后重试1次
网关调用超时与降级
- pom
<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>
- yaml
小结
Sentinel的详细使用与配置,后续再进行深入
实战:Dubbo基本使用
- 为什么要引入Dubbo?
当微服务体系足够大了之后,除了对外暴露的服务,使用Http协议,内部服务间调用,最好还是使用性能更加高的。
而Dubbo,就是其中比较好的。
- 案例:auth模块编写一个接口供gateway调用
pom
- 在生产者和消费者中,都添加以下pom
<!-- Apache Dubbo -->
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
</dependency>
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-registry-nacos</artifactId>
</dependency>
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-serialization-kryo</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
<exclusion>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-common</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.alibaba.spring</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
生产者
- 编写一个普通接口
public interface AuthServiceDubbo {
R getTime(String msg);
}
- 实现这个接口
package com.zhangln.chan.auth.service;
import com.zhangln.chan.auth.api.AuthServiceDubbo;
import com.zhangln.chan.common.vo.R;
import org.apache.dubbo.config.annotation.Service;
import java.time.LocalDateTime;
/**
* @author sherry
* @description
* @date Create in 2020/8/24
* @modified By:
*/
@Service(version = "1.0.0")
public class AuthServiceDubboImpl implements AuthServiceDubbo {
@Override
public R getTime(String msg) {
return R.ok("当前时间"+msg+":"+ LocalDateTime.now());
}
}
并通过@Service注解暴露服务
- yaml
dubbo:
scan:
base-packages: com.zhangln.chan.auth.service
protocol:
name: dubbo
port: -1
serialization: kryo
registry:
address: nacos://localhost:8848
provider:
loadbalance: roundrobin
消费者
- 当做普通Service使用即可
package com.zhangln.chan.gateway.service;
import com.zhangln.chan.auth.api.AuthServiceDubbo;
import com.zhangln.chan.common.vo.R;
import org.apache.dubbo.config.annotation.Reference;
import org.springframework.stereotype.Service;
/**
* @author sherry
* @description
* @date Create in 2020/8/24
* @modified By:
*/
@Service
public class AccessService {
@Reference(version = "1.0.0")
private AuthServiceDubbo authServiceDubbo;
public R getTime(String msg) {
R r = authServiceDubbo.getTime(msg);
return r;
}
}
- yaml
management:
health:
dubbo:
status:
defaults: memory
extras: threadpool
dubbo:
scan:
base-packages: com.zhangln.chan.gateway.service
protocol:
name: dubbo
port: -1
serialization: kryo
registry:
address: nacos://localhost:8848
验证
启动后,我们可以在Nacos中看到这两个服务
实战:Web开发基础
通用返回对象
全局异常
package com.zhangln.chan.common.web;
import com.zhangln.chan.common.vo.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @author sherry
* @description
* @date Create in 2020/8/22
* @modified By:
*/
@RestControllerAdvice
@Slf4j
public class ControllerCheckAdvice {
/**
* 全局异常兜底
*
* @param e
* @return
*/
@ExceptionHandler(Throwable.class)
public R globalError(Throwable e) {
log.error("全局异常:", e);
return R.error(e.getMessage());
}
}
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.zhangln.chan.common.web.ControllerCheckAdvice
自定义异常
- 异常类
public class ChanError extends Throwable {
private Integer code;
private String message;
public ChanError(Integer code, String message) {
super(message);
this.code = code;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
}
- 全局捕获
@RestControllerAdvice
@Slf4j
public class ControllerCheckAdvice {
/**
* 全局异常兜底
*
* @param e 异常
* @return R 返回
*/
@ExceptionHandler(Throwable.class)
public R globalError(Throwable e) {
log.error("全局异常:", e);
return R.error(e.getMessage());
}
@ExceptionHandler(ChanError.class)
public R chanError(ChanError e) {
log.error("全局异常:", e);
return R.error(e);
}
}
AOP日志
package com.zhangln.chan.common.web;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* @author sherry
* @description
* @date Create in 2020/8/22
* @modified By:
*/
@Aspect
@Component
@Slf4j
public class ReqResAop {
@Pointcut("execution(public * com.zhangln.chan.*.controller.*.*(..))")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint pjp) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String uri = request.getRequestURI();
log.info("AOP拦截:{}", uri);
Object proceed = null;
try {
proceed = pjp.proceed();
} catch (Throwable e) {
log.error(e.getMessage(), e);
throw new RuntimeException("AOP异常:" + e.getMessage());
}
return proceed;
}
}
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.zhangln.chan.common.web.ControllerCheckAdvice,\
com.zhangln.chan.common.web.ReqResAop
配置文件
- 假设在yaml文件中有这么几项要注入
chan-gateway:
ignore:
- /auth/pub/**
- /user/pub/**
- 在common中创建类
@Component
@Data
@Configuration
@ConfigurationProperties(prefix = "chan-gateway")
public class ChanGatewayConfig {
private List<String> ignore;
}
- 在需要用到的地方添加
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.zhangln.chan.common.prop.ChanGatewayConfig
包分层规则与领域对象
- 每一个package下,都创建一个package-info.java,说明此package的作用
- 开放接口层
- Controller
- Api/feign/dubbo
- VO/BO/Query
如果是对外的,那一般就是Controller接口层;
如果是内部调用的,那一般是有一个单独的api包,通过feign或dubbo的方式,供内部系统调用;
内部系统引入对应api的pom后,像调用本地方法一样调用集群中其他应用的api
请求参数为Query对象
返回数据封装成VO对象(Controller接口);如果是Dubbo调用,即将Service暴露服务,则返回BO对象
一个Service对应一个Controller,一个Manager,更多的是通用业务层的复用;
各个层级之间的领域对象传输类型,如箭头所示。
也不一定非要按照上述层级编写代码,最终目的就是让每一层级的代码各司其事,让代码更加简介易读有序,方便重构优化,便于小步快速迭代。
实战:基本权限系统设计
基本思路
- 不打算使用Shiro或Spring Security框架
- 理由一:自己写更加灵活
- 理由二:Spring Security太复杂了,扩展不方便;Shiro在分布式系统中集成有点麻烦
总之,综合起来,还是自己实现一套吧,也不是什么特别复杂的东西。RBAC和OAuth2.0都是很成熟的协议
- 目标
第一阶段:仅针对需要认证的接口校验token是否合法即可,至于token是否有权限访问,不管;仅账号密码模式,密码不加密
第二阶段:添加上token和接口之间的权限校验;其他更加特性的校验,如:token和访问目标租户之间的数据权限,放到具体接口中单独校验;支持多种认证模式;认证信息加密
不使用JWT传输数据
认证授权流程设计
1、请求到网关
2、网关通过Dubbo访问chan-auth-svc
3、chan-auth-svc判断本次请求是否允许
4、网关通过chan-auth-svc的请求结果决定是否正常路由
表结构设计
就设计了两张表,太简单了,以至于都不想讲了
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for t00_token_validate
-- ----------------------------
DROP TABLE IF EXISTS `t00_token_validate`;
CREATE TABLE `t00_token_validate` (
`id` bigint(20) NOT NULL,
`username` varchar(127) COLLATE utf8mb4_bin NOT NULL,
`token` varchar(127) COLLATE utf8mb4_bin NOT NULL,
`exp_time` datetime NOT NULL COMMENT '过期时间',
`create_dt` datetime DEFAULT CURRENT_TIMESTAMP,
`update_dt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `t00_token_validate_pk_2` (`username`),
UNIQUE KEY `t00_token_validate_token_uindex` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='token校验';
-- ----------------------------
-- Table structure for t00_user_auth
-- ----------------------------
DROP TABLE IF EXISTS `t00_user_auth`;
CREATE TABLE `t00_user_auth` (
`id` bigint(20) NOT NULL,
`username` varchar(127) COLLATE utf8mb4_bin DEFAULT '' COMMENT '账号',
`pwd` varchar(255) COLLATE utf8mb4_bin DEFAULT '' COMMENT '密码',
`create_dt` datetime DEFAULT CURRENT_TIMESTAMP,
`update_dt` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `t00_user_auth_username_uindex` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户认证信息';
SET FOREIGN_KEY_CHECKS = 1;
整合MyBatis-Plus
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
- 执行自动代码生成工具,进行代码生成
- 配置数据库连接信息
spring:
application:
name: chan-auth-svc
main:
allow-bean-definition-overriding: true
cloud:
nacos:
discovery:
server-addr: localhost:8848
# config:
# server-addr: localhost:8848
sentinel:
transport:
dashboard: localhost:8888
datasource:
type: com.zaxxer.hikari.HikariDataSource
# 注意,生产环境要加密
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/chan_auth?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
hikari:
# 是否允许暂停连接池,暂停后getConnection方法都会被阻塞,用于模拟连接故障,生产环境使用默认配置false
allow-pool-suspension: false
# 空闲连接数
minimum-idle: ${DB_IDLE:1}
# 最大连接数
maximum-pool-size: ${DB_POOLSIZE:5}
connection-test-query: select 1
# 一个连接idle状态的最大时长(毫秒),超时则被释放(retired),缺省:10分钟
idle-timeout: 100000
# 一个连接的生命时长(毫秒),超时而且没被使用则被释放(retired),缺省:30分钟,建议设置比数据库超时时长少30秒
max-lifetime: 120000
connection-timeout: 30000
connection-init-sql: set names utf8mb4
pool-name: DatebookHikariCP
- MyBatis配置
package com.zhangln.chan.auth.config;
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.zhangln.chan.common.id.SnowIdDto;
import com.zhangln.chan.common.id.SnowManager;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* @author sherry
* @description
* @date Create in 2020/8/3
* @modified By:
*/
@EnableTransactionManagement
@Configuration
@MapperScan(basePackages = "com.zhangln.chan.**.mapper")
@Slf4j
public class MyBatisPlusConfig {
@Value("${spring.application.name:chan-auth-svc}")
private String appName;
private final SnowManager snowManager;
public MyBatisPlusConfig(SnowManager snowManager) {
this.snowManager = snowManager;
}
/**
* 乐观锁
*
* @return
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor() {
return new OptimisticLockerInterceptor();
}
/**
* 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
return paginationInterceptor;
}
@Bean
public IdentifierGenerator idGenerator() {
SnowIdDto snowId = snowManager.getSnowId(appName);
log.info("{} 使用雪花算法作为id生成策略:{}", appName, snowId);
return new DefaultIdentifierGenerator(snowId.getWorkerId(), snowId.getDataId());
}
}
- 解决雪花算法可能导致的id重复问题
详见:SnowManager类
- 测试方法
@SpringBootTest(classes = ChanAuthApplication.class)
@RunWith(SpringRunner.class)
public class T00UserAuthDaoTest {
@Autowired
private T00UserAuthDao t00UserAuthDao;
/**
* 测试新增
*/
@Test
public void testSaveUserAuth() {
T00UserAuthEntity t00UserAuthEntity = new T00UserAuthEntity();
t00UserAuthEntity.setUsername("admin");
t00UserAuthEntity.setPwd("123456");
t00UserAuthDao.save(t00UserAuthEntity);
}
}
核心代码逻辑
- 获取token:供客户端调用,http协议
@PostMapping(value = "/token", params = "type=get")
public R getTokenByUsernameAndPassword(@Validated GetTokenQuery getTokenQuery) {
TokenVo tokenVo = authService.getTokenByUsernameAndPassword(getTokenQuery.getUsername(), getTokenQuery.getPwd());
return R.ok(tokenVo);
}
- 校验token:供网关调用,dubbo协议
public interface AuthServiceDubbo {
/**
* 校验token是否有效
*
* @param token
* @param uri
* @return
*/
R<TokenCheckResultBo> checkToken(String token, String uri);
}
这里的校验比较简单,我只校验token本身是否合法
网关在校验之前,先判断当前uri是否允许匿名调用,
允许匿名调用的uri都配置在网关的yaml中
后续可以配置到数据库中,程序启动的时候加载进缓存
实战:分布式事务之Seata
前置准备
既然是分布式事务,那么我们至少得有两个数据库,一个就是前面认证授权的chan_auth库,另一个我们就为chan_user创建一个库,并且建一张t00_user_info表
并且整合MyBatis-Plus
再创建一个chan_log应用和数据库
最终是,auth作为调用方调用user和log模块
部署Seata
这里就只讲操作,原理就不细究了,等下一章节再说
执行命令即可
docker-compose up -d
启动成功
- 在所有需要分布式事务的数据库上,都创建以下表
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
Dubbo
- Dubbo案例,auth正常调用user和auth异常调用user,触发回滚
- 先用JDBC演示,再用MyBatis演示
1、编写Dubbo接口
2、配置Seata,即 file.conf和registry.conf
3、配置yml
4、配置SeataConfiguration(Seata参与者和管理者都要配置)
- 参与者的SeataConfiguration
package com.zhangln.chan.log.config;
import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import io.seata.spring.annotation.GlobalTransactionScanner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
public class SeataConfiguration {
@Value("${spring.application.name}")
private String appName;
@Bean("hikariDataSource")
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public HikariDataSource hikariDataSource() {
HikariDataSource hikariDataSource = new HikariDataSource();
return hikariDataSource;
}
/**
* 将本地数据源交给Seata进行管理
*
* @param hikariDataSource
* @return
*/
@Primary
@Bean("dataSource")
public DataSourceProxy dataSourceProxy(DataSource hikariDataSource) {
return new DataSourceProxy(hikariDataSource);
}
/**
* 因为数据源交给了Seata进行代理,所以和数据源相关的一系列对象,都需要自己进行构建,并且需要排除自动配置
*
* @param dataSource
* @return
*/
@Bean
@ConditionalOnBean(DataSourceProxy.class)
public JdbcTemplate jdbcTemplate(DataSourceProxy dataSource) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
return jdbcTemplate;
}
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner(appName, "tx_group");
}
}
- 如果我们使用MyBatis操作数据库,而不是JDBCTemplate,则使用DataSourceProxy重新初始化一下SQLSessionFactory即可
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
.getResources("classpath*:/mapper/*.xml"));
return factoryBean.getObject();
}
- 如果我们使用MyBatis-Plus,则更加方便,只需配置好DataSourceProxy即可
Feign
- 发起者添加配置
package com.zhangln.seata.feign.main.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.context.annotation.Configuration;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import io.seata.core.context.RootContext;
/**
* 内部调用需要token校验,这边将页面端的token和平台属性转发给feign
*/
@Configuration
@Slf4j
public class FeignConfig implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
String xid = RootContext.getXID();
if (StringUtils.isNotBlank(xid)) {
log.info("feign 获得分布式事务xid:" + xid);
}
requestTemplate.header(RootContext.KEY_XID, xid);
}
}
- 写作者添加配置
package com.zhangln.seata.feign.db2.config;
import io.seata.core.context.RootContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class FescarXidFilter extends OncePerRequestFilter {
protected Logger logger = LoggerFactory.getLogger(FescarXidFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String xid = RootContext.getXID();
String restXid = request.getHeader(RootContext.KEY_XID);
log.info("设置 Xid:{}",restXid);
boolean bind = false;
if (StringUtils.isBlank(xid) && StringUtils.isNotBlank(restXid)) {
RootContext.bind(restXid);
bind = true;
if (logger.isDebugEnabled()) {
logger.debug("bind[" + restXid + "] to RootContext");
}
}
try {
filterChain.doFilter(request, response);
} finally {
if (bind) {
String unbindXid = RootContext.unbind();
if (logger.isDebugEnabled()) {
logger.debug("unbind[" + unbindXid + "] from RootContext");
}
if (!restXid.equalsIgnoreCase(unbindXid)) {
logger.warn("xid in change during http rest from " + restXid + " to " + unbindXid);
if (unbindXid != null) {
RootContext.bind(unbindXid);
logger.warn("bind [" + unbindXid + "] back to RootContext");
}
}
}
}
}
}
- 参考
https://www.zhangln.com/2020/05/14/fen-bu-shi-kai-fa-yu-wei-fu-wu/fen-bu-shi-shi-wu-jie-jue-fang-an-zhi-seata/