阅读 1240

带你从0到1撸一个简单版的模块化服务系统

笔者知识和理解水平有限,如有错误之处,欢迎指出和讨论

从业务痛点说起

笔者参与的系统里面,需要接入大量的资源方的资源。对于每一个资源方,最开始的做法是,一个资源方对应着一个模块。所有模块都依赖于共同的SDK,SDK提供了获取资源各种数据的模板方法。资源模块要做的事情就是实现SDK的方法,并且往方法里面编写获取资源数据的逻辑。然后,在运行期,通过SPI机制动态去发现模块,获取相应的资源数据。过程如图所示:

首先是前端发送一个请求,会携带着资源方的ID,这样后端服务解析到资源方ID,根据这个ID去调用对应资源模块的对应方法获取对应的资源数据。

一开始,这样的实现方式运行良好,开发资源模块只需要在对应方法调用资源方接口,进行异构数据适配即可。

但是,随着接入的资源方变得越来越多,会发现存在以下问题:

  • 项目代码膨胀:一个三方资源对应着一个模块
  • 无法动态上线新的资源方资源:因为基于SPI,我们需要事前开发好资源模块,资源模块一块打包部署,才能在运行期使用资源
  • 联调成本高:由于都是远程进行接口联调,沟通效率低导致联调成本高
  • 重复工作:接入资源方资源工作其实都是重复的,无非就是调用三方资源接口,适配数据

其中项目代码膨胀、无法动态上线新的资源方资源、联调成本高是最核心的问题。

那么,如何来解决这些问题呢?

对于项目代码膨胀问题,解决思路是拆离资源模块为独立的工程,主项目工程不依赖于资源模块。联调成本高问题,我们在思考是否可以让资源提供商基于我们提供的SDK为我们编写模块,最后按照我们约定的格式打包给到我们。这样,就会省去熟悉资源方数据结构时间、降低联调接口的成本,对大家都是有好处。重复工作问题,我们只能做到尽可能减少重复的工作。在我们预定好的SDK前提下,我们是否可以让有额外开发时间的小伙伴帮忙开发从而加快开发进度呢?

实际上,使用模块化开发就可以解决这些问题。具体如何使用模块化开发解决上述问题,各位看官且稍安勿躁,且听我慢慢道来。

模块化开发的三种方式

相信大家对模块化开发都不陌生,从前端的seajs、requirejs到后端的JarsLink、OSGI 。都是一套模块化开发的解决方案的开源框架。

实际上,我们平时开发后端服务的时候,也在使用了模块化开发 —— 基于 maven 多模块。

对于后端工程来说,本文以Java maven工程来举例,模块化开发我们时时刻刻都在接触。各位看官且看下面三张图:

基于 maven 多模块子工程的模块化

在开发期,我们使用不同的模块开发不同的功能,在编译期,所有的模块都会统一打包。在运行期,由相同的类加载器来加载各个模块的jar包进来。由于使用了相同的类加载器,所以所有的模块代码都在同一个类路径下。

基于Spring Context 隔离的模块化

与基于 maven 多模块子工程开发不同的是,基于 Spring Context 隔离的模块化开发,在运行期,我们可以指定不同的 Spring Context 加载不同的模块jar包,来达到运行期模块隔离的目标。同样,也是使用了相同的类加载器加载类,所以所有模块代码还是在同一个类路径下。

基于类加载器隔离的模块化

基于类加载器隔离的模块化开发,和上面两种不同的是,在运行期,不同的模块jar包由不同的类加载器加载。我们知道,判断一个类是否相等根据是:

  • 加载类的类加载器相同
  • 类的全限定名相同

只有同时满足上述两个条件,两个类才是相等的。

基于类的加载器隔离的模块化开发最本质的思想,笔者认为,就是上述判断类是否相等的条件。在运行期,由不同的类加载不同的模块的jar包,我们知道,这样即使不同模块类的全限定名是一样的,那么它们也是不相等的。

三种模块化开发方式的特点比较

下面列出几点笔者认为较核心的特点:

开发方式 优点 缺点
基于 maven 多子模块 通过 maven 管理依赖,部署打包很方便 运行期模块之间无法进行隔离,如果出现不同模块有相同的类,或者类的版本不兼容,那么在运行期是会报错的;无法动态添加资源模块;
基于 Spring Context 通过 maven 管理依赖,部署打包很方便,基于Spring集成实现简单 和Spring框架耦合
基于类加载器 运行期模块做到完全隔离;不和任何框架耦合,非常轻量级 模块jar包的管理复杂度会变高

