Halo博客项目源码学习
Halo1.x停更到1.6版本,2.x使用了reactive架构,想基于halo进行二次开发,1.x有部分前后端未分离内容,2.x架构、设计更复杂,考虑到学习成本,先熟悉1.x的代码。
Halo1.x前台页面使用freemarker作为模板引擎,后台管理界面基于Vue,后端数据访问是SpringData JPA,自实现了缓存,参照springsecurity自实现了认证,代码中大量使用了事件监听、切面、函数式编程。
项目结构
- annotation,定义了控制接口是否禁用的标记敏感信息的两个注解,对应的处理切面在aspect包中。
- aspect,只有对应annotation包中的两个切面。
- cache,自实现的缓存,支持内存、level-db、redis三种实现。
- config,java配置类包
attibuteconverter包下是JPA的配置,没怎么用过JPA,不熟悉。
HaloConfiguration简单注入了ObjectMapper、RestTemplate和自实现缓存的实现类。
HaloMvcConfiguration配置了springmvc
HaloRequestMappingHandlerMapping过滤了一些静态资源路径,避免匹配到controller,也可以通过配置统一前缀的方式,并且监听了这些路径的变化。在HalomvcConfig注入。
SwaggerConfiguration,api文档配置。 - controller,接口
- core
freemarker,自定义了一些freemarker指令。
其余几个分别是对响应response作了处理、全局异常处理、接口日志切面和springdata page对象的序列化器。 - event,各种事件
- exception,各种自定义异常。
- converter,自定义了字符串转枚举的spring converter。
- filter,一个记录日志的filter,一个处理跨域的filter。
- handler,各种对象存储的处理、数据库迁移、主题解析。
- listener,事件监听器。
- mail,邮件服务。
- model,模型对象。
- repository,spring data repository。
- security,自实现的认证流程,对每一个请求,在过滤器中将当前用户信息(也可能是Null)设置到线程变量中,请求处理完后清除。主要工作在filter中,拦截一些请求,验证token,判断博客是否初始化过等。
- service,service层。
- task,处理过期文章的定时任务。
- theme,主题处理,包括远程拉取、解压、上传等处理。
启动流程分析
项目启动后,spring容器会发布ApplicationStartEvent,在listener.StartedListener中做了以下几件事。
- 数据库迁移,halo在版本升级过程中,数据库结构发生过变化,项目启动后自动完成迁移。
- 创建了工作目录、数据备份目录、数据导出目录,工作目录在用户目录下的.halo或者.halo-dev(profile是dev时)。
- 初始化主题,默认主题是caicai_anotole,会自动将resources/templates/themes/anotole下的主题文件复制到~/.halo/templates/themes/caicat_anotole下。
- 打印一些启动信息
- 配置git,远程拉取github主题用到。
在FreemarkerConfigAwareListener完成了以下工作
6. 解析当前主题,设置相关变量到freemarker上下文中,供模板使用。
7. 将博客的一些配置设置到freemarker中,如博客标题、描述等信息。
8. 加载当前用户信息,设置到freemarker上下文。
每次上述信息改变后会发布事件并由此类监听,更新模板上下文。
完成以上工作后就可以等待处理请求了。
请求流程分析
启动项目后,以访问http://localhost:8090/main为例,分析执行流程。
首先进入过滤器中,根据配置的顺序,依次执行的filter是
- LogFilter,简单记录日志
- CorsFilter,处理请求头,允许跨域。
- ContentFilter,拦截/main,doFilterInternal中判断博客是否初始化,否,到ContenAuthenticationFailureHandler中返回重定向到/install。
- /install请求同样经过LogFilter,CorsFilter。
- 到ContentFilter,放行。
- 到ApiAuthenticationFilter,放行。
- 到AdminAuthenticationFilter,放行。
- 到HaloRequestMappingHandlerMapping,不在黑名单中,返回spring默认查找的handler。
- 到MainController中的installation方法,返回index.html视图,请求结束。
请求的大致流程如上,部分请求还有切面、全局异常处理等。
敏感信息的处理
通常手机号、邮箱等信息不会随意发送到前端,用户看到的一般是110****8980这样的形式,比较直接的解决方式是在VO的get方法中不返回真实的信息,在Halo中,定义了@SensitiveConceal注解和处理该注解的切面,可以作为一个参考。
@Aspect
@Component
public class SensitiveConcealAspect {
@Pointcut("within(run.halo.app.repository..*) "
+ "&& @annotation(run.halo.app.annotation.SensitiveConceal)")
public void pointCut() {
}
private Object sensitiveMask(Object comment) {
if (comment instanceof BaseComment) {
((BaseComment) comment).setEmail("");
((BaseComment) comment).setIpAddress("");
}
return comment;
}
@Around("pointCut()")
public Object mask(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (SecurityContextHolder.getContext().isAuthenticated()) {
return result;
}
if (result instanceof Iterable) {
((Iterable<?>) result).forEach(this::sensitiveMask);
}
return sensitiveMask(result);
}
}
缓存的实现
项目中缓存的实现应该是参考了SpringCache,没有SpringCache的复杂度,又能满足项目需求,缓存的实现全部在run.app.halo.cache包下。和SpringCache源码对比了一下,发现了一些问题。
在SpringCache的@Caheable注解中有sync属性,用于避免多线程环境下相同的请求同时去访问缓存,而缓存又刚好未命中,给数据库造成压力。
SpringCache中,对于sync为true的方法进行了特殊处理,如下所示,在org.springframework.cache.interceptor.CacheAspectSupport.execute方法中
// Special handling of synchronized invocation
if (contexts.isSynchronized()) {
CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();
if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = context.getCaches().iterator().next();
try {
return wrapCacheValue(method, handleSynchronizedGet(invoker, key, cache));
}
catch (Cache.ValueRetrievalException ex) {
// Directly propagate ThrowableWrapper from the invoker,
// or potentially also an IllegalArgumentException etc.
ReflectionUtils.rethrowRuntimeException(ex.getCause());
}
}
else {
// No caching required, only call the underlying method
return invokeOperation(invoker);
}
}
在handleSynchronizedGet方法中会限制线程并发访问缓存,但具体还是依赖于Cache的实现。
- 基于ConcurrentHashMap的Cache,因为map本就是线程安全的,在ConcurrenMapCache中,Spring对各种操作没有作任何限制。
- 在RedisCache中,存在一个私有的synchronized修饰的getSynchronized方法。
在Halo中,以下两点让人疑惑
- InMemoryCache使用ConcurrentHashMap实现,get方法中使用了可重入锁。
- RedisCache没有作任何限制。
参照SpringSecurity的认证实现
SpringSecurity是一个比较重量级的框架,且保持有前后端不分离的风格,提供的功能多且复杂,学习成本也较高。Halo仅需要用到一小部分功能,参照框架自实现了一套认证流程,在security包下。
TransactionalEventListener
这篇博客讲得很不错,TransactionalEventListener使用场景以及实现原理。
Halo项目的应用场景是评论通知。