在软件开发的世界里,依赖管理是一项至关重要却又常常被忽视的工作。最近,领导给我布置了一个特别的任务 —— 了解我们部门提供给业务方的核心二方包及其版本号,以便为后续的兼容升级工作提供有力支持。这看似简单的需求,背后却蕴含着不少技术挑战和思考。在本文中,我将与大家分享在完成这个任务过程中的探索与实践,希望能为正在面临类似问题的你提供一些启发。

知识小科普:一方包、二方包、三方包的区别

在深入探讨获取二方包版本号的方法之前,我们先来科普一下一方包、二方包和三方包的概念。

一方包

一方包指的是本工程中各模块之间的相互依赖。它们就像是一个团队内部成员之间的协作,只在项目内部发挥作用,是项目实现自身功能的重要组成部分。比如,在一个电商项目中,用户模块、订单模块、支付模块等之间的相互调用所依赖的代码包,就属于一方包。一方包的使用范围局限于项目内部,对项目的功能实现起着关键作用。

二方包

二方包是公司内部的依赖库,通常是公司内部其他项目发布的 jar 包。可以把它想象成公司内部不同部门之间的协作,虽然不在同一个项目组,但都在公司这个大环境下,为了实现公司的整体目标而共享资源。例如,公司的基础服务团队开发了一套通用的日志记录组件,其他业务部门在开发项目时可以直接引用这个二方包,实现统一的日志记录功能。二方包的使用范围限于公司内部,能够提高公司内部开发的效率和代码的复用性。

三方包

三方包是来自公司之外的开源库,像大家熟知的 apache、google 等发布的依赖就属于三方包。它们就像是外界提供的各种工具和资源,供整个行业或公众使用。比如,在开发 Web 应用时,我们常常会使用 Apache 的 HttpComponents 库来处理 HTTP 请求,这个库就是一个典型的三方包。三方包的使用范围广泛,能够帮助我们快速集成各种功能,加速项目的开发进程。

总结来说,一方包、二方包和三方包的主要区别在于权限范围和使用范围:一方包限于项目内部,二方包限于公司内部,而三方包则是面向公众或行业开放的。

回到正题:获取业务系统使用的二方包版本号的挑战

由于核心二方包是我们部门开发提供的,我们清楚地知道有哪些核心二方包。但问题在于,我们并不了解业务方到底使用了哪些二方包以及相应的版本。这就好比我们生产了一堆工具,却不知道客户具体使用了哪些工具以及工具的版本。而获取业务系统使用的二方包及其版本号,就成为了我们完成领导需求的关键所在。接下来,我将详细介绍几种获取二方包版本号的方法及其优缺点。

方法一:拉通业务方获取信息

最直接的方法就是拉通各个业务方,让他们提供正在使用的二方包及其版本。这个方法看似简单可行,但在实际操作中却存在不少问题。首先,这个需求是我们发起的,对于业务方来说,他们只是配合方,这件事情的优先级对他们来说可能并不高。他们可能有自己手头更重要的业务任务需要处理,很难将大量的时间和精力投入到我们的这个需求中。其次,业务方在配合时会考虑投入产出比。如果我们不能清晰地向他们阐述这件事情对他们的价值和收益,他们可能会对配合工作产生抵触情绪,导致沟通成本大幅增加。因此,这种方法虽然直接,但效率较低,并不是最佳选择。

方法二:埋点上报方式获取信息

如何获取二方包版本

获取二方包版本的关键在于读取META-INF/MANIFEST.MF文件中的Implementation-Version属性。这个属性记录了二方包的版本信息。虽然在某些情况下,Implementation-Version可能为空,但对于我们提供的二方包来说,这个问题并不存在。因为我们在构建二方包时,在pom文件的 GAV(GroupId、ArtifactId、Version)配置中引入了生成Implementation-Version的插件。具体配置如下:

 <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.2.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

使用该插件会生成形如下内容

Manifest-Version: 1.0
Implementation-Title: Spring Cloud Alibaba Sentinel
Implementation-Version: 2.1.0.RELEASE
Built-By: yizhan
Implementation-Vendor-Id: com.alibaba.cloud
Created-By: Apache Maven 3.5.0
Build-Jdk: 1.8.0_131
Implementation-URL: https://github.com/alibaba/spring-cloud-alibaba/sp
 ring-cloud-alibaba-sentinel
Implementation-Vendor: Pivotal Software, Inc.

那么,如何获取Implementation-Version呢?最容易想到的方法就是解析MANIFEST.MF文件。下面是一个解析工具类的示例代码:

