先点赞再看,养成好习惯
背景
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月
而整个Log4j2框架 ,在2019年6月到2021年3月之间,发布了9次Release版本
整个项目更新了两年,快十个版本中,XmlConfiguration 只更新过一次,说明更新频率很低。而且对比变更记录发现,这个类近几次的更新内容也很少。
这么一想,我就算重写XmlConfiguration又怎么样,这么低的更新频率,这么少的更新内容,重写的风险也很低啊。而且我也不是全部重写,只是拷贝原有的代码,加上一点自定义标签的支持而已,改动量并不大。就算需要跟着Log4j2 更新的话,对比一下代码,重新调整一遍也不是难事。
就这样我说服了自己,开始拉代码……
fork/clone 代码,本地环境搭建
spring-boot 仓库地址:https://github.com/spring-projects/spring-boot
- 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
然后,选择要pr的分支,创建pr即可
然后需要详细填写你这个PR的描述
我详细的描述了我提交的功能,以及我上面分析的兼容性和风险问题:
Enhance the configuration of log4j2 (xml), support Profile-specific Configuration (
<SpringProfile>
), consistent with logback extension.
Spring Boot currently only enhances the Logback (XML) configuration to support the tag. This feature is very useful, but is not supported by Log4j2.
I copied the code in Log4j2 XML to parse the XML configuration and created a new SpringBootXmlConfiguration to support the tag, which is as simple and easy to use as Logback Extension.
Compatibility issues with rewriting the Log4j2 parsing code:
- I just copied the
XmlConfiguration
code directly from Log4j2, adding very little code and making no big changes like formatting. If there is an update to Log4j2, it is easy to rewrite the parsing class and update it accordingly.- The
XmlConfiguration
class in Log4j2 was last updated in June 2019, with no updates between [2.12.0,2.14.1] and the default dependent version of Log4j2 in Springboot (master) is 2.14.1
To sum up, there is no risk in this kind of enhancement
被冷漠无情的CI检查卡住
在提交PR后,我以为事情到这里就告一段落了……
结果Spring Boot的Github Action有一个CI检查,漫长的等待之后,告诉我构建失败……
这里details可以进入详情查看具体构建日志
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:
https://github.com/spring-projects/spring-boot/issues/22149
总结
虽然Spring Boot没有接受我贡献的代码,但并不是因为我的代码写的屎 😂,而是这种方式侵入性太强,有风险,并不够友好,通过扩展的方式去实现会更好。
这也体现了程序的扩展性是多么重要,在设计程序或者框架的时候,一定要多考虑扩展性,遵循开闭原则。
这次拒绝了我的贡献也不要紧,至少Spring Boot官方了解到有这个需求,并且有现成的实现代码,日后有机会的话,我还是会继续贡献其他的代码。
附录
这次提交的代码,和相关的PR地址都在这了,有兴趣的同学可以参考一下。
- 「Pull Request」 log4j2(xml) configuration enhancement, support tag
- 「源码地址」
- 「Log4j2 issue」Investigate impact of switching default logging system to log4j2
原创不易,转载请联系作者。如果我的文章对您有帮助,请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