因为之前学过Eureka 所以很多基础的设置就不记录了
本文对应的项目代码-CloudBase
几个仓库供学习参考
Spring Cloud基础教程
涵盖大部分核心组件使用的Spring Cloud教程
2021.1.15
提交了一个根据vue-admin-template修改的一个基本的前端架子
然后跟本项目做了联调 实现了登录注销等、用户信息的获取是在consumer模块中 把信息写死了 admin角色
前端仓库-VueAdmin
注册中心
下载自行百度 顺便可以把sentinal-dashboard也下载了 跟Eureka不同 nacos可以直接使用jar包启动就行了
然后访问localhost:8848/nacos 就可以进入页面了 账号密码都是nacos (sentinal的dashboard的登录账号密码都是sentinal)
只需要下面三步 启动就可以注册上nacos里了
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
server:
port: 8881
spring:
application:
name: cloudstudy-provider
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
配置中心
导入依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
server:
port: 9101
spring:
#配置环境
#profiles:
#active: dev
application:
name: nacos-config-client
cloud:
nacos:
discovery:
server-addr: localhost:8848 #Nacos地址
config:
server-addr: localhost:8848 #Nacos地址
file-extension: yml #这里我们获取的yaml格式的配置
项目的配置文件要使用bootstrap.yml 配置信息如上
nacos中的配置文件的dataid的组成格式及与SpringBoot配置文件中的属性对应关系
spring.profiles.active为配置的环境
假设我当前项目用的是dev环境 项目名为consumer 后缀是yml 那么组合起来就是 consumer-dev.yml 就会根据这个去配置中心找对应的文件 读取
${
spring.application.name}-${
spring.profiles.active}.${
spring.cloud.nacos.config.file-extension}
对应在nacos上创建配置文件 然后测试一下是否将配置文件记载进来 使用@Value注入
然后调用接口返回值 是否对应配置的值即可验证
添加其他的配置文件 共享一些公共的配置 例如mysql redis之类的
#共享配置文件
shared-configs[0]:
data-id: commom.yml
group: DEFAULT_GROUP
refresh: true #要配置刷新
# 这样也可以共享配置文件
# extension-configs[0]:
# - data-id: shareconfig3.yml
# group: SHARE3_GROUP
# refresh: true
看启动的控制台可以发现加载到了对应的两个配置文件
使用RestTemplate
在之前的例子中,已经使用过RestTemplate
来向服务的某个具体实例发起HTTP请求,但是具体的请求路径是通过拼接完成的,对于开发体验并不好。但是,实际上,在Spring Cloud中对RestTemplate做了增强,只需要稍加配置,就能简化之前的调用方式。
比如:
@EnableDiscoveryClient
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@Slf4j
@RestController
static class TestController {
@Autowired
RestTemplate restTemplate;
@GetMapping("/test")
public String test() {
String result = restTemplate.getForObject("http://alibaba-nacos-discovery-server/hello?name=didi", String.class);
return "Return : " + result;
}
}
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
可以看到,在定义RestTemplate的时候,增加了@LoadBalanced
注解,而在真正调用服务接口的时候,原来host部分是通过手工拼接ip和端口的,直接采用服务名的时候来写请求路径即可。在真正调用的时候,Spring Cloud会将请求拦截下来,然后通过负载均衡器选出节点,并替换服务名部分为具体的ip和端口,从而实现基于服务名的负载均衡调用。
使用WebClient(可以不看)
WebClient是Spring 5中最新引入的,可以将其理解为reactive版的RestTemplate。下面举个具体的例子,它将实现与上面RestTemplate一样的请求调用:
@EnableDiscoveryClient
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@Slf4j
@RestController
static class TestController {
@Autowired
private WebClient.Builder webClientBuilder;
@GetMapping("/test")
public Mono test() {
Mono result = webClientBuilder.build()
.get()
.uri("http://alibaba-nacos-discovery-server/hello?name=didi")
.retrieve()
.bodyToMono(String.class);
return result;
}
}
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
return WebClient.builder();
}
}
可以看到,在定义WebClient.Builder的时候,也增加了@LoadBalanced
注解,其原理与之前的RestTemplate时一样的。关于WebClient的完整例子也可以通过在文末的仓库中查看。
使用Feign(建议使用 与平常的SpringBoot项目类似 Controller调用Service层 只不过service层使用feign去调用对应的服务提供者的接口)
上面介绍的RestTemplate和WebClient都是Spring自己封装的工具,下面介绍一个Netflix OSS中的成员,通过它可以更方便的定义和使用服务消费客户端。下面也举一个具体的例子,其实现内容与上面两种方式结果一致:
第一步:在pom.xml
中增加openfeign的依赖:
org.springframework.cloud
spring-cloud-starter-openfeign
第二步:定义Feign客户端和使用Feign客户端:
@EnableDiscoveryClient
@SpringBootApplication
@EnableFeignClients
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@Slf4j
@RestController
static class TestController {
@Autowired
Client client;
@GetMapping("/test")
public String test() {
String result = client.hello("didi");
return "Return : " + result;
}
}
@FeignClient("alibaba-nacos-discovery-server")
interface Client {
@GetMapping("/hello")
String hello(@RequestParam(name = "name") String name);
}
}
这里主要先通过@EnableFeignClients
注解开启扫描Spring Cloud Feign客户端的功能;然后又创建一个Feign的客户端接口定义。使用@FeignClient
注解来指定这个接口所要调用的服务名称,接口中定义的各个函数使用Spring MVC的注解就可以来绑定服务提供方的REST接口,比如下面就是绑定alibaba-nacos-discovery-server
服务的/hello
接口的例子。最后,在Controller中,注入了Client接口的实现,并调用hello方法来触发对服务提供方的调用。
feign相关配置
cloudstudy-provider: #对应FeignClient注解上的服务名
ribbon:
# NFLoadBalancerRuleClassName: com.zyfgoup.config.MyRule #自己写的只取第一个服务
# NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule #配置规则 随机
# NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule #配置规则 轮询
# NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RetryRule #配置规则 重试
# NFLoadBalancerRuleClassName: com.netflix.loadbalancer.WeightedResponseTimeRule #配置规则 响应时间权重
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.BestAvailableRule #配置规则 最空闲连接策略
ConnectTimeout: 500 #请求连接超时时间
ReadTimeout: 1000 #请求处理的超时时间
OkToRetryOnAllOperations: true #对所有请求都进行重试
MaxAutoRetriesNextServer: 2 #切换实例的重试次数
MaxAutoRetries: 1 #对当前实例的重试次数
深入思考
不论我用的是RestTempalte
也好、还是用的WebClient
也好,还是用的Feign
也好,似乎跟我用不用Nacos没啥关系?我们在之前介绍Eureka和Consul的时候,也都是用同样的方法来实现服务调用的,不是吗?
确实是这样,对于Spring Cloud老手来说,就算我们更换了Nacos作为新的服务注册中心,其实对于我们应用层面的代码是没有影响的。那么为什么Spring Cloud可以带给我们这样的完美编码体验呢?实际上,这完全归功于Spring Cloud Common的封装,由于在服务注册与发现、客户端负载均衡等方面都做了很好的抽象,而上层应用方面依赖的都是这些抽象接口,而非针对某个具体中间件的实现。所以,在Spring Cloud中,我们可以很方便的去切换服务治理方面的中间件。
下载对应的sentinel-dashboard的jar文件 启动即可 端口号为8080 登录账号密码都是sentinel
导入依赖和相关的配置
>
>com.alibaba.cloud >
>spring-cloud-alibaba-sentinel >
>
server:
port: 8882
spring:
application:
name: cloudstudy-cunsumer
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
sentinel:
transport:
dashboard: localhost:8080
port: 8719 #这是sentinel会创建一个HttpServer的端口号 默认8719 被占用则+1+1.... 当设置了一些规则后 传到这个server然后再注册到sentinel上
@ResourceSentinel
value 指定资源名 可自定义 一般与请求路径一直即可
fallback:失败调用,若本接口出现未知异常,则调用fallback指定的接口。
blockHandler:sentinel定义的失败调用或限制调用,若本次访问被限流或服务降级,则调用blockHandler指定的接口。
要注意指定的blockHandler方法与原本的方法 返回的参数要一致
(假设一个接口里抛出了异常 则会去执行对应的fallback方法 如果又在sentinal设置了限流等 则调用了几次后 返回blockhandler对应的方法)
blockHandlerClass 指定自定义全局的处理服务降级熔断后的返回方法 这样就不用一个方法又对应一个熔断方法
@GetMapping("/get/{i}")
@SentinelResource(value = "getI",blockHandler = "getI_Handler")
public String getI(@PathVariable("i") Integer i) throws InterruptedException {
//地址 返回值类型 参数在地址后面用数字表示 对应后面的可变长参数
return String.valueOf(restTemplate.getForObject(PROVIDERSERVICEURL+"/get/{1}",Integer.class,i));
}
public String getI_Handler(Integer i,BlockException e){
return "使用Sentinel实现服务降级";
}
控制台页面如上所示
当启动项目后 需要先访问一次端口号 才会在控制台有显示
可以看到有流控、降级、热点
流控是可以直接指定一个qps阈值 超过的话 则会去调用blockhandler对应的方法进行返回
降级是定义一个规则 例如RT(响应时间)超过多久就会进行降级 然后进行熔断一段时间(如下图中的时间窗口) 调用blockhandler
热点就是可以设立某个资源当传入某些某个参数时会进行降级 参数下标从0开始
每次重启服务时,配置的各种规则都是消失,所以需要做规则的持久化
>
>com.alibaba.csp >
>sentinel-datasource-nacos >
>
sentinel:
transport:
dashboard: localhost:8858
port: 8719
datasource:
ds1:
nacos:
server-addr: localhost:8848
dataId: cloudstudy-consumer
groupId: DEFAULT_GROUP
data-type: json
rule-type: flow
打开nacos控制台 创建配置
[
{
"resource": "getI",
"limitApp": "default",
"grade": 1,
"count": 1,
"strategy": 0,
"controlBehavior": 0,
"clusterMode": false
}
]
resource是资源名
limitApp是来源应用
grade是阈值类型,0是线程,1是QPS
count是阈值
strategy是流控模式,0是直接,1是关联,2是链接
controlBehavior是流控效果,0是直接,1是warm up,2是排队
clusterMode是集群模式
这样就配置成功每次运行时都会使byGlobalRescource按照QPS快速直接,阈值为1非集群环境下限流,实现了持久化。
全局服务降级方法
定义一个全局的服务降级处理类
public class ConsumerBlockHandler {
public static String handlerException1(BlockException e){
return "全局服务降级方法1";
}
public static String handlerException2(BlockException e){
return "全局服务降级方法2";
}
}
//使用时 只需要对应的接口上使用注解 定义对应的处理类和处理的方法
@SentinelResource(value = "global",blockHandlerClass = ConsumerBlockHandler.class,blockHandler = "handlerException1" )
使用feign的服务降级
(启动类要用@EnableFeignClients)
在consumer模块中 定义service层 但是不需要具体实现
只需要使用FeignClient 配置对应的服务提供的ming 要使用服务降级方法 则可以配置对应类
对应的fallback处理类 继承了对应的服务 在对应的方法上实现服务降级时的返回即可
feign:
sentinel:
enabled: true
具体的一些断言、过滤可看这篇文章
需要用到的依赖
org.springframework.cloud
spring-cloud-starter-gateway
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
org.springframework.boot
spring-boot-starter-data-redis-reactive
创建一个启动类 启动服务注册与发现
配置文件
server:
port: 8888
spring:
redis:
host: localhost
password: 123456
port: 6379
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: true #表明gateway开启服务注册和发现的功能,并且spring cloud gateway自动根据服务发现为每一个服务创建了一个router,这个router将以服务名开头的请求路径转发到对应的服务。
lowerCaseServiceId: true #是将请求路径上的服务名配置为小写(因为服务注册的时候,向注册中心注册时将服务名转成大写的了),比如以/service-hi/*的请求路径被路由转发到服务名为service-hi的服务上。
routes:
- id: cloudsyudy-comsumer
uri: lb://cloudstudy-consumer
predicates: # 断言,路径相匹配的进行路由 test在实际应用中应为某个模块的名字 例如/user/**
- Path=/api/test/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 #每秒允许处理的请求数量
redis-rate-limiter.burstCapacity: 20 #每秒最大处理的请求数量
key-resolver: "#{@ipKeyResolver}" #限流策略,对应策略的Bean
- StripPrefix=2 #会把/api/test/去掉 然后如果匹配上的话 请求路径就是cloudstudy-consumer的地址+/**
nacos:
discovery:
server-addr: 127.0.0.1:8848
启动服务注册与发现和服务名配置为小写的配置(注册上nacos的服务名默认是大写的)
这里只使用了简单的一个路径匹配的断言 和 去除前缀的过滤
这样只要我浏览器访问localhost:8888/api/test/** 后面随意写 然后会去找到 cloudstudy-consumer这个服务名
然后将localhost:8888替换成 cloudstudy-cloud的地址 然后 去除/api/test 把后面的接上 去访问对应的接口
consumer中又使用feign去调用对应的provider即可。当然也可以在这里直接到provider 看个人需要
ip限制 配置文件上都有注释
@Configuration
public class RedisRateLimiterConfig {
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}
}
问题:如果我配置了多个不同的id 每个uri都是同一个消费者,但是断言是不同的 好像是不能去匹配最匹配的
2021.1.15更新
将JwtUtils修改为传入String类型构造jwt,这样在拿到自己构建的UserDetail的实例时 不再只用userid来构建jwt,而是使用实例转化为json字符串去构建,
方便前端拿到token时可以解析token拿到用户信息 显示在网站左上角当前的用户信息等
改动如下 UserVO就是在登录成功后的用户信息 方便前端显示
这样在后面前端写时 如果关闭浏览器了 session里存的用户信息无效了,还是可以解析token拿到用户信息
AuthUser authUser = (AuthUser) authResult.getPrincipal();
UserVO userVO = new UserVO();
BeanUtils.copyProperties(authUser,userVO);
//生成token 根据User生成
String jwtToken = JwtUtils.generateToken(JSON.toJSONString(userVO));
由于使用前后端分离 所以这里使用JWT的方式来作为认证(Token详解请百度)
简单的说就是登陆的时候 根据登录用户的信息生成jwt,然后附在响应头上或者在响应体中返回给前端,vue(我用vue写的前端)中拿到token 存起来 然后每次请求都在请求头上带上token即可
之前单个SpringBoot项目 我是用Shiro来做前后端分离(项目,这个项目是之前的课设项目,就使用到了shiro+jwt),这次用SpringSecurity来实现(其实理解了跟Shiro也是差不多的)
先说大概的实现思路
1.gateway中可以定义filter 拦截所有的请求 那么我就可以将注册、登录、注销不拦截,其余的都进行拦截判断三点:
(1)请求头中和Redis是否有token
(2)是否过期(token生成时可以定义过期时间 比对当前日期)
(3)进行匹配Redis中存放的对应的用户权限(目前是将角色和资源url权限放到一起了,但是在匹配的时候匹配的是路径,所以如果需要都实现 则也可以将角色放到redis中 先判断角色 再判断url ,也可以单拿到userid获取角色 然后匹配登录成功时存放的角色权限 来比对)
那么只需要我们在登录成功后 将登录用户的的token、权限信息存到Redis中即可实现
2.在auth模块集成SpringSecurity 实现登录注册注销即可 原本应该还有授权 但是授权我们提到gateway中实现了 所以并没有什么存在意义,但是在模块中也还是实现了
具体实现:
具体代码、依赖就不贴了 看模块内的代码就好了
先定义相关的Security配置
构造密码加密方式的Bean
配置具体的用户信息实现类和密码的加密方式
实现UserDetailService这个接口 只有一个方法
这个方法其实就是根据username去查询数据库 看用户是否存在,如果存在则构建一个UserDetails的实例,包含用户名、密码、权限
再回到配置类,配置了登录、注册、注销的请求路径
还有配置两个filter(认证和授权,授权其实没啥用)和一个filter错误的统一处理(感觉没啥用)
注意:由于filter、handler构建的比context早 如果在这些类里面使用Autowire可能无效 所以用过构造器注入的方式注入要用的bean
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
//这两个请求 不拦截
.antMatchers(HttpMethod.POST, "/login","/register").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.logoutUrl("/logout")
//注销处理
.logoutSuccessHandler(new MyLogoutHandler(redisTemplate,jwtUtils))
.and()
//这是认证
.addFilterBefore(new JWTAuthenticationFilter(authenticationManager(), redisTemplate,jwtUtils), UsernamePasswordAuthenticationFilter.class)
//这是授权
.addFilterBefore(new JWTAuthorizationFilter(authenticationManager(),redisTemplate,jwtUtils), UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint());
}
重点只讲认证的filter
attemptAuthentication()方法就是根据请求拿到界面输入的用户名密码 然后去认证
认证的过程其实就是对密码使用前面注入的加密的bean 去加密 然后匹配 UserDetailsService实现类方法里获取到的UserDetail实例 比对密码是否正确
由于前后端分离 数据都是json格式 所以这里要将request里的数据转换成User类
successfulAuthentication()方法就是认证成功后调用的方法,认证成功则生成token 放入响应头中
AuthUser authUser = (AuthUser) authResult.getPrincipal();
这行代码就是拿的前面构建的UserDetail实例
里面有对应的权限
那么就可以将token、权限存放到redis中,key由userid来构建(token是根据userid生成的 网上也有一些可以直接根据实体类来生成 自己选择即可)
认证失败则返回对应的错误信息即可
/**
* 从请求拿到账号密码 然后走到定义的userDetailsServiceImpl的方法
* loadUserByUsername中 根据username 去拿到数据库的user 构建成一个实例
* 然后和token比对 密码使用配置里定义好的加密方式
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
//将json数据转化为User对象
User user = jsonToUser(request);
String username = user.getUsername();
String password = user.getPassword();
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
/**
* 上面方法验证成功后便执行到这里 生成jwttoken 返回即可
* @param request
* @param response
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@SneakyThrows
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
AuthUser authUser = (AuthUser) authResult.getPrincipal();
//生成token
String jwtToken = jwtUtils.generateToken(authUser.getId());
//application/json
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setHeader("Authorization",jwtToken);
//将Authorization在响应首部暴露出来
response.setHeader("Access-control-Expose-Headers", "Authorization");
//token
String key = "JWT" + authUser.getId() + ":";
//权限
String authKey = key + ":Authorities";
//jwtUtils.getExpire() 配置文件配置的过期时间 使用config配置中心 可以动态改
redisTemplate.opsForValue().set(key,jwtToken,jwtUtils.getExpire(),TimeUnit.SECONDS);
redisTemplate.opsForValue().set(authKey, JSONObject.toJSONString(authUser.getAuthorities()), jwtUtils.getExpire() , TimeUnit.SECONDS);
response.getWriter().write(JSONObject.toJSONString(Result.succ(jwtToken)));
}
/**
* 认证失败 则到这里 根据异常判断是账号不存在 还是密码错误
* @param request
* @param response
* @param failed
* @throws IOException
* @throws ServletException
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.error("登录失败",failed);
Result result = null;
int status = 401;
if (failed instanceof BadCredentialsException){
result = Result.fail(401,null, "用户名或者密码不正确");
}
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(status);
response.getWriter().write(JSONObject.toJSONString(result));
}
private User jsonToUser(HttpServletRequest request){
StringBuffer jb = new StringBuffer();
String line = null;
try {
BufferedReader reader = request.getReader();
while ((line = reader.readLine()) != null) {
jb.append(line);
}
}catch (IOException e){
e.printStackTrace();
}
User user = JSON.parseObject(jb.toString(),User.class);
return user;
}
退出就把redis中对应的信息删除即可
gateway模块中定义Filter
思路也是如上面一开始写的 判断token是否存在、过期 然后匹配权限
需要注意的是 这里还没有实现配置文件中定义的去掉一些前缀路径 所以匹配路径时需要手动去掉进行匹配
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {
AntPathMatcher antPathMatcher = new AntPathMatcher();
/**
* 登录注册注销放行
*/
private static final String[] EXCLUSIONURLS = {
"/api/auth/login","/api/auth/register","/api/auth/logout"};
private JwtUtils jwtUtils = new JwtUtils();
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String headerToken = request.getHeaders().getFirst("Authorization");
log.info("headerToken:{}", headerToken);
//1、只要带上了token, 就需要判断Token是否有效
if ( !StringUtils.isEmpty(headerToken) && !verifierToken(headerToken)){
return getVoidMono(response, 401, "token无效");
}
String path = request.getURI().getPath();
log.info("request path:{}", path);
//2、判断是否是过滤的路径, 是的话就放行
for (String exclusionurl : EXCLUSIONURLS) {
if (path.equals(exclusionurl)){
return chain.filter(exchange);
}
}
//3、判断请求的URL是否有权限
boolean permission = hasPermission(headerToken , path);
if (!permission){
//gateway不能使用web依赖
return getVoidMono(response, 403, "无访问权限");
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
private boolean verifierToken(String headerToken){
Claims claim = jwtUtils.getClaimByToken(headerToken);
String userid = claim.getSubject();
//去redis找是否有 校验是否有效
String redisToken = redisTemplate.opsForValue().get("JWT"+userid+":");
if ("".equals(redisToken)||!redisToken.equals(headerToken)) {
log.error("token不合法,检测不过关");
return false;
}
//校验超时
if(claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) {
// token过期了
log.error("token已经过期");
return false;
}
return true;
}
private boolean hasPermission(String headerToken, String path){
if (StringUtils.isEmpty(headerToken)){
return false;
}
String userid = jwtUtils.getClaimByToken(headerToken).getSubject();
//生成Key, 把权限放入到redis中
String key = "JWT" + userid+ ":";
String authKey = key + ":Authorities";
String authStr = redisTemplate.opsForValue().get(authKey);
if (StringUtils.isEmpty(authStr)){
return false;
}
//去掉前1个
String[] str = path.split("/");
StringBuilder newPath = new StringBuilder("/");
//从第三位 因为/../../ 第一个/前面也是有的 只是为空
for (int i = 2; i <str.length-1 ; i++) {
newPath.append(str[i]+"/");
}
newPath.append(str[str.length-1]);
List<Authority> authorities = JSON.parseArray(authStr , Authority.class);
return authorities.stream().anyMatch(authority -> antPathMatcher.match(authority.getAuthority(), newPath.toString()));
}
private Mono<Void> getVoidMono(ServerHttpResponse response, int i, String msg) {
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
response.setStatusCode(HttpStatus.OK);
Result failed = Result.fail(i, null,msg);
byte[] bits = JSON.toJSONString(failed).getBytes();
DataBuffer buffer = response.bufferFactory().wrap(bits);
return response.writeWith(Mono.just(buffer));
}
}
测试
注意 数据是json格式 (用户名密码对应自己demo所写的即可)
可以看到返回的数据和响应头都带有token
在构建UserDetail实例时 模拟返回了一个url权限 /**会去匹配所有
访问consumer模块的资源 在header中带着token 可以看到正常返回了(返回的信息涉及到下一节的全局异常处理)
没有对应权限时
exception模块
定义基本Exception
ErrorCode是枚举类 里面定义了所有的异常的信息
自定义异常时,只需要继承BaseException 然后调用父类的构建方法传入对应的ErrorCode即可
全局异常处理类
通过返回ResponseEntity这个实体类 其实就是Response.getWriter().write(…)的意思
要传入Header、HttpStatus(响应的状态码)和body body就使用Result 传入自定义异常的自定义错误码和错误信息
异常有多个匹配的处理方法时 会走最匹配的方法
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 通用 具体的异常信息在ErrorCode和对应的自定义异常类里定义
* @param ex
* @param request
* @return
*/
@ExceptionHandler(BaseException.class)
public ResponseEntity<?> handleAppException(BaseException ex, HttpServletRequest request){
return new ResponseEntity<>(Result.fail(ex.getErrorCode(),ex.getErrorMsg(),null),new HttpHeaders(),ex.getStatus());
}
上面的全局异常处理只能处理handler和controller层面抛出的异常,filter的异常无法被捕获处理,因为filter属于Servlet的api 抛出的异常会走默认的异常处理控制器
如果还记得Servlet的知识就能想到Servlet中可以配置出现错误时的请求路径,然后会指定跳到错误页面
那么解决方案就是 定义返回的错误地址 错误地址到我们定义的Controller方法里来将request里的异常抛出 那么Controller层的异常就能被捕获处理了
那么我们只需要实现这个异常处理控制器(ErrorController) 返回指定的错误路径,顺便写个方法作为这个错误路径的处理方法 在Controller层把异常再抛出即可
注意:往往filter的方法里都限制了只能抛出某些异常及其子类,没办法抛出我们自定义的异常,所以在处理方法上 可以直接捕获处理Exception异常或者专门的定义处理某个异常
然后返回自定义异常码 和错误信息(错误信息由抛出异常时所写的)
例:我直接定义处理Exception的方法 然后返回的实体类的错误码都是1000 错误信息为异常抛出时所写即可
在前端axios 返回拦截时 如果是response.data.code(即自定义的错误码)为1000时 直接显示错误信息即可
自定义Filter
还需要在启动类加上@ServletComponentScan注解
@WebFilter(filterName = "myFilter",urlPatterns = "/*")
public class MyFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
throw new ServletException("测试Filter抛出异常 捕获解决");
}
@Override
public void destroy() {
}
}
定义在exception模块的ErrorController的实现类
@RestController
public class ErrorControllerImpl implements ErrorController {
@Override
public String getErrorPath() {
return "/error";
}
@GetMapping("/error")
public void handlerError(HttpServletRequest request) throws Throwable{
if (request.getAttribute("javax.servlet.error.exception") != null) {
throw (Throwable) request.getAttribute("javax.servlet.error.exception");
}
}
}
GoableExceptionHandler
/**
* 不太确定的错误 统一处理
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAppException(Exception ex, HttpServletRequest request){
return new ResponseEntity<>(Result.fail(1000,ex.getMessage(),null),new HttpHeaders(),HttpStatus.INTERNAL_SERVER_ERROR);
}
问题:在auth模块的里的filter抛出的异常无法使用这种方式解决,是因为SpringSecurity的有自己的filter异常处理机制,专门处理认证和授权里相关的异常
贴一个网上对此的解释 ExceptionTranslationFilter 过滤器专门用来处理异常
解决方案:
配置对应的处理类即可
总结:
普遍的filter都可以使用第一个方法进行统一处理 如果像SpringSecurity这种的有专门的处理器那就对应实现就好了
其实还有个更统一的方式 就是假设要抛出异常时不往外抛 而是将内容写在response里面 其实也就相当于在全局异常处理类里面捕获处理了一下,只是这样就需要每个都写具体的返回的Result内容
例如
在auth模块里如果认证失败 不抛出异常 直接response里写内容就好了 那么前端拿到的信息就是这个错误信息
如果我注释掉 直接抛出对应的异常 那么拿到的就是定义的认证异常处理类的返回内容
前端错误信息处理思路:
Vue的处理
在前端中axios中 可以写拦截器 在服务端返回时 进行拦截,判断response的状态
1.如果不是200 那么就是有错误的返回(当然 你也可以做成全部都是200的返回 然后再判断data里code是不是200还是其他自定义的错误码)
2.判断我们返回的data.code里的错误码
3.某些特定的 如token过期之类的 那么我们就提示信息 然后路由到登录页面 或者其他的操作 这些就是一些指定的 自定义异常的一些处理
4.如果错误码是1000 (我定义的1000为统一错误)只显示返回的data.msg的错误信息即可
有一个upload依赖不好依赖进来 建议官方文档可以下载Demo 然后把该依赖手动加载进来也可以百度 有很多教程
然后只要开通相关视频点播控制台即可 上传视频都很简单(控制台得设置默认的转码 不然上传上去了 播放不来 自己看控制台点点操作一下就就能看到了)
播放视频可以有两种方式 一种根据上传成功后返回的videoid 去拿到播放地址 但是这个地址好像不是不变的 所以不能存到数据库另外一个就是通过videoid+playauth的方式 我写的模块中就是使用这种方式
其他的更多的可以看文档
主要看这两部分的即可 Demo下载也在文档里
播放的话 使用Vue 可以使用文档那种方式 也有一个VueAliplayer组件 但是我的用了一下 一直不行也不知道为什么 就使用了文档的那种HTML5的播放方式,网上也很多教程(对应的前端项目还没上传到github 因为后面要写毕设 打算写完再上传 然后再补吧)
gateway整合Swagger2以及swagger-bootstarp-ui
基础配置请看这篇别人写的博文gateway整合swagger教学
但是有一些改动
这里cloudbase-auth 就是后面通过gateway访问文档 根据分组来查看的分组名
Authorization就是配置请求头的参数 非必输入 不配置的话 在测试接口时没有对应输入框
com.zyfgoup就是扫描包的位置
其他模块配置都是同理了
auth模块使用了SpringSecurity 所以要配置白名单 把swagger对应的资源请求都允许了
如果要检测是否有效 可以访问对应模块的 http://host:port/swagger-ui.html或者是http://host:port/doc.html(这是swagger-bootstrap-ui版本的页面)
然后就是gateway模块的配置
相关配置都和教程一样
有一个需要注意
这里去掉前缀必须是把对应的前缀都匹配去掉 所以要两位,因为要访问到对应的host:port/doc.html
我之前写测试 consumer模块只去了一位前缀 就找不到对应的页面了 相当于 访问到了localhost:8882/consumer/doc.html 那肯定是没有的
然后就是gateway的auth拦截器里 要把一个地址加入到白名单
之前匹配白名单的时候是判断字符串是否一致 不能匹配正则表达式
现在改成这样了 就能匹配*这些符号了
访问网址一样是 http://host:port/swagger-ui.html或者是http://host:port/doc.html 这里就是网关模块的端口了
注意:
需要使用@Api @ApiOperation注解才能被扫描到
如果想不使用这些注解都能扫描到就把对应的两行注释掉即可
这样会把所有controller都显示出来 可能有些是不需要 例如前面说处理Filter异常的BaseController的实现类
另外 例如auth模块的login logout是扫描不到 因为不是写在Controller里的 这只能通过postman或者其他工具来测试了
整合完结!
(已完成) 前端使用Vue的simple-upload组件进行大文件分片上传,后端只需要实现两个接口即可 代码加consumer模块中的upload 包括要配置gateway的跨域问题 vue的请求到gateway
将全部魔法值 定义到相应的常量管理类 一些可配置的值放到配置文件中