public final class VersionNoFetcher {

    private VersionNoFetcher() {
    }

    /**
     * Return the full version string of the present codebase, or {@code null}
     * if it cannot be determined.
     * @return the version or {@code null}
     * @see Package#getImplementationVersion()
     */
    public static VersionMeta getVersion(ClassLoader classLoader,String libraryClassName) {
        if(classLoader == null){
            classLoader = Thread.currentThread().getContextClassLoader();
        }
        return determineVersion(classLoader,libraryClassName);
    }


    private static VersionMeta determineVersion(ClassLoader classLoader,String libraryClassName) {
        try {
        Class<?> libraryClass = Class.forName(libraryClassName,true,classLoader);
        String implementationVersion = libraryClass.getPackage().getImplementationVersion();
        if (implementationVersion != null) {
            return new VersionMeta(implementationVersion, libraryClassName,libraryClass.getPackage().getName());
        }
        VersionMeta versionMeta = getVersionMetaFromCodeSource(libraryClassName, libraryClass);
        if(versionMeta != null){
            return versionMeta;
        }
        return getVersionMetaFromManifest(classLoader,libraryClass);

        } catch (Exception ex) {
            return new VersionMeta();
        }

    }

    private static VersionMeta getVersionMetaFromCodeSource(String libraryClassName, Class<?> libraryClass) throws IOException, URISyntaxException {
        String implementationVersion;
        CodeSource codeSource = libraryClass.getProtectionDomain().getCodeSource();
        if (codeSource == null) {
            return null;
        }
        URL codeSourceLocation = codeSource.getLocation();

        URLConnection connection = codeSourceLocation.openConnection();
        if (connection instanceof JarURLConnection) {
            implementationVersion =  getImplementationVersion(((JarURLConnection) connection).getJarFile());
            if (implementationVersion != null){
                return new VersionMeta(implementationVersion, libraryClassName, libraryClass.getPackage().getName());
            }
        }
        try (JarFile jarFile = new JarFile(new File(codeSourceLocation.toURI()))) {
            implementationVersion = getImplementationVersion(jarFile);
            if (implementationVersion != null){
                return new VersionMeta(implementationVersion, libraryClassName, libraryClass.getPackage().getName());
            }
        }
        return null;
    }


    private static VersionMeta getVersionMetaFromManifest(ClassLoader classLoader,Class<?> libraryClass) {

        try (InputStream is = classLoader.getResourceAsStream("META-INF/MANIFEST.MF")) {
            if (is == null) {
                return new VersionMeta();
            }

            // 创建Manifest对象并从输入流中读取
            Manifest manifest = new Manifest(is);
            // 获取主属性集
            Attributes attributes = manifest.getMainAttributes();

            // 从属性集中获取版本号
            String version = attributes.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
            if (version != null) {
                return new VersionMeta(version, libraryClass.getName(), libraryClass.getPackage().getName());
            }
        } catch (Exception e) {

        }
        return new VersionMeta();
    }

    private static String getImplementationVersion(JarFile jarFile) throws IOException {
        return jarFile.getManifest().getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
    }

}
2、如何上报

获取到二方包版本后,我们需要将这些信息上报到指定的服务端。一种常见的上报方式是通过 HTTP 请求。以下是一个上报的参考示例代码:

public final class VersionNoUtils {
    private static final String APP_ID_KEY = "appid";
    private static final String VERSION_NO_KEY = "versionNo";


    private VersionNoUtils(){}

    public static void reportVersion2RemoteSvc(String url,String appId,ClassLoader classLoader,String libraryClassNames)  {
        if(!StringUtils.hasText(libraryClassNames) || !StringUtils.hasText(url)){
            return;
        }
        Map<String,Object> params = new HashMap<>();
        params.put(APP_ID_KEY,appId);
        if(libraryClassNames.contains(EXTENSION_SEPARATOR)){
            String[] libClassNames = libraryClassNames.split(EXTENSION_SEPARATOR);
            for(String libClassName : libClassNames){
                addVersionForClass(classLoader, libClassName,params);
            }
        }else{
            addVersionForClass(classLoader, libraryClassNames,params);
        }

        sendVersion2RemoteSvc(url, params);

    }


 
    private static void sendVersion2RemoteSvc(String url, Map<String, Object> params) {
        if(!params.isEmpty()){
            try {
                String jsonContent = JsonConverter.mapToJson(params);
                HttpUtils.getInstance().postJson(url, jsonContent);
            } catch (IOException e) {

            }
        }
    }
    }
