给顶级开源项目 Spring Boot 贡献代码是一种什么样的体验?

先点赞再看,养成好习惯

背景

Spring Boot的默认日志框架一直是 Logback,支持的很好。并且针对Logback,Spring Boot还提供了一个扩展功能 - <springProfile>,这个标签能够在Logback的XML配置文件中使用,用于配合Spring的profile来区分环境,很是方便。html

好比你能够像下面这样,只配置一个logback-spring.xml配置文件,而后用<springProfile>来区分环境,开发环境只输出到控制台,而其余环境输出到文件java

<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的官方文档))node

可是有时候为了性能或其余缘由,咱们会选择log4j2做为Spring Boot的日志框架。Spirng Boot固然也是支持log4j2的。git

切换到 log4j2 虽然很简单,可是Spring Boot并无对 log4j2进行扩展!log4j2的xml配置方式,并不支持<SpringProfile>标签,不能愉快的配置多环境!搜索了一下,StackOverflow上也有人有相同的困惑,并且这个功能目前并无任何人提供github

因而,我萌生了一个大胆的想法 :本身开发一个Spring Boot - Log4j2 XML的扩展,让 log4j2 的XML也支持<SpringProfile>标签,而后贡献给Spring Boot,万一被采纳了岂不妙哉。spring

并且这可不是改个注释,改个标点符号,改个变量名之类的PR;这但是一个新 feature,一旦被采纳,Spring Boot的文档上就会有个人一份力了!
image.pngexpress

功能开发

说干就干,先分析Log4j2 XML解析的源码,看看好很差下手apache

Log4j2 XML解析源码分析

通过一阵分析,找到了 Log4j2 的 XML 文件解析代码在 org.apache.logging.log4j.core.config.xml.XmlConfiguration,仔细阅读+DEBUG这个类以后,发现这个XML解析类各类解析方法不是static就是private,设计之初就没有考虑过提供扩展,定制标签的功能。好比这个递归解析标签的方法,直接就是private的:segmentfault

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月
image.png

而整个Log4j2框架 ,在2019年6月到2021年3月之间,发布了9次Release版本
image.png

整个项目更新了两年,快十个版本中,XmlConfiguration 只更新过一次,说明更新频率很低。并且对比变动记录发现,这个类近几回的更新内容也不多。

这么一想,我就算重写XmlConfiguration又怎么样,这么低的更新频率,这么少的更新内容,重写的风险也很低啊。并且我也不是所有重写,只是拷贝原有的代码,加上一点自定义标签的支持而已,改动量并不大。就算须要跟着Log4j2 更新的话,对比一下代码,从新调整一遍也不是难事。

就这样我说服了本身,开始拉代码……

fork/clone 代码,本地环境搭建

spring-boot 仓库地址:https://github.com/spring-projects/spring-boot

  1. Fork一份 Spring Boot的代码
  2. clone 这个fork的仓库
  3. 基于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
image.png

而后,选择要pr的分支,建立pr便可
image.png
而后须要详细填写你这个PR的描述
image.png

我详细的描述了我提交的功能,以及我上面分析的兼容性和风险问题:

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:

  1. 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.
  2. 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检查,漫长的等待以后,告诉我构建失败……
image.png

这里details能够进入详情查看具体构建日志
image.png

checkFormat/checkStyle 失败……

卧草大意了,忘了有checkStyle了,这种开源项目对代码风格要求必定很严格,个人代码是从Log4j2拷过来的,两个项目代码风格标准确定不同!

调整代码风格

我又回过头去翻Spring Boot的贡献指南,发现他们提到了一个spring-javaformat插件,用于检查/格式化代码,Eclipse/Idea插件都有,还有gradle/maven插件。

我天真的觉得,这个IDEA插件能够很方便的把个人代码格式化成Spring 的规范,装上以后,Reformat Code发现并无什么卵用,仍然过不了checkstyle………有知道怎么用的同窗,能够在评论区分享下

而后我就开始在本地执行它的checkstyle task,不断的调整代码风格……

这个checkstyle/checkformat的执行,是经过Gradle执行的,因此也能够在IDEA 的Gradle面板上执行:
image.png

Spring Boot的代码风格很是严谨,好比注释必须加句号啊,文件尾部必须空行结尾啊,导包顺序要求啊,每行代码长度要求啊等等等等……很是多

在执行checkstyle/checkformat插件后,插件会提示你哪一个文件,哪一行有什么问题,跟着修改就行

通过我一个多小时的调整,终于经过了代码检查……眼镜都花了

再次提交代码

代码风格/格式调整完成后,我又一次的提交了代码,仍是原来的分支。这里提交的话,那个PR里的CI检查会自动触发。

大概过了二十多分钟,终于构建完成,而且经过
image.png

来自官方人员的回复

过了三四天,我收到了官方人员的回复,随之而来的是我提交的PR被关闭了……
image.png

官方的回复态度仍是很友好的,大概意思是,不管我提交的代码稳定性如何,但这种暴力重写的方式仍是不太好,他们但愿由Log4j2来提供一个扩展,而后Spring Boot经过扩展来实现对Log4j2的加强。

而且附上了一个issue,主题就是Spring Boot 对Log4j2支持的问题,而且追加了我此次的PR:
https://github.com/spring-projects/spring-boot/issues/22149

image.png

总结

虽然Spring Boot没有接受我贡献的代码,但并非由于个人代码写的屎 😂,而是这种方式侵入性太强,有风险,并不够友好,经过扩展的方式去实现会更好。

这也体现了程序的扩展性是多么重要,在设计程序或者框架的时候,必定要多考虑扩展性,遵循开闭原则。

此次拒绝了个人贡献也没关系,至少Spring Boot官方了解到有这个需求,而且有现成的实现代码,往后有机会的话,我仍是会继续贡献其余的代码。

附录

此次提交的代码,和相关的PR地址都在这了,有兴趣的同窗能够参考一下。

原创不易,转载请联系做者。若是个人文章对您有帮助,请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤
相关文章
相关标签/搜索