通过上面几点分析,基于类加载器隔离的模块化开发方式是最适合我们现有的业务需求的,可以做到隔离模块、动态管理模块。

由于各个模块之间不需要通信,都是完全独立的,Spring Context 的优点在我们业务中体现不出来,并且还耦合了Spring框架,反而会比基于类加载器隔离的方式会显得重一点。

从0到1开始撸一个简单版的模块化开发系统

我们的目标是,可以动态加载模块jar包,动态管理(安装模块jar包、启动模块、暂停模块、下线模块)。所以,我们首要解决的也是最核心的问题是如何去动态加载资源模块的jar包,并且不同资源模块jar包的加载互不影响。 这就涉及到类的加载机制。下面,我们先从Java的类加载机制说起。

从类加载机制说起

我们知道,JVM在加载一个类的时候,默认是遵循类的双亲委派模型的。具体什么是类的双亲委派模型,以及类的加载机制不在这篇文章详细分析范围。还不熟悉的看官们可以某度或者某歌,即可搜到一大堆相关文章。

我们先来看一张关于类的双亲委派模型图

一般情况,我们不自定义类加载器来加载类的话,使用JDK默认的类加载器进行类的加载,其加载过程遵循双亲委派模型,如图:

如果我们有自定义类加载器,并且遵循双亲委派模型的话,那么我们的类加载是长这样的:

如果我们采用JDK默认的类的双亲委派机制去加载不同资源模块jar包的话,会出现下面问题的:

  • 不同资源模块jar包依赖了不同版本的相同依赖,比如一个依赖httpclient 3版本,一个依赖httpclient 4版本。如果三分jar不同版本不兼容的话,势必在运行期出现错误,常见的错误有NoClassDefError、MethodNotFound
  • 可能不同资源模块有相同的全限定名的类,这时候是有冲突的
  • 不同资源模块的jar包难以独立管理,更无从独立启动、暂停、下线等操作

所以,应该如何解决呢?相信有经验的看官内心已经有了自己的答案。嘘.....各位看官按耐住寄己,先假装不知道,再让笔者继续吹吹水。

思考过程远比问题的最终解决方案要重要的多。下面,就让笔者道来自己不成熟的思考过程。希望对各位看官能有所启发,实乃寒生之大幸!

对于如何去解决在类的双亲委派模型下导致的问题,我们可以采用类比的思想。对Tomcat了解的看官们一定知道,在一个Tomcat中,我们可以部署多个web项目。那么,Tomcat在启动的时候是如何加载这些不同的项目的呢?是如何进行项目的隔离的呢?是如何处理项目共同依赖的呢?仔细想一想,如果把我们的主项目当作一个Tomcat,那么我们一个个资源模块jar包不就是相当于Tomcat里面的一个个项目的war包吗?Tomcat解决这些问题的姿势不正是我们可以借鉴(抄袭)的吗?

下面,我们先来用一张图解释Tomcat类加载机制,是如何打破双亲委派模型,以及如何对不同的项目进行保护。

灵感来源于Tomcat的类加载机制

在Tomcat中,自定义了Shared ClassLoader、Catalina ClassLoader、Common ClassLoader(默认下,这三个类加载器都是Common ClassLoader),来加载指定目录下的jar包和class文件。一方面是因为Tomcat启动需要引入一些jar包,而这些jar包是所有项目共同的依赖,所以使用统一的类加载器来加载。Web App ClassLoader对应着是加载web项目的类加载器。不同的web项目会有不同的Web App ClassLoader,但是它们都会共享 Common ClassLoader 加载的jar包和class文件。其实,这里是做了一个优化,有些共同的依赖没有必要每一个Web App ClassLoader都加载一遍,这样会导致jvm内存中的方法区(jdk8是Meta Space)占用内存过大。

我们不细究Tomcat的类加载机制,到那时从Tomcat的类加载机制大体来看,其实有两个原则:

  • 确定的共同依赖使用相同的类加载器加载,这样加快Tomcat启动速度,也节约了内存空间
  • 每一个web项目都是一个独立的类加载器加载,这样会保证不同web项目相同依赖不会出现版本兼容问题

用一张图看懂模块化服务系统类加载机制

上面和各位看官简单说了Tomcat的类加载机制,其实思想是完全看可以借鉴过来的。我们分析一下,关于资源模块在开发过程中的现状:

  • 所有资源模块都会依赖于相同的SDK包
  • 不同的资源模块可自由依赖的三方jar包
  • 不同的资源模块可自由命名类文件、创建包

