背景
Spring Boot的默认日志框架一直是 Logback,支持的很好。而且针对Logback,Spring Boot还提供了一个扩展功能 - <springProfile>
,这个标签可以在Logback的XML配置文件中使用,用于配合Spring的profile来区分环境,非常方便。
比如你可以像下面这样,只配置一个logback-spring.xml
配置文件,然后用<springProfile>
来区分环境,开发环境只输出到控制台,而其他环境输出到文件
<Root level="INFO">
<!-- 开发环境使用Console Appender,生产环境使用File Appender -->
<springProfile name="dev">
<AppenderRef ref="Console"/>
</springProfile>
<SpringProfile name="!dev">
<AppenderRef ref="File"/>
</SpringProfile>
</Root>
复制代码
这样做的好处是,我只需要一个logback.xml配置文件,就可以解决多环境的问题,而不是每个环境一个logback-spring.xml,实在太香了(这个Profile 的语法还可以有一些更灵活的语法,详细参考Spring Boot的官方文档)
但是有时候为了性能或其他原因,我们会选择log4j2作为Spring Boot的日志框架。Spirng Boot当然也是支持log4j2的。
切换到 log4j2 虽然很简单,但是Spring Boot并没有对 log4j2进行扩展!log4j2的xml配置方式,并不支持标签,不能愉快的配置多环境!搜索了一下,StackOverflow上也有人有相同的困惑,而且这个功能目前并没有任何人提供
于是,我萌生了一个大胆的想法 :自己开发一个Spring Boot - Log4j2 XML的扩展,让 log4j2 的XML也支持<SpringProfile>
标签,然后贡献给Spring Boot,万一被采纳了岂不妙哉。
而且这可不是改个注释,改个标点符号,改个变量名之类的PR;这可是一个新 feature,一旦被采纳,Spring Boot的文档上就会有我的一份力了!
功能开发
说干就干,先分析Log4j2 XML解析的源码,看看好不好下手
Log4j2 XML解析源码分析
经过一阵分析,找到了 Log4j2 的 XML 文件解析代码在 org.apache.logging.log4j.core.config.xml.XmlConfiguration
,仔细阅读+DEBUG这个类之后,发现这个XML解析类各种解析方法不是static就是private,设计之初就没有考虑过提供扩展,定制标签的功能。比如这个递归解析标签的方法,直接就是private的:
private void constructHierarchy(final Node node, final Element element) {
processAttributes(node, element);
final StringBuilder buffer = new StringBuilder();
final NodeList list = element.getChildNodes();
final List<Node> children = node.getChildren();
for (int i = 0; i < list.getLength(); i++) {
final org.w3c.dom.Node w3cNode = list.item(i);
if (w3cNode instanceof Element) {
final Element child = (Element) w3cNode;
final String name = getType(child);
final PluginType<?> type = pluginManager.getPluginType(name);
final Node childNode = new Node(node, name, type);
constructHierarchy(childNode, child);
if (type == null) {
final String value = childNode.getValue();
if (!childNode.hasChildren() && value != null) {
node.getAttributes().put(name, value);
} else {
status.add(new Status(name, element, ErrorType.CLASS_NOT_FOUND));
}
} else {
children.add(childNode);
}
} else if (w3cNode instanceof Text) {
final Text data = (Text) w3cNode;
buffer.append(data.getData());
}
}
final String text = buffer.toString().trim();
if (text.length() > 0 || (!node.hasChildren() && !node.isRoot())) {
node.setValue(text);
}
}
复制代码
连解析后的数据,也是private
private Element rootElement;
复制代码
想通过继承的方式,只重写部分方法来实现根本不可能,除非重写整个类才能扩展自定义的标签……
风险 & 兼容性的思考
这下就尴尬了,重写整个类虽然也可以,但兼容性就得不到保证了。因为一旦Log4j2 的 XML配置有更新,我这套扩展就废了,不管是大更新还是小更新,但凡是这个类有变动我这个扩展就得跟着重写,实在不稳妥。
但我在查看了XmlConfiguration
这个类的提交历史后发现,它最近一次更新的时间在2019年6月
整个项目更新了两年,快十个版本中,XmlConfiguration 只更新过一次,说明更新频率很低。而且对比变更记录发现,这个类近几次的更新内容也很少。
这么一想,我就算重写XmlConfiguration又怎么样,这么低的更新频率,这么少的更新内容,重写的风险也很低啊。而且我也不是全部重写,只是拷贝原有的代码,加上一点自定义标签的支持而已,改动量并不大。就算需要跟着Log4j2 更新的话,对比一下代码,重新调整一遍也不是难事。
就这样我说服了自己,开始拉代码……
fork/clone 代码,本地环境搭建
spring-boot 仓库地址:github.com/spring-proj…
- Fork一份 Spring Boot的代码
- clone 这个fork的仓库
- 基于master,新建一个log4j2_enhancement分支用于开发
这里也可以直接通过IDEA clone,不过前提是你有个“可靠又稳定”的网络。
由于Spring/Spring Boot已经将构建工具从Maven迁移到了Gradle,所以IDEA版本最好不要太老,太老的版本可能对Gradle支持的不够好。
如果你的网络足够“可靠和稳定”,那么只需要在IDEA中打开Spring Boot的源码,就可以自定构建好开发环境,直接运行测试了。否则可能会遇到Gradle和相关包下载失败,Maven仓库包下载失败等各种问题……
Spring Boot对Logback的支持扩展
既然Spring Boot对Logback(XML)进行了增强,那么先来看看它是怎么增强的,待会我支持Log4j2的话能省很多事。
经过一阵分析,找到了这个Logback的扩展点:
class SpringBootJoranConfigurator extends JoranConfigurator {
private LoggingInitializationContext initializationContext;
SpringBootJoranConfigurator(LoggingInitializationContext initializationContext) {
this.initializationContext = initializationContext;
}
@Override
public void addInstanceRules(RuleStore rs) {
super.addInstanceRules(rs);
Environment environment = this.initializationContext.getEnvironment();
rs.addRule(new ElementSelector("configuration/springProperty"), new SpringPropertyAction(environment));
rs.addRule(new ElementSelector("*/springProfile"), new SpringProfileAction(environment));
rs.addRule(new ElementSelector("*/springProfile/*"), new NOPAction());
}
}
复制代码
就……这么简单?顺着这个类又分析了一遍JoranConfigurator和相关的类之后,发现这都是Logback的功劳。
Logback文档中提到,这个Joran 实际上是一个通用的配置系统,可以独立于日志系统使用。但我搜索了一下,除了Logback的文档以外,并没有找到这个Joran的出处在哪。
不过这并不重要,我就把他当做一个通用的配置解析器,被logback引用了而已。
这个解析器比较灵活,可以自定义标签/标签解析的行为,只需要重写addInstanceRules这个方法,添加自定义的标签名和行为类即可:
@Override
public void addInstanceRules(RuleStore rs) {
super.addInstanceRules(rs);
Environment environment = this.initializationContext.getEnvironment();
rs.addRule(new ElementSelector("configuration/springProperty"), new SpringPropertyAction(environment));
//就是这么简单……
rs.addRule(new ElementSelector("*/springProfile"), new SpringProfileAction(environment));
rs.addRule(new ElementSelector("*/springProfile/*"), new NOPAction());
}
复制代码
然后在SpringProfileAction中,通过Spring的Environment对象,拿到当前激活的Profiles进行匹配就能搞定
如法炮制,添加Log4j2 的自定义扩展
虽然Log4j2的XML解析并不能像Logback那样灵活,直接插入扩展。但是基于我前面的风险&兼容性分析,重写XmlConfiguration也是可以实现自定义标签解析的:
先创建一个SpringBootXmlConfiguration
这个类的代码,是完全复制了org.apache.logging.log4j.core.config.xml.XmlConfiguration
,然后增加俩Environment相关的参数:
private final LoggingInitializationContext initializationContext;
private final Environment environment;
复制代码
接着在构造函数中增加initializationContext并注入:
public SpringBootXmlConfiguration(final LoggingInitializationContext initializationContext,
final LoggerContext loggerContext, final ConfigurationSource configSource) {
super(loggerContext, configSource);
this.initializationContext = initializationContext;
this.environment = initializationContext.getEnvironment();
...
}
复制代码
最后只需要调整上面提到的递归解析方法,增加SpringProfile标签的支持即可:
private void constructHierarchy(final Node node, final Element element, boolean profileNode) {
//SpringProfile节点不需要处理属性
if (!profileNode) {
processAttributes(node, element);
}
final StringBuilder buffer = new StringBuilder();
final NodeList list = element.getChildNodes();
final List<Node> children = node.getChildren();
for (int i = 0; i < list.getLength(); i++) {
final org.w3c.dom.Node w3cNode = list.item(i);
if (w3cNode instanceof Element) {
final Element child = (Element) w3cNode;
final String name = getType(child);
//如果是<SpringProfile>标签,就跳过plugin的查找和解析
// Enhance log4j2.xml configuration
if (SPRING_PROFILE_TAG_NAME.equalsIgnoreCase(name)) {
//如果定义的Profile匹配当前激活的Profiles,就递归解析子节点,否则就跳过当前节点(和子节点)
if (acceptsProfiles(child.getAttribute("name"))) {
constructHierarchy(node, child, true);
}
// Break <SpringProfile> node
continue;
}
//查找节点对应插件,解析节点,添加到node,构建rootElement树
//......
}
}
//判断profile是否符合规则,从Spring Boot - Logback里复制的……
private boolean acceptsProfiles(String profile) {
if (this.environment == null) {
return false;
}
String[] profileNames = StringUtils.trimArrayElements(StringUtils.commaDelimitedListToStringArray(profile));
if (profileNames.length == 0) {
return false;
}
return this.environment.acceptsProfiles(Profiles.of(profileNames));
}
复制代码
在配置SpringBootXmlConfiguration的入口
好了,大功告成,就这么简单,这么点代码就完成了Log4j2 XML的增强。现在只需要在装配Log4j2的时候,将默认的XmlConfiguration换成我的SpringBootXmlConfiguration即可:
//org.springframework.boot.logging.log4j2.Log4J2LoggingSystem
......
LoggerContext ctx = getLoggerContext();
URL url = ResourceUtils.getURL(location);
ConfigurationSource source = getConfigurationSource(url);
Configuration configuration;
if (url.toString().endsWith("xml") && initializationContext != null) {
//XML文件并且initializationContext不为空时,就使用增强的SpringBootXmlConfiguration进行解析
configuration = new SpringBootXmlConfiguration(initializationContext, ctx, source);
}
else {
configuration = ConfigurationFactory.getInstance().getConfiguration(ctx, source);
}
......
复制代码
准备单元测试
功能已经完成了,现在要准备单元测试。这里还是可以参考Logback 相关的单元测试类,直接拷贝过来,修改成Log4j2的版本。
Spring Boot目前的版本使用的是Junit5,现在新建一个SpringBootXmlConfigurationTests类,然后模仿Logback的单元测试类写一堆测试方法和测试配置文件:
<!--profile-expression.xml-->
<springProfile name="production | test">
<logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>
<!--production-file.xml-->
<springProfile name="production">
<logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>
<!--multi-profile-names.xml-->
<springProfile name="production, test">
<logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>
<!--nested.xml-->
<springProfile name="outer">
<springProfile name="inner">
<logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>
</springProfile>
...
复制代码
void profileActive();
void multipleNamesFirstProfileActive();
void multipleNamesSecondProfileActive();
void profileNotActive();
void profileExpressionMatchFirst();
void profileExpressionMatchSecond();
void profileExpressionNoMatch();
void profileNestedActiveActive();
void profileNestedActiveNotActive();
......
复制代码
折腾了一会,终于把单元测试编写完成,并全部测试通过。接下来可以准备提PR了
提交PR
首先,在fork后的项目中,进行Pull request我详细的描述了我提交的功能,以及我上面分析的兼容性和风险问题:
To sum up, there is no risk in this kind of enhancement
被冷漠无情的CI检查卡住
在提交PR后,我以为事情到这里就告一段落了……
结果Spring Boot的Github Action有一个CI检查,漫长的等待之后,告诉我构建失败……checkFormat/checkStyle 失败……
卧草大意了,忘了有checkStyle了,这种开源项目对代码风格要求一定很严格,我的代码是从Log4j2拷过来的,两个项目代码风格标准肯定不一样!
调整代码风格
我又回过头去翻Spring Boot的贡献指南,发现他们提到了一个spring-javaformat插件,用于检查/格式化代码,Eclipse/Idea插件都有,还有gradle/maven插件。
我天真的以为,这个IDEA插件可以很方便的把我的代码格式化成Spring 的规范,装上之后,Reformat Code发现并没有什么卵用,仍然过不了checkstyle………有知道怎么用的同学,可以在评论区分享下
然后我就开始在本地执行它的checkstyle task,不断的调整代码风格……
这个checkstyle/checkformat的执行,是通过Gradle执行的,所以也可以在IDEA 的Gradle面板上执行:Spring Boot的代码风格非常严谨,比如注释必须加句号啊,文件尾部必须空行结尾啊,导包顺序要求啊,每行代码长度要求啊等等等等……非常多
在执行checkstyle/checkformat插件后,插件会提示你哪个文件,哪一行有什么问题,跟着修改就行
经过我一个多小时的调整,终于通过了代码检查……眼镜都花了
再次提交代码
代码风格/格式调整完成后,我又一次的提交了代码,还是原来的分支。这里提交的话,那个PR里的CI检查会自动触发。
大概过了二十多分钟,终于构建完成,并且通过来自官方人员的回复
过了三四天,我收到了官方人员的回复,随之而来的是我提交的PR被关闭了……官方的回复态度还是很友好的,大概意思是,无论我提交的代码稳定性如何,但这种暴力重写的方式还是不太好,他们希望由Log4j2来提供一个扩展,然后Spring Boot通过扩展来实现对Log4j2的增强。
并且附上了一个issue,主题就是Spring Boot 对Log4j2支持的问题,并且追加了我这次的PR: github.com/spring-proj…
总结
虽然Spring Boot没有接受我贡献的代码,但并不是因为我的代码写的屎 ?,而是这种方式侵入性太强,有风险,并不够友好,通过扩展的方式去实现会更好。
这也体现了程序的扩展性是多么重要,在设计程序或者框架的时候,一定要多考虑扩展性,遵循开闭原则。
这次拒绝了我的贡献也不要紧,至少Spring Boot官方了解到有这个需求,并且有现成的实现代码,日后有机会的话,我还是会继续贡献其他的代码。
原文链接:https://juejin.cn/post/6948419381462302751
最后小编还给大家整理了一份面试宝典 有需要的私信小助理【666】来领取!!