給頂級開源項目 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地址都在這了,有興趣的同窗能夠參考一下。

原創不易,轉載請聯繫做者。若是個人文章對您有幫助,請點贊/收藏/關注鼓勵支持一下吧❤❤❤❤❤❤
相關文章
相關標籤/搜索