3、何时上报?

确定了获取版本和上报的方式后,接下来就是选择合适的上报时机。以下是几种常见的上报时机及其优缺点分析:

a、 在业务方项目编译期期间进行上报

可以利用 APT(Annotation Processing Tool)或者自定义 Maven 插件在编译期进行上报。这种方式的优点是对项目运行的性能影响最低,因为在编译期就完成了上报工作,不会对项目的运行时性能产生额外的开销。然而,它的缺点也很明显,一旦在编译期出现问题,排查起来会非常困难。因为编译期的错误信息往往比较复杂,涉及到编译工具和插件的配置等多个方面,定位问题的根源需要花费大量的时间和精力。

b、 在业务方项目 install 或者 deploy 期间进行上报

利用自定义 Maven 插件在业务方执行install或者deploy操作时进行上报。这种方式可以确保在项目发布到本地仓库或远程仓库之前完成版本号的上报。但是,这种方式需要业务方在他们的项目中引用相应的插件。对于业务方来说,引入这个插件可能不会给他们带来直接的收益,反而可能会增加项目的复杂性和维护成本。因此,业务方可能不太愿意配合这种方式。

c、 在业务方项目启动时上报

可以利用 Spring 提供的扩展点,在业务方项目启动时进行上报。Spring 提供了丰富的扩展点,如CommandLineRunner、ApplicationListener等,我们可以通过实现这些接口来完成上报工作。这种方式的优点是相对简单易行,而且对业务方的侵入性较小。我们可以将上报代码直接内嵌到我们提供的二方包中,业务方在使用二方包时,上报功能会自动生效,对业务方基本上是无感的。当然,这种无感是相对的,业务方通过抓包或者监控手段还是可以发现上报请求的存在。以下是一个在项目启动时上报的示例代码:

@Component
public class VersionFetchCommandLineRunner implements CommandLineRunner {

    ExecutorService executorService = Executors.newSingleThreadExecutor(new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, "report-version-thread");
        }
    });

    @Override
    public void run(String... args) throws Exception {
        executorService.execute(() -> {
            reportVersion2RemoteSvc("http://localhost:8080/version/report", "10000", Thread.currentThread().getContextClassLoader(), "org.apache.catalina.startup.Tomcat,org.springframework.beans.factory.BeanFactory");
        });

    }
}

上报的内容示例如下:

{"org.apache.catalina.startup": "9.0.21", "appid": "10000", "org.springframework.beans.factory": "5.1.8.RELEASE"}

d、 在业务方项目关闭时上报

可以使用 JVM 钩子函数,或者监听 Spring 的关闭事件,在业务方项目关闭时进行上报。这种方式的优点是可以确保在项目运行的整个生命周期结束时完成上报工作,获取到项目在运行过程中使用的二方包版本信息。但是,它的缺点是如果项目在运行过程中出现异常终止等情况,可能会导致上报失败。而且,在项目关闭时进行上报,可能会对项目的关闭过程产生一定的影响,增加项目关闭的时间。
综合比较以上几种上报时机,在业务方项目启动或者关闭时进行上报是相对可行的方式。尤其是在项目启动时上报,通过将上报代码内嵌到二方包中,可以实现对业务方的低侵入性,同时保证上报工作的顺利进行。

总结

获取业务系统使用的二方包版本号这个需求,在大多数业务开发场景中可能并不常见,但在开发基础组件或进行依赖管理时却非常重要。通过本文介绍的埋点上报方式,我们可以以较低的侵入性获取到业务方使用的二方包及其版本号。在实施过程中,需要注意以下几点:首先,上报操作一定要使用异步方式,避免对业务造成堵塞;其次,如果使用自定义 Maven 插件进行上报,要注意类加载器的问题,因为 Maven 插件的类加载器是自定义类加载器;最后,在开发公共组件时,可以考虑添加HasFeatures功能,将公共组件的能力封装进去,方便后续的功能扩展和管理。

至于什么是HasFeatures,可以查看我之前的文章聊聊如何感知项目引入哪些功能特性

示例代码链接

本文涉及的示例代码已上传至 GitHub,你可以通过以下链接获取:https://github.com/lyb-geek/springboot-learning/tree/master/springboot-fetch-version
希望本文的内容能够对你有所帮助。如果你在实践过程中遇到任何问题,或者有更好的解决方案,欢迎在评论区留言交流。让我们一起在技术的道路上不断探索前行!


linyb极客之路
336 声望193 粉丝