所以,参考Tomcat的类加载机制,笔者在自己的模块化系统里面实现了自己的类加载机制,具体做法:

  • 针对每一个资源模块jar包,都会自定义一个类加载器来加载。所有的类加载器都有共同的父类加载器,在笔者实现的系统中是 Application ClassLoader。
  • 每一个资源模块共同依赖的SDK包由模块的自定义类加载器的父类加载器加载。
  • 自定义的模块类加载器不遵循双亲委派模型,具体规则是:读取配置,获取遵循双亲委派模型的class文件。在加载类的时候,如果该类不在配置文件里面,则由模块自定义类加载器加载,如果在,就先由父类加载器加载,完全遵循双亲委派模型。

用一张流程图解释如下:

好了,具体实现思路已经给各位看官说完了,接下来,就是 show you guys the code 。

开发各模块共同依赖的SDK

如图,我们定义一个IDemo接口,这个接口为我们提供sayHello服务:

IDemo代码:

package com.modularization.sdk

interface IDemo {
    fun sayHello(): String
}
复制代码

开发模块加载系统

我们采用的是基于类加载器方式来实现模块化开发的,最核心的代码就是实现自定义的类加载器实现。

核心代码不多,也就是编写一个自定义类加载器,在适当的时机打破双亲委派模型即可。

下面只给出自定义类加载器加载的代码,其它代码请下载文末的项目代码来阅读。

核心代码实现:

class ModuleClassLoader : URLClassLoader {

    private var excludePackages: List<String> = mutableListOf()

    constructor(urls: Array<out URL>?, excludePackages: List<String>) : super(urls) {
        this.excludePackages = excludePackages
    }

    override fun loadClass(name: String?): Class<*> {
        // 需要遵循双亲委派模型的类的加载先交给父类加载
        if (isExcludePackages(name)) return super.loadClass(name)
        synchronized(getClassLoadingLock(name)) {
            // 代码执行到这里,说明这个类是需要打破双亲委派模型的,先由子类的类加载器加载,加载不到再丢给父类加载
            // 从已加载的缓存中寻找类
            var c = findLoadedClass(name)
            if (c == null) {
                // c == null 表示子类类加载器还没有加载过此类
                try {
                    // 寻找类
                    c = findClass(name)
                } catch (e: Exception) {
                    // 因为如果类加载器找不到类会抛出一个异常,所以这里应该捕获异常,但是捕获了异常后什么都不用干
                    // do nothing
                }
            }
            // c != null 表示子类类加载器加载到类,直接返回
            if (c != null) return c
            
            // 执行到这里,说明子类类加载器加载不到类,交给父类去加载
            return super.loadClass(name)
        }
    }

    /**
     * 配置哪些包下面的类的加载不打破双亲委派模型
     */
    private fun isExcludePackages(className: String?): Boolean {
        if (className.isNullOrEmpty()) return false
        excludePackages.forEach {
            if (className!!.startsWith(it)) {
                return true
            }
        }
        return false
    }
}
复制代码

剩余代码可以下载文末给出的工程代码查看。

定义模块约束

我们约定模块开发,需要配置模块唯一ID、模块版本等。配置文件统一命名为module.properties,位置统一放在src/main/resources/META-INF下,当然我们可以不采用这种约定配置,也可以使用其它的,具体看业务要求,如图:

配置信息详细看下面:

# 模块唯一ID
module.appCode=fz
# 模块版本
module.jarVersion=1.0
# 模块提供服务的类路径,当然,我们可以根据约定配置更多服务的类路径
module.demo.location=com.modularization.fz.FzDemo
复制代码

定义模块打包规则

每一个模块都是maven工程,引入下面pom.xml配置:

<build>
        <plugins>
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                        </manifest>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
复制代码

开发资源模块

在这篇文章,笔者给看官们准备三个资源模块,它们都实现IDemo接口,提供自己的数据。为了测试方便,笔者直接就在一个 maven 项目里面编写了资源模块代码,实际上我们应该采用独立的工程来实现的。

模块工程目录如图: 图中有 fz、tm、tw三个资源。

fz资源工程目录如图所示:

其它的资源模块,tm 和 tw 的实现和工程目录和 fz 一样,只不过是 sayHello 方法返回值不一样而已。

这样,我们就模拟开发了三个资源模块

简单测试验证

编译打包 tm、tw、fz 资源模块代码,获取jar包地址。 编写测试类,如下:

测试代码

package com.modularization.server

import com.modularization.sdk.IDemo
import com.modularization.server.core.ModuleClassLoader
import com.modularization.server.core.exception.ModuleNotFoundException
import com.modularization.server.core.util.ResourceUtils
import com.modularization.server.proxy.DemoDynamicProxy
import org.junit.Before
import org.junit.Test
import java.net.URL

