
联系我: pig4cloud.com 欢迎署名转载此账号文章
http interface从 Spring 6 和 Spring Boot 3 开始,Spring 框架支持将远程 HTTP 服务代理成带有特定注解的 Java http interface。类似的库,如 OpenFeign 和 Retrofit 仍然可以使用,但 http interface 为 Spring 框架添加内置支持。什么是声明式客户端声明式 http 客户端主旨是使得编写 java http 客户端更容易。为了贯彻这个理念,采用了通过处理注解来自动生成请求的方式(官方称呼为声明式、模板化)。通过声明式 http 客户端实现我们就可以在 java 中像调用一个本地方法一样完成一次 http 请求,大大减少了编码成本,同时提高了代码可读性。举个例子,如果想调用 /tenants 的接口,只需要定义如下的接口类即可public interface TenantClient { @GetExchange("/tenants") Flux<User> getAll(); }Spring 会在运行时提供接口的调用的具体实现,如上请求我们可以如 Java 方法一样调用@Autowired TenantClient tenantClient; tenantClient.getAll().subscribe( );测试使用1. maven 依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- For webclient support --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>如下图: 目前官方只提供了非阻塞 webclient 的 http interface 实现,所以依赖中我们需要添加 webflux2. 创建 Http interface 类型需要再接口类上添加 @HttpExchange 声明此类事 http interface 端点@HttpExchange public interface DemoApi { @GetExchange("/admin/tenant/list") String list();方法上支持如下注解@GetExchange: for HTTP GET requests. @PostExchange: for HTTP POST requests. @PutExchange: for HTTP PUT requests. @DeleteExchange: for HTTP DELETE requests. @PatchExchange: for HTTP PATCH requests.方法参数支持的注解@PathVariable: 占位符参数. @RequestBody: 请求body. @RequestParam: 请求参数. @RequestHeader: 请求头. @RequestPart: 表单请求. @CookieValue: 请求cookie.2. 注入声明式客户端通过给 HttpServiceProxyFactory 注入携带目标接口 baseUrl 的的 webclient,实现 webclient 和 http interface 的关联@Bean DemoApi demoApi() { WebClient client = WebClient.builder().baseUrl("http://pigx.pigx.vip/").build(); HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build(); return factory.createClient(DemoApi.class); }3. 单元测试调用 http interface@SpringBootTest class DemoApplicationTests { @Autowired private DemoApi demoApi; @Test void testDemoApi() { demoApi.list(); } }“基于Spring Boot 2.7、 Spring Cloud 2021 & Alibaba、 SAS OAuth2 一个可支持企业各业务系统或产品快速开发实现的开源微服务应用开发平台
POST /auth/oauth2/token?grant_type=password&scope=server HTTP/1.1 Host: pig-gateway:9999 Authorization: Basic dGVzdDp0ZXN0 Content-Type: application/x-www-form-urlencoded Content-Length: 32 username=admin&password=YehdBPev⓪ 网关前置处理验证码校验 ValidateCodeGatewayFilter.java参考资料: 验证码配置开关前端已加密的密码进行解密 PasswordDecoderFilter.java , 主要就是把如下图的 password 密文转成明文交由 SpringSecurity 处理参考资料: 前端登录请求加密流程参考① 客户端认证处理如上图在登录请求中会携带 Basic base64(clientId:clientSecret), 那么首先OAuth2ClientAuthenticationFilter 会通过调用 RegisteredClientRepository (数据库存储) 来判断传入的客户端是否正确③ 正式接收登录请求OAuth2TokenEndpointFilter 会接收通过上文 OAuth2ClientAuthenticationFilter 客户端认证的请求④ 组装认证对象AuthenticationConverter 会根据请求中的参数和授权类型组装成对应的授权认证对象⑤ 登录认证对象public class XXXAuthenticationToken extends OAuth2ResourceOwnerBaseAuthenticationToken { }⑥ 授权认证调用⑦ 核心认证逻辑多用户体系匹配 UserDetailsService密码匹配校验用户状态校验⑧ 用户查询逻辑用户查询逻辑的多种实现形式解耦: 通过 feign 查询其他系统获取并组装成 UserDetails简单: 认证中心直接查询 DB 并组装成 UserDetails⑨ 密码校验逻辑默认支持加密方式如下: {noop}密码明文 {加密特征码}密码密文 PasswordEncoder 会自动根据特征码匹配对应的加密算法,所以上一步 ⑧ 查询用户对象组装成 UserDetails 需要特殊处理return new UserDetails(user.getUsername(),"{bcrypt}"+"数据库存储的密文");⑩ 生成 OAuth2AccessToken⑪ Token 存储持久化当前 SAS 仅支持 JDBC 和内存 ,PIG 扩展支持 Redis 实现⑫ 登录成功事件处理基于 SpringEvent 事件处理,可以在这里做更多的处理 日志、个性化等处理逻辑⑬ 请求结果输出 Tokenprivate void sendAccessTokenResponse(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) authentication; OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken(); OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken(); Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters(); // 无状态 注意删除 context 上下文的信息 SecurityContextHolder.clearContext(); this.accessTokenHttpResponseConverter.write(accessTokenResponse, null, httpResponse); }定义具体的输出返回格式等逻辑本文配套源码: https://github.com/pig-mesh/pig
以下全文 Spring Authorization Server 简称为: SAS背景Spring 团队正式宣布 Spring Security OAuth 停止维护,该项目将不会再进行任何的迭代目前 Spring 生态中的 OAuth2 授权服务器是 Spring Authorization Server 已经可以正式生产使用作为 SpringBoot 3.0 的过渡版本 SpringBoot 2.7.0 过期了大量关于 SpringSecurity 的配置类,如沿用旧版本过期配置无法向上升级。迁移过程本文以PIG 微服务开发平台为演示,适用于 Spring Security OAuth 2.3 <-> 2.5 的认证中心迁移① Java 1.8 支持目前最新的 SAS 0.3 基于 Java 11 构建,低版本 Java 无法使用经过和 Spring Security 官方团队的沟通 0.3.1 将继续兼容 Java 1.8我们联合 springboot 中文社区编译了适配 java 1.8 的版本坐标如下 <dependency> <groupId>io.springboot.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>0.3.0</version> </dependency>② 授权模式扩展扩展支持密码模式,SAS 基于 oauth 2.1 协议不支持密码模式扩展支持短信登录③ Redis 令牌存储官方目前没有提供基于 Redis 令牌持久化方案PIG 扩展 PigRedisOAuth2AuthorizationService 支持④ Token 输出格式化使用自省令牌的情况下 默认实现为ku4R4n7YD1f584KXj4k_3GP9o-HbdY-PDIIh-twPVJTmvHa5mLIoifaNhbBvFNBbse6_wAMcRoOWuVs9qeBWpxQ5zIFrF1A4g1Q7LhVAfH1vo9Uc7WL3SP3u82j0XU5x为方便结合 redis 高效检索 token ,结合 RDM 分组也可以更方便的图形化观察统一前缀::令牌类型::客户端ID::用户名::uuid@Bean public OAuth2TokenGenerator oAuth2TokenGenerator() { CustomeOAuth2AccessTokenGenerator accessTokenGenerator = new CustomeOAuth2AccessTokenGenerator(); // 注入Token 增加关联用户信息 accessTokenGenerator.setAccessTokenCustomizer(new CustomeOAuth2TokenCustomizer()); return new DelegatingOAuth2TokenGenerator(accessTokenGenerator, new OAuth2RefreshTokenGenerator()); }⑤ Token 输出增强使用自省令牌,默认情况下输出的 Token 格式{ "access_token": "xx", "refresh_token": "xx", "scope": "server", "token_type": "Bearer", "expires_in": 43199 }Token 增强输出关联用户信息{ "sub": "admin", "clientId": "test", "access_token": "xx", "refresh_token": "xx", "license": "https://pig4cloud.com", "user_info": { "username": "admin", "accountNonExpired": true, "accountNonLocked": true, "credentialsNonExpired": true, "enabled": true, "id": 1, "deptId": 1, "phone": "17034642999", "name": "admin", "attributes": {} } }⑥ 授权码模式个性化⑦ 资源服务器自省方案扩展支持资源资源服务器本地查询扩展资源服务器本地自省- 优势: 1. 用户状态实时更新 2. 减少网络调用提升性能源码: https://github.com/pig-mesh/pig
官方标准运行方式 下载解压可运行包 curl -O https://github.com/alibaba/nacos/releases/download/1.3.2/nacos-server-1.3.2.tar.gz tar -zxvf nacos-server-1.3.2.tar.gz cd nacos/bin 执行运行 # Linux/Unix/Mac 启动命令(standalone代表着单机模式运行,非集群模式): sh startup.sh -m standalone # 如果您使用的是ubuntu系统,或者运行脚本报错提示[[符号找不到,可尝试如下运行: bash startup.sh -m standalone # Windows 启动命令(或者双击startup.cmd运行文件) cmd startup.cmd 为什么要源码化运行 1. 方便开发过程使用 如果从 Spring Cloud Netflix 体系迁移到 Spring Cloud Alibaba 技术体系,明显的感受是整个体系得到简化。 Nacos 承担整个 Spring Cloud 的服务发现、配置管理部分的实现。 是整个开发过程中强依赖,启动微服务业务要去检查 Nacos Server 是否已经启动,解压安装的方式变的非常不便。 如果把 Nacos Server 作为整个微服务框架的一部分直接 Main 启动,是不是更加方便便利? 2. UI 个性定制化 若以解压运行方式,修改 UI 几乎不可能。可以下载 Nacos 源码继续修改 然后重新打包运行。 非常的不方便 git clone https://github.com/alibaba/nacos.git cd nacos/ mvn -Prelease-nacos -Dmaven.test.skip=true clean install -U ls -al distribution/target/ // change the $version to your actual path cd distribution/target/nacos-server-$version/nacos/bin 若以源码方式运行,可以试试的调整 UI 然后 build 看到效果。 3. 保证 Server & Client 保持一致 pig 作为微服务开源项目,更新迭代速度非常快。每个版本依赖的 Nacos Client 版本都可能发生变化,这就意味着对应的 Nacos Server 版本也要对应升级,这需要用户自行下载升级成本很高。 Nacos 具有良好小版本向下兼容性,但是大版本功能变化挺大,比如 1.2 、1.3 权限的变更。所以建议大家在实际开发过程中保持版本一致。 若以源码运行的方式,可以很好的解决此问题。 如何实现 1. 下载 Nacos 源码 只需保留 nacos console 模块,其他模块均可删除 2. console 源码结构说明 ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── alibaba │ │ └── nacos │ │ ├── Nacos.java # main 启动类 │ │ └── console # 控制台相关源码 │ └── resources │ ├── application.properties # nacos 配置文件 │ └── static # 静态页面目录 └── test # 单元测试部分 3. 修改 Nacos.java 类 主要在 main 方法中增加 两个参数,是否是单机启动 & 是否关闭权限校验 @SpringBootApplication(scanBasePackages = "com.alibaba.nacos") @ServletComponentScan @EnableScheduling public class Nacos { public static void main(String[] args) { # 通过环境变量的形式 设置 单机启动 System.setProperty(ConfigConstants.STANDALONE_MODE, "true"); # 通过环境变量的形式 设置 关闭权限校验 System.setProperty(ConfigConstants.AUTH_ENABLED, "false"); SpringApplication.run(Nacos.class, args); } } 4. 修改 console/pom.xml 由于不在使用 nacos bom 管理,需要给所有依赖坐标增加版本号 由于 nacos-config /nacos-naming 等包没有上传至中央参考 无法下载到,groupId 变更为 com.pig4cloud.nacos 即可下载 变更后参考如下 <dependency> <groupId>com.pig4cloud.nacos</groupId> <artifactId>nacos-config</artifactId> <version>1.3.2</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>7.0.59</version> </dependency> <dependency> <groupId>com.pig4cloud.nacos</groupId> <artifactId>nacos-naming</artifactId> <version>1.3.2</version> </dependency> ... 总结 以上修改后源码参考: https://gitee.com/log4j/pig 是否以源码形式运行,此问题仁者见仁智者见智 根据你们实际情况来。
Redis 客户端缓存 缓存的解决方案一般有两种: 【L1】 内存缓存(如 Caffeine、Ehcache) —— 速度快,进程内可用,但重启缓存丢失,出现缓存雪崩的问题。 【L2】集中式缓存(如 Redis)—— 可同时为多节点提供服务,但高并发下,带宽成为瓶颈。 业内有很多开源框架来解决以上问题,既能有 L1 速度,并且拥有 L2 集群态。如下 J2Cache 两级缓存框架 hotkey 热点数据实时同步 在 redis 6.0 版本中,已经默认支持了客户端缓存功能,Java 中主流的连接客户端 lettuce 在最新的快照版本 (6.0.0.BUILD-SNAPSHOT) 已经提供支持。 下边就通过代码来体验一下客户端缓存的神奇功能。 Redis 6.0 安装 安装 redis 6,这里通过 Docker 安装命令如下 docker run --name redis6 -p 6379:6379 --restart=always -d redis:6.0.6 Jar 依赖 注意: 这里使用 lettuce 客户端,注意当前使用 6.0 的快照版本 ,需要在 pom 增加 lettuce 快照仓库 1.lettuce 6.0 快照依赖 <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.0.0.BUILD-SNAPSHOT</version> </dependency> 配置快照仓库 <repositories> <repository> <id>sonatype-snapshots</id> <name>Sonatype Snapshot Repository</name> <url>https://oss.sonatype.org/content/repositories/snapshots/</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> </repositories> 代码操作 使用 lettuce 连接 redis ,并循环查看 k1 的值 // <1> 创建单机连接的连接信息 RedisURI redisUri = RedisURI.builder() // .withHost("127.0.0.1") .withPort(6379) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> otherParty = redisClient.connect(); RedisCommands<String, String> commands = otherParty.sync(); StatefulRedisConnection<String, String> connection = redisClient.connect(); // <2> 创建缓存访问器 Map<String, String> clientCache = new ConcurrentHashMap<>(); //map 自动保存所有操作key的 key=value CacheFrontend<String, String> frontend = ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, TrackingArgs.Builder.enabled()); // <3> 客户端正常写入测试数据 k1 v1 String key = "k1"; commands.set(key, "v1"); // <4> 循环读取 while (true) { // <4.1> 缓存访问器中的值,查看是否和 Redis 服务端同步 String cachedValue = frontend.get(key); System.out.println("当前 k1 的值为:--->" + cachedValue); Thread.sleep(3000); } redis-cli 客户端同时操作 k1 修改 k1 的值 ./redis-cli -h 127.0.0.1 -p 6379 > set k1 v2 注意查看 控制台日志 ... 当前 k1 的值为:--->v1 当前 k1 的值为:--->v1 当前 k1 的值为:--->v1 当前 k1 的值为:--->v2 当前 k1 的值为:--->v2 当前 k1 的值为:--->v2 .... 如上: k1 的值在其他客户端(redis-cli)修改,lettuce 客户端确实感知到了数据变化。 但 lettuce 到底 CacheFrontend.get 到底有没有查询 redis 呢? 我们可以通过以下监控看下客户端具体的操作细节 监控 ./redis-cli -h 127.0.0.1 -p 6379 > MONITOR OK 1595922453.165088 [0 172.16.1.96:57482] "SET" "k1" "v1" # 对应 <3> 写入测试数据 1595922453.168238 [0 172.16.1.96:57483] "GET" "k1" # <4.1> 缓存访问器中的值,由于第一次查询为空需要穿透去查询 redis-server 1595922466.525942 [0 172.16.1.96:57498] "COMMAND" # 其他客户端 redis-cli 接入 提醒 1595922472.046488 [0 172.16.1.96:57498] "set" "k1" "v2" # 其他客户端 操作 k1 1595922474.208214 [0 172.16.1.96:57483] "GET" "k1" # 由于k1 值发生变化,循环 <4.1> 会重新查询redis-server 如上: 虽然是个死循环,但是关于 redis 操作只有以上注释的几条,说明客户端缓存生效。 总结 当前仅有 lettuce 支持此功能,jedis 还未支持 spring-boot-data-redis 暂未支持此功能,估计需要 spring boot 2.5 版本
为什么多级缓存 缓存的引入是现在大部分系统所必须考虑的 redis 作为常用中间件,虽然我们一般业务系统(毕竟业务量有限)不会遇到如下图 在随着 data-size 的增大和数据结构的复杂的造成性能下降,但网络 IO 消耗会成为整个调用链路中不可忽视的部分。尤其在 微服务架构中,一次调用往往会涉及多次调用 例如pig oauth2.0 的 client 认证 Caffeine 来自未来的本地内存缓存,性能比如常见的内存缓存实现性能高出不少详细对比。 综合所述:我们需要构建 L1 Caffeine JVM 级别缓存 , L2 Redis 缓存。 设计难点 目前大部分应用缓存都是基于 Spring Cache 实现,基于注解(annotation)的缓存(cache)技术,存在的问题如下: Spring Cache 仅支持 单一的缓存来源,即:只能选择 Redis 实现或者 Caffeine 实现,并不能同时使用。 数据一致性:各层缓存之间的数据一致性问题,如应用层缓存和分布式缓存之前的数据一致性问题。 缓存过期:Spring Cache 不支持主动的过期策略 业务流程 如何使用 引入依赖 <dependency> <groupId>com.pig4cloud.plugin</groupId> <artifactId>multilevel-cache-spring-boot-starter</artifactId> <version>0.0.1</version> </dependency> 开启缓存支持 @EnableCaching public class App { public static void main(String[] args) { SpringApplication.run(App.class, args); } } 目标接口声明 Spring Cache 注解 @Cacheable(value = "get",key = "#key") @GetMapping("/get") public String get(String key){ return "success"; } 性能比较 为保证性能 redis 在 127.0.0.1 环路安装 OS: macOS Mojave CPU: 2.3 GHz Intel Core i5 RAM: 8 GB 2133 MHz LPDDR3 JVM: corretto_11.jdk Benchmark Mode Cnt Score Units 多级实现 thrpt 2 2716.074 ops/s 默认 redis thrpt 2 1373.476 ops/s 代码原理 自定义 CacheManager 多级缓存实现 public class RedisCaffeineCacheManager implements CacheManager { @Override public Cache getCache(String name) { Cache cache = cacheMap.get(name); if (cache != null) { return cache; } cache = new RedisCaffeineCache(name, stringKeyRedisTemplate, caffeineCache(), cacheConfigProperties); Cache oldCache = cacheMap.putIfAbsent(name, cache); log.debug("create cache instance, the cache name is : {}", name); return oldCache == null ? cache : oldCache; } } 多级读取、过期策略实现 public class RedisCaffeineCache extends AbstractValueAdaptingCache { protected Object lookup(Object key) { Object cacheKey = getKey(key); // 1. 先调用 caffeine 查询是否存在指定的值 Object value = caffeineCache.getIfPresent(key); if (value != null) { log.debug("get cache from caffeine, the key is : {}", cacheKey); return value; } // 2. 调用 redis 查询在指定的值 value = stringKeyRedisTemplate.opsForValue().get(cacheKey); if (value != null) { log.debug("get cache from redis and put in caffeine, the key is : {}", cacheKey); caffeineCache.put(key, value); } return value; } } 过期策略,所有更新操作都基于 redis pub/sub 消息机制更新 public class RedisCaffeineCache extends AbstractValueAdaptingCache { @Override public void put(Object key, Object value) { push(new CacheMessage(this.name, key)); } @Override public ValueWrapper putIfAbsent(Object key, Object value) { push(new CacheMessage(this.name, key)); } @Override public void evict(Object key) { push(new CacheMessage(this.name, key)); } @Override public void clear() { push(new CacheMessage(this.name, null)); } private void push(CacheMessage message) { stringKeyRedisTemplate.convertAndSend(topic, message); } } MessageListener 删除指定 Caffeine 的指定值 public class CacheMessageListener implements MessageListener { private final RedisTemplate<Object, Object> redisTemplate; private final RedisCaffeineCacheManager redisCaffeineCacheManager; @Override public void onMessage(Message message, byte[] pattern) { CacheMessage cacheMessage = (CacheMessage) redisTemplate.getValueSerializer().deserialize(message.getBody()); cacheMessage.getCacheName(), cacheMessage.getKey()); redisCaffeineCacheManager.clearLocal(cacheMessage.getCacheName(), cacheMessage.getKey()); } } 源码地址 [https://github.com/pig-mesh/multilevel-cache-spring-boot-starter](https://github.com/pig-mesh/multilevel-cache-spring-boot-starter) https://gitee.com/log4j/pig
为什么需要单元测试 单元测试拥有保证代码质量、尽早发现软件 Bug、简化调试过程、促进变化并简化集成、使流程更灵活等优势。单元测试是针对代码单元的独立测试,核心是“独立”,优势来源也是这种独立性,而所面临的不足也正是因为其独立性:既然是“独立”,就难以测试与其他代码和依赖环境的相互关系。单元测试与系统测试是互补而非代替关系。单元测试的优势,正是系统测试的不足,单元测试的不足,又恰是系统测试的优势。不能将单元测试当做解决所有问题的万金油,而需理解其优势与不足,扬长避短,与系统测试相辅相成,实现测试的最大效益。 OAuth2 系统单元测试困难 接口测试依赖于 UPMS (用户权限管理),无法做到解耦独立 spring-security-test 模块未提供相关标准实现 场景复杂既要包含无状态 token 调用,又要保证上线文传递业务 解决方案 参考 @WithMockUser ,在 Mock 拦截器中自动执行相关的增强(token 获取),并通过扩展 WithSecurityContextFactory 实现上下文 token 的传递。具体可以参考源码 pig-common-test[1] 引入依赖 <dependency> <groupId>com.pig4cloud</groupId> <artifactId>pig-common-test</artifactId> <version>${last.version}</version> <scope>test</scope> </dependency> 单元测试 Controller 接口 指定认证中心接口 配置在 test/resources/application.yml security: oauth2: client: access-token-uri: http://pig-gateway:3000/oauth/token 模拟测试 controller 接口 @RunWith(SpringRunner.class) @SpringBootTest public class SysLogControllerTest { private MockMvc mvc; @Autowired private WebApplicationContext applicationContext; // 注入WebApplicationContext @Before public void setUp() { this.mvc = MockMvcBuilders.webAppContextSetup(applicationContext).build(); } @Test @SneakyThrows @WithMockOAuth2User public void testMvcToken() { mvc.perform(delete("/log/1").with(token())).andExpect(status().isOk()); } } 模拟测试 FeignClient 传递 token -直接注入 FeignClient 实现即可 使用 @WithMockOAuth2User 注解测试类即可 WithMockOAuth2User 属性说明 当前用例获取 token 使用的用户名 String username() default "admin"; 当前用例获取 token 使用的密码 String password() default "123456"; 写在最后源码参考 pig-common-test 模块 目前仅在 pig 2.10 做了实现,理论支持低版本,直接 install 此模块即可
幂等概述 幂等性原本是数学上的概念,即使公式:f(x)=f(f(x)) 能够成立的数学性质。用在编程领域,则意为对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。 幂等性是分布式系统设计中十分重要的概念,具有这一性质的接口在设计时总是秉持这样的一种理念:调用接口发生异常并且重复尝试时,总是会造成系统所无法承受的损失,所以必须阻止这种现象的发生。 实现幂等的方式很多,目前基于请求令牌机制适用范围较广。其核心思想是为每一次操作生成一个唯一性的凭证,也就是 token。一个 token 在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果(报错)等。参考《幂等性浅谈》 幂等处理实现 加入依赖 <dependency> <groupId>com.pig4cloud.plugin</groupId> <artifactId>idempotent-spring-boot-starter</artifactId> <version>0.0.1</version> </dependency> 配置 Redis 链接 默认情况下,可以不配置。理论是支持 redisson-spring-boot-starter 全部配置 spring: redis: host: 127.0.0.1 port: 6379 接口 @Idempotent(key = "#key", expireTime = 10, info = "请勿重复查询") @GetMapping("/test") public String test(String key) { return "success"; } 测试 10 个独立线程请求 执行查看结果,10 个请求只会有一个成功 查看后台异常报错,9 个异常报错满足预期 idempotent 注解说明 key: 幂等操作的唯一标识,使用 spring el 表达式 用#来引用方法参数 。 可为空则取当前 url + args 做请求的唯一标识 expireTime: 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来 timeUnit: 时间单位 默认:s (秒) info: 幂等失败提示信息,可自定义 delKey: 是否在业务完成后删除 key true:删除 false:不删除 幂等处理设计原理 流程设计参考 1.请求开始前,根据 key 查询 查到结果:报错 未查到结果:存入 key-value-expireTime key=ip+url+args 2.请求结束后,直接删除 key 不管 key 是否存在,直接删除 是否删除,可配置 3.expireTime 过期时间,防止一个请求卡死,会一直阻塞,超过过期时间,自动删除 过期时间要大于业务执行时间,需要大概评估下; 4.此方案直接切的是接口请求层面。 5.过期时间需要大于业务执行时间,否则业务请求 1 进来还在执行中,前端未做遮罩,或者用户跳转页面后再回来做重复请求 2,在业务层面上看,结果依旧是不符合预期的。 6.建议 delKey = false。即使业务执行完,也不删除 key,强制锁 expireTime 的时间。预防 5 的情况发生。 7.实现思路:同一个请求 ip 和接口,相同参数的请求,在 expireTime 内多次请求,只允许成功一次。 8.页面做遮罩,数据库层面的唯一索引,先查询再添加,等处理方式应该都处理下。 9.此注解只用于幂等,不用于锁,100 个并发这种压测,会出现问题,在这种场景下也没有意义,实际中用户也不会出现 1s 或者 3s 内手动发送了 50 个或者 100 个重复请求,或者弱网下有 100 个重复请求; 总结 pig-mesh/pig pig-mesh/idempotent-spring-boot-starter
背景 在我们开发过程中为了支持 Docker 容器化,一般使用 Maven 编译打包然后生成镜像,能够大大提供上线效率,同时能够快速动态扩容,快速回滚,着实很方便。docker-maven-plugin 插件就是为了帮助我们在 Maven 工程中,通过简单的配置,自动生成镜像并推送到仓库中。 spotify 、fabric8 这里主要使用的主要是如下两种插件 spotify 、fabric8 , ... -配置通过 xml 定义出 Dockerfile 或者挂载外部 Dockerfile 通过调用 Docker remote api 构建出镜像 pig 微服务平台所有的容器化都是基于此构建 <plugin> <groupId>com.spotify</groupId> <artifactId>docker-maven-plugin</artifactId> ... -配置通过 xml 定义出 Dockerfile 或者挂载外部 Dockerfile </plugin> <plugin> <groupId>io.fabric8</groupId> <artifactId>docker-maven-plugin</artifactId> ... -配置通过 xml 定义出 Dockerfile 或者挂载外部 Dockerfile </plugin> 执行相应的插件周期即可 mvn docker:build && mvn docker:push jib 项目每次发布实际上变更的代码量不大,尤其依赖的 jar 变动的可能性较小,如果使用前两种插件构建镜像,会导致每次都全量构建,会导致存储和带宽资源浪费。 jib 是 Google 于 18 年 7 月发布的一个针对 Java 应用的构建镜像的工具(支持 Maven 和 Gradle) ,好处是能够复用构建缓存,能够加快构建,减小传输体积 <!--配置通过 xml 定义出 Dockerfile ,本质上和外挂 Dockerfile 并无区别--> <plugin> <groupId>com.google.cloud.tools</groupId> <artifactId>jib-maven-plugin</artifactId> </plugin> mvn jib:dockerBuild 以上三种方案的问题 在实际开发过程中,大部分的 spring boot 项目构建 Dockerfile 都是相同,不需要通过的 XML 或者通过外挂 Dockerfile 来重新定义 以上插件需要对 Dockerfile 的定义知识有相对的了 对开发并不友好 没充分理由 Spring Boot 2.3 以后的 Jar 分层技术。 解决方案 Spring Boot 2.4 推出了自己的 docker 构建工具 整合在原有的 spring-boot-maven-plugin 中,只需要配置对应目标仓库和主机信息即可完成镜像构建。 如下配置即可完成上图中 通过开发机器在不安装 Docker 的同时,通过 192.168.0.10 的 Docker Remote API 完成镜像构建并发布到 192.168.0.20 的镜像仓库 <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <image> <name>192.168.0.20/pig4cloud/${project.artifactId}</name> <!-- 执行完build 自动push --> <publish>true</publish> </image> <!--配置构建宿主机信息,本机不用配置--> <docker> <host>http://192.168.0.10:2375</host> <tlsVerify>false</tlsVerify> <publishRegistry> <username>username</username> <password>password</password> <url>192.168.0.20</url> </publishRegistry> </docker> </configuration> </plugin> 执行以下命令即可完成 镜像的构建和自动发布 mvn spring-boot:build-image 其他说明 docker host 配置不生效 如下图 ① 处配置 节点,但是 ② 报错提示 host 不一致 检查本地是否配置 &dollar;DOCKER_HOST 环境变量,经过阅读源码后发现优先读取此变量。 ⋊> ~ echo $DOCKER_HOST 11:07:51 tcp://172.17.0.111:2375 网络支持 截取部分构建过程中的日志,如下需要从 github 下载相关的依赖 约 100M ,这个过程大概率会失败。建议通过配置代理或者使用国外 ECS 来解决。 :: Spring Boot :: (v2.4.0) [INFO] > Running creator [INFO] [creator] Downloading from https://github.com/bell-sw/Liberica/releases/download/8u275+1/bellsoft-jre8u275+1-linux-amd64.tar.gz [INFO] [creator] JVMKill Agent 1.16.0: Contributing to layer [INFO] [creator] Downloading from https://github.com/cloudfoundry/jvmkill/releases/download/v1.16.0.RELEASE/jvmkill-1.16.0-RELEASE.so [INFO] [creator] Downloading from https://repo.spring.io/release/org/springframework/cloud/spring-cloud-bindings/1.6.0/spring-cloud-bindings-1.6.0.jar [INFO] [creator] Verifying checksum [INFO] [creator] 192.168.0.20/pig4cloud/demo:latest [INFO] [INFO] Successfully built image '192.168.0.20/pig4cloud/demo:latest' [INFO] > Pushing image '192.168.0.20/pig4cloud/demo:latest' 100% [INFO] > Pushed image '192.168.0.20/pig4cloud/demo:latest' [INFO] BUILD SUCCESS
说起 cron 表达式大家一定不陌生,我们常用来作为定时任务执行策略规则。 在 Spring Boot 框架中 cron 表达式主要配合 @Scheduled 注解在应用程序中使用。 在 Spring Boot 2.4 (既 Spring 5.3)以后,引入了 CronExpression表达式处理器来替代原有的 CronSequenceGenerator。 为什么要替代原有的 CronSequenceGenerator ? 此处理器是基于 java.util.Calendar局限性比较大,无法完成last day of month 语义。 例如利用CronExpression 计算表达式下次执行时间 LocalDateTime now = LocalDateTime.now(); System.out.println("当前运行时间: " + now); // 每小时执行一次 CronExpression expression1 = CronExpression.parse("0 0 0/1 * * *"); LocalDateTime nextTime = expression1.next(now); System.out.println("每小时执行一次 -> 下次执行时间: " + nextTime); // 每小时第十分执行一次 CronExpression expression2 = CronExpression.parse("0 10 0/1 * * *"); LocalDateTime nextTime2 = expression2.next(now); System.out.println("每小时第十分执行 -> 下次执行时间: " + nextTime2); 执行结果 当前运行时间: 2020-11-14T23:04:46.302739 每小时执行一次 -> 下次执行时间: 2020-11-15T00:00 每小时第十分执行 -> 下次执行时间: 2020-11-14T23:10 新增常用表达式通用宏 对于非开发人员来说 cron 表达式并不容易理解,所以很难在出现错误的时候进行修复。比如笔者会把 cron 表达式 在在线网站 模拟运行一下,确认执行过程方便排查问题。 为了提高可读性,Spring Boot 现在支持以下代表常用表达式的宏。可以使用这些宏而不是六位的表达式,因此: @Scheduled(cron = "@hourly")。 相当于 @Scheduled(cron = "0 0 * * * *") 其他常用宏命令 宏 cron 表达式 含义 @yearly 0 0 0 1 1 * 每年执行一次 @monthly 0 0 0 1 * * 每月执行一次 @weekly 0 0 0 * * 0 每周执行一次 @daily 或@annually 0 0 0 * * * 每天执行一次 @hourly 0 0 * * * * 每小时执行一次 增强原有表达式 最后几天 每周的第几天 | ∨ * * * * * * ^ | 每月的第几天 如上其中的 每月的第几天、每周的第几天 支持 最后几天 (L) 的语义例如: 0 0 0 L * * 每月最后一天的零时 0 0 0 L-3 * * 每月最后第三天的零时 (L-d 格式) 0 0 0 * * 5L 每月最后的星期五零时 (dL 格式) 0 0 0 * * FRIL 每月最后的星期五零时 ( (星期一星期天的英文缩写)L 格式) 增强原有表达式 工作日 * * * * * * ^ | 每月的第几天 如上其中的 每月的第几天 支持 工作日 (W)的语义例如: 0 0 0 1W * * 每月的第一个工作日零时 0 0 0 LW * * 每月的最后一个工作日零时 增强原有表达式 几周的星期几 每周的第几天 | ∨ * * * * * * 如上其中的 每周的第几天 支持 每月第几周的第几天语义例如 0 0 0 ? * 5#2 每月第二周的星期五零时 0 0 0 ? * MON#1 每月周一的星期一零时
自动分析瘦身 Spring Boot 项目最终构建处理 JAR 包大小一直是个诟病,需要把所有依赖包内置最终输出可运行的 jar。 当然可以使用其他的插件扩展 实现依赖 JAR 和 可运行 jar 分离可以参考 slot-maven-plugin, 但此种方法治标不治本并不能减少原有依赖的 JAR 的大小。 Spring Boot 2.4 提供对构建输出 JAR 分析自动瘦身的功能,自动在构建输出可运行 JAR 时删除 empty starter dependencies 效果展示 先来分别基于 Spring Boot 2.4.0 和 Spring Boot 2.3.6 来构建一个可运行的 jar ,再来聊什么是 empty starter 使用 start.spring.io 创建一个空的 Spring Boot 项目,注意不需要引入任何依赖 mvn clean install 构建出来相关可运行 jar 分别解压两个 jar 到两个不同的目录 tar -zxvf demo-2.3.6.jar -C demo-2.3.6/ tar -zxvf demo-2.4.0.jar -C demo-2.4.0/ 统计依赖 jar 个数, 2.3.6 共计 19 个 依赖 jar 而 2.4.0 只有 18 个依赖 jar ,缺少了 spring-boot-starter.jar cd demo-2.3.6/BOOT-INF/lib && ll -h | wc -l 19 cd demo-2.4.0/BOOT-INF/lib && ll -h | wc -l 18 什么是 empty starter 如上文所述,我们在基于 start.spring.io 创建项目的时候 已经默认引入了, 但在 Spring Boot 2.4 中会自动删除此类 empty starter dependencies jar <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> 我们来看一下 spring-boot-stater 有什么特殊性? ① 空 jar 不包含任何代码 ② 有引用其他 jar,只为批量导入其他 jar 所以此类型 jar 在构建成可运行 jar 时并未实际意义,因为批量导入的依赖 jar 都可以被引入。目前 spring boot 提供的 redis、amqp等大部分 starter 均是此类 jar,所以在构建后会自动删除。 自定义 jar 实现自动瘦身 创建 MANIFEST.MF jar 包元信息,添加一行 Spring-Boot-Jar-Type: dependencies-starter 即可 resources ├── META-INF └── MANIFEST.MF
XSS 是什么 XSS(Cross Site Scripting)攻击全称跨站脚本攻击,为了不与 CSS(Cascading Style Sheets)名词混淆,故将跨站脚本攻击简称为 XSS,XSS 是一种常见 web 安全漏洞,它允许恶意代码植入到提供给其它用户使用的页面中。 xss 攻击流程 简单 xss 攻击示例 若网站某个表单没做相关的处理,用户提交相关恶意代码,浏览器会执行相关的代码。 解决方案 XSS 过滤说明 对表单绑定的字符串类型进行 xss 处理。 对 json 字符串数据进行 xss 处理。 提供路由和控制器方法级别的放行规则。 使用 mica-xss 引入一下 依赖即可 <!--XSS 安全过滤--> <dependency> <groupId>net.dreamlu</groupId> <artifactId>mica-core</artifactId> <version>2.0.9-GA</version> </dependency> <dependency> <groupId>net.dreamlu</groupId> <artifactId>mica-xss</artifactId> <version>2.0.9-GA</version> </dependency> 测试 XSS 过滤 测试 GET 参数过滤 创建目标接口,模拟 get 提交 @GetMapping("/xss") public String xss(String params){ return params; } 返回为空 ⋊> ~ curl --location --request GET 'http://localhost:8080/xss?params=%3Cscript%3Ealert(%27xxx%27)%3C/script%3E' 测试 POST form 参数过滤 创建目标接口,模拟 post form 提交 @PostMapping("/xss") public String xss(String params){ return params; } 返回为空 curl --location --request POST 'http://localhost:8080/xss' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'params=<script>alert('\''xxx'\'')</script>' 测试 POST body 参数过滤 创建目标接口,模拟 post body 提交 @PostMapping("/xss") public String xss(@RequestBody Map<String,String> body){ return body.get("params"); } 返回为空 curl --location --request POST 'http://localhost:8080/xss' \ --header 'Content-Type: application/json' \ --data-raw '{ "params":"<script>alert('\''XXX'\'')</script>" }' 跳过某些接口过滤 可以使用 @XssCleanIgnore 注解对方法和类级别进行忽略。 @XssCleanIgnore @PostMapping("/xss") public String xss(@RequestBody Map<String,String> body){ return body.get("params"); } 原理分析 常见实现剖析 目前网上大多数的方案如下图,新增 XssFilter 拦截用户提交的参数,进行相关的转义和黑名单排除,完成相关的业务逻辑。在整个过程中最核心的是通过包装用户的原始请求,创建新的 requestwrapper 保证请求流在后边的流程可以重复读。 mica-xss 实现 1. 自定义 WebDataBinder 编辑器支持 form 过滤 Spring WebDataBinder 的作用是从 web request 中把 web 请求里的parameters绑定到对应的JavaBean上,在 Controller 方法中的参数类型可以是基本类型,也可以是封装后的普通 Java 类型。若这个普通的 Java 类型没有声明任何注解,则意味着它的每一个属性都需要到 Request 中去查找对应的请求参数,而 WebDataBinder 则可以帮助我们实现从 Request 中取出请求参数并绑定到 JavaBean 中。 SpringMVC 在绑定的过程中提供了用户自定义编辑绑定的接口,注入即可在参数绑定 JavaBean 过程中执行过滤。 2. 自定义 JsonDeserializer 反序列化支持 Json 过滤 在 Spring Boot 中默认是使用 Jackson 进行序列化和反序列化 JSON 数据的,那么除了可以用默认的之外,我们也可以编写自己的 JsonSerializer 和 JsonDeserializer 类,来进行自定义操作。用户提交 JSON 报文会通过 Jackson 的 JsonDeserializer 绑定到 JavaBean 中。我们只需要自定义 JsonDeserializer 即可完成在绑定 JavaBean 中执行过滤。 核心过滤逻辑 在 mica-xss 中并未采取上文所述通过自己手写黑名单或者转义方式的实现方案,而是直接实现 Jsoup 这个工具类。 jsoup 实现 WHATWG HTML5 规范,并将 HTML 解析为与现代浏览器相同的 DOM。 从 URL,文件或字符串中刮取和解析 HTML 使用 DOM 遍历或 CSS 选择器查找和提取数据 操纵 HTML 元素,属性和文本 清除用户提交的内容以防止安全白名单,以防止 XSS 攻击 输出整洁的 HTML
背景 Spring Boot 项目随着项目开发过程中引入中间件数量的增加,启动耗时逐渐增加。 笔者在 《Spring Boot 2.4.0 正式 GA,全面拥抱云原生》文章评论下发现了 Spring 生态复杂,非官方插件并未严格按官方标准实现。例如 @Configuration 注解提供了 proxyBeanMethods 属性默认开启,建议常见情况手动关闭提高性能。笔者在观察大部分非官方插件 stater 并未引入此属性。诸如此类的优化策略很多(建议翻一下笔者历史博客),但往往被开发者忽略,导致使用该插件会影响应用启动效率。 启动过程中串行初始化逻辑较多,严重影响启动效率。例如 Druid 数据库连接池初始化设置不合理导致创建物理链接缓慢影响启动效率。 如上两点,我认为 SpringBoot 启动缓慢和框架本身没有太大关系,取决于开发者的能力。如何能够在开发中准确的分析启动过程,定位到每个耗时操作? 单纯从启动日志的维度是无法实现,Spring Boot 2.4.0 提供了启动过程监控的端点,非常方便的让开发者在开发过程中观察每个组件的初始化过程、消耗时间等。 上手体验 引入 actuator 依赖 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> 配置暴露 startup 端点 management: endpoints: web: exposure: include: startup Main 启动类声明缓冲池,这里注意若应用依赖较多,建议把 capacity 容量参数设置大些,尽可能的保留全部监控日志。 @SpringBootApplication public class DemoApplication { public static void main(String[] args) { // 建议仅在开发或者排除时开启此配置 new SpringApplicationBuilder(DemoApplication.class) .applicationStartup(new BufferingApplicationStartup(20480)) .run(args); } } 获取启动数据 ,POST 请求 /actuator/startup 端点返回监控数据 ⋊> ~ curl -XPOST http://localhost:8080/actuator/startup 11:49:51 {"springBootVersion":"2.4.0","timeline":{"startTime":"2020-12-04T01:38:15.028114Z","events":[{"startupStep":{"name":"spring.event.invoke-listener","id":296,"parentId":0,"tags":[{"key":"event","value":"ServletRequestHandledEvent: url=[/actuator/startup]; client=[0:0:0:0:0:0:0:1]; method=[POST]; servlet=[dispatcherServlet]; session=[null]; user=[null]; time=[83ms]; status=[OK]"},{"key":"listener","value":"org.springframework.boot.context.config.DelegatingApplicationListener@2053d869"}]},"startTime":"2020-12-04T01:38:28.402870279Z","endTime":"2020-12-04T01:38:28.402929390Z","duration":"PT0.000059111S"}]}} 测试案例 新增 RestTemplate Bean,并模拟初始化耗时 @Configuration(proxyBeanMethods = false) public class DemoConfiguration { @Bean public RestTemplate restTemplate() throws InterruptedException { // 模拟初始化过程中的耗时操作 Thread.sleep(5000); return new RestTemplate(); } } 获取端点日志, 准确输出在启动过程中初始化 RestTemplate 耗时情况 根据耗时排序 端点接口并未提供相关的接口,而是按照启动加载顺序展示。没有必要手动处理获取这些数据排序,可以通过 https://www.bejson.com/json/jsonsort/ 在线格式化排序 选择按照耗时排序即可
关于延迟加载 在 Spring 中,默认情况下所有定的 bean 及其依赖项目都是在应用启动时创建容器上下文是被初始化的。测试代码如下: @Slf4j @Configuration public class DemoConfig { public DemoConfig() { log.warn(" > > > demoConfig 被初始化 > > >"); } } 启动应用日志: [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1193 ms [ main] c.p.c.global.lazy.config.DemoConfig : > > > demoConfig 被初始化 > > > [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor' [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 如上日志: 在 Tomcat started 之前 DemoConfig bean 已经被初始化创建。 一般情况程序在启动时时有大量的 Bean 需要初始化,例如 数据源初始化、缓存初始化等导致应用程序启动非常的慢。在 spring boot 2.2 之前的版本,我们对这些 bean 使用手动增加 @Lazy 注解,来实现启动时不初始化,业务程序在调用需要时再去初始化,如上代码修改为即可: @Lazy @Configuration public class DemoConfig {} 为什么需要全局懒加载 同上文中提到我们需要手动在 bean 增加 @Lazy 注解,这就意味着我们仅能对程序中自行实现的 bean 进行添加。但是现在 spring boot 应用中引入了很多第三方 starter ,比如 druid-spring-boot-starter 数据源注入、spring-boot-starter-data-redis 缓存等默认情况下, 引入即注入了相关 bean 我们无法去修改添加 @Lazy。 spring boot 2.2 新增全局懒加载属性,开启后全局 bean 被设置为懒加载,需要时再去创建 spring: main: lazy-initialization: true #默认false 关闭 个别 bean 可以通过设置 @Lazy(false) 排除,设置为启动时加载 @Lazy(false) @Configuration public class DemoConfig {} 当然也可以指定规则实现 LazyInitializationExcludeFilter 规则实现排除 @Bean LazyInitializationExcludeFilter integrationLazyInitExcludeFilter() { return LazyInitializationExcludeFilter.forBeanTypes(DemoConfig.class); } 全局懒加载的问题 通过设置全局懒加载,我们可以减少启动时的创建任务从而大幅度的缩减应用的启动时间。但全局懒加载的缺点可以归纳为以下两点: Http 请求处理时间变长。 这里准确的来说是第一次 http 请求处理的时间变长,之后的请求不受影响(说到这里自然而然的会联系到 spring cloud 启动后的第一次调用超时的问题)。 错误不会在应用启动时抛出,不利于早发现、早解决、早下班。 总结 以上源码: spring-boot-course 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
简介 在企业级开发中、我们经常会有编写数据库表结构文档的时间付出,从业以来,待过几家企业,关于数据库表结构文档状态:要么没有、要么有、但都是手写、后期运维开发,需要手动进行维护到文档中,很是繁琐、如果忘记一次维护、就会给以后工作造成很多困扰、无形中制造了很多坑留给自己和后人,于是需要一个插件工具 screw来维护。 screw 特点 简洁、轻量、设计良好。不需要 powerdesigner 这种重量的建模工具 多数据库支持 。支持市面常见的数据库类型 MySQL、Oracle、SqlServer 多种格式文档。支持 MD、HTML、WORD 格式 灵活扩展。支持用户自定义模板和展示样式 支持数据库类型 [✔️] MySQL [✔️] MariaDB [✔️] TIDB [✔️] Oracle [✔️] SqlServer [✔️] PostgreSQL [✔️] Cache DB 依赖 这里以 mysql8 数据库为例子 <!--数据库文档核心依赖--> <dependency> <groupId>cn.smallbun.screw</groupId> <artifactId>screw-core</artifactId> <version>1.0.2</version> </dependency> <!-- HikariCP --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>3.4.5</version> </dependency> <!--mysql driver--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.20</version> </dependency> 1. 通过自定义代码配置文档生成 @Test public void shouldAnswerWithTrue() { //数据源 HikariConfig hikariConfig = new HikariConfig(); hikariConfig.setDriverClassName("com.mysql.cj.jdbc.Driver"); hikariConfig.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test"); hikariConfig.setUsername("root"); hikariConfig.setPassword("root"); //设置可以获取tables remarks信息 hikariConfig.addDataSourceProperty("useInformationSchema", "true"); hikariConfig.setMinimumIdle(2); hikariConfig.setMaximumPoolSize(5); DataSource dataSource = new HikariDataSource(hikariConfig); //生成配置 EngineConfig engineConfig = EngineConfig.builder() //生成文件路径 .fileOutputDir("/Users/lengleng") //打开目录 .openOutputDir(true) //文件类型 .fileType(EngineFileType.HTML) //生成模板实现 .produceType(EngineTemplateType.freemarker).build(); //忽略表 ArrayList<String> ignoreTableName = new ArrayList<>(); ignoreTableName.add("test_user"); ignoreTableName.add("test_group"); //忽略表前缀 ArrayList<String> ignorePrefix = new ArrayList<>(); ignorePrefix.add("test_"); //忽略表后缀 ArrayList<String> ignoreSuffix = new ArrayList<>(); ignoreSuffix.add("_test"); ProcessConfig processConfig = ProcessConfig.builder() //忽略表名 .ignoreTableName(ignoreTableName) //忽略表前缀 .ignoreTablePrefix(ignorePrefix) //忽略表后缀 .ignoreTableSuffix(ignoreSuffix).build(); //配置 Configuration config = Configuration.builder() //版本 .version("1.0.0") //描述 .description("数据库设计文档生成") //数据源 .dataSource(dataSource) //生成配置 .engineConfig(engineConfig) //生成配置 .produceConfig(processConfig).build(); //执行生成 new DocumentationExecute(config).execute(); } 2. 通过插件的形式生成文档 <build> <plugins> <plugin> <groupId>cn.smallbun.screw</groupId> <artifactId>screw-maven-plugin</artifactId> <version>1.0.2</version> <dependencies> <!-- HikariCP --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>3.4.5</version> </dependency> <!--mysql driver--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.20</version> </dependency> </dependencies> <configuration> <!--username--> <username>root</username> <!--password--> <password>root</password> <!--driver--> <driverClassName>com.mysql.cj.jdbc.Driver</driverClassName> <!--jdbc url--> <jdbcUrl>jdbc:mysql://127.0.0.1:3306/test</jdbcUrl> <!--生成文件类型--> <fileType>HTML</fileType> <!--文件输出目录--> <fileOutputDir>/Users/lengleng</fileOutputDir> <!--打开文件输出目录--> <openOutputDir>false</openOutputDir> <!--生成模板--> <produceType>freemarker</produceType> <!--描述--> <description>数据库文档生成</description> <!--版本--> <version>${project.version}</version> <!--标题--> <title>数据库文档</title> </configuration> <executions> <execution> <phase>compile</phase> <goals> <goal>run</goal> </goals> </execution> </executions> </plugin> </plugins> </build> 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
背景 在互联网发展的今天,近乎所有的云厂商都提供对象存储服务。一种海量、安全、低成本、高可靠的云存储服务,适合存放任意类型的文件。容量和处理能力弹性扩展,多种存储类型供选择,全面优化存储成本。 当我们在使用对应云厂商产品的时候,只需要引入对应尝试提供的 SDK ,根据其开发文档实现即可。但是当我们接入的云厂商较多(或者能够保证接口水平迁移时),我们要根据目标厂商接口破坏性修改。 如下提供了几家厂商接口 SDK 上传实例: 阿里云 // Endpoint以杭州为例,其它Region请按实际情况填写。 String endpoint = "http://oss-cn-hangzhou.aliyuncs.com"; String accessKeyId = "<yourAccessKeyId>"; String accessKeySecret = "<yourAccessKeySecret>"; // 创建OSSClient实例。 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); // 创建PutObjectRequest对象。 String content = "Hello OSS"; PutObjectRequest putObjectRequest = new PutObjectRequest("<yourBucketName>", "<yourObjectName>", new ByteArrayInputStream(content.getBytes())); // 上传字符串。 ossClient.putObject(putObjectRequest); // 关闭OSSClient。 ossClient.shutdown(); 华为云 String endPoint = "https://your-endpoint"; String ak = "*** Provide your Access Key ***"; String sk = "*** Provide your Secret Key ***"; // 创建ObsClient实例 ObsClient obsClient = new ObsClient(ak, sk, endPoint); obsClient.putObject("bucketname", "objectname", new File("localfile")); // localfile为待上传的本地文件路径,需要指定到具体的文件名 七牛云 Configuration cfg = new Configuration(Region.region0()); UploadManager uploadManager = new UploadManager(cfg); String accessKey = "your access key"; String secretKey = "your secret key"; String localFilePath = "/home/qiniu/test.png"; String key = null; Auth auth = Auth.create(accessKey, secretKey); String upToken = auth.uploadToken(bucket); Response response = uploadManager.put(localFilePath, key, upToken); 解决方案 Amazon S3 协议 Amazon 是最早提供对象存储服务 的厂商,制定文件存储相关的业内标准,这意味着只需要实现 S3 协议即可接入兼容此协议的文件存储厂商和中间件。当然 S3 协议不仅仅是技术实现要求标准,对于可用性等都有具体的要求。 兼容 S3 协议国内云厂商 名称 地址 阿里云 https://www.aliyun.com 华为云 https://www.huaweicloud.com 腾讯云 https://cloud.tencent.com 七牛云 https://www.qiniu.com 金山云 https://www.ksyun.com 如何使用 引入依赖。 引入此依赖,无需在引入云厂商 SDK <dependency> <groupId>com.pig4cloud.plugin</groupId> <artifactId>oss-spring-boot-starter</artifactId> <version>0.0.1</version> </dependency> 配置文件存储 oss: path-style-access: false #请求路径是否 XXX/{bucketName} endpoint: s3-cn-east-1.qiniucs.com access-key: xxx # 云厂商提供的key secret-key: xxx # 云厂商提供的密钥 bucketName: pig4cloud # 上文创建的桶名称 操作 @Autowire private final OssTemplate ossTemplate; ossTemplate.putObject(CommonConstants.BUCKET_NAME, fileName, file.getInputStream()); 支持 MINIO 等自建文件存储 创建 minio docker run -p 9000:9000 --name minio1 \ -e "MINIO_ACCESS_KEY=lengleng" \ -e "MINIO_SECRET_KEY=lengleng" \ minio/minio server /data 配置 minio 参数 # 文件系统 oss: path-style-access: true endpoint: http://IP:9000 access-key: lengleng secret-key: lengleng bucketName: lengleng 使用 OssTemplate 上传即可 源码地址: https://github.com/pig-mesh/oss-spring-boot-starter 欢迎 fork 扩展 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
Redis 客户端缓存 缓存的解决方案一般有两种: 【L1】 内存缓存(如 Caffeine、Ehcache) —— 速度快,进程内可用,但重启缓存丢失,出现缓存雪崩的问题。 【L2】集中式缓存(如 Redis)—— 可同时为多节点提供服务,但高并发下,带宽成为瓶颈。 业内有很多开源框架来解决以上问题,既能有 L1 速度,并且拥有 L2 集群态。如下 J2Cache 两级缓存框架 hotkey 热点数据实时同步 在 redis 6.0 版本中,已经默认支持了客户端缓存功能,Java 中主流的连接客户端 lettuce 在最新的快照版本 (6.0.0.BUILD-SNAPSHOT) 已经提供支持。 下边就通过代码来体验一下客户端缓存的神奇功能。 Redis 6.0 安装 安装 redis 6,这里通过 Docker 安装命令如下 docker run --name redis6 -p 6379:6379 --restart=always -d redis:6.0.6 Jar 依赖 注意: 这里使用 lettuce 客户端,注意当前使用 6.0 的快照版本 ,需要在 pom 增加 lettuce 快照仓库 1.lettuce 6.0 快照依赖 <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.0.0.BUILD-SNAPSHOT</version> </dependency> 配置快照仓库 <repositories> <repository> <id>sonatype-snapshots</id> <name>Sonatype Snapshot Repository</name> <url>https://oss.sonatype.org/content/repositories/snapshots/</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> </repositories> 代码操作 使用 lettuce 连接 redis ,并循环查看 k1 的值 // <1> 创建单机连接的连接信息 RedisURI redisUri = RedisURI.builder() // .withHost("127.0.0.1") .withPort(6379) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> otherParty = redisClient.connect(); RedisCommands<String, String> commands = otherParty.sync(); StatefulRedisConnection<String, String> connection = redisClient.connect(); // <2> 创建缓存访问器 Map<String, String> clientCache = new ConcurrentHashMap<>(); //map 自动保存所有操作key的 key=value CacheFrontend<String, String> frontend = ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, TrackingArgs.Builder.enabled()); // <3> 客户端正常写入测试数据 k1 v1 String key = "k1"; commands.set(key, "v1"); // <4> 循环读取 while (true) { // <4.1> 缓存访问器中的值,查看是否和 Redis 服务端同步 String cachedValue = frontend.get(key); System.out.println("当前 k1 的值为:--->" + cachedValue); Thread.sleep(3000); } redis-cli 客户端同时操作 k1 修改 k1 的值 ./redis-cli -h 127.0.0.1 -p 6379 > set k1 v2 注意查看 控制台日志 ... 当前 k1 的值为:--->v1 当前 k1 的值为:--->v1 当前 k1 的值为:--->v1 当前 k1 的值为:--->v2 当前 k1 的值为:--->v2 当前 k1 的值为:--->v2 .... 如上: k1 的值在其他客户端(redis-cli)修改,lettuce 客户端确实感知到了数据变化。 但 lettuce 到底 CacheFrontend.get 到底有没有查询 redis 呢? 我们可以通过以下监控看下客户端具体的操作细节 监控 ./redis-cli -h 127.0.0.1 -p 6379 > MONITOR OK 1595922453.165088 [0 172.16.1.96:57482] "SET" "k1" "v1" # 对应 <3> 写入测试数据 1595922453.168238 [0 172.16.1.96:57483] "GET" "k1" # <4.1> 缓存访问器中的值,由于第一次查询为空需要穿透去查询 redis-server 1595922466.525942 [0 172.16.1.96:57498] "COMMAND" # 其他客户端 redis-cli 接入 提醒 1595922472.046488 [0 172.16.1.96:57498] "set" "k1" "v2" # 其他客户端 操作 k1 1595922474.208214 [0 172.16.1.96:57483] "GET" "k1" # 由于k1 值发生变化,循环 <4.1> 会重新查询redis-server 如上: 虽然是个死循环,但是关于 redis 操作只有以上注释的几条,说明客户端缓存生效。 总结 当前仅有 lettuce 支持此功能,jedis 还未支持 spring-boot-data-redis 暂未支持此功能,估计需要 spring boot 2.5 版本 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
代码生成 在企业软件开发过程中,大多数时间都是面向数据库表的增删改查开发。通过通用的增删改查代码生成器,可以有效的提高效率,降低成本;把有规则的重复性劳动让机器完成,解放开发人员。 MyBatis Generator MyBatis Generator 是 MyBatis 提供的一个代码生成工具 可以帮我们生成表对应的持久化对象(po)、操作数据库的接口(dao)、CRUD sql 的 xml(mapper)。 <plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-generator-maven-plugin</artifactId> <version>${last.version}</version> <configuration> <!--mybatis的代码生成器的配置策略文件--> <configurationFile>mybatis-generator-config.xml</configurationFile> </configuration> </plugin> 配置代码生成相关的策略文件 mybatis-generator-config.xml <generatorConfiguration> <context> <!-- jdbc连接 --> <jdbcConnection> ... </jdbcConnection> <!-- schema为数据库名,tableName为对应的数据库表名 --> <table> ... </table> <!-- 注释 --> <commentGenerator> ... </commentGenerator> <!-- 类型转换 --> <javaTypeResolver> ... </javaTypeResolver> <!-- 生成实体类配置 --> <javaModelGenerator> ... </javaModelGenerator> <!-- 生成Mapper.xml文件配置 --> <sqlMapGenerator> ... </sqlMapGenerator> <!-- 生成Mapper.java 接口--> <javaClientGenerator> ... </javaClientGenerator> </context> </generatorConfiguration> 缺点 每次代码生成需要配置对应的 mybatis-generator-config 通过 XML 的形式配置相关生成属性和规则 无法生成通用的 Controller、Service 类,无法自定义模板等 综上两点: mybatis-generator 使用非常不方便 EasyCode EasyCode 是基于 IntelliJ IDEA Ultimate 版开发的一个代码生成插件,主要通过自定义模板(基于 velocity)来生成各种你想要的代码。通常用于生成 Entity、Dao、Service、Controller。如果你动手能力强还可以用于生成 HTML、JS、PHP 等代码。理论上来说只要是与数据有关的代码都是可以生成的。 快速上手 安装 IDEA EasyCode 插件。 支持在线安装,插件市场搜索安装即可。 使用 IDEA 连接目标数据源 选择目标表进行代码生成 进阶配置 如上即可完成基于单表的增删改查方法,包括 Controller、Service、Mapper、Entity。 但默认生成是基于原生 MyBatis 的通用文件,不适用于 MyBatisPlus、通用 Mapper 等 Mybatis 扩展插件。我们可以通过编辑 EasyCode 的模板文件,来动态添加我们的生成规则,并且可以导出给其他人使用。 甚至于可以配置新的模板生成前端页面,比如基于 Element 的增删改查 总结 当然很多脚手架都会内置代码生成功能。 例如 pig 的开发平台模块 ,通过自定义模板引擎形式实现代码生成,能够更好的整合现有业务提开发效率。 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
官方标准运行方式 下载解压可运行包 curl -O https://github.com/alibaba/nacos/releases/download/1.3.2/nacos-server-1.3.2.tar.gz tar -zxvf nacos-server-1.3.2.tar.gz cd nacos/bin 执行运行 # Linux/Unix/Mac 启动命令(standalone代表着单机模式运行,非集群模式): sh startup.sh -m standalone # 如果您使用的是ubuntu系统,或者运行脚本报错提示[[符号找不到,可尝试如下运行: bash startup.sh -m standalone # Windows 启动命令(或者双击startup.cmd运行文件) cmd startup.cmd 为什么要源码化运行 1. 方便开发过程使用 如果从 Spring Cloud Netflix 体系迁移到 Spring Cloud Alibaba 技术体系,明显的感受是整个体系得到简化。 Nacos 承担整个 Spring Cloud 的服务发现、配置管理部分的实现。 是整个开发过程中强依赖,启动微服务业务要去检查 Nacos Server 是否已经启动,解压安装的方式变的非常不便。 如果把 Nacos Server 作为整个微服务框架的一部分直接 Main 启动,是不是更加方便便利? 2. UI 个性定制化 若以解压运行方式,修改 UI 几乎不可能。可以下载 Nacos 源码继续修改 然后重新打包运行。 非常的不方便 git clone https://github.com/alibaba/nacos.git cd nacos/ mvn -Prelease-nacos -Dmaven.test.skip=true clean install -U ls -al distribution/target/ // change the $version to your actual path cd distribution/target/nacos-server-$version/nacos/bin 若以源码方式运行,可以试试的调整 UI 然后 build 看到效果。 3. 保证 Server & Client 保持一致 pig 作为微服务开源项目,更新迭代速度非常快。每个版本依赖的 Nacos Client 版本都可能发生变化,这就意味着对应的 Nacos Server 版本也要对应升级,这需要用户自行下载升级成本很高。 Nacos 具有良好小版本向下兼容性,但是大版本功能变化挺大,比如 1.2 、1.3 权限的变更。所以建议大家在实际开发过程中保持版本一致。 若以源码运行的方式,可以很好的解决此问题。 如何实现 1. 下载 Nacos 源码 只需保留 nacos console 模块,其他模块均可删除 2. console 源码结构说明 ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── alibaba │ │ └── nacos │ │ ├── Nacos.java # main 启动类 │ │ └── console # 控制台相关源码 │ └── resources │ ├── application.properties # nacos 配置文件 │ └── static # 静态页面目录 └── test # 单元测试部分 3. 修改 Nacos.java 类 主要在 main 方法中增加 两个参数,是否是单机启动 & 是否关闭权限校验 @SpringBootApplication(scanBasePackages = "com.alibaba.nacos") @ServletComponentScan @EnableScheduling public class Nacos { public static void main(String[] args) { # 通过环境变量的形式 设置 单机启动 System.setProperty(ConfigConstants.STANDALONE_MODE, "true"); # 通过环境变量的形式 设置 关闭权限校验 System.setProperty(ConfigConstants.AUTH_ENABLED, "false"); SpringApplication.run(Nacos.class, args); } } 4. 修改 console/pom.xml 由于不在使用 nacos bom 管理,需要给所有依赖坐标增加版本号 由于 nacos-config /nacos-naming 等包没有上传至中央参考 无法下载到,groupId 变更为 com.pig4cloud.nacos 即可下载 变更后参考如下 <dependency> <groupId>com.pig4cloud.nacos</groupId> <artifactId>nacos-config</artifactId> <version>1.3.2</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>7.0.59</version> </dependency> <dependency> <groupId>com.pig4cloud.nacos</groupId> <artifactId>nacos-naming</artifactId> <version>1.3.2</version> </dependency> ... 总结 以上修改后源码参考: https://gitee.com/log4j/pig 是否以源码形式运行,此问题仁者见仁智者见智 根据你们实际情况来。 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
为什么多级缓存 缓存的引入是现在大部分系统所必须考虑的 redis 作为常用中间件,虽然我们一般业务系统(毕竟业务量有限)不会遇到如下图 在随着 data-size 的增大和数据结构的复杂的造成性能下降,但网络 IO 消耗会成为整个调用链路中不可忽视的部分。尤其在 微服务架构中,一次调用往往会涉及多次调用 例如pig oauth2.0 的 client 认证 Caffeine 来自未来的本地内存缓存,性能比如常见的内存缓存实现性能高出不少详细对比。 综合所述:我们需要构建 L1 Caffeine JVM 级别缓存 , L2 Redis 缓存。 设计难点 目前大部分应用缓存都是基于 Spring Cache 实现,基于注解(annotation)的缓存(cache)技术,存在的问题如下: Spring Cache 仅支持 单一的缓存来源,即:只能选择 Redis 实现或者 Caffeine 实现,并不能同时使用。 数据一致性:各层缓存之间的数据一致性问题,如应用层缓存和分布式缓存之前的数据一致性问题。 缓存过期:Spring Cache 不支持主动的过期策略 业务流程 如何使用 引入依赖 <dependency> <groupId>com.pig4cloud.plugin</groupId> <artifactId>multilevel-cache-spring-boot-starter</artifactId> <version>0.0.1</version> </dependency> 开启缓存支持 @EnableCaching public class App { public static void main(String[] args) { SpringApplication.run(App.class, args); } } 目标接口声明 Spring Cache 注解 @Cacheable(value = "get",key = "#key") @GetMapping("/get") public String get(String key){ return "success"; } 性能比较 为保证性能 redis 在 127.0.0.1 环路安装 OS: macOS Mojave CPU: 2.3 GHz Intel Core i5 RAM: 8 GB 2133 MHz LPDDR3 JVM: corretto_11.jdk Benchmark Mode Cnt Score Units 多级实现 thrpt 2 2716.074 ops/s 默认 redis thrpt 2 1373.476 ops/s 代码原理 自定义 CacheManager 多级缓存实现 public class RedisCaffeineCacheManager implements CacheManager { @Override public Cache getCache(String name) { Cache cache = cacheMap.get(name); if (cache != null) { return cache; } cache = new RedisCaffeineCache(name, stringKeyRedisTemplate, caffeineCache(), cacheConfigProperties); Cache oldCache = cacheMap.putIfAbsent(name, cache); log.debug("create cache instance, the cache name is : {}", name); return oldCache == null ? cache : oldCache; } } 多级读取、过期策略实现 public class RedisCaffeineCache extends AbstractValueAdaptingCache { protected Object lookup(Object key) { Object cacheKey = getKey(key); // 1. 先调用 caffeine 查询是否存在指定的值 Object value = caffeineCache.getIfPresent(key); if (value != null) { log.debug("get cache from caffeine, the key is : {}", cacheKey); return value; } // 2. 调用 redis 查询在指定的值 value = stringKeyRedisTemplate.opsForValue().get(cacheKey); if (value != null) { log.debug("get cache from redis and put in caffeine, the key is : {}", cacheKey); caffeineCache.put(key, value); } return value; } } 过期策略,所有更新操作都基于 redis pub/sub 消息机制更新 public class RedisCaffeineCache extends AbstractValueAdaptingCache { @Override public void put(Object key, Object value) { push(new CacheMessage(this.name, key)); } @Override public ValueWrapper putIfAbsent(Object key, Object value) { push(new CacheMessage(this.name, key)); } @Override public void evict(Object key) { push(new CacheMessage(this.name, key)); } @Override public void clear() { push(new CacheMessage(this.name, null)); } private void push(CacheMessage message) { stringKeyRedisTemplate.convertAndSend(topic, message); } } MessageListener 删除指定 Caffeine 的指定值 public class CacheMessageListener implements MessageListener { private final RedisTemplate<Object, Object> redisTemplate; private final RedisCaffeineCacheManager redisCaffeineCacheManager; @Override public void onMessage(Message message, byte[] pattern) { CacheMessage cacheMessage = (CacheMessage) redisTemplate.getValueSerializer().deserialize(message.getBody()); cacheMessage.getCacheName(), cacheMessage.getKey()); redisCaffeineCacheManager.clearLocal(cacheMessage.getCacheName(), cacheMessage.getKey()); } } 源码地址 [https://github.com/pig-mesh/multilevel-cache-spring-boot-starter](https://github.com/pig-mesh/multilevel-cache-spring-boot-starter) https://gitee.com/log4j/pig 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
为什么需要单元测试 单元测试拥有保证代码质量、尽早发现软件 Bug、简化调试过程、促进变化并简化集成、使流程更灵活等优势。单元测试是针对代码单元的独立测试,核心是“独立”,优势来源也是这种独立性,而所面临的不足也正是因为其独立性:既然是“独立”,就难以测试与其他代码和依赖环境的相互关系。单元测试与系统测试是互补而非代替关系。单元测试的优势,正是系统测试的不足,单元测试的不足,又恰是系统测试的优势。不能将单元测试当做解决所有问题的万金油,而需理解其优势与不足,扬长避短,与系统测试相辅相成,实现测试的最大效益。 OAuth2 系统单元测试困难 接口测试依赖于 UPMS (用户权限管理),无法做到解耦独立 spring-security-test 模块未提供相关标准实现 场景复杂既要包含无状态 token 调用,又要保证上线文传递业务 解决方案 参考 @WithMockUser ,在 Mock 拦截器中自动执行相关的增强(token 获取),并通过扩展 WithSecurityContextFactory 实现上下文 token 的传递。具体可以参考源码 pig-common-test[1] 引入依赖 <dependency> <groupId>com.pig4cloud</groupId> <artifactId>pig-common-test</artifactId> <version>${last.version}</version> <scope>test</scope> </dependency> 单元测试 Controller 接口 指定认证中心接口 配置在 test/resources/application.yml security: oauth2: client: access-token-uri: http://pig-gateway:3000/oauth/token 模拟测试 controller 接口 @RunWith(SpringRunner.class) @SpringBootTest public class SysLogControllerTest { private MockMvc mvc; @Autowired private WebApplicationContext applicationContext; // 注入WebApplicationContext @Before public void setUp() { this.mvc = MockMvcBuilders.webAppContextSetup(applicationContext).build(); } @Test @SneakyThrows @WithMockOAuth2User public void testMvcToken() { mvc.perform(delete("/log/1").with(token())).andExpect(status().isOk()); } } 模拟测试 FeignClient 传递 token -直接注入 FeignClient 实现即可 使用 @WithMockOAuth2User 注解测试类即可 WithMockOAuth2User 属性说明 当前用例获取 token 使用的用户名 String username() default "admin"; 当前用例获取 token 使用的密码 String password() default "123456"; 写在最后源码参考 pig-common-test 模块 目前仅在 pig 2.10 做了实现,理论支持低版本,直接 install 此模块即可 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
Spring Boot 2.4.0.M2 刚刚发布,它对 application.properties 和 application.yml 文件的加载方式进行重构。如果应用程序仅使用单个 application.properties 或 application.yml 作为配置文件,那么可能感受不到任何区别。但是如果您的应用程序使用更复杂的配置(例如,Spring Cloud 配置中心等),则需要来了解更改的内容以及原因。 为什么要进行这些更改 随着最新版本 Spring Boot 发布,Spring 一直在努力提升对 Kubernetes 的原生支持。在 Spring Boot 2.3 中,官方增加 Kubernetes Volume 的配置支持,但是未能实现。 Volume 配置挂载是 Kubernetes 的一项常用功能,其中 ConfigMap 指令用于直接在文件系统上显示配置。您可以装载包含多个键和值合并的完整 YAML 文件,也可以使用更简单的目录树格式,其中文件名是键,文件内容是值。 希望同时提供两者的支持,并且能够兼容我们现有的 application.properties 和 application.yml 。为此需要修改 ConfigFileApplicationListener 类。 ConfigFileApplicationListener 问题 在 Spring Boot 中配置文件加载类 ConfigFileApplicationListener 属于比较核心的底层代码,每次维护都是非常的困难。并不是因为代码编写错误或者缺少相关单元测试,而是在添加新功能时,很难解决之前存在的问题。 即: 配置文件非常灵活,可以在当前文件启用其他配置文件。 文档加载顺序不固定。 以下面的例子来说: security.user.password: usera --- spring.profiles: local security.user.password: userb runlocal: true --- spring.profiles: !dev spring.profiles.include: local security.user.password: userc 在这里,我们有一个 多文档 YAML文件(一个文件由三个逻辑文档组成,由 --- 分隔)。 如果使用 --spring.profile.actives=prod 运行,那么 security.user.password 的值是什么?是否设置 runlocal 属性?中间部分文档是否包括在内,因为配置文件在处理时没有激活? 我们经常会遇到关于这个文件处理逻辑的问题,但是每当试图修复它们时,最后带来各种各样的负面问题。 因此,在 Spring boot 2.4 中对 Properties 和 YAML 文件的加载方式进行两个重大更改: 文档将按定义的顺序加载。 profiles 激活开关不能被配置在特定环境中。 文档排序 从 Spring Boot 2.4 开始,加载 Properties 和 YAML 文件时候会遵循, 在文档中声明排序靠前的属性将被靠后的属性覆盖 。 这点与 .properties 的排序规则相同。我们可以想一想,每次将一个 Value 放入 Map ,具有相同 key 的新值放入时,将替换已经存在的 Value。 同理对 Multi-document 的 YAML 文件,较低的排序也将被较高的覆盖: test: "value" --- test: "overridden-value" Properties 文件支持多文档属性 在 Spring Boot 2.4 中, Properties 支持类似 YAML 多文档功能。多文档属性文件使用注释( # )后跟三个(---)破折号来分隔文档( 选择使用注释,以使现有的 IDE 正常支持 )。 例如,上面的 YAML 等效的 properties 为: test=value #--- test=overridden-value 特定环境激活配置 上述示例实际上没有任何意义,在我们开发过程中更为常见是声明某个属性仅在特定环境生效激活。 在 Spring Boot 2.3 中可以配置 spring.profiles 来实现。但在 Spring Boot 2.4 中 属性更改 为 spring.config.activate.on-profile 。 例如,我们想要 test 属性仅仅在 dev Profile 激活时覆盖它,则可以使用以下配置: test=value #--- spring.config.activate.on-profile=dev test=overridden-value Profile Activation 使用 spring.profiles.active 属性在 application.properties 或 application.yaml 文件的 根配置文件 来激 相关环境文件。 例如,下面这样: test=value spring.profiles.active=local #--- spring.config.activate.on-profile=dev test=overridden value 不允许的是将 spring.profiles.active 属性与 spring.config.activate.on-profile 一起使用。例如,以下文件将引发异常: test=value #--- spring.config.activate.on-profile=dev spring.profiles.active=local # will fail test=overridden value 通过这一新限制能使 application.properties 和 application.yml 文件更加容易理解。使得 Spring Boot 本身更易于管理和维护。 Profile Groups Profile Groups 是 Spring Boot 2.4 中的一项新功能,可让您将单个配置文件扩展为多个子配置文件。例如,假设有一组复杂的 @Configuration 类,可以使用 @Profile 注释有条件地启用它们。使用 @Profile("proddb") 开启数据库配置,使用 @Profile("prodmq") 开启消息配置等等。 使用多个配置文件可以使我们的代码更易于理解,但是对于部署而言并不是理想的选择。若用户需要同时激活 proddb , prodmq , prodmetrics 等。那么 Profile Groups 可让您做到这一点。 您可以在 application.properties 或 application.yml 文件中定义 spring.profiles.group,那么开启 prod 则就相当于激活了此组的全部环境 。例如: spring.profiles.group.prod=proddb,prodmq,prodmetrics Importing 扩展 Configuration 现在,我们已经解决了配置文件处理的基本问题,我们终于能够考虑我们想要提供的新功能。我们使用 Spring Boot 2.4 提供的主要功能是支持导入其他配置。 对于早期版本的 Spring Boot,很难在 application.properties 和 application.yml 之外导入其他 properties 或 yaml 文件。可以使用 spring.config.additional-location 属性但它可以处理的文件类型非常有限。 在 Spring Boot 2.4 可以直接在 application.properties 或 application.yml 文件中使用新的 spring.config.import 属性。例如希望导入一个 "忽略的 git" 的 developer.properties 文件,以便团队中的任何开发人员都可以快速更改属性: application.name=myapp spring.config.import=developer.properties 甚至可以将 spring.config.import 与 spring.config.activate.on-profile 结合起来使用。例如,这里 prod.properties 仅在 prod 配置文件处于激活状态时加载: spring.config.activate.on-profile=prod spring.config.import=prod.properties Import 可以被视为在声明它们的文档下方插入的其他文档。它们 遵循与常规多文档文件相同的自上而下的顺序:导入仅被导入一次,无论声明了多少次。 volume 挂载配置 导入定义使用与 URL 一样语法作为其值。如果您的位置没有前缀,则它被视为常规文件或文件夹。但是,如果您使用 configtree: 前缀,则告诉 Spring Boot,您将期望在该位置使用 Kubernetes volume 装载的配置树。 例如,您可以在 application.properties 配置: spring.config.import=configtree:/etc/config 如果您有以下装载的内容: etc/ +- config/ +- my/ | +- application +- test 将在 Spring Environment 中拥有 my.application 和 test 属性。 my.application 的值是 /etc/config/my/application 的内容, test 的值是 /etc/config/test 的内容。 根据云平台类型激活 如果只希望 Volume 挂载的配置(或该内容的任何属性)在特 定的云平台上 处于激活状态,可以使用 spring.config.activate.on-cloud-platform 属性。它的工作方式与 spring.config.activate.on-profile 类似,但它使用 CloudPlatform 的值,而不是配置文件名称。 如果我们想要在部署到 Kubernetes 时启用上述配置树,我们可以执行以下操作: spring.config.activate.on-cloud-platform=kubernetes spring.config.import=configtree:/etc/config 支持其他位置 spring.config.import 属性中指定的位置字符串是完全可插拔的,可以通过编写几个自定义类来扩展,第三方库将对自定义位置提供支持。例如,你能想到的第三方 jar 文件,例如 archaius://… , vault://… 或 zookeeper://… 。 如果您有兴趣添加其他位置支持,请查看 org.springframework.boot.context.config 包 ConfigDataLocationResolver 和 ConfigDataLoader 的 javadoc。 版本回滚 正如上文所描述的,Spring Boot 针对配置文件的功能变更是非常大的。考虑到低版本的兼容性 可以设置 spring.config.use-legacy-processing=true 属性即可,恢复到之前版本的文件处理机制。 如果发现关于此处的问题,则需要切换到旧版处理,请 在 GitHub 上提出问题,官方将尝试解决该问题。 总结 官方希望新的配置数据处理更加好用,并且不会引起太多升级麻烦。如果您想了解更多有关它们的信息,可以查阅更新的 参考文档。 欢迎关注我,后续会通过代码来详细说明此处变更。 翻译: 冷冷、如梦技术 原文链接:https://spring.io/blog/2020/08/14/config-file-processing-in-spring-boot-2-4 项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注
背景 在我们实际生产容器化部署过程中,往往会遇到 Docker 镜像很大,部署发布很慢的情况 影响 docker 镜像大小的因素,主要有以下三个方面: 基础镜像的大小 。尽量选择 aphine 作为基础镜像 减少操作系统内置软件 Dockerfile 指令层数。 这就要求我们优化 Dockerfile 能合并在一行的尽量合并等 应用 jar 的大小。这是今天要分享的重点内容 helloworld 镜像 我们先来基于 spring boot 2.3.0 构建一个最简单的 web helloworld,然后构建镜像。 FROM adoptopenjdk:11-jre-hotspot as builder WORKDIR application ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} application.jar ENTRYPOINT ["java", "-jar application.jar"] docker build --build-arg JAR_FILE=./demo-layer-0.0.1-SNAPSHOT.jar . -t demo:v1.0 查看镜像分层信息 我们通过 docker inspect demo:v1.0 来看下此镜像的每层的散列值 // demo:v1.0 版本镜像分层信息摘要 "Layers": [ "sha256:b7f7d2967507ba709dbd1dd0426a5b0cdbe1ff936c131f8958c8d0f910eea19e", "sha256:a6ebef4a95c345c844c2bf43ffda8e36dd6e053887dd6e283ad616dcc2376be6", "sha256:838a37a24627f72df512926fc846dd97c93781cf145690516e23335cc0c27794", "sha256:28ba7458d04b8551ff45d2e17dc2abb768bf6ed1a46bb262f26a24d21d8d7233", "sha256:55c91231ac46fdd63c3cf84b88b11f8a04c1870482dcff033029a601bc50e1ab", "sha256:9816c2d488754509f6024a267738b1e5fe33a7cd33bd25c5a9cdf6d4d7bfed1d", "sha256:f5fb3f91797d57a92f3f7e033398b8edd094df664db849a4950eabf2f5474535", "sha256:b87d2ff74819f83038ea2f89736a19cfcf99bfa080b8017d191c900a09a7524f" ] helloworld 升级重新构建 我们对 helloworld 程序进行部分修改(模拟开发过程),然后重新构建镜像 docker build --build-arg JAR_FILE=./demo-layer-0.0.1-SNAPSHOT.jar . -t demo:v1.1 此时镜像分层信息如下 docker inspect demo:v1.1 // demo:v1.1 版本镜像分层信息摘要 "Layers": [ "sha256:b7f7d2967507ba709dbd1dd0426a5b0cdbe1ff936c131f8958c8d0f910eea19e", "sha256:a6ebef4a95c345c844c2bf43ffda8e36dd6e053887dd6e283ad616dcc2376be6", "sha256:838a37a24627f72df512926fc846dd97c93781cf145690516e23335cc0c27794", "sha256:28ba7458d04b8551ff45d2e17dc2abb768bf6ed1a46bb262f26a24d21d8d7233", "sha256:55c91231ac46fdd63c3cf84b88b11f8a04c1870482dcff033029a601bc50e1ab", "sha256:9816c2d488754509f6024a267738b1e5fe33a7cd33bd25c5a9cdf6d4d7bfed1d", "sha256:f5fb3f91797d57a92f3f7e033398b8edd094df664db849a4950eabf2f5474535", "sha256:c1b6350d545fea605e0605c4bfd7f4529cfeee3f6759750d6a5ddeb9c882fc8f" ] 比较 v1.0、v1.1 镜像 通过比较 v1.0 和 v1.1 版本的镜像摘要信息,我们会发现只有最后的一层发生了变化,我们通过 Dive 是一个用 Go 语言编写的 Docker 镜像分析工具 来确定一下 最后一层是做了哪些事情 dive demo:v1.0,大家会看到是最后的 jar 不一样 导致 16M 的内容需要重新构建,当你的业务 jar 很大时,这块就是性能瓶颈 spring boot 默认打包解密 默认情况下,spring boot 构建出来的 jar ,解压后可以看到如下目录结构。默认会当做一个整体 ,在构建镜像时作为一个单独层,没有区分业务 classes 和 引用的第三方 jar META-INF/ MANIFEST.MF org/ springframework/ boot/ loader/ BOOT-INF/ classes/ lib/ layer jar 通过上文大家就可以知道分层 jar 的思想就是把,jar 再根据规则细分,业务 class 和 三方 jar 分别对应镜像的不同层,这样改动业务代码,只需变动很少的内容 提高构建速度。 开启分层打包 <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration> </plugin> 编写支持分层 Dockerfile 核心是通过 spring boot 提供的 layertools 工具,将 jar 进行拆分 然后通过 COPY 指令去分别加载 FROM adoptopenjdk:11-jre-hotspot as builder WORKDIR application ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} application.jar RUN java -Djarmode=layertools -jar application.jar extract FROM adoptopenjdk:11-jre-hotspot WORKDIR application COPY --from=builder application/dependencies/ ./ COPY --from=builder application/spring-boot-loader/ ./ COPY --from=builder application/snapshot-dependencies/ ./ COPY --from=builder application/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] 构建新镜像并查看分层信息 docker build --build-arg JAR_FILE=./demo-layer-0.0.1-SNAPSHOT.jar . -t demo:v2.0 "Layers": [ "sha256:b7f7d2967507ba709dbd1dd0426a5b0cdbe1ff936c131f8958c8d0f910eea19e", "sha256:a6ebef4a95c345c844c2bf43ffda8e36dd6e053887dd6e283ad616dcc2376be6", "sha256:838a37a24627f72df512926fc846dd97c93781cf145690516e23335cc0c27794", "sha256:28ba7458d04b8551ff45d2e17dc2abb768bf6ed1a46bb262f26a24d21d8d7233", "sha256:55c91231ac46fdd63c3cf84b88b11f8a04c1870482dcff033029a601bc50e1ab", "sha256:9816c2d488754509f6024a267738b1e5fe33a7cd33bd25c5a9cdf6d4d7bfed1d", "sha256:f5fb3f91797d57a92f3f7e033398b8edd094df664db849a4950eabf2f5474535", "sha256:06fe18cf8ae7384f120f2c6a3a33b31999dd0460cf1edae45e8f13adeab35942", "sha256:16cf814564b8a667fcc9f07314b6084cbef8dc8c0a6565c7a2d91d74faf7e7de", "sha256:94be40f716016b68cdd6b99d2cb8154acf8475c3a170a898a22f95a8ef40ffd3", "sha256:427d87d6a5fe6da13cb4233939c3a1ff920bc6b4d2f14b5d78af7aef98fda7de" ] 修改代码部分业务代码,重新构建 docker build --build-arg JAR_FILE=./demo-layer-0.0.1-SNAPSHOT.jar . -t demo:v2.1 "Layers": [ "sha256:b7f7d2967507ba709dbd1dd0426a5b0cdbe1ff936c131f8958c8d0f910eea19e", "sha256:a6ebef4a95c345c844c2bf43ffda8e36dd6e053887dd6e283ad616dcc2376be6", "sha256:838a37a24627f72df512926fc846dd97c93781cf145690516e23335cc0c27794", "sha256:28ba7458d04b8551ff45d2e17dc2abb768bf6ed1a46bb262f26a24d21d8d7233", "sha256:55c91231ac46fdd63c3cf84b88b11f8a04c1870482dcff033029a601bc50e1ab", "sha256:9816c2d488754509f6024a267738b1e5fe33a7cd33bd25c5a9cdf6d4d7bfed1d", "sha256:f5fb3f91797d57a92f3f7e033398b8edd094df664db849a4950eabf2f5474535", "sha256:06fe18cf8ae7384f120f2c6a3a33b31999dd0460cf1edae45e8f13adeab35942", "sha256:16cf814564b8a667fcc9f07314b6084cbef8dc8c0a6565c7a2d91d74faf7e7de", "sha256:94be40f716016b68cdd6b99d2cb8154acf8475c3a170a898a22f95a8ef40ffd3", "sha256:8a20c60d361696a4e480fb6fbe1daf8b88bc54c579a98e209da1fb76e25de5aa" ] 查看区别层镜像 最后一层变动大小为 5KB 总结 16MB -> 5KB 变动,在实际开发过程中 效果会更加明显 可以通过 spring boot maven plugin 指定分层逻辑,具体可以参考官方文档 官方文档: https://docs.spring.io/spring-boot/docs/2.3.0.RELEASE/maven-plugin/reference/html
背景说明 一个账号只能一处登录,类似的业务需求在现有后管类系统是非常常见的。 但在原有的 spring security oauth2 的令牌方法流程(所谓的登录)无法满足类似的需求。 我们先来看 TokenEndpoint 的方法流程 客户端 带参访问 /oauth/token 接口,最后去调用 TokenGranter TokenGranter 根据不同的授权类型,获取用户认证信息 并去调用TokenServices 生成令牌 重新 TokenService 重写发放逻辑createAccessToken,当用户管理的令牌存在时则删除重新创建,这样会导致之前登陆获取的token 失效,顺理成章的被挤掉。 @Transactional public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException { OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication); OAuth2RefreshToken refreshToken = null; // 重写此处,当用户关联的token 存在时,删除原有令牌 if (existingAccessToken != null) { tokenStore.removeAccessToken(existingAccessToken); } else if (refreshToken instanceof ExpiringOAuth2RefreshToken) { ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken; if (System.currentTimeMillis() > expiring.getExpiration().getTime()) { refreshToken = createRefreshToken(authentication); } } OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); tokenStore.storeAccessToken(accessToken, authentication); // In case it was modified refreshToken = accessToken.getRefreshToken(); if (refreshToken != null) { tokenStore.storeRefreshToken(refreshToken, authentication); } return accessToken; } 重写 Token key 生成逻辑 如上代码,我们实现用户单一终端的唯一性登录,什么是单一终端 我们可以类比 QQ 登录 移动端和 PC 端可以同时登录,但 移动端 和移动端不能同时在线。 如何能够实现 在不同客户端也能够唯一性登录呢? 先来看上文源码 `OAuth2AccessToken existingAccessToken=tokenStore.getAccessToken(authentication);`是如何根据用户信息判断 token 存在的呢? public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) { String key = authenticationKeyGenerator.extractKey(authentication); // redis 查询逻辑,根据 key return accessToken; } AuthenticationKeyGenerator key值生成器 默认情况下根据 username/clientId/scope 参数组合生成唯一token public String extractKey(OAuth2Authentication authentication) { Map<String, String> values = new LinkedHashMap<String, String>(); OAuth2Request authorizationRequest = authentication.getOAuth2Request(); if (!authentication.isClientOnly()) { values.put(USERNAME, authentication.getName()); } values.put(CLIENT_ID, authorizationRequest.getClientId()); if (authorizationRequest.getScope() != null) { values.put(SCOPE, OAuth2Utils.formatParameterList(new TreeSet<String>(authorizationRequest.getScope()))); } return generateKey(values); } 若想实现,多终端的唯一性登录,只需要使得同一个用户在多个终端生成的 token 一致,加上上文提到的 createToken 修改逻辑,既去掉extractKey 的 clientId 条件,不区分终端即可 public String extractKey(OAuth2Authentication authentication) { Map<String, String> values = new LinkedHashMap<String, String>(); OAuth2Request authorizationRequest = authentication.getOAuth2Request(); if (!authentication.isClientOnly()) { values.put(USERNAME, authentication.getName()); } if (authorizationRequest.getScope() != null) { values.put(SCOPE, OAuth2Utils.formatParameterList(new TreeSet<String>(authorizationRequest.getScope()))); } return generateKey(values); } 最后在 authserver 中注入新的 TokenService 即可
问题背景 有同学私信问了这样的问题,访问 pig4cloud 的演示环境 查看登录请求 network 返回报文如下: { "access_token":"16d35799-9cbb-4c23-966d-ab606029a623", "token_type":"bearer", "refresh_token":"495dbde5-1bbb-43c9-b06b-ecac50aa5d53", "expires_in":41000, "scope":"server" } 而本地部署运行的时,登录请求返回的报文如下: { "access_token":"c262afbe-441e-4023-afb4-f88c8a0a7d51", "token_type":"bearer", "refresh_token":"ea642d50-5cf5-48ad-9ef9-cb57c9dde00a", "scope":"server" } 缺少 expires_in 过期参数,所以客户端无法知悉何时执行刷新行为。 源码剖析 我们来看下 oauth2 的令牌方法机制,如果客户端 配置的 validitySeconds (令牌有效期) 大于 0 会返回当前令牌的有效时间 expires_in 参数, OAuth2AccessToken createAccessToken() { DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString()); int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request()); if (validitySeconds > 0) { token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L))); } token.setRefreshToken(refreshToken); token.setScope(authentication.getOAuth2Request().getScope()); return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token; } tokenStore 去存储 令牌的时候,若过期参数为 0 或者 小于 0 Expiration 为空,不会设置有效时间也就意味着为永久有效,所以此时不会客户端响应 expires_in 参数 if (token.getExpiration() != null) { int seconds = token.getExpiresIn(); conn.expire(accessKey, seconds); conn.expire(authKey, seconds); conn.expire(authToAccessKey, seconds); conn.expire(clientId, seconds); conn.expire(approvalKey, seconds); } 永久有效的令牌是否应该返回 expires_in 参数呢? 我们先来看下oauth2 协议规范 HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store Pragma: no-cache { "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", "token_type":"bearer", "expires_in":3600, "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", "scope":"create" } access_token (必需) 授权服务器发出的访问令牌 token_type (必需)这是令牌的类型,通常只是字符串“bearer”。 expires_in (推荐)如果访问令牌过期过期时间。 refresh_token(可选)刷新令牌,在访问令牌过期后,可使用此令牌刷新。 scope(可选)如果用户授予的范围与应用程序请求的范围相同,则此参数为可选。 此处 expires_in 推荐返回,无论是有设置有效期限制还是无有效期限制。所以此处 spring security oauth2 的处理并不符合协议规范 emmm 。
背景 @ResponseBody 默认情况返回的数据格式是什么?所谓默认情况 后台接口不指定 produces MediaType @Controller public class DemoController { @ResponseBody @GetMapping(value = "/demo") public DemoVO demo() { return new DemoVO("lengleng", "123456"); } } 使用百度搜索 @ResponseBody 排名第一的答案, @ResponseBody 的作用其实是将 java 对象转为 json 格式的数据。 正确答案 我们先来公布正确的答案。 @ResponseBody 的输出格式,默认情况取决于客户端的 Accept 请求头。 源码剖析 RequestResponseBodyMethodProcessor public class RequestResponseBodyMethodProcessor { // 处理 ResponseBody 标注的方法 @Override public boolean supportsReturnType(MethodParameter returnType) { return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) || returnType.hasMethodAnnotation(ResponseBody.class)); } // 处理返回值 @Override public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) { mavContainer.setRequestHandled(true); ServletServerHttpRequest inputMessage = createInputMessage(webRequest); ServletServerHttpResponse outputMessage = createOutputMessage(webRequest); // 处理返回值 writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage); } } writeWithMessageConverters protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType, ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) { HttpServletRequest request = inputMessage.getServletRequest(); // 获取请求头中的目标资源类型 List<MediaType> acceptableTypes = getAcceptableMediaTypes(request); // 获取接口指定支持的资源类型 List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType); // 获取能够输出资源类型 List<MediaType> mediaTypesToUse = new ArrayList<>(); for (MediaType requestedType : acceptableTypes) { for (MediaType producibleType : producibleTypes) { if (requestedType.isCompatibleWith(producibleType)) { mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType)); } } } /// 排序 MediaType.sortBySpecificityAndQuality(mediaTypesToUse); for (MediaType mediaType : mediaTypesToUse) { // 判断资源类型是否是具体的类型,而不是带通配符 * 这种 if (mediaType.isConcrete()) { selectedMediaType = mediaType; break; } else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) { selectedMediaType = MediaType.APPLICATION_OCTET_STREAM; break; } } selectedMediaType = selectedMediaType.removeQualityValue(); // 查找支持选中资源类型的 HttpMessageConverter,输出body for (HttpMessageConverter<?> converter : this.messageConverters) { GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null); if (genericConverter != null ? ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) : converter.canWrite(valueType, selectedMediaType)) { body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, (Class<? extends HttpMessageConverter<?>>) converter.getClass(), inputMessage, outputMessage); return; } } } 为什么我要去研究这个问题 当升级至 spring cloud alibaba 2.2.1 时, sentinel 模块 引入以下依赖 当依赖中出现 dataformat jar 时候, RestTemplate ,会在默认 Accept 请求头增加 application/xml | text/xml | application/*+xml public MappingJackson2XmlHttpMessageConverter(ObjectMapper objectMapper) { super(objectMapper, new MediaType("application", "xml", StandardCharsets.UTF_8), new MediaType("text", "xml", StandardCharsets.UTF_8), new MediaType("application", "*+xml", StandardCharsets.UTF_8)); Assert.isInstanceOf(XmlMapper.class, objectMapper, "XmlMapper required"); } 当我们使用 RestTemplate 调用接口时候,若不指定 Accept 会返回 XML ,导致不能平滑升级
个性化token 背景 上一篇文章《Spring Security OAuth 个性化token(一)》有提到,oauth2.0 接口默认返回的报文格式如下: { "access_token": "e6669cdf-b6cd-43fe-af5c-f91a65041382", "token_type": "bearer", "refresh_token": "da91294d-446c-4a89-bdcf-88aee15a75e8", "expires_in": 43199, "scope": "server" } 通过上篇文章我们已经可以扩展增加部分业务字段。 { "access_token":"a6f3b6d6-93e6-4eb8-a97d-3ae72240a7b0", "token_type":"bearer", "refresh_token":"710ab162-a482-41cd-8bad-26456af38e4f", "expires_in":42396, "scope":"server", "tenant_id":1, "license":"made by pigx", "dept_id":1, "user_id":1, "username":"admin" } 「在一些场景下我们需要自定义一下返回报文的格式,例如pig 使用R 对象返回,全部包含code业务码信息」 { "code":1, "msg":"", "data":{ "access_token":"e6669cdf-b6cd-43fe-af5c-f91a65041382", "token_type":"bearer", "refresh_token":"da91294d-446c-4a89-bdcf-88aee15a75e8", "expires_in":43199, "scope":"server" } } 方法一:HandlerMethodReturnValueHandler 顾名思义这是 Spring MVC 提供给我们修改方法返回值的接口 public class FormatterToken implements HandlerMethodReturnValueHandler { private static final String POST_ACCESS_TOKEN = "postAccessToken"; @Override public boolean supportsReturnType(MethodParameter returnType) { // 判断方法名是否是 oauth2 的token 接口,是就处理 return POST_ACCESS_TOKEN.equals(Objects .requireNonNull(returnType.getMethod()).getName()); } // 获取到返回值然后使用 R对象统一包装 @Override public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer container, NativeWebRequest request) throws Exception { ResponseEntity<OAuth2AccessToken> responseEntity = (ResponseEntity) returnValue; OAuth2AccessToken body = responseEntity.getBody(); HttpServletResponse response = request.getNativeResponse(HttpServletResponse.class); assert response != null; WebUtils.renderJson(response, R.ok(body)); } } 注入FormatterToken,一定要这么处理,不要直接使用 MVCconfig 注入,保证此Handler比 SpringMVC 默认的提前执行。 public class FormatterTokenAutoConfiguration implements ApplicationContextAware, InitializingBean { private ApplicationContext applicationContext; @Override public void afterPropertiesSet() { RequestMappingHandlerAdapter handlerAdapter = applicationContext.getBean(RequestMappingHandlerAdapter.class); List<HandlerMethodReturnValueHandler> returnValueHandlers = handlerAdapter.getReturnValueHandlers(); List<HandlerMethodReturnValueHandler> newHandlers = new ArrayList<>(); newHandlers.add(new FormatterToken()); assert returnValueHandlers != null; newHandlers.addAll(returnValueHandlers); handlerAdapter.setReturnValueHandlers(newHandlers); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } } 方法二:aop 拦截增强 /oauth/token 接口 @Around("execution(* org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken(..))") public Object handlePostAccessTokenMethod(ProceedingJoinPoint joinPoint) throws Throwable { // 获取原有值,进行包装返回 Object proceed = joinPoint.proceed(); ResponseEntity<OAuth2AccessToken> responseEntity = (ResponseEntity<OAuth2AccessToken>) proceed; OAuth2AccessToken body = responseEntity.getBody(); return ResponseEntity .status(HttpStatus.OK) .body(R.ok(body)); } } 总结 实际项目中不建议修改此接口的访问格式,不兼容oauth2协议 导致其他组件不能正常使用 例如 swagger 自带的认证授权 其他网关组件自带的oauth2 https://docs.konghq.com/hub/kong-inc/oauth2/ spring security oauth2 自带的 sso 功能 都将失效总体来权衡 弊大于利
Token 校验逻辑 // CheckTokenEndpoint.checkToken @RequestMapping(value = "/oauth/check_token") @ResponseBody public Map<String, ?> checkToken(@RequestParam("token") String value) { // 根据 token 查询保存在 tokenStore 的令牌全部信息 OAuth2AccessToken token = resourceServerTokenServices.readAccessToken(value); if (token == null) { throw new InvalidTokenException("Token was not recognised"); } if (token.isExpired()) { throw new InvalidTokenException("Token has expired"); } // 根据 token 查询保存的 认证信息 还有权限角色等 (业务信息) OAuth2Authentication authentication = resourceServerTokenServices.loadAuthentication(token.getValue()); return accessTokenConverter.convertAccessToken(token, authentication); } 当客户端带着 header token 访问 oauth2 资源服务器,资源服务器会自动拦截 token 发送 token 到 认证服务器 校验 token 合法性 若认证服务器返回给资源服务器是token不合法,则资源服务器返回给客户端对应的信息 Token 生成逻辑 //DefaultTokenServices.createAccessToken 代码逻辑 public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException { // 根据用户信息(username),查询已下发的token OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication); OAuth2RefreshToken refreshToken = null; // 存在已下发的token if (existingAccessToken != null) { // 1. token 已经被标志过期,则删除 if (existingAccessToken.isExpired()) { if (existingAccessToken.getRefreshToken() != null) { refreshToken = existingAccessToken.getRefreshToken(); tokenStore.removeRefreshToken(refreshToken); } tokenStore.removeAccessToken(existingAccessToken); } else { // 直接返回存在的 token,并保存一下token 和 用户信息的关系 (username) tokenStore.storeAccessToken(existingAccessToken, authentication); return existingAccessToken; } } // 不存在则创建新的 token OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); tokenStore.storeAccessToken(accessToken, authentication); // In case it was modified refreshToken = accessToken.getRefreshToken(); if (refreshToken != null) { tokenStore.storeRefreshToken(refreshToken, authentication); } return accessToken; } 当我们通过oauth2 去获取 token 时,若当前用户已经存在对应的token,直接返回而不不会创建新 token。 这就意味着,虽然设置了对应客户端获取 token 的有效时间,这里获取到的token。若是已下发旧token,有效时间不会和session 机制一样自动续期。 综上情况,在操作过程中token 过期是一个常态化的问题。 Token 刷新逻辑 curl --location --request POST 'http://auth-server/oauth/token?grant_type=refresh_token' \ --header 'Authorization: Basic dGVzdDp0ZXN0' \ --header 'VERSION: dev' \ --data-urlencode 'scope=server' \ --data-urlencode 'refresh_token=eccda61e-0c68-43af-8f67-6302cb389612' 若上,当 前端拿着正确的(未过期且未使用过)refresh_token 去调用 认证中心的刷新 端点刷新时,会 触发RefreshTokenGranter, 返回新的 Token public class RefreshTokenGranter extends AbstractTokenGranter { @Override protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) { String refreshToken = tokenRequest.getRequestParameters().get("refresh_token"); return getTokenServices().refreshAccessToken(refreshToken, tokenRequest); } } refreshAccessToken 代码实现,调用 tokenStore 生成新的token @Transactional(noRollbackFor={InvalidTokenException.class, InvalidGrantException.class}) public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException { createRefreshedAuthentication(authentication, tokenRequest); if (!reuseRefreshToken) { tokenStore.removeRefreshToken(refreshToken); refreshToken = createRefreshToken(authentication); } OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); tokenStore.storeAccessToken(accessToken, authentication); if (!reuseRefreshToken) { tokenStore.storeRefreshToken(accessToken.getRefreshToken(), authentication); } return accessToken; } 客户端(前端)何时刷新 被动刷新 客户端携带 token 访问资源服务器资源 资源服务器拦截 token 去认证服务器 check_token 认证服务器返回 token 过期错误,资源服务器包装错误信息返回给客户端 客户端根据返回错误信息(响应码),直接调用认证服务器 refresh_token 认证服务器返回新的 token 给客户端, 然后再次发起 资源调用 被动请求的缺点是,用户当次请求会失败(返回token失败),对一些业务连贯的操作不是很友好 主动刷新 客户端存在计算逻辑,计算下发token 有效期 若token要过期之前,主动发起刷新 主动请求的缺点是,客户端占用部分计算资源来处理 token 失效问题 // 10S检测token 有效期 refreshToken() { this.refreshTime = setInterval(() => { const token = getStore({ name: 'access_token', debug: true }) if (this.validatenull(token)) { return } if (this.expires_in <= 1000 && !this.refreshLock) { this.refreshLock = true this.$store .dispatch('RefreshToken') .catch(() => { clearInterval(this.refreshTime) }) this.refreshLock = false } this.$store.commit('SET_EXPIRES_IN', this.expires_in - 10) }, 10000) }, 源码 基于Spring Boot 2.2、 Spring Cloud Hoxton & Alibaba、 OAuth2 的RBAC 权限管理系统
背景 先来回忆一下, nginx 如何配置多个实例的负载均衡,配置如下: upstream serverList { server 172.17.0.111:9999; server 172.17.0.110:9999; } server { location / { proxy_pass http://serverList; } } 当我们的服务实例变化时,要手动修改 nginx.conf 然后 nginx -s reload 。 在微服务架构下,我们的服务均已经注册到 注册中心 例如(nacos/eureka),注册中心已经维护所有服务实例的 IP:PORT 列表 ,为何不直接通过 nginx 来获取注册中心中的IP:PORT 列表自动配置 upstream 和热更新。如上思路实现有如下: 使用 nginx-lua-module 模块编写 lua 脚本, 调用注册中心的 Http API 来获取实例列表 配置 upstream,定时 reload 热更新 使用 JAVA/Golang 编写单独的agent,直接使用nacos 对应语言的 SDK ,获取实例列表生成 upstream,并且使用 Naocs SDK 监听服务变化 reload nacos-nginx-template 使用 nacos-nginx-template 以上的第二种思路实现以Agent的形式让Nginx实现对Nacos的服务发现。 下载二进制包 点击此处下载:最新稳定版 配置config.toml 配置文件使用TOML进行配置 nginx_cmd = "/usr/sbin/nginx" nacos_addr = "172.16.0.100:8848,172.16.0.101:8848,172.16.0.102:8848" reload_interval = 1000 [discover_config1] nginx_config = "/etc/nginx/nginx.conf" nginx_upstream = "upsteam1" nacos_service_name = "service1" [discover_config2] nginx_config = "/etc/nginx/nginx.conf" nginx_upstream = "upsteam2" nacos_service_name = "service2" 参数 描述 例子 nginx_cmd nginx命令的全路径 "/usr/sbin/nginx" nacos_addr nacos的地址 "172.16.0.100:8848,172.16.0.101:8848,172.16.0.102:8848" reload_interval nginx reload命令执行间隔时间(ms 默认值1000) 1000 nacos_service_name nacos服务名 "com.nacos.service.impl.NacosService" nginx_config 需要修改nginx配置的路径 "/etc/nginx/nginx.conf" nginx_upstream nginx中upstream的名字 "nacos-service" 启动,即可使用 sh bin/startup.sh 核心代码 获取 config.toml 配置的信息,支持多个 upstream ,调用Nacos Api 拉取实例列表 for (DiscoverConfigBO configBO : list) { namingService.subscribe(configBO.getServiceName(), event -> { List<Instance> instances = namingService .getAllInstances(configBO.getServiceName()); //更新nginx中的upstream refreshUpstream(instances, configBO.getUpstream(), configBO.getConfigPath()); } ); } 根据实例列表,拼凑 upstream private boolean refreshUpstream(List<Instance> instances, String nginxUpstream, String nginxConfigPath) { //获取到upstream 名称 Pattern pattern = Pattern.compile(UPSTREAM_REG.replace(PLACEHOLDER, nginxUpstream)); //获取到配置文件内容 String conf = FileUtl.readStr(nginxConfigPath); //拼接新的upstream String newUpstream = UPSTREAM_FOMAT.replace(PLACEHOLDER, nginxUpstream); StringBuffer servers = new StringBuffer(); if (instances.size() > 0) { for (Instance instance : instances) { //不健康或不可用的跳过 if (!instance.isHealthy() || !instance.isEnabled()) { continue; } servers.append(formatSymbol + " server " + instance.getIp() + ":" + instance.getPort() + ";\n"); } } servers.append(formatSymbol); newUpstream = newUpstream.replace(PLACEHOLDER_SERVER, servers.toString()); //替换原有的upstream conf = matcher.replaceAll(newUpstream); return true; } -Java 调用nginx reload Runtime.getRuntime().exec("nginx -s reload");
疫情期间大家都在讨论 远程办公的实现,推荐看下黄东旭大佬 写的 《PingCAP的5年远程办公实践》,以下内网映射工具作为常用补充。 来讲讲为啥要做内网映射 从公网中访问自己的内网设备一直是个麻烦事情,尤其是做微信开发等。设备可能处于路由器后,或者运营商因为IP地址短缺不给你分配公网IP地址。如果我们想直接访问到这些设备,一般非常麻烦。 求网管大佬在路由器上给自己内网加个端口映射 购买 花生壳 等动态域名解析软件 使用 natapp 等免费(也有付费的)的提供的内网映射服务 基于ngrok/frp自建内网映射服务 为什么放弃 ngrok,使用 frp 我们在2016年提供了一个ngrok 的免费服务,并且分享了搭建的步骤可以参考《Angrok 一个内网穿透服务》 ,搭建步骤对于一般的用户非常不友好,后边也就停止了相关的服务转向了 frp。 Github 的关注度对比 穿透协议支持 frp 支持 http ssh tcp udp ftp 等协议 开始动手 准备工作 搭建一个完整的frp服务,我们需要 公网IP 的 ECS 一台 域名 (若不需要解析则不需要) 安装 frp (frps)服务端 下载 frp 安装包 https://github.com/fatedier/frp/releases/download/v0.31.1/frp_0.31.1_darwin_amd64.tar.gz 解压压缩包,修改 frps.ini [common] bind_port = 7000 # frps 服务启动,占用的端口 vhost_http_port = 80 # frps 服务监听转发的端口 启动 frps 服务 ./frps -c ./frps.ini 安装 frp(frpc)客户端 在目标内网设备机器,安装客户端。 根据操作系统下载不同版本 自定义域名访问内网服务 修改 frpc.ini [common] server_addr = ECS的公网IP server_port = 7000 [随意但必须唯一] type = http local_port = 本地目标服务的端口 custom_domains = 自定义的域名 启动客户端 ./frpc -c ./frpc.ini 访问 自定义域名即可访问内网的服务 使用ssh访问公司内网机器 修改 frpc.ini [common] server_port = 7000 [随意但必须唯一] type = tcp local_ip = 127.0.0.1 local_port = 22 remote_port = 10022 启动客户端 ./frpc -c ./frpc.ini 通过 ssh 访问内网机器 ssh -p 10022 root@x.x.x.x
Redis 6.0 release notes ======================= Upgrade urgency LOW: This is the first RC of Redis 6. Introduction to the Redis 6 release =================================== Redis 6 improves Redis in a number of key areas and is one of the largest Redis releases in the history of the project, so here we'll list only the biggest features in this release: * A Redis Cluster proxy was released here: https://github.com/artix75/redis-cluster-proxy 集群代理 上文 redis 6.0 发版日志,新增 redis cluster proxy 模块。 在 Redis 集群中,客户端会非常分散,现在为此引入了一个集群代理,可以为客户端抽象 Redis 群集,使其像正在与单个实例进行对话一样。同时在简单且客户端仅使用简单命令和功能时执行多路复用。 安装 git clone https://github.com/artix75/redis-cluster-proxy cd redis-cluster-proxy make install ps: 依赖 gcc 4.9 以上版本 升级 gcc yum -y install centos-release-scl yum -y install devtoolset-6-gcc devtoolset-6-gcc-c++ devtoolset-6-binutils scl enable devtoolset-6 bash echo "source /opt/rh/devtoolset-6/enable" >>/etc/profile 启动 redis-cluster-proxy 172.17.0.111:7000 redis-cluster-proxy 监听 7777 使用 ./redis-cli -h host -p 7777
项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注最近升级项目的依赖 到最新版本 版本变化 依赖 项目版本 目标版本 Spring Boot 2.1.9.RELEASE 2.2.0.RELEASE Spring Cloud Greenwich.SR3 Hoxton.RC1 Spring Boot Admin 2.1.6 2.2.0 Hoxton 版本依赖厂库 目前 Spring Cloud Hoxton 未发布 RELEASE 版本,官方计划 本月发布 使用 Hoxton.RC1 版本需要配置 spring 仓库 <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> spring boot admin 未发布 2.2.0 适配版本 解决方法: 使用快照版本2.2.0-SNAPSHOT, 需要配置快照厂库 <repository> <id>sonatype-nexus-snapshots</id> <name>Sonatype Nexus Snapshots</name> <url>https://oss.sonatype.org/content/repositories/snapshots/</url> <snapshots> <enabled>true</enabled> </snapshots> <releases> <enabled>false</enabled> </releases> </repository> 升级中遇到的问题 spring boot 2.2.0 bug ,造成 和mybatis 3.5.2 不兼容 官方issue: https://github.com/spring-projects/spring-boot/issues/18670 构造器注入的问题, mybatis 私有构造器不能绑定属性, 造成其他 依赖mybatis 的框架 类型 mybatis-plus 这种问题 https://gitee.com/baomidou/mybatis-plus/issues/I143DB Failed to bind properties under 'mybatis-plus.configuration.incomplete-result-maps[0].assistant.configuration.mapped-statements[0].parameter-map.parameter-mappings[0]' to org.apache.ibatis.mapping.ParameterMapping 解决方法: 我们重新编译了 mybatis 3.5.2 、mybatis-plus 3.2.0 修改部分源码兼容 私有构造器改成public, maven 坐标修改为 <dependency> <groupId>com.pig4cloud</groupId> <artifactId>mybatis-plus</artifactId> <version>3.2.0</ 总结 由于使用的 Spring Cloud RC 版本未同步到 阿里云等国内镜像厂库 mvn clean install 可能会出现失败,建议重复执行几次即可 鉴于Spring Boot 2.2.0 和 mybatis 的不兼容问题,2.2.1 会修复这个问题,建议暂时不要升级2.2.0 直接使用 2.2.1 2.2.1 已经发布,请直接升级到2.2.1 即可解决兼容问题 (2019年11月07补充)
项目推荐: Spring Cloud 、Spring Security OAuth2的RBAC权限管理系统 欢迎关注 问题 由于生产环境的各种原因,我们需要对现有服务器进行迁移,包括线上正在运行的 redis 集群环境 如何去做? 涉及到数据源变动,原有数据如何平滑迁移到新实例,从而可以实现无缝迁移? 方案汇总 基于 redis 自身的RDB/AOF 备份机制 执行 save\bgsave 触发数据持久化 RDB文件 拷贝redis备份文件(dump.rdb)到目标机器 重启目标实例重新load RDB 文件 关于 save/bgsave 的区别 命令 save bgsave IO阻塞 同步 异步 复杂度 O(n) O(n) 缺点 阻塞客户端 需要fork,消耗内存 基于 redis-dump导入导出 json备份 redis-dump 基于JSON 备份还原Redis的数据https://github.com/delano/redis-dump # 导出命令 redis-dump –u 127.0.0.1:6379 > lengleng.json # 导出指定数据库数据 redis-dump -u 127.0.0.1:6379 -d 15 > lengleng.json # 如果redis设有密码 redis-dump –u :password@127.0.0.1:6379 > lengleng.json # 导入命令 < lengleng.json redis-load # 指定redis密码 < lengleng.json redis-load -u :password@127.0.0.1:6379 基于 redis-shake 实现 redis-cluster 迁移 redis-shake是阿里云Redis&MongoDB团队开源的用于redis数据同步的工具https://github.com/alibaba/RedisShake。 基于 Docker 创建两个集群 docker run --name redis-cluster1 -e CLUSTER_ANNOUNCE_IP=192.168.0.31 -p 8000-8005:7000-7005 -p 18000-18005:17000-17005 pig4cloud/redis-cluster:4.0 docker run --name redis-cluster2 -e CLUSTER_ANNOUNCE_IP=192.168.0.31 -p 8000-8005:7000-7005 -p 18000-18005:17000-17005 pig4cloud/redis-cluster:4.0 配置 redis-shake.conf source.type: cluster source.address: master@192.168.0.31:7000 #配置一个节点自动发现 target.type: cluster target.address: master@192.168.0.31:8000 #配置一个节点自动发现 执行全量、增量同步 restful监控指标 # 用户可以通过restful监控指标查看内部运行状况,默认的restful端口是9320: http://127.0.0.1:9320/metric 最近时间宽裕。整点花哨的系列,欢迎关注。
最近又在翻 黄老师的 《Redis 设计与实现》,想到几道面试题 结合实际生产过程中的一些步骤作为总结 问题 如上图如何能快速的从两个Redis实例怎么快速对比哪些数据不一致? 什么是数据不一致 key不一致 相同key名 在不同实例上的数据类型不一致 key 存在于源 redis 不存在目标 redis key 存在于 目标redis 不存在源redis value 不一致 string 类型的值,在不同实例上不一致 其他类型,同key 判断. 工具推荐 redis-full-check 是阿里云Redis&MongoDB团队开源的用于校验2个redis数据是否一致的工具,支持单节点、主从、集群版、以及多种proxy,支持同构以及异构对比,redis的版本支持2.x-5.x。 下载工具 RedisFullCheck 目前仅支持 Linux环境 ,其他环境自行安装Golang 自行交叉编译 运行使用 参数说明 -t 目标库 -s 源库 ./redis-full-check -t 10.101.72.137:30661 -s 10.101.72.137:30551 查看结果 # 三轮比较 则会参数三个 db 文件 sqlite3 result.db.1 > .tables FINAL_RESULT field_1 key_1 > select * from key_1;
2019 年 10 月 17 日,支流科技 API 网关 APISIX 进入 Apache 开始孵化。笔者表示去搜索了一下这家公司 OpenResty 圈内顶级大牛《OpenResty 最佳实践》作者 温铭 和 王院生,这就非常有意思了 APISIX 是一个高性能、可扩展的微服务 API 网关。它是基于 Nginx 和 etcd 来实现,和传统 API 网关相比,APISIX 作为微服务请求⽹关,通过插件提供负载平衡,⽇志记录,身份验证等功能: 动态负载均衡: ⽀持不同上游服务的动态负载均衡 安全插件: 内置安全处理层,⽀持如OAuth2、ACL、CORS、动态 SSL 和IP 限制等 流量控制插件: 速率限制,请求⼤⼩限制和响应速率限制等 分析和监控插件:借助如 Prometheus,Datadog 和 Runscope 产品,完成API 流量的可视化、检查和监控 ⽇志插件:记录请求或响应⽇志,并通过 HTTP、TCP 或 UDP 等⽅式发送到你的系统(⽐如: StatsD, Syslog) github: https://github.com/iresty , 可以看到相较于于 Kong 、 Traefik 从源码角度非常简洁。 安装 安装 openresty 基于 OpenResty 实现的,记住 OpenResty一个基于Nginx 与Lua 的高性能Web 平台. yum install yum-utils yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo yum install -y openresty 安装 etcd etcd 一个 (key-value) 强一致性NoSQL数据库。相较于 Kong使用的PostgreSQL关系型数据库,又是一大亮点 yum install -y etcd service etcd start yum install -y https://github.com/iresty/apisix/releases/download/v0.8/apisix-0.8-0.el7.noarch.rpm 启动 apisix sudo apisix start 访问控制台: http://127.0.0.1:9080/apisix/dashboard/ ,直接访问即可 PS: 登录功能没有实现,骗人的! 功能体验 目标我们实现web服务的反向代理,并且可以实现限流 upstream > 添加 2 . routes > 添加 令牌桶限流配置 rate # 流速 每秒 burst # 令牌桶的容积 key #根据哪个header 来限流 rejected_code # 返回错误码 访问: ip:9080/ 体验限流效果 在线演示版本 官方部署了一个在线的 dashboard ,方便大家了解 APISIX。http://apisix.iresty.com
背景: 网上很多讲配置 oauth2 ,配置方法 复杂纷繁对于初学者很不友好,让人望而却步 欢迎关注本系列博客 基于 spring cloud 最新版本 hoxton 完成oauth2 的实践 基于 Spring Cloud OAuth ,用简洁的方式搭建oauth的认证中心, 关于oauth2 的授权模式 请直接参考 [阮一峰 OAuth 2.0 的四种方式的详细介绍](http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html) 项目版本核心说明 名称 版本 Spring Boot 2.2.0.M5 Spring Cloud Hoxton.M2 Spring Cloud OAuth2 2.2.0.M2 开始配置认证服务器 maven 依赖引入 这里只需要引入web、 cloud-oauth 即可,暂不引入spring cloud 其他组件 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> </dependencies> 配置web安全,拦截全部的请求 获取web 上下文AuthenticationManager 注入到spring中,方便后边oauth server注入 创建UserDetailsService的内存实现,注入一个测试用户 @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { /** * 必须注入 AuthenticationManager,不然oauth 无法处理四种授权方式 * * @return * @throws Exception */ @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } /** * 必须注入UserDetailsService ,不然oauth 密码模式等死循环问题 * * @return */ @Bean @Override protected UserDetailsService userDetailsService() { InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager(); userDetailsManager.createUser(User.withUsername("lengleng").password("{noop}lengleng").authorities("USER").build()); return userDetailsManager; } } 配置oauth2 认证服务器 配置clientId 信息,及其支持的授权模式,特别注意这里是五种包含一个刷新操作 @Configuration @EnableAuthorizationServer public class BigAuthServerConfiguration extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("appid") .secret("{noop}secret") .authorizedGrantTypes("password", "authorization_code", "client_credentials", "implicit", "refresh_token") .scopes("all"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.authenticationManager(authenticationManager) .userDetailsService(userDetailsService); } } 以上完成了认证服务器的功能 测试密码模式 curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'grant_type=password&username=lengleng&password=lengleng&scope=all' "http://appid:secret@localhost:8764/oauth/token" 开始配置资源服务器 maven 依赖引入 这里只需要引入web、 cloud-oauth 即可,暂不引入spring cloud 其他组件 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> </dependencies> 配置客户端信息 security: oauth2: client: client-id: appid client-secret: secret scope: all resource: # 认证中心的check_token 接口地址 token-info-uri: http://127.0.0.1:8764/oauth/check_token 应用声明资源服务器 @EnableResourceServer 即可完成接入 // 接入oauth2 ,声明为资源服务器 @EnableResourceServer @EnableDiscoveryClient @SpringBootApplication public class BigUpmsServerApplication { public static void main(String[] args) { SpringApplication.run(BigUpmsServerApplication.class, args); } } 上文配置的认证服务器暴露check_token 若不处理接口check_token 403 public class BigAuthServerConfiguration extends AuthorizationServerConfigurerAdapter { /** * checkTokenAccess 权限设置为isAuthenticated,不然资源服务器 来请求403 * @param oauthServer */ @Override public void configure(AuthorizationServerSecurityConfigurer oauthServer) { oauthServer .allowFormAuthenticationForClients() .checkTokenAccess("isAuthenticated()"); } } 资源服务器demo 接口 @RestController public class DemoController { @GetMapping("/info") public Authentication authentication(Authentication authentication) { return authentication; } } 通过上文获取的token 访问测试接口 获取token 通过token 请求测试接口获取当前用户信息 总结 更多关于oauth2 扩展方面欢迎翻我的博客https://my.oschina.net/giegie 配套实践项目欢迎关注 基于Spring Boot 2.1.7、 Spring Cloud Greenwich.SR2、 OAuth2 的RBAC 权限管理系统
之前分享过 一篇 《Spring Cloud Gateway 原生的接口限流该怎么玩》, 核心是依赖Spring Cloud Gateway 默认提供的限流过滤器来实现 原生RequestRateLimiter 的不足 配置方式 spring: cloud: gateway: routes: - id: requestratelimiter_route uri: lb://pigx-upms order: 10000 predicates: - Path=/admin/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 1 redis-rate-limiter.burstCapacity: 3 key-resolver: "#{@remoteAddrKeyResolver}" #SPEL表达式去的对应的bean - StripPrefix=1 RequestRateLimiterGatewayFilterFactory public GatewayFilter apply(Config config) { KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver); RateLimiter<Object> limiter = getOrDefault(config.rateLimiter, defaultRateLimiter); boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey); HttpStatusHolder emptyKeyStatus = HttpStatusHolder .parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode)); return (exchange, chain) -> { return exchange.getResponse().setComplete(); }); }); }; } 在实际生产过程中,必定不能满足我们的需求 生产中路由信息是保存数据库持久化或者配置中心,`RequestRateLimiterGatewayFilterFactory` 并不能随着持久化数据的改变而动态改变限流参数,不能做到实时根据流量来改变流量阈值 Sentinel Spring Cloud Gateway 流控支持 Sentinel 是什么? 随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性,分布式系统的流量防卫兵。 从 1.6.0 版本开始,Sentinel 提供了 Spring Cloud Gateway 的适配模块,可以提供两种资源维度的限流:route 维度:即在 Spring 配置文件中配置的路由条目,资源名为对应的 routeId自定义 API 维度:用户可以利用 Sentinel 提供的 API 来自定义一些 API 分组 pom 依赖 <!--Spring Cloud Alibaba 封装的 sentinel 模块--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId> </dependency> <!--使用nacos 保存限流规则--> <dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> </dependency> 配置本地路由规则及其sentinel数据源 spring: application: name: sentinel-spring-cloud-gateway cloud: gateway: enabled: true discovery: locator: lower-case-service-id: true routes: - id: pigx_route uri: https://api.readhub.cn predicates: - Path=/topic/** sentinel: datasource.ds1.nacos: server-addr: 127.0.0.1:8848 data-id: gw-flow group-id: DEFAULT_GROUP ruleType: gw-api-group filter: enabled: true 配置nacos数据源中的限流策略 常用限流策略 常量 以客户端IP作为限流因子 public static final int PARAM_PARSE_STRATEGY_CLIENT_IP = 0; 以客户端HOST作为限流因子 public static final int PARAM_PARSE_STRATEGY_HOST = 1; 以客户端HEADER参数作为限流因子 public static final int PARAM_PARSE_STRATEGY_HEADER = 2; 以客户端请求参数作为限流因子 public static final int PARAM_PARSE_STRATEGY_URL_PARAM = 3; 以客户端请求Cookie作为限流因子 public static final int PARAM_PARSE_STRATEGY_COOKIE = 4; 核心源码解析 SentinelGatewayFilter sentinel通过扩展Gateway的过滤器,通过选择的不同GatewayParamParser 过处理请求限流因子和数据源中的配置进行比较源码如下: public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR); Mono<Void> asyncResult = chain.filter(exchange); if (route != null) { String routeId = route.getId(); Object[] params = paramParser.parseParameterFor(routeId, exchange, r -> r.getResourceMode() == SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID); String origin = Optional.ofNullable(GatewayCallbackManager.getRequestOriginParser()) .map(f -> f.apply(exchange)) .orElse(""); asyncResult = asyncResult.transform( new SentinelReactorTransformer<>(new EntryConfig(routeId, EntryType.IN, 1, params, new ContextConfig(contextName(routeId), origin))) ); } Set<String> matchingApis = pickMatchingApiDefinitions(exchange); for (String apiName : matchingApis) { Object[] params = paramParser.parseParameterFor(apiName, exchange, r -> r.getResourceMode() == SentinelGatewayConstants.RESOURCE_MODE_CUSTOM_API_NAME); asyncResult = asyncResult.transform( new SentinelReactorTransformer<>(new EntryConfig(apiName, EntryType.IN, 1, params)) ); } return asyncResult; } 效果演示 以上nacos 配置为 每秒只能通过5个请求,我们使用jmeter 4.0 来并发10个线程测试一下 通过上图可以结果证明sentinel限流确实有效 动态修改限流参数 sentinel-datasource-nacos 作为sentinel的数据源,可以从如上 nacos 管理台实时刷新限流参数及其阈值 目前sentinel dashboard 1.6.2 暂未实现gateway 流控图形化控制 , 1.7.0 会增加此功能 总结 以上源码参考个人项目 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台
背景 最近来了个实习僧小弟,安排他实现对目标网站 连通性检测的小功能,简单讲就是将下边的shell 脚本换成Java 代码来实现 #!/bin/bash URL="https://www.baidu" HTTP_CODE=`curl -o /dev/null -s -w "%{http_code}" "${URL}"` #echo $HTTP_CODE if [ $HTTP_CODE != '200' ];then curl 'https://oapi.dingtalk.com/robot/send?access_token=xx' \ -H 'Content-Type: application/json' \ -d '{"msgtype": "text", "text": { "content": "百度平台状态不正常,请注意!" }, "isAtAll": true }' fi 功能实现 使用spring task @Scheduled(cron = "0 0 0/1 * * ? ") public void startSchedule() { log.info("开始执行定时任务 ,检测百度网站连通性"); try { HttpResponse response = HttpRequest.get("").execute(); if (HttpStatus.HTTP_OK != response.getStatus()) { this.send2DingTalk(response.getStatus()); } log.info("请求百度成功,返回报文:{}",response.body()); } catch (HttpException e) { log.error("请求异常百度:{}", e); this.send2DingTalk(e.getMessage()); } log.info("执行检测百度网站连通任务完毕"); } 问题描述 部署在服务器上,我的老jio本 都已经呼叫任务状态不正常了,可是小弟的Java 代码还是没有执行通知 去翻生产日志,只输入了开始并没有输出定时任务结束,感觉是哪里卡死,想当然以为如果超时总会到catch 逻辑,排查无果 由于任务是一小时一次,如何快速触发一下这个异常,还原事故现场 由于使用简单的Spring Task 没有图形化界面和API接口 Arthas 还原事故现场,重新触发任务 核心拿到 spring context 然后执行它的 startSchedule 方法 确定监控点 SpringMVC 的请求会通过 RequestMappingHandlerAdapter 执行invokeHandlerMethod 到达目标接口上进行处理 而在 RequestMappingHandlerAdapter类中有 getApplicationContext() @Nullable public final ApplicationContext getApplicationContext() throws IllegalStateException { if (this.applicationContext == null && this.isContextRequired()) { throw new IllegalStateException("ApplicationObjectSupport instance [" + this + "] does not run in an ApplicationContext"); } else { return this.applicationContext; } } 任意执行一次请求获取到 RequestMappingHandlerAdapter target 目标,然后执行 getApplicationContext tt命令 获取到ApplicationContext arthas 执行 tt tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod 任意执行一次web 请求,tt 即可捕获 根据目标的索引,执行自定义 OGNL 表达式即可 tt -i 1019 -w 'target.getApplicationContext()' 使用ApplicationContext获取 定时任务bean 执行 startSchedule tt -i 1000 -w 'target.getApplicationContext().getBean("baiduSchedule").startSchedule()' ok 任务重新触发了 事故原因调查清楚,由于使用hutool 的工具类 没有设置timeout 导致无限等待,所以没有执行catch 逻辑 总结 以上吓哭实习僧的操作禁止生产操作,只是提供个思路 ,当然可以衍生其他业务场景的操作 核心是通过Arthas 来抓取Spring ApplicationContext 对象,然后获取bean 进行执行方法 关于Arthas 是Alibaba开源的Java诊断工具,深受开发者喜爱 欢迎关注我们获得更多的好玩JavaEE 实践
灰度发布 什么是灰度发布,概念请参考,我们来简单的通过下图来看下,通俗的讲: 为了保证服务升级过程的平滑过渡提高客户体验,会一部分用户 一部分用户递进更新,这样生产中会同时出现多个版本的客户端,为了保证多个版本客户端的可用需要对应的多个版本的服务端版本。灰度发布就是通过一定策略保证 多个版本客户端、服务端间能够正确对应。 所谓灰度发布,即某个服务存在多个实例时,并且实例版本间的版本并不一致,通过 实现方案 nginx + lua (openresty) Netflix Zuul 只需要自定义ribbon 的断言即可,核心是通过TTL 获取上下请求header中的版本号 @Slf4j public class MetadataCanaryRuleHandler extends ZoneAvoidanceRule { @Override public AbstractServerPredicate getPredicate() { return new AbstractServerPredicate() { @Override public boolean apply(PredicateKey predicateKey) { String targetVersion = RibbonVersionHolder.getContext(); RibbonVersionHolder.clearContext(); if (StrUtil.isBlank(targetVersion)) { log.debug("客户端未配置目标版本直接路由"); return true; } DiscoveryEnabledServer server = (DiscoveryEnabledServer) predicateKey.getServer(); final Map<String, String> metadata = server.getInstanceInfo().getMetadata(); if (StrUtil.isBlank(metadata.get(SecurityConstants.VERSION))) { log.debug("当前微服务{} 未配置版本直接路由"); return true; } if (metadata.get(SecurityConstants.VERSION).equals(targetVersion)) { return true; } else { log.debug("当前微服务{} 版本为{},目标版本{} 匹配失败", server.getInstanceInfo().getAppName() , metadata.get(SecurityConstants.VERSION), targetVersion); return false; } } }; } } 维护请求中的版本号 public class RibbonVersionHolder { private static final ThreadLocal<String> context = new TransmittableThreadLocal<>(); public static String getContext() { return context.get(); } public static void setContext(String value) { context.set(value); } public static void clearContext() { context.remove(); } } Spring Cloud Gateway 中实现 第一反应,参考zuul 的实现,自定义断言,然后从上下中获取版本信息即可。但由于 spring cloud gateway 是基于webflux 的反应式编程,所以传统的TTL或者 RequestContextHolder 都不能正确的维护上下文请求。 先来看 spring clou的 gateway 默认的lb 策略实现 LoadBalancerClientFilter public class LoadBalancerClientFilter implements GlobalFilter, Ordered { @Override public int getOrder() { return LOAD_BALANCER_CLIENT_FILTER_ORDER; } @Override @SuppressWarnings("Duplicates") public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return chain.filter(exchange); } protected ServiceInstance choose(ServerWebExchange exchange) { return loadBalancer.choose( ((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost()); } } 我们只需要重写 choose 方法,把上下文请求传递到路由断言中即可,如下 @Override protected ServiceInstance choose(ServerWebExchange exchange) { HttpHeaders headers = exchange.getRequest().getHeaders(); return loadBalancer.choose(((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost(), headers); } 然后在路由断言中通过 PredicateKey获取到即可 public abstract class AbstractDiscoveryEnabledPredicate extends AbstractServerPredicate { /** * {@inheritDoc} */ @Override public boolean apply(@Nullable PredicateKey input) { return input != null && input.getServer() instanceof NacosServer && apply((NacosServer) input.getServer(), (HttpHeaders) input.getLoadBalancerKey()); } } 最后根据版本来计算 public class GrayMetadataAwarePredicate extends AbstractDiscoveryEnabledPredicate { @Override protected boolean apply(NacosServer server, HttpHeaders headers) { PigxRibbonRuleProperties ribbonProperties = SpringContextHolder.getBean(PigxRibbonRuleProperties.class); if (!ribbonProperties.isGrayEnabled()) { log.debug("gray closed,GrayMetadataAwarePredicate return true"); return true; } final Map<String, String> metadata = server.getMetadata(); String version = metadata.get(CommonConstants.VERSION); // 判断Nacos服务是否有版本标签 if (StrUtil.isBlank(version)) { log.debug("nacos server tag is blank ,GrayMetadataAwarePredicate return true"); return true; } // 判断请求中是否有版本 String target = headers.getFirst(CommonConstants.VERSION); if (StrUtil.isBlank(target)) { log.debug("request headers version is blank,GrayMetadataAwarePredicate return true"); return true; } log.debug("请求版本:{} ,当前服务版本:{}", target, version); return target.equals(version); } } 整合nacos 结合nacos的动态配置可以非常方便的实现灰度
背景分析 1.客户端携带认证中心发放的token,请求资源服务器A(Spring Security OAuth 发放Token 源码解析) 2.客户端携带令牌直接访问资源服务器,资源服务器通过对token 的校验 ([Spring Cloud OAuth2 资源服务器CheckToken 源码解析](https://my.oschina.net/giegie/blog/3005999)) 判断用户的合法性,并保存到上下文中 3.A服务接口接收到请求,需要通过Feign或者其他RPC框架调用B服务来组装返回数据 本文主要来探讨第三部 A --> B ,token 自定维护的源码实现 如何实现token 传递 配置OAuth2FeignRequestInterceptor 即可 此类是Feign 的拦截器实现 @Bean @ConditionalOnProperty("security.oauth2.client.client-id") public RequestInterceptor oauth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext, OAuth2ProtectedResourceDetails resource,) { return new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource); } 源码解析 获取上下文中的token ,组装到请求头 public class OAuth2FeignRequestInterceptor implements RequestInterceptor { // 给请求增加 token @Override public void apply(RequestTemplate template) { template.header(header, extract(tokenType)); } protected String extract(String tokenType) { OAuth2AccessToken accessToken = getToken(); return String.format("%s %s", tokenType, accessToken.getValue()); } // 从spring security 上下文中获取token public OAuth2AccessToken getToken() { OAuth2AccessToken accessToken = oAuth2ClientContext.getAccessToken(); if (accessToken == null || accessToken.isExpired()) { try { accessToken = acquireAccessToken(); } } return accessToken; } } 再来看AccessTokenContextRelay, 上下文token 中转器.非常简单从上下文获取认证信息得到把 token 放到上下文 public class AccessTokenContextRelay { private OAuth2ClientContext context; public AccessTokenContextRelay(OAuth2ClientContext context) { this.context = context; } public boolean copyToken() { if (context.getAccessToken() == null) { Authentication authentication = SecurityContextHolder.getContext() .getAuthentication(); if (authentication != null) { Object details = authentication.getDetails(); if (details instanceof OAuth2AuthenticationDetails) { OAuth2AuthenticationDetails holder = (OAuth2AuthenticationDetails) details; String token = holder.getTokenValue(); DefaultOAuth2AccessToken accessToken = new DefaultOAuth2AccessToken( token); String tokenType = holder.getTokenType(); if (tokenType != null) { accessToken.setTokenType(tokenType); } context.setAccessToken(accessToken); return true; } } } return false; } } 什么时候执行中转,oauth2 资源服务器非常简单暴力,加了个拦截器给转发。 源码非常简单 谈谈spring security oauth 实现的问题 当请求上线文没有Token,如果调用feign 会直接,这个OAuth2FeignRequestInterceptor 肯定会报错,因为上下文copy 失败 如果设置线程隔离,这里也会报错。导致安全上下问题传递不到子线程中。 强制使用拦截器去处理 token 转发到这里上下文,使用的业务场景只有这里,影响性能高 这三个问题,大家在使用的过程中一定会遇到 自定义OAuth2FeignRequestInterceptor 通过外部条件是否执行token中转 public void apply(RequestTemplate template) { Collection<String> fromHeader = template.headers().get(SecurityConstants.FROM); if (CollUtil.isNotEmpty(fromHeader) && fromHeader.contains(SecurityConstants.FROM_IN)) { return; } accessTokenContextRelay.copyToken(); if (oAuth2ClientContext != null && oAuth2ClientContext.getAccessToken() != null) { super.apply(template); } } 手动调用accessTokenContextRelay的copy,当然需要覆盖原生oauth 客户端的配置
业务需求 提供所有微服务数据源的图形化维护功能 代码生成可以根据选择的数据源加载表等源信息 数据源管理要支持动态配置,实时生效附录效果图 实现思路 本文提供方法仅供类似简单业务场景,在生产环境和复杂的业务场景 请使用分库分表的中间件(例如mycat)或者框架 sharding-sphere (一直在用)等 先来看Spring 默认的数据源注入策略,如下代码默认的事务管理器在初始化时回去加载数据源实现。这里就是我们动态数据源的入口 // 默认的事务管理器 ppublic class DataSourceTransactionManager extends AbstractPlatformTransactionManager implements ResourceTransactionManager, InitializingBean { // 启动时候注入一个数据源 public void setDataSource(@Nullable DataSource dataSource) { if (dataSource instanceof TransactionAwareDataSourceProxy) { this.dataSource = ((TransactionAwareDataSourceProxy) dataSource).getTargetDataSource(); } else { this.dataSource = dataSource; } } 」 通过注入一个新的DataSourceTransactionManager 实现,并且给它设置多个 DataSource 来实现多数据源实现 看下Spring 默认提供的路由数据源字段 public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean { // 用户设置的全部的数据源配置 @Nullable private Map<Object, Object> targetDataSources; // 为空默认的数据源配置 @Nullable private Object defaultTargetDataSource; // 路由键查找实现 private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup(); // 最终有效的数据源配置(一般清空对应上边用户的设置) @Nullable private Map<Object, DataSource> resolvedDataSources; } 开始动手 实现AbstractRoutingDataSource,定一个动态数据源实现,只需要实现他的路由key 查找方法即可。这里的路由key 对应其实是resolvedDataSources Map 的key哟 @Slf4j public class DynamicDataSource extends AbstractRoutingDataSource { /** * 指定路由Key,这里很简单 获取 threadLocal 中目标key 即可 * * @return */ @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSourceType(); } } 把我们动态数据源实现注入到Spring 的事务管理器,去数据库查询出来全部的数据源信息,定义一个个具体的数据源实现 我这里使用的HikariDataSource 给他赋值等等 @Slf4j @Configuration @AllArgsConstructor public class DynamicDataSourceConfig implements TransactionManagementConfigurer { private final Map<Object, Object> dataSourceMap = new HashMap<>(8); private final DataSourceProperties dataSourceProperties; @Bean("dynamicDataSource") public DynamicDataSource dataSource() { JdbcTemplate(dds).queryForList(DataSourceConstant.QUERY_DS_SQL); log.info("开始 -> 初始化动态数据源"); Optional.of(dbList).ifPresent(list -> list.forEach(db -> { log.info("数据源:{}", db.get(DataSourceConstant.DS_NAME)); HikariDataSource ds = new HikariDataSource(); dataSourceMap.put(db.get(DataSourceConstant.DS_ROUTE_KEY), ds); })); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(dataSourceMap); return ds; } @Bean public PlatformTransactionManager txManager() { return new DataSourceTransactionManager(dataSource()); } @Override public PlatformTransactionManager annotationDrivenTransactionManager() { return txManager(); } } 怎么使用 只需要根据用户前台选择的数据源key ,在业务类保存到TTL 即可,会自动根据选择路由数据源 DynamicDataSourceContextHolder.setDataSourceType(key) 这里当然也可以根据AOP 自定义注解等实现。 如何动态数据源动态配置 上边其实已经完成了 我们想要的需求功能,但是有什么问题呢? 我们在数据源管理面维护了数据源,动态去修改这个 dataSourceMap 其实是无效的,不能做到实时刷新 我们来看下 AbstractRoutingDataSource 的加载map 数据源的源码,只有在初始化的时候调用 afterPropertiesSet 去初始数据源map. 那我们只要获取当前的DynamicDataSource bean 手动调用afterPropertiesSet 即可。整个代码如下 public class DynamicDataSourceConfig implements TransactionManagementConfigurer { private final Map<Object, Object> dataSourceMap = new HashMap<>(8); private final DataSourceProperties dataSourceProperties; private final StringEncryptor stringEncryptor; @Bean("dynamicDataSource") public DynamicDataSource dataSource() { DynamicDataSource ds = new DynamicDataSource(); HikariDataSource cads = new HikariDataSource(); cads.setJdbcUrl(dataSourceProperties.getUrl()); cads.setDriverClassName(dataSourceProperties.getDriverClassName()); cads.setUsername(dataSourceProperties.getUsername()); cads.setPassword(dataSourceProperties.getPassword()); ds.setDefaultTargetDataSource(cads); dataSourceMap.put(0, cads); ds.setTargetDataSources(dataSourceMap); return ds; } /** * 组装默认配置的数据源,查询数据库配置 */ @PostConstruct public void init() { DriverManagerDataSource dds = new DriverManagerDataSource(); dds.setUrl(dataSourceProperties.getUrl()); dds.setDriverClassName(dataSourceProperties.getDriverClassName()); dds.setUsername(dataSourceProperties.getUsername()); dds.setPassword(dataSourceProperties.getPassword()); List<Map<String, Object>> dbList = new JdbcTemplate(dds).queryForList(DataSourceConstant.QUERY_DS_SQL); log.info("开始 -> 初始化动态数据源"); Optional.of(dbList).ifPresent(list -> list.forEach(db -> { log.info("数据源:{}", db.get(DataSourceConstant.DS_NAME)); HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl(String.valueOf(db.get(DataSourceConstant.DS_JDBC_URL))); ds.setDriverClassName(Driver.class.getName()); ds.setUsername((String) db.get(DataSourceConstant.DS_USER_NAME)); String decPwd = stringEncryptor.decrypt((String) db.get(DataSourceConstant.DS_USER_PWD)); ds.setPassword(decPwd); dataSourceMap.put(db.get(DataSourceConstant.DS_ROUTE_KEY), ds); })); log.info("完毕 -> 初始化动态数据源,共计 {} 条", dataSourceMap.size()); } /** * 重新加载数据源配置 */ public Boolean reload() { init(); DynamicDataSource dataSource = dataSource(); dataSource.setTargetDataSources(dataSourceMap); dataSource.afterPropertiesSet(); return Boolean.FALSE; } @Bean public PlatformTransactionManager txManager() { return new DataSourceTransactionManager(dataSource()); } @Override public PlatformTransactionManager annotationDrivenTransactionManager() { return txManager(); } 总结 以上源码参考个人项目 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台 QQ: 2270033969 一起来聊聊你们是咋用 spring cloud 的吧。 欢迎关注我们获得更多的好玩JavaEE 实践
本文单纯从简单的技术实现来讲,不涉及开放平台的多维度的运营理念。 什么是开放平台 通过开放自己平台产品服务的各种API接口,让其他第三方开发者在开发应用时根据需求直接调用,例如微信登录、QQ登录、微信支付、微博登录、热门等。 让第三方应用通过开发平台,使得自身海量数据资源得到沉淀(变现) 目前国内主流的网站的的开放平台,都是基于oauth2.0 协议进行做的开放平台 微信开放平台授权机制流程图 微博开放平台授权机制流程图 oauth2.0 授权码模式 授权码模式(authorization code)是功能最完整、流程最严密的授权模式。 它的特点就是通过客户端的后台服务器,与"服务提供商"的认证服务器进行互动,能够满足绝大多数开放平台认证授权的需求。 引入相关依赖 <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-security</artifactId> </dependency> 配置认证服务器 通过内存模式,初始化一个支持授权码模式的客户端 @Configuration @AllArgsConstructor @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Override @SneakyThrows public void configure(ClientDetailsServiceConfigurer clients) { clients.inMemory() .withClient("pigx") // client_id .secret("pigx") // client_secret .authorizedGrantTypes("authorization_code") // 该client允许的授权类型 .scopes("app"); // 允许的授权范围 } } 初步完成,测试一下 注意这里是 /oauth/authorize 不是 /oauth/token 接口,只需要带 client_id 即可。 localhost:9999/oauth/authorize?client_id=pigx&response_type=code&redirect_uri=https://pig4cloud.com 先进行basic 登录,默认用户user,密码已经打在控制台自己查即可 授权确认 登录成功带着code回调到目标接口 通过/oauth/token获取登录令牌 简单的几步就完成上图微信或者其他网站的授权流程,不过目前为止 略显简陋 登录没有界面,用户密码数据库没有保存 确认授权界面太丑,没有个性化 配置安全登录 配置未登录拦截重定向到 loginPage 配置登录完成提交的页面路径 这里会被spring security 接管 @Primary @Order(90) @Configuration public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter { @Override @SneakyThrows protected void configure(HttpSecurity http) { http .formLogin() .loginPage("/token/login") .loginProcessingUrl("/token/form") .and() .authorizeRequests() .anyRequest().authenticated(); } } 认证服务器配置用户加载规则实现 @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.userDetailsService(pigxUserDetailsService) } // 通过这步去加载数据的用户名密码 public interface UserDetailsService { UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException; } 重写原有认证页面 默认逻辑/oauth/confirm_access,让他重定向到我们自己的路径,然后进行个性哈 @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints .userDetailsService(pigxUserDetailsService) .pathMapping("/oauth/confirm_access", "/token/confirm_access") } 获取上下文中的授权信息,传给前端 /** * 确认授权页面 * * @param request * @param session * @param modelAndView * @return */ @GetMapping("/confirm_access") public ModelAndView confirm(HttpServletRequest request, HttpSession session, ModelAndView modelAndView) { Map<String, Object> scopeList = (Map<String, Object>) request.getAttribute("scopes"); modelAndView.addObject("scopeList", scopeList.keySet()); Object auth = session.getAttribute("authorizationRequest"); if (auth != null) { AuthorizationRequest authorizationRequest = (AuthorizationRequest) auth; ClientDetails clientDetails = clientDetailsService.loadClientByClientId(authorizationRequest.getClientId()); modelAndView.addObject("app", clientDetails.getAdditionalInformation()); modelAndView.addObject("user", SecurityUtils.getUser()); } modelAndView.setViewName("ftl/confirm"); return modelAndView; } 最终效果 把用户头像等信息展示出来就蛮好看了 总结 以上源码参考个人项目 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台
用户携带token 请求资源服务器 资源服务器拦截器 携带token 去认证服务器 调用tokenstore 对token 合法性校验 资源服务器拿到token,默认只会含有用户名信息 通过用户名调用userdetailsservice.loadbyusername 查询用户全部信息 详细性能瓶颈分析,请参考上篇文章《扩展jwt解决oauth2 性能瓶颈》 本文是针对传统使用UUID token 的情况进行扩展,提高系统的吞吐率,解决性能瓶颈的问题 默认check-token 解析逻辑 RemoteTokenServices 入口 @Override public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException { MultiValueMap<String, String> formData = new LinkedMultiValueMap<String, String>(); formData.add(tokenName, accessToken); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", getAuthorizationHeader(clientId, clientSecret)); // 调用认证服务器的check-token 接口检查token Map<String, Object> map = postForMap(checkTokenEndpointUrl, formData, headers); return tokenConverter.extractAuthentication(map); } 解析认证服务器返回的信息 DefaultAccessTokenConverter public OAuth2Authentication extractAuthentication(Map<String, ?> map) { Map<String, String> parameters = new HashMap<String, String>(); Set<String> scope = extractScope(map); // 主要是 用户的信息的抽取 Authentication user = userTokenConverter.extractAuthentication(map); // 一些oauth2 信息的填充 OAuth2Request request = new OAuth2Request(parameters, clientId, authorities, true, scope, resourceIds, null, null, null); return new OAuth2Authentication(request, user); } 组装当前用户信息 DefaultUserAuthenticationConverter public Authentication extractAuthentication(Map<String, ?> map) { if (map.containsKey(USERNAME)) { Object principal = map.get(USERNAME); Collection<? extends GrantedAuthority> authorities = getAuthorities(map); if (userDetailsService != null) { UserDetails user = userDetailsService.loadUserByUsername((String) map.get(USERNAME)); authorities = user.getAuthorities(); principal = user; } return new UsernamePasswordAuthenticationToken(principal, "N/A", authorities); } return null; } 问题分析 认证服务器check-token 返回的全部信息 资源服务器在根据返回信息组装用户信息的时候,只是用了username 如果设置了 userDetailsService 的实现则去调用 loadUserByUsername 再去查询一次用户信息 造成问题现象 如果设置了userDetailsService 即可在spring security 上下文获取用户的全部信息,不设置则只能得到用户名。 增加了一次查询逻辑,对性能产生不必要的影响 解决问题 扩展UserAuthenticationConverter 的解析过程,把认证服务器返回的信息全部组装到spring security的上下文对象中 /** * @author lengleng * @date 2019-03-07 * <p> * 根据checktoken 的结果转化用户信息 */ public class PigxUserAuthenticationConverter implements UserAuthenticationConverter { private static final String N_A = "N/A"; // map 是check-token 返回的全部信息 @Override public Authentication extractAuthentication(Map<String, ?> map) { if (map.containsKey(USERNAME)) { Collection<? extends GrantedAuthority> authorities = getAuthorities(map); String username = (String) map.get(USERNAME); Integer id = (Integer) map.get(SecurityConstants.DETAILS_USER_ID); Integer deptId = (Integer) map.get(SecurityConstants.DETAILS_DEPT_ID); Integer tenantId = (Integer) map.get(SecurityConstants.DETAILS_TENANT_ID); PigxUser user = new PigxUser(id, deptId, tenantId, username, N_A, true , true, true, true, authorities); return new UsernamePasswordAuthenticationToken(user, N_A, authorities); } return null; } } 给remoteTokenServices 注入这个实现 public class PigxResourceServerConfigurerAdapter extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter(); UserAuthenticationConverter userTokenConverter = new PigxUserAuthenticationConverter(); accessTokenConverter.setUserTokenConverter(userTokenConverter); remoteTokenServices.setRestTemplate(lbRestTemplate); remoteTokenServices.setAccessTokenConverter(accessTokenConverter); resources. .tokenServices(remoteTokenServices); } } 完成扩展,再来看文章开头的流程图就变成了如下 关注我 个人项目 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台 QQ: 2270033969 一起来聊聊你们是咋用 spring cloud 的吧。
oauth2 性能瓶颈 资源服务器的请求都会被拦截 到认证服务器校验合法性 (如下图) 用户携带token 请求资源服务器 资源服务器拦截器 携带token 去认证服务器 调用tokenstore 对token 合法性校验 资源服务器拿到token,默认只会含有用户名信息 通过用户名调用userdetailsservice.loadbyusername 查询用户全部信息 如上步骤在实际使用,会造成认证中心的负载压力过大,成为造成整个系统瓶颈的关键点。 check-token 过程中涉及的源码 更为详细的源码讲解可以参考我上篇文章《Spring Cloud OAuth2 资源服务器CheckToken 源码解析》 check-token 涉及到的核心类 扩展jwt 生成携带用户详细信息 为什么使用jwt 替代默认的UUID token ? 通过jwt 访问资源服务器后,不再使用check-token 过程,通过对jwt 的解析即可实现身份验证,登录信息的传递。减少网络开销,提高整体微服务集群的性能 spring security oauth 默认的jwttoken 只含有username,通过扩展TokenEnhancer,实现关键字段的注入到 JWT 中,方便资源服务器使用 @Bean public TokenEnhancer tokenEnhancer() { return (accessToken, authentication) -> { if (SecurityConstants.CLIENT_CREDENTIALS .equals(authentication.getOAuth2Request().getGrantType())) { return accessToken; } final Map<String, Object> additionalInfo = new HashMap<>(8); PigxUser pigxUser = (PigxUser) authentication.getUserAuthentication().getPrincipal(); additionalInfo.put("user_id", pigxUser.getId()); additionalInfo.put("username", pigxUser.getUsername()); additionalInfo.put("dept_id", pigxUser.getDeptId()); additionalInfo.put("tenant_id", pigxUser.getTenantId()); additionalInfo.put("license", SecurityConstants.PIGX_LICENSE); ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo); return accessToken; }; } 生成的token 如下,含有关键的字段 重写默认的资源服务器处理行为 不再使用RemoteTokenServices 去掉用认证中心 CheckToken,自定义客户端TokenService @Slf4j public class PigxCustomTokenServices implements ResourceServerTokenServices { @Setter private TokenStore tokenStore; @Setter private DefaultAccessTokenConverter defaultAccessTokenConverter; @Setter private JwtAccessTokenConverter jwtAccessTokenConverter; @Override public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException { OAuth2Authentication oAuth2Authentication = tokenStore.readAuthentication(accessToken); UserAuthenticationConverter userTokenConverter = new PigxUserAuthenticationConverter(); defaultAccessTokenConverter.setUserTokenConverter(userTokenConverter); Map<String, ?> map = jwtAccessTokenConverter.convertAccessToken(readAccessToken(accessToken), oAuth2Authentication); return defaultAccessTokenConverter.extractAuthentication(map); } @Override public OAuth2AccessToken readAccessToken(String accessToken) { return tokenStore.readAccessToken(accessToken); } } 解析jwt 组装成Authentication /** * @author lengleng * @date 2019-03-17 * <p> * jwt 转化用户信息 */ public class PigxUserAuthenticationConverter implements UserAuthenticationConverter { private static final String USER_ID = "user_id"; private static final String DEPT_ID = "dept_id"; private static final String TENANT_ID = "tenant_id"; private static final String N_A = "N/A"; @Override public Authentication extractAuthentication(Map<String, ?> map) { if (map.containsKey(USERNAME)) { Collection<? extends GrantedAuthority> authorities = getAuthorities(map); String username = (String) map.get(USERNAME); Integer id = (Integer) map.get(USER_ID); Integer deptId = (Integer) map.get(DEPT_ID); Integer tenantId = (Integer) map.get(TENANT_ID); PigxUser user = new PigxUser(id, deptId, tenantId, username, N_A, true , true, true, true, authorities); return new UsernamePasswordAuthenticationToken(user, N_A, authorities); } return null; } private Collection<? extends GrantedAuthority> getAuthorities(Map<String, ?> map) { Object authorities = map.get(AUTHORITIES); if (authorities instanceof String) { return AuthorityUtils.commaSeparatedStringToAuthorityList((String) authorities); } if (authorities instanceof Collection) { return AuthorityUtils.commaSeparatedStringToAuthorityList(StringUtils .collectionToCommaDelimitedString((Collection<?>) authorities)); } throw new IllegalArgumentException("Authorities must be either a String or a Collection"); } } 资源服务器配置中注入以上配置即可 @Slf4j public class PigxResourceServerConfigurerAdapter extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter(); UserAuthenticationConverter userTokenConverter = new PigxUserAuthenticationConverter(); accessTokenConverter.setUserTokenConverter(userTokenConverter); PigxCustomTokenServices tokenServices = new PigxCustomTokenServices(); // 这里的签名key 保持和认证中心一致 JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey("123"); converter.setVerifier(new MacSigner("123")); JwtTokenStore jwtTokenStore = new JwtTokenStore(converter); tokenServices.setTokenStore(jwtTokenStore); tokenServices.setJwtAccessTokenConverter(converter); tokenServices.setDefaultAccessTokenConverter(accessTokenConverter); resources .authenticationEntryPoint(resourceAuthExceptionEntryPoint) .tokenServices(tokenServices); } } 使用JWT 扩展后带来的问题 JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。 去认证服务器校验的过程就是 通过tokenstore 来控制jwt 安全性的一个方法,去掉Check-token 意味着 jwt token 安全性不可保证 JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。 关注我 个人项目 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台
自动降级目的 在Spring Cloud 使用feign 的时候,需要明确指定fallback 策略,不然会提示错误。 先来看默认的feign service 是要求怎么做的。feign service 定义一个 factory 和 fallback 的类 @FeignClient(value = ServiceNameConstants.UMPS_SERVICE, fallbackFactory = RemoteLogServiceFallbackFactory.class) public interface RemoteLogService {} 但是我们大多数情况的feign 降级策略为了保证幂等都会很简单,输出错误日志即可。类似如下代码,在企业中开发非常不方便 @Slf4j @Component public class RemoteLogServiceFallbackImpl implements RemoteLogService { @Setter private Throwable cause; @Override public R<Boolean> saveLog(SysLog sysLog, String from) { log.error("feign 插入日志失败", cause); return null; } } 自动降级效果 @FeignClient(value = ServiceNameConstants.UMPS_SERVICE) public interface RemoteLogService {} Feign Service 完成同样的降级错误输出 FeignClient 中无需定义无用的fallbackFactory FallbackFactory 也无需注册到Spring 容器中 代码变化,去掉FeignClient 指定的降级工厂 代码变化,删除降级相关的代码 核心源码 注入我们个性化后的Feign @Configuration @ConditionalOnClass({HystrixCommand.class, HystrixFeign.class}) protected static class HystrixFeignConfiguration { @Bean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @ConditionalOnProperty("feign.hystrix.enabled") public Feign.Builder feignHystrixBuilder(FeignContext feignContext) { return PigxHystrixFeign.builder(feignContext) .decode404() .errorDecoder(new PigxFeignErrorDecoder()); } } PigxHystrixFeign.target 方法是根据@FeignClient 注解生成代理类的过程,注意注释 @Override public <T> T target(Target<T> target) { Class<T> targetType = target.type(); FeignClient feignClient = AnnotatedElementUtils.getMergedAnnotation(targetType, FeignClient.class); String factoryName = feignClient.name(); SetterFactory setterFactoryBean = this.getOptional(factoryName, feignContext, SetterFactory.class); if (setterFactoryBean != null) { this.setterFactory(setterFactoryBean); } // 以下为获取降级策略代码,构建降级,这里去掉了降级非空的非空的校验 Class<?> fallback = feignClient.fallback(); if (fallback != void.class) { return targetWithFallback(factoryName, feignContext, target, this, fallback); } Class<?> fallbackFactory = feignClient.fallbackFactory(); if (fallbackFactory != void.class) { return targetWithFallbackFactory(factoryName, feignContext, target, this, fallbackFactory); } return build().newInstance(target); } 构建feign 客户端执行PigxHystrixInvocationHandler的增强 Feign build(@Nullable final FallbackFactory<?> nullableFallbackFactory) { super.invocationHandlerFactory((target, dispatch) -> new PigxHystrixInvocationHandler(target, dispatch, setterFactory, nullableFallbackFactory)); super.contract(new HystrixDelegatingContract(contract)); return super.build(); } PigxHystrixInvocationHandler.getFallback() 获取降级策略 @Override @Nullable @SuppressWarnings("unchecked") protected Object getFallback() { // 如果 @FeignClient 没有配置降级策略,使用动态代理创建一个 if (fallbackFactory == null) { fallback = PigxFeignFallbackFactory.INSTANCE.create(target.type(), getExecutionException()); } else { // 如果 @FeignClient配置降级策略,使用配置的 fallback = fallbackFactory.create(getExecutionException()); } } PigxFeignFallbackFactory.create 动态代理逻辑 public T create(final Class<?> type, final Throwable cause) { return (T) FALLBACK_MAP.computeIfAbsent(type, key -> { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(key); enhancer.setCallback(new PigxFeignFallbackMethod(type, cause)); return enhancer.create(); }); } PigxFeignFallbackMethod.intercept, 默认的降级逻辑,输出降级方法信息和错误信息,并且把错误格式 public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) { log.error("Fallback class:[{}] method:[{}] message:[{}]", type.getName(), method.getName(), cause.getMessage()); if (R.class == method.getReturnType()) { final R result = cause instanceof PigxFeignException ? ((PigxFeignException) cause).getResult() : R.builder() .code(CommonConstants.FAIL) .msg(cause.getMessage()).build(); return result; } return null; } 关注我们 Spring Cloud 微服务开发核心包mica 基于Spring Cloud、OAuth2.0开发基于Vue前后分离的开发平台
## CheckToken的目的 当用户携带token 请求资源服务器的资源时, OAuth2AuthenticationProcessingFilter 拦截token,进行token 和userdetails 过程,把无状态的token 转化成用户信息。 ## 详解 OAuth2AuthenticationManager.authenticate(),filter执行判断的入口 当用户携带token 去请求微服务模块,被资源服务器拦截调用RemoteTokenServices.loadAuthentication ,执行所谓的check-token过程。源码如下 CheckToken 处理逻辑很简单,就是调用redisTokenStore 查询token的合法性,及其返回用户的部分信息 (username ) 继续看 返回给 RemoteTokenServices.loadAuthentication 最后一句 tokenConverter.extractAuthentication 解析组装服务端返回的信息 最重要的 userTokenConverter.extractAuthentication(map); 最重要的一步,是否判断是否有userDetailsService实现,如果有 的话去查根据 返回的 查询一次全部的用户信息,没有实现直接返回username,这也是很多时候问的为什么只能查询到username 也就是 EnablePigxResourceServer.details true 和false 的区别。 那根据的你问题,继续看 UerDetailsServiceImpl.loadUserByUsername 根据用户名去换取用户全部信息。 关于pig 基于Spring Cloud、oAuth2.0开发基于Vue前后分离的开发平台,支持账号、短信、SSO等多种登录,提供配套视频开发教程。
2019.01.23 期待已久的Spring Cloud Greenwich 发布了release版本,作为我们团队也第一时间把RC版本替换为release,以下为总结,希望对你使用Spring Cloud Greenwich 有所帮助Greenwich 只支持 Spring Boot 2.1.x 分支。如果使用 2.0.x 请使用Finchley版本, pom坐标 主要是适配JAVA11 <!--支持Spring Boot 2.1.X--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.1.2.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> <!--Greenwich.RELEASE--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Greenwich.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> 升级netflix版本,DiscoveryClient支持获取InstanceId Spring Cloud Config 提供了新的存储介质 除了Git、File、JDBC,新版本提供 在Cloud Foundry的CredHub存储功能 spring: profiles: active: credhub cloud: config: server: credhub: url: https://credhub:8844 Spring Cloud Gateway 支持整合OAuth2 这里提供了一个例子: Spring Cloud Gateway and Spring Security OAuth2 整合的时候有个坑可以参考这个issue:ReactiveManagementWebSecurityAutoConfiguration Prevent's oauth2Login from being defaulted 新增重写响应头过滤器 spring: cloud: gateway: routes: - id: rewriteresponseheader_route uri: http://example.org filters: - RewriteResponseHeader=X-Response-Foo, , password=[^&]+, password=*** Feign 的新特性和坑 @SpringQueryMap 对Get请求进行了增强 终于解决这个问题了 不用直接使用OpenFeign新增的@QueryMap,由于缺少value属性 QueryMap注释与Spring不兼容... 异常解决 对Spring Cloud Finchley 进行直接升级时候发现feign启动报错了 *************************** APPLICATION FAILED TO START *************************** Description: The bean 'pigx-upms-biz.FeignClientSpecification', defined in null, could not be registered. A bean with that name has already been defined in null and overriding is disabled. Action: Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true Process finished with exit code 1 第一种粗暴的解决方法,异常日志中说明了,在 bootstrap.yml中配置 spring.main.allow-bean-definition-overriding=true 这是Spring Boot 2.1 后新增的属性运行bean 覆盖,不要配置到配置中心里面,不然无效 第二种,就是把通过同一个服务调用的代码,移动到同一个@FeignClient中 contextId ,这个是@FeignClient 新增的一个属性 This will be used as the bean name instead of name if present, but will not be used as a service id. 就可以用这个属性区分@FeigenClient 标志的同一个service 的接口 总结 Spring Cloud F -- > G 变化很小,微乎其微主要是JAVA11的兼容 很遗憾没有看到 Spring Cloud Alibaba 加油。 Spring Cloud LoadBalancer 还是老样子。目前来看暂时无法替代 ribbon 欢迎加我Q2270033969,讨论Spring Cloud ^_^
动态路由背景 无论你在使用Zuul还是Spring Cloud Gateway 的时候,官方文档提供的方案总是基于配置文件配置的方式 例如: # zuul 的配置形式 routes: pig-auth: path: /auth/** serviceId: pig-auth stripPrefix: true # gateway 的配置形式 routes: - id: pigx-auth uri: lb://pigx-auth predicates: - Path=/auth/** filters: - ValidateCodeGatewayFilter 配置更改需要重启服务,不能满足实际生产过程中的动态刷新、实时变更的业务需求。 基于以上分析 pig已经提供了基于Zuul版本的动态路由功能,附Git 地址传送门,效果如下图可以实时配置修改刷新。 Spring Cloud Gateway 路由加载源码 DispatcherHandler 接管用户请求 RoutePredicateHandlerMapping 路由匹配 根据RouteLocator获取 RouteDefinitionLocator 返回多个RouteDefinitionLocator.getRouteDefinitions()的路由定义信息 FilteringWebHandler执行路由定义中的filter 最后路由到具体的业务服务中 Spring Cloud Gateway 默认动态路由实现 GatewayControllerEndpoint 基于actuate端点的默认实现,支持JVM 级别的动态路由,不能序列化存储 // 上图动态路由的信息保存的默认实现是基于内存的实现 public class InMemoryRouteDefinitionRepository implements RouteDefinitionRepository { private final Map<String, RouteDefinition> routes = synchronizedMap(new LinkedHashMap<String, RouteDefinition>()); @Override public Mono<Void> save(Mono<RouteDefinition> route){} @Override public Mono<Void> delete(Mono<String> routeId){} @Override public Flux<RouteDefinition> getRouteDefinitions(){} } 扩展基于Mysql + Redis存储分布式动态组件 为什么使用Mysql的同时,又要使用Redis? spring cloud gateway 基于webflux 背压,暂时不支持mysql 数据库 Redis-reactive 支持 spring cloudgateway 的背压,同时还可以实现分布式,高性能 扩展思路 增加一个路由管理模块,参考GatewayControllerEndpoint实现,启动时加载数据库中配置文件到Redis 网关模块重写RouteDefinitionRepository,getRouteDefinitions()取Redis中读取即可实现 前端配合 json-view 类似插件,直接修改展示。 具体实现 路由管理模块核心处理逻辑,获取路由和更新路由 /** * @author lengleng * @date 2018年11月06日10:27:55 * <p> * 动态路由处理类 */ @Slf4j @AllArgsConstructor @Service("sysRouteConfService") public class SysRouteConfServiceImpl extends ServiceImpl<SysRouteConfMapper, SysRouteConf> implements SysRouteConfService { private final RedisTemplate redisTemplate; private final ApplicationEventPublisher applicationEventPublisher; /** * 获取全部路由 * <p> * RedisRouteDefinitionWriter.java * PropertiesRouteDefinitionLocator.java * * @return */ @Override public List<SysRouteConf> routes() { SysRouteConf condition = new SysRouteConf(); condition.setDelFlag(CommonConstant.STATUS_NORMAL); return baseMapper.selectList(new EntityWrapper<>(condition)); } /** * 更新路由信息 * * @param routes 路由信息 * @return */ @Override public Mono<Void> editRoutes(JSONArray routes) { // 清空Redis 缓存 Boolean result = redisTemplate.delete(CommonConstant.ROUTE_KEY); log.info("清空网关路由 {} ", result); // 遍历修改的routes,保存到Redis List<RouteDefinitionVo> routeDefinitionVoList = new ArrayList<>(); routes.forEach(value -> { log.info("更新路由 ->{}", value); RouteDefinitionVo vo = new RouteDefinitionVo(); Map<String, Object> map = (Map) value; Object id = map.get("routeId"); if (id != null) { vo.setId(String.valueOf(id)); } Object predicates = map.get("predicates"); if (predicates != null) { JSONArray predicatesArray = (JSONArray) predicates; List<PredicateDefinition> predicateDefinitionList = predicatesArray.toList(PredicateDefinition.class); vo.setPredicates(predicateDefinitionList); } Object filters = map.get("filters"); if (filters != null) { JSONArray filtersArray = (JSONArray) filters; List<FilterDefinition> filterDefinitionList = filtersArray.toList(FilterDefinition.class); vo.setFilters(filterDefinitionList); } Object uri = map.get("uri"); if (uri != null) { vo.setUri(URI.create(String.valueOf(uri))); } Object order = map.get("order"); if (order != null) { vo.setOrder(Integer.parseInt(String.valueOf(order))); } redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(RouteDefinitionVo.class)); redisTemplate.opsForHash().put(CommonConstant.ROUTE_KEY, vo.getId(), vo); routeDefinitionVoList.add(vo); }); // 逻辑删除全部 SysRouteConf condition = new SysRouteConf(); condition.setDelFlag(CommonConstant.STATUS_NORMAL); this.delete(new EntityWrapper<>(condition)); //插入生效路由 List<SysRouteConf> routeConfList = routeDefinitionVoList.stream().map(vo -> { SysRouteConf routeConf = new SysRouteConf(); routeConf.setRouteId(vo.getId()); routeConf.setFilters(JSONUtil.toJsonStr(vo.getFilters())); routeConf.setPredicates(JSONUtil.toJsonStr(vo.getPredicates())); routeConf.setOrder(vo.getOrder()); routeConf.setUri(vo.getUri().toString()); return routeConf; }).collect(Collectors.toList()); this.insertBatch(routeConfList); log.debug("更新网关路由结束 "); this.applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this)); return Mono.empty(); } } 网关自定义RedisRouteDefinitionRepository @Slf4j @Component @AllArgsConstructor public class RedisRouteDefinitionWriter implements RouteDefinitionRepository { private final RedisTemplate redisTemplate; @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.flatMap(r -> { RouteDefinitionVo vo = new RouteDefinitionVo(); BeanUtils.copyProperties(r, vo); log.info("保存路由信息{}", vo); redisTemplate.opsForHash().put(CommonConstant.ROUTE_KEY, r.getId(), vo); return Mono.empty(); }); } @Override public Mono<Void> delete(Mono<String> routeId) { routeId.subscribe(id -> { log.info("删除路由信息{}", id); redisTemplate.opsForHash().delete(CommonConstant.ROUTE_KEY, id); }); return Mono.empty(); } @Override public Flux<RouteDefinition> getRouteDefinitions() { redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(RouteDefinitionVo.class)); List<RouteDefinitionVo> values = redisTemplate.opsForHash().values(CommonConstant.ROUTE_KEY); List<RouteDefinition> definitionList = new ArrayList<>(); values.forEach(vo -> { RouteDefinition routeDefinition = new RouteDefinition(); BeanUtils.copyProperties(vo, routeDefinition); definitionList.add(vo); }); log.debug("redis 中路由定义条数: {}, {}", definitionList.size(), definitionList); return Flux.fromIterable(definitionList); } } 3.库表定义
分析下目前遇到的痛点 你在开发工作的是否遇到这个问题,微服务模块划分过细,基础模块依赖的比较多? 比如你要进行微服务开发则需要启动以下基础模块 注册中心(eureka) 配置中心(spring cloud config) 网关(zuul) 认证中心(oauth) ... 如上图红色标注的服务模块,而你需要编码或者需要的只有那么一个业务微服务模块,本地启动这么微服务模块对开发机器的要求性能较高,并且影响开发效率。 为了解决这种问题,团队一般都会把通用的基础模块部,提供统一的开发环境,方便大家开发,如上图 只需要考虑你的业务模块(serviceA、serviceB) 即可,提高开发效率。 这种统一开发基础环境问题存在小小的问题,比如当开发A维护serviceA,开发B维护serviceB 不会出现冲突;如果开发A、B同时维护一个模块时候,就会出现冲突如下图所示:A的本地请求会被路由到B正在开发的biz-service,而不是目标的A的biz-service,因为zuul 是更具服务名称进行路由的 解决原理 重写网关的转发规则,其实就是重写ribbon的路由规则,根据客户端不同的用户请求(可以根据入参不同区分)路由到对应的微服务上,所以对应的微服务上要打上标签。biz-service A开发版本,biz-service B开发版本 代码实现 在微服务的eureka客户端声明版本所属 eureka: instance: metadata-map: version: v1.0 # A的开发版本 自定义ribbon 的断言 /** * @author lengleng * @date 2018/10/16 * <p> * 路由微服务断言 * <p> * 1. eureka metadata 存在版本定义时候进行判断 * 2. 不存在 metadata 直接返回true */ @Slf4j public class MetadataCanaryRuleHandler extends ZoneAvoidanceRule { @Override public AbstractServerPredicate getPredicate() { return new AbstractServerPredicate() { @Override public boolean apply(PredicateKey predicateKey) { String targetVersion = RibbonVersionHolder.getContext(); RibbonVersionHolder.clearContext(); if (StrUtil.isBlank(targetVersion)) { log.debug("客户端未配置目标版本直接路由"); return true; } DiscoveryEnabledServer server = (DiscoveryEnabledServer) predicateKey.getServer(); final Map<String, String> metadata = server.getInstanceInfo().getMetadata(); if (StrUtil.isBlank(metadata.get(SecurityConstants.VERSION))) { log.debug("当前微服务{} 未配置版本直接路由"); return true; } if (metadata.get(SecurityConstants.VERSION).equals(targetVersion)) { return true; } else { log.debug("当前微服务{} 版本为{},目标版本{} 匹配失败", server.getInstanceInfo().getAppName() , metadata.get(SecurityConstants.VERSION), targetVersion); return false; } } }; } } 版本上下文TTL public class RibbonVersionHolder { private static final ThreadLocal<String> context = new TransmittableThreadLocal<>(); public static String getContext() { return context.get(); } public static void setContext(String value) { context.set(value); } public static void clearContext() { context.remove(); } } 初始化ribbon 路由配置 @Configuration @ConditionalOnClass(DiscoveryEnabledNIWSServerList.class) @AutoConfigureBefore(RibbonClientConfiguration.class) @ConditionalOnProperty(value = "zuul.ribbon.metadata.enabled") public class RibbonMetaFilterAutoConfiguration { @Bean @ConditionalOnMissingBean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public ZoneAvoidanceRule metadataAwareRule() { return new MetadataCanaryRuleHandler(); } } 客户端调用时 header 传入版本,以 axios 为例 axios.interceptors.request.use(config => { NProgress.start() // start progress bar if (store.getters.access_token) { config.headers['Authorization'] = 'Bearer ' + token config.headers['version'] = 'v1.0' // 开发人员自己的版本标志,对应eureka metadata 配置 } return config } 总结 扩展ribbon 的路由规则,根据客户端来去不同版本的服务,也可以理解为灰度发布。 生产环境可以借助Kong、Traefik 集合zuul 来实现灰度发布 代码请参考微服务权限框架pig的灰度发布功能,已经全部开源 关于pig: 基于Spring Cloud、oAuth2.0开发基于Vue前后分离的开发平台,支持账号、短信、SSO等多种登录,提供配套视频开发教程。 https://gitee.com/log4j/pig
关于pigX:**全网最新的微服务脚手架,Spring Cloud Finchley、oAuth2的最佳实践** 在微服务架构下,通常每个微服务都会使用Swagger来管理我们的接口文档,当微服务越来越多,接口查找管理无形中要浪费我们不少时间,毕竟懒是程序员的美德。 由于swagger2暂时不支持webflux 走了很多坑,完成这个效果感谢 @dreamlu @世言。 文档聚合效果 通过访问网关的 host:port/swagger-ui.html,即可实现: pig聚合文档效果预览传送门 通过右上角的Select a spec 选择服务模块来查看swagger文档 Pig的Zuul 核心实现 获取到zuul配置的路由信息,主要到SwaggerResource /** * 参考jhipster * GatewaySwaggerResourcesProvider */ @Component @Primary public class RegistrySwaggerResourcesProvider implements SwaggerResourcesProvider { private final RouteLocator routeLocator; public RegistrySwaggerResourcesProvider(RouteLocator routeLocator) { this.routeLocator = routeLocator; } @Override public List<SwaggerResource> get() { List<SwaggerResource> resources = new ArrayList<>(); List<Route> routes = routeLocator.getRoutes(); routes.forEach(route -> { //授权不维护到swagger if (!StringUtils.contains(route.getId(), ServiceNameConstant.AUTH_SERVICE)){ resources.add(swaggerResource(route.getId(), route.getFullPath().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; } } PigX的Spring Cloud Gateway 实现 注入路由到SwaggerResource @Component @Primary @AllArgsConstructor public class SwaggerProvider implements SwaggerResourcesProvider { public static final String API_URI = "/v2/api-docs"; private final RouteLocator routeLocator; private final GatewayProperties gatewayProperties; @Override public List<SwaggerResource> get() { List<SwaggerResource> resources = new ArrayList<>(); List<String> routes = new ArrayList<>(); routeLocator.getRoutes().subscribe(route -> routes.add(route.getId())); gatewayProperties.getRoutes().stream().filter(routeDefinition -> routes.contains(routeDefinition.getId())) .forEach(routeDefinition -> routeDefinition.getPredicates().stream() .filter(predicateDefinition -> "Path".equalsIgnoreCase(predicateDefinition.getName())) .filter(predicateDefinition -> !"pigx-auth".equalsIgnoreCase(routeDefinition.getId())) .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("2.0"); return swaggerResource; } } 提供swagger 对外接口配置 @Slf4j @Configuration @AllArgsConstructor public class RouterFunctionConfiguration { private final SwaggerResourceHandler swaggerResourceHandler; private final SwaggerSecurityHandler swaggerSecurityHandler; private final SwaggerUiHandler swaggerUiHandler; @Bean public RouterFunction routerFunction() { return RouterFunctions.route( .andRoute(RequestPredicates.GET("/swagger-resources") .and(RequestPredicates.accept(MediaType.ALL)), swaggerResourceHandler) .andRoute(RequestPredicates.GET("/swagger-resources/configuration/ui") .and(RequestPredicates.accept(MediaType.ALL)), swaggerUiHandler) .andRoute(RequestPredicates.GET("/swagger-resources/configuration/security") .and(RequestPredicates.accept(MediaType.ALL)), swaggerSecurityHandler); } } 业务handler 的实现 @Override public Mono<ServerResponse> handle(ServerRequest request) { return ServerResponse.status(HttpStatus.OK) .contentType(MediaType.APPLICATION_JSON_UTF8) .body(BodyInserters.fromObject(swaggerResources.get())); } @Override public Mono<ServerResponse> handle(ServerRequest request) { return ServerResponse.status(HttpStatus.OK) .contentType(MediaType.APPLICATION_JSON_UTF8) .body(BodyInserters.fromObject( Optional.ofNullable(securityConfiguration) .orElse(SecurityConfigurationBuilder.builder().build()))); } @Override public Mono<ServerResponse> handle(ServerRequest request) { return ServerResponse.status(HttpStatus.OK) .contentType(MediaType.APPLICATION_JSON_UTF8) .body(BodyInserters.fromObject( Optional.ofNullable(uiConfiguration) .orElse(UiConfigurationBuilder.builder().build()))); } swagger路径转换 通过以上配置,可以实现文档的参考和展示了,但是使用swagger 的 try it out 功能发现路径是路由切割后的路径比如: swagger 文档中的路径为:主机名:端口:映射路径 少了一个 服务路由前缀,是因为展示handler 经过了 StripPrefixGatewayFilterFactory 这个过滤器的处理,原有的 路由前缀被过滤掉了! 方案1,通过swagger 的host 配置手动维护一个前缀 return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) .host("主机名:端口:服务前缀") //注意这里的主机名:端口是网关的地址和端口 .select() .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)) .paths(PathSelectors.any()) .build() .globalOperationParameters(parameterList); 方案2,增加X-Forwarded-Prefix swagger 在拼装URL 数据时候,会增加X-Forwarder-Prefix 请求头里面的信息为前缀 通过如上分析,知道应该在哪里下手了吧,在 网关上追加一个请求头即可 @Component public class SwaggerHeaderFilter extends AbstractGatewayFilterFactory { private static final String HEADER_NAME = "X-Forwarded-Prefix"; @Override public GatewayFilter apply(Object config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); if (!StringUtils.endsWithIgnoreCase(path, SwaggerProvider.API_URI)) { return chain.filter(exchange); } String basePath = path.substring(0, path.lastIndexOf(SwaggerProvider.API_URI)); ServerHttpRequest newRequest = request.mutate().header(HEADER_NAME, basePath).build(); ServerWebExchange newExchange = exchange.mutate().request(newRequest).build(); return chain.filter(newExchange); }; } } 总结 相对zuul的实现,核心逻辑都是一样,获取到配置路由信息,重写swaggerresource gateway的配置稍微麻烦,资源的提供handler,swagger url 重写的细节 我的知识星球:《微服务最前沿》 免费的微服务资讯分享 源码获取:基于Spring Cloud Finchley.RELEASE、oAuth2 实现的权限系统
2022年12月
2022年06月
2020年12月
2020年10月
2019年10月