在实际开发中,对于一些通用业务和公共组件,我们可能想将其做成一个Spring Boot Starter便于所有系统使用,这就需要我们定义自己的Spring Boot Starter。一个Spring Boot Starter都需要具备哪些能力:
① 提供了统一的dependency版本管理:仅需要导入对应的Starter依赖,相关的library,甚至是中间件,都一次性被引入了,而且要保证各dependency之间是不冲突的。例如当我们引入mybatis-spring-boot-starter依赖,mybatis和mybatis-spring等相关依赖也顺带被导入了。
② 提供自动装配的能力:Starter可以自动的向Spring容器中注入需要的Bean,并且完成对应的配置。
③ 对外暴露恰当的properties:Starter不可能提前知道全部的配置信息,有些配置信息只有在应用集成这个Starter的时候才能明确。例如对于mybatis,configLocation、mapperLocation这些参数在每个项目中都可能不同,所以只有应用自己知道这些参数的值该是什么。mybatis-spring-boot-starter对外暴露了一组properties,例如如果我们想指定mapper文件的存放位置,只需要在application.properties中添加mybatis.mapperLocations=classpath:mapping/*.xml
即可
创建 view-spring-boot-starter 项目并导入依赖,利用maven的间接依赖特性,在Starter的maven pom.xml中声明所有需要的dependency,这样在项目工程导入这个Starter时,相关的依赖就都被一起导入了。下面是 view-spring-boot-starter 的 pom.xml 。
<artifactId>view-spring-boot-starterartifactId>
<dependencies>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-lang3artifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-netflix-eureka-clientartifactId>
dependency>
<dependency>
<groupId>com.netflix.eurekagroupId>
<artifactId>eureka-clientartifactId>
dependency>
dependencies>
在包 config 下创建类 ViewProperties。实现属性配置:
@ConfigurationProperties(prefix = "hh.view")
@Data
public class ViewProperties {
private final static String DEFAULT_VIEW_TEMPLATE_PATH = "classpath:/view-template";
/**
* 统一化模板保存路径,默认在 classpath:/view-template 目录下
*/
private String templatePath = DEFAULT_VIEW_TEMPLATE_PATH;
}
在包config下创建类ViewAutoConfiguration。实现自动配置,把服务注入到Spring中
@Configuration
@Slf4j
@ComponentScan(value = "com.hh.view")
@EnableConfigurationProperties(ViewProperties.class)
public class ViewAutoConfiguration {
// 向 Spring 容器中注册 ServiceRegisterClient
@Bean
public ServiceRegisterClient serviceRegisterClient(ApplicationContext applicationContext) {
return new FeignClientBuilder(applicationContext).forType(ServiceRegisterClient.class, "view").build();
}
// 向 Spring 容器中注册 ViewTemplateRegisterClient
@Bean
public ViewTemplateRegisterClient viewTemplateRegisterClient(ApplicationContext applicationContext) {
return new FeignClientBuilder(applicationContext).forType(ViewTemplateRegisterClient.class, "view").build();
}
}
@ComponentScan 注解注解时用于配置类上的,一般和 @Configuration 注解一起使用,主要的作用就是定义包扫描的规则,Spring会去自动扫描 base-package 指定的包及其子包下的带有@Service
,@Component
,@Repository
,@Controller
注解的类,并将这些类自动装配到Spring容器内,然后交由Spring容器进行统一管理。
@EnableConfigurationProperties 获取读取配置文件的属性并注入到ViewProperties属性配置类中。
@Bean 向Spring容器中注入 ServiceRegisterClient 实例。
在资源目录下,创建文件 META-INF\spring.factories,指定自动配置类的路径:
# AutoConfiguration
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hh.view.config.ViewAutoConfiguration
创建自定义的 Spring Boot Starter 并不是什么难事,自定义 starter 的步骤:
① 确保在 pom.xml 文件中声明了使用该组件所需要的全部 dependency
② 利用 @ConfigurationProperties 注解对外暴露恰当的 properties
③ 利用条件注解 @ConditionalXXX编写XXXAutoConfiguration 类
④ 把写好的 XXXAutoConfiguration 类加到 META-INF/spring.factories 文件的 EnableAutoConfiguration 配置中,这样在应用启动时就会自动加载并执行 XXXAutoConfiguration。
当其他项目需要使用 view-spring-boot-starter 时,就需要使用导入该依赖。
创建一个新的SpringBoot项目 incident ,在项目的pom.xml文件中引入自定义SpringBoot Starter的依赖配置如下:
<dependency>
<groupId>com.hhgroupId>
<artifactId>ngsoc-view-spring-boot-starterartifactId>
<version>3.0.1version>
dependency>
hh:
view:
templatePath: view-template
ngsoc:
security:
api-key: 78464adf485cddf6c18615341fd4ebf813f02e06722b913721009134bee3a548
在 resource/view-template 目录下创建:incident.json,incidentTemplate.json、incidentRecordTemplate.json,这些文件就是统一化模板的配置文件,读取这些文件进行统一化模板的配置。
当 incident 项目启动的时候,会去加载 view-spring-boot-starter 中的自动配置类 ViewAutoConfiguration。
① 当 incident 项目启动的时候,会去加载 view-spring-boot-starter 中的自动配置类 ViewAutoConfiguration:
@Configuration
@Slf4j
@ComponentScan(value = "com.hh.view")
@EnableConfigurationProperties(ViewProperties.class)
public class ViewAutoConfiguration {
// 向 Spring 容器中注册 ServiceRegisterClient
@Bean
public ServiceRegisterClient serviceRegisterClient(ApplicationContext applicationContext) {
return new FeignClientBuilder(applicationContext).forType(ServiceRegisterClient.class, "view").build();
}
// 向 Spring 容器中注册 ViewTemplateRegisterClient
@Bean
public ViewTemplateRegisterClient viewTemplateRegisterClient(ApplicationContext applicationContext) {
return new FeignClientBuilder(applicationContext).forType(ViewTemplateRegisterClient.class, "view").build();
}
}
在该配置类上的注解 @ComponentScan(value = “com.hh.view”) 会去扫描 com.hh.view 包及其子包下所有带有@Service
,@Component
,@Repository
,@Controller
注解的类,并将这些类自动装配到Spring容器内,然后交由Spring容器进行统一管理。
② 在 com.hh.view.inialializer 包存在 ViewTemplateInitializer 类:
@Service
@Slf4j
@ComponentScan(value = "com.hh.view")
public class ViewTemplateInitializer extends AbstractRegister implements InitializingBean {
// ...
}
可以看到 ViewTemplateInitializer 类加了 @Service 注解,表示该类的生命周期会交给 Spring 容器去管理,Spring容器在启动的时候会完成bean的初始化。ViewTemplateInitializer 类不仅继承了AbstractRegister 而且实现了InitializingBean:
@Slf4j
@RequiredArgsConstructor
@Data
public abstract class AbstractRegister implements InitializingBean {
// 创建一个线程池,其中核心线程数10个,最大线程数20个,阻塞队列的大小为10,用来执行异步任务
public static final ExecutorService REGISTER_POOL = new ThreadPoolExecutor(
10, 20, 200L, TimeUnit.MICROSECONDS,
new LinkedBlockingDeque<>(10),
new ThreadFactoryBuilder().setNameFormat("view-register-runner-%d").build()
);
// 服务是否已经可用
private Boolean ready = false;
// 已经重试次数
private int retryTimes = 0;
// 重试等待时间
private final int waitSeconds;
/**
* Spring启动后,初始化Bean时,若该Bean实现 InitializingBean 接口,
* 会自动调用 afterPropertiesSet()方法,完成一些自定义的初始化操作。
*/
@Override
public void afterPropertiesSet() {
// 向线程池提交任务,当有任务到达线程池后,就会创建创建一个核心工作线程来执行线程池中的任务
REGISTER_POOL.submit(this::execute);
}
public void execute() {
while (!this.ready) {
retryTimes++;
try {
// 注册服务
register();
onSuccess();
this.ready = true;
} catch (Throwable e) {
onFailed(e);
try {
TimeUnit.SECONDS.sleep(waitSeconds);
} catch (InterruptedException interruptedException) {
// ignore
}
}
}
}
@PreDestroy
public void destroy() {
this.ready = true;
// 关闭线程池,该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
REGISTER_POOL.shutdown();
}
/**
* 注册
*/
public abstract void register();
/**
* 成功时 回调
*/
public abstract void onSuccess();
/**
* 失败时 回调
*
* @param e 异常
*/
public abstract void onFailed(Throwable e);
}
我们知道Spring启动后,初始化Bean时,若该Bean实现 InitializingBean 接口,会自动调用 afterPropertiesSet()方法,完成一些自定义的初始化操作。
① 创建一个线程池,其中核心线程数10个,最大线程数20个,阻塞队列的大小为10,用来执行异步任务;
② 当Spring容器启动后初始化 AbstractRegister 后,会去执行 afterPropertiesSet() 方法,在该方法中向线程池中提交任务;
③ 线程池在收到任务后会创建一个工作线程来执行该任务。
④ 最终会执行 AbstractRegister 子类 ViewTemplateInitializer 的 registerRemote() 方法。
@Service
@Slf4j
@ComponentScan(value = "com.hh.view")
public class ViewTemplateInitializer extends AbstractRegister implements InitializingBean {
@Setter(onMethod_ = @Autowired)
private ViewTemplateRegisterClient viewTemplateRegisterClient;
// 省略。。。
@Override
public void register() {
// 注册到view服务上
registerRemote(viewTemplateProvider.provide());
}
// 远程注册统一化模板
private void registerRemote(List<ViewTemplateRegistry> viewTemplates) {
RegisterQo<List<ViewTemplateRegistry>> registerQo = new RegisterQo<>();
registerQo.setRegisterBody(viewTemplates);
registerQo.setAppName(this.appName);
// 探讨的重点!!!!!!这里就是我今天引入的入口!!!!!!!!!!!!!!!!
final ApiResponse<Void> result = viewTemplateRegisterClient.register(registerQo);
log.info("注册统一化模板结果为:{}", result);
if (result.getCode() != ApiResponse.CODE_OK) {
throw new DataQueryException("注册统一化模板失败,原因:" + result.getData());
}
}
}
可以看到在该方法中会远程调用 view 服务下的 register() 方法:
@FeignClient(value = "view")
public interface ViewTemplateRegisterClient {
/**
* 注册服务
*
* @param registerQo 注册内容
* @return 注册结果
*/
@RequestMapping(value = "/VIEW/api/v1/view/template/register", method = RequestMethod.POST)
@SystemRequest
ApiResponse<Void> register(@RequestBody RegisterQo<List<ViewTemplateRegistry>> registerQo);
}
@RestController
@RequestMapping("/api/v1/view/template")
@ResponseResult
@Api(tags = "页面模板管理")
@Validated
public class ViewTemplateController {
@Setter(onMethod_ = @Autowired)
private ViewTemplateService viewTemplateService;
@ApiOperation("注册页面模板")
@PostMapping("/register")
@PreAuthorize("hasAnyAuthority('superAdmin')")
public void register(@RequestBody @Validated RegisterQo<List<ViewTemplateRegistry>> registerQo) {
viewTemplateService.register(registerQo.getAppName(), registerQo.getRegisterBody());
}
}
那么问题来了,在 incident 项目启动时,需要加载自定义的 Spring Boot Starter,而在我们自定义的 view-spring-boot-starter 项目下的 ViewTemplateInitializer 中远程调用了 view 项目中的统一化模板注册接口,由于项目使用 SpringSecurity Oauth2 搭建了认证服务器和授权服务器,因此访问 view 项目中的受限资源时,需要带着 accessToken,而此时由于系统仍然在后台启动中未处于登录状态,所以访问 view 项目下的请求时并未携带 accessToken 导致无权限访问。
即当访问 /api/v1/view/template/register 路径下的受限资源时,需要携带 access_token ,此时就需要自定义认证方式来实现获取 accessToken 来访问系统受限资源。
我们可以看到 ViewTemplateRegisterClient 的 register() 方法上加了一个自定义注解 @SystemRequest,该自定义注解 @SystemRequest 中实现了通过自定义认证方式获取 access_token 并访问受限资源的功能,下面重点来看这个注解吧。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SystemRequest {
}
@ConfigurationProperties("ngsoc.security")
@Data
public class FeignRequestProperties {
/**
* apikey
*/
private String apiKey;
/**
* token类型 默认是bearer
*/
private String tokenType;
/**
* 登录地址
*/
private String loginUrl;
}
@Slf4j
public class SystemRequestHandler {
public final RedisTemplate<String, String> redisTemplate;
public final ReentrantLock lock = new ReentrantLock();
/**
* 当前登录的token
*/
public volatile String token;
/**
* feign请求的属性
*/
public final FeignRequestProperties properties;
public SystemRequestHandler(RedisTemplate<String, String> redisTemplate, FeignRequestProperties properties) {
this.redisTemplate = redisTemplate;
this.properties = properties;
}
/**
* 尝试通过ApiKey登录
*
* @param requestTemplate 请求模板
*/
public void tryLoginByApiKey(RequestTemplate requestTemplate) {
lock.lock();
try {
// token 超时则尝试登录
if (StringUtils.isBlank(this.token) || !redisTokenExists(this.token)) {
login();
}
requestTemplate.header("Authorization", properties.getTokenType() + " " + this.token);
} catch (Exception e) {
log.error("API 登陆失败", e);
} finally {
lock.unlock();
}
}
/**
* 校验token是否存在
*
* @param token token
* @return 校验结果
*/
private boolean redisTokenExists(final String token) {
return ngsocAuthTokenExists(token) && oAuthTokenExists(token);
}
/**
* redis中token是否存在
*
* @param token token
* @return redis中是否存在这个token
*/
public boolean ngsocAuthTokenExists(final String token) {
if (StringUtils.isBlank(token)) {
return false;
}
final Boolean isTokenExits = redisTemplate.hasKey(RedisKeyUtil.getAuthAccessTokenKey(token));
return Objects.nonNull(isTokenExits) && isTokenExits;
}
/**
* redis中token是否存在
*
* @param token token
* @return redis中是否存在这个token
*/
public boolean oAuthTokenExists(String token) {
if (StringUtils.isBlank(token)) {
return false;
}
final Boolean isTokenExits = redisTemplate.hasKey(RedisKeyUtil.getOauthTokenKey(token));
return Objects.nonNull(isTokenExits) && isTokenExits;
}
/**
* 判断当前feign方法是否需要登录apiKey
*
*
* @param methodMetadata 方法元数据
* @return 是否需要登录apiKey
*/
public boolean isSystemRequest(MethodMetadata methodMetadata) {
final String apiKey = properties.getApiKey();
final Method method = methodMetadata.method();
return method.isAnnotationPresent(SystemRequest.class) && Objects.nonNull(apiKey);
}
/**
* 尝试登录并初始化
*/
public void login() throws AuthenticationException {
RestTemplate restTemplate = new RestTemplate();
// 构造接口登录参数(body)
Map<String, String> loginParams = new HashMap<>(1);
loginParams.put("apiKey", properties.getApiKey());
// 发送接口请求
String tokenUrl = properties.getLoginUrl();
log.info("尝试使用API-KEY登陆 :{}, 参数 :{} ", tokenUrl, loginParams);
HttpEntity<Map<String, String>> request = new HttpEntity<>(
loginParams
);
final ParameterizedTypeReference<ApiResponse<ApiKeyToken>> typeReference = new ParameterizedTypeReference<>() {
};
// 在这里会调用 SpringSecurity Oauth2 的 自定义认证方式
final ResponseEntity<ApiResponse<ApiKeyToken>> result = restTemplate
.exchange(tokenUrl, HttpMethod.POST, request, typeReference);
log.info("API-KEY登陆结果 :{}", result);
Objects.requireNonNull(result.getBody());
Objects.requireNonNull(result.getBody().getData());
if (result.getBody().getCode() != ApiResponse.CODE_OK) {
throw new AuthenticationException(result.getBody().getMessage());
}
this.token = result.getBody().getData().getAccessToken();
log.info("成功刷新token缓存, token={}", this.token);
}
}
具体如何实现自定义认证方式,我们下篇文章再看吧。