/**
 * @author wu
 * @since 2019/8/5
 */
class DemoTest {

    val moduleCache = HashMap<String, ModuleJar>()

    @Before
    fun init() {
        val excludePackages = arrayListOf("java", "com.modularization.sdk")
        val moduleJarUrls = arrayListOf(
                "file:/Users/admin/Desktop/work/modularization-demo/modularization-fz/target/modularization-fz-1.0-SNAPSHOT.jar",
                "file:/Users/admin/Desktop/work/modularization-demo/modularization-tm/target/modularization-tm-1.0-SNAPSHOT.jar",
                "file:/Users/admin/Desktop/work/modularization-demo/modularization-tw/target/modularization-tw-1.0-SNAPSHOT.jar")
        moduleJarUrls.forEach {
            val moduleJarUrl = URL(it)
            val moduleClassLoader = ModuleClassLoader(arrayOf(moduleJarUrl), excludePackages)
            val properties = ResourceUtils.getProperties(moduleClassLoader.getResourceAsStream("META-INF/module.properties"))
            val moduleJar = ModuleJar()
            moduleJar.appCode = properties.getProperty(ModuleSettingConstants.APP_CODE) ?: throw ModuleNotFoundException("${moduleJarUrl.path} ${ModuleSettingConstants.APP_CODE}没有配置")
            moduleJar.jarVersion = properties.getProperty(ModuleSettingConstants.JAR_VERSION) ?: throw ModuleNotFoundException("${moduleJarUrl.path} ${ModuleSettingConstants.JAR_VERSION}没有配置")
            moduleJar.moduleDemoLocation = properties.getProperty(ModuleSettingConstants.MODULE_DEMO_LOCATION) ?: ""
            moduleJar.moduleClassLoader = moduleClassLoader
            moduleJar.moduleJarUrl = moduleJarUrl.path
            // 模拟缓存加载完成的模块
            moduleCache[moduleJar.appCode] = moduleJar
            println("模块:${moduleJar.appCode}启动完成")
        }
    }

    @Test
    fun sayHelloTest() {

        val fzModuleJar = moduleCache["fz"]
        val twModuleJar = moduleCache["tw"]
        val tmModuleJar = moduleCache["tm"]

        val fzDemo = DemoDynamicProxy().bind(fzModuleJar!!.moduleClassLoader!!
                .loadClass(fzModuleJar.moduleDemoLocation).newInstance()) as IDemo

        val twDemo = DemoDynamicProxy().bind(twModuleJar!!.moduleClassLoader!!
                .loadClass(twModuleJar.moduleDemoLocation).newInstance()) as IDemo

        val tmDemo = DemoDynamicProxy().bind(tmModuleJar!!.moduleClassLoader!!
                .loadClass(tmModuleJar.moduleDemoLocation).newInstance()) as IDemo

        println(tmDemo.sayHello())
        println(twDemo.sayHello())
        println(fzDemo.sayHello())
    }
}
复制代码

输出结果

模块:fz启动完成
模块:tm启动完成
模块:tw启动完成
00:44:48.667 [main] INFO com.modularization.server.proxy.DemoDynamicProxy - 执行: class => com.modularization.tm.TmDemo, method => sayHello, 参数 => null, 耗时 => 0ms 
 执行结果 =>  I am Tm 
 I am Tm 
00:44:48.678 [main] INFO com.modularization.server.proxy.DemoDynamicProxy - 执行: class => com.modularization.tw.TwDemo, method => sayHello, 参数 => null, 耗时 => 0ms 
 执行结果 =>  I am Tw 
 I am Tw 
00:44:48.678 [main] INFO com.modularization.server.proxy.DemoDynamicProxy - 执行: class => com.modularization.fz.FzDemo, method => sayHello, 参数 => null, 耗时 => 0ms 
 执行结果 =>  I am Fz 
 I am Fz 
复制代码

总结

到此,和各位客观把笔者参与的模块化开发系统的最主要的东西分析了一遍,实现了一个简单版本的,实际上,在生产环境上使用,还不能直接这样就完事,我们还应该考虑SDK版本的上下兼容、模块开发的JDK版本兼容、模块的缓存以及生命周期的管理等等。但是,只要大家能弄明白这篇文章讲的全部内容,根据自家业务需求,自己实现一个可用于生产环境的模块化服务系统是完全没有问题的。

源码地址

笔者github过于简陋,源码就通过百度云链接下载吧。各位看官莫笑。

资源地址:链接:pan.baidu.com/s/1rFqY7uot… 密码:ztfn

示例代码是使用kotlin开发的,各位看官完全可以对着源码使用java写一遍。