Dubbo 3.0 规模化落地基石 - 社区持续集成机制解析

作者 | 熊聘


背景


Dubbo 3.0 在阿里巴巴各条业务线包括淘宝、考拉、饿了么、钉钉、达摩院等已全量或开始分批上线,社区企业如小米、工商银行、平安健康等也纷纷引入了 3.0 版本的新特性。支持如此大规模的体量的业务平稳运行,对 Dubbo 3.0 的稳定性有很高的要求,尤其是考虑到社区极高的活跃度,这个挑战就更大了。目前社区有超过 600 位 contributor,近一年以来每天有大约 60 个 commits,陆续支持了 Triple 协议、多实例等功能,这些功能都会涉及到大量的代码变更。每一次提交代码的质量对 Dubbo 3.0 版本的质量、稳定性、可靠性等方面有着至关重要的影响。


现状


Dubbo 保证提交代码的质量主要体现在以下几个方面:

  • dubbo 代码仓库中的单测
  • dubbo-samples 中的集成测试
  • dubbo-benchmark 中的性能测试
  • code review 机制


其中,dubbo 的单测主要针对 Dubbo 代码单元的独立测试,侧重点在功能、分支覆盖等方面,是最基本的测试。dubbo-samples 主要是对 Dubbo 中常用的功能组合和场景进行测试,同时也会对齐 2.7.x 和 3.0 版本之间的功能。dubbo-benchmark 主要是在每次有重大版本升级变更的时候对性能进行压测。code review 机制是在每一次提交的代码都通过所有的单测和集成测试以后由社区的 contributor 和 committer 对提交的 pr 进行 review,当大家的意见不一致会在社区内进行讨论最终达成一致。

问题


集成测试和 benchmark 测试都是独立的代码仓库,进过 Dubbo 社区多年的演进,相对来说变更较少已经比较稳定了。code review 机制主要是通过人的主观判断来对代码的质量进行评估,Dubbo 社区一直在不断吸纳更多优秀的 contributor 加入,理论上这方面的质量是在不断提高的。相对而言,dubbo 的单测集中在 100 多个 module 中,是变更最多、最频繁的,也是最容易出问题的地方。


目前 dubbo 的单测存在以下几个问题:


1、耗时长


dubbo 的单测会在 Ubuntu 和 Windows 上分别对 JDK8 和 11 进行测试,其中 Ubuntu 和 Windows 上运行单测的超时时间分别是 40 分钟和 50 分钟,但是我们发现经常会出现超时的情况。


2、多注册中心场景无法覆盖


dubbo 支持多注册中心,但是在单测中很难覆盖到与多注册中心相关的逻辑。dubbo 支持的注册中心包含 zookeeper、nacos、apollo 等,多种不同注册中心的混合场景更加难以覆盖。


分析


通过对单测运行日志进行抽查和分析我们发现,dubbo 单测的耗时主要集中在与 zookeeper 相关的 module 中。

Testcase Time elapsed(s)
org.apache.dubbo.rpc.protocol.dubbo.ArgumentCallbackTest 76.013
org.apache.dubbo.config.spring.ConfigTest 61.33
org.apache.dubbo.config.spring.schema.DubboNamespaceHandlerTest 50.437
org.apache.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessorTest 44.678
org.apache.dubbo.rpc.cluster.support.AbstractClusterInvokerTest 29.954
org.apache.dubbo.remoting.zookeeper.curator.CuratorZookeeperClientTest 25.847
org.apache.dubbo.config.spring.beans.factory.annotation.MethodConfigCallbackTest 25.635
org.apache.dubbo.registry.client.event.listener.ServiceInstancesChangedListenerTest 24.866
org.apache.dubbo.remoting.zookeeper.curator5.Curator5ZookeeperClientTest 19.664


导致这一问题的最主要原因是因为我们在写单测的时候通常会使用 zookeeper 作为注册中心和元数据中心,而在运行单测的过程中要对 zookeeper 进行频繁的 start 和 stop 操作。每一个单测希望彼此之间互不干扰,都采用了独立的 zookeeper 作为注册中心和元数据中心。当将单测并行运行时又会出现 zookeeper 端口冲突的问题。


现在需要解决的问题是:如何处理 dubbo 单测与注册中心和元数据中心的依赖问题。

解决方案


这个问题看似比较简单,但是在实践过程中却发现比想象的要复杂的多,分别对各种解决方案进行了尝试:


1、TestingServer


TestingServer 是 curator-test 包中提供的专门用来 mock zookeeper 的工具类,目前 dubbo 单测中大量使用了 TestingServer。对与多注册中心的场景是通过同时启动两个 TestingServer 对外暴露不同的端口的方式来实现的。代码如下:


class ZookeeperRegistryCenter extends AbstractRegistryCenter {

    /**
     * Initialize the default registry center.
     */
    public ZookeeperRegistryCenter(int... ports) {
        this.ports = ports;
        this.instanceSpecs = new ArrayList<>(this.ports.length);
        this.zookeeperServers = new ArrayList<>(this.ports.length);
    }

    private static final Logger logger = LoggerFactory.getLogger(ZookeeperRegistryCenter.class);

    /**
     * The type of the registry center.
     */
    private static final String DEFAULT_REGISTRY_CENTER_TYPE = "zookeeper";

    private int[] ports;

    private List<InstanceSpec> instanceSpecs;

    private List<TestingServer> zookeeperServers;

    private AtomicBoolean started = new AtomicBoolean(false);

    /**
     * start zookeeper instances.
     */
    @Override
    public void startup() throws RpcException {
        try {
            if (started.compareAndSet(false, true)) {
                logger.info("The ZookeeperRegistryCenter is starting...");
                for (int port : this.ports) {
                    InstanceSpec instanceSpec = this.createInstanceSpec(port);
                    this.instanceSpecs.add(instanceSpec);
                    this.zookeeperServers.add(new TestingServer(instanceSpec, true));
                }
                logger.info("The ZookeeperRegistryCenter is started successfully");
            }
        } catch (Exception exception) {
            started.set(false);
            throw new RpcException("Failed to initialize ZookeeperRegistryCenter instance", exception);
        }
    }

    /**
     * destroy the zookeeper instances.
     */
    @Override
    public void shutdown() throws RpcException {
        logger.info("The ZookeeperRegistryCenter is stopping...");
        List<RpcException> exceptions = new ArrayList<>(this.zookeeperServers.size());
        for (TestingServer testingServer : this.zookeeperServers) {
            try {
                testingServer.close();
                logger.info(String.format("The zookeeper instance of %s is shutdown successfully",
                    testingServer.getConnectString()));
            } catch (IOException exception) {
                RpcException rpcException = new RpcException(String.format("Failed to close zookeeper instance of %s",
                    testingServer.getConnectString()),
                    exception);
                exceptions.add(rpcException);
                logger.error(rpcException);
            }
        }
        this.instanceSpecs.clear();
        this.zookeeperServers.clear();
        if (!exceptions.isEmpty()) {
            logger.info("The ZookeeperRegistryCenter failed to close.");
            // throw any one of exceptions
            throw exceptions.get(0);
        } else {
            logger.info("The ZookeeperRegistryCenter close successfully.");
        }
    }
}


1、优点

  • 实现简单
  • 启动和停止 zookeeper 速度快


2、缺点

  • 无法模拟真实的 zookeeper
  • 无法测试 Curator 和 zookeeper 的版本兼容性问题
  • nacos 和 apollo 没有提供这种 embedded 的方式,无法适用于所有类型的注册中心


2、Github Actions


GitHub Actions 是 GitHub 上的 CI/CD,所有提交代码的单测都会在这上面运行。通过各种 action 来制定 workflow,可以在 workflow 中可以初始化运行的环境,所以想在初始化环境的时候将 zookeeper(或者 nacos 和 apollo)部署起来,然后再运行单测。当尝试这种方案的时候,我们进行了大量的尝试,也踩了不少的坑。


1、docker

zookeeper、nacos 和 apollo 的镜像都有官方的版本,如果我们通过 docker 来拉起这些镜像,这个问题就迎刃而解了。


通过查看 GitHub Actions 的官网发现 GitHub 提供的环境是内置了 docker 环境的,所以这条道路貌似比较容易,但是当 yml 文件写好以后发现在 Ubuntu 上是可以正常运行的,在 Windows上 会报错,错误信息如下:


ERROR: no matching manifest for windows/amd64 in the manifest list entries


通过研究发现这个问题主要是由于 Linux 上的镜像在 Windows 上无法识别,需要 Switch to Linux Container,这 GitHub Actions 中只能通过命令来实现,具体命令为:"C:\Program Files\Docker\Docker\DockerCli.exe" -SwitchLinuxEngine,但是执行这条命令后需要重启一下 docker。如果是在自己的 Windows 机器上这么操作是可行的,但是在 Github Actions 中操作显然是行不通的。


当然,还有另一个思路:那就是 build 一个基于 Windows 版本的镜像。很快我们也尝试了这种方案,发现 Windows 上创建 zookeeper 的镜像与 Windows 系统的版本密切相关,并不适用于所有版本的 Windows 操作系统。同时,创建镜像一般都是采用 Powershell 脚本,这种脚本相比 shell 脚本来说使用的要少很多,必然会给后面的维护带来了很多的隐患。在编写 GitHub workflows 是还要考虑两个不同分支的问题。


最后,我们通过修改现有的镜像生成脚本创建了基于 Windows 系统的 zookeeper 镜像,这个问题貌似总算是解决了。不幸的是,当我们运行单测的时候发现,在 Ubuntu 环境下拉起 zookeeper 镜像的耗时大约是 20 秒,而在 Windows 环境下需要 2 分钟左右,完全不是一个量级,这是我们完全不能接受的。通过分析我们发现,zookeeper 在 Ubuntu 上的镜像大小为 100MB 以内,而在 Windows 上却是 2.7G 左右,在 Windows 上拉取镜像的耗时最多,同时 zookeeper 在 Ubuntu 上的启动时间要比 Windows 快。


2、shell 脚本

docker 的方式既然行不通,那是不是可以尝试一下使用脚本部署的方式呢?考虑到 Windows 和 Linux 环境上脚本的差异,事先查了一下 Github Actions 上看看在不通操作系统上对脚本支持的情况:

Dubbo 3.0 规模化落地基石 - 社区持续集成机制解析

我们可以看到在 Windows 上对 bash shell 的支持仅仅是 Git for Windows 附带的 bash shell,往往一些复杂一点的 bash shell 命令就不支持了。由于我们使用的都是比较常见的 bash shell 命令,所以想试一试,于是就开始撸起了 shell 脚本。


ZOOKEEPER_BINARY_URL="https://archive.apache.org/dist/zookeeper/zookeeper-$ZK_VERSION/apache-zookeeper-$ZK_VERSION-bin.tar.gz"
case $1 instart)    echo "Setup zookeeper instances...."    # download zookeeper instances    mkdir -p $ZK_2181_DIR $ZK_2182_DIR $LOG_DIR/2181 $LOG_DIR/2182    # download zookeeper archive binary if necessary    if [ ! -f "$DIR/zookeeper/apache-zookeeper-$ZK_VERSION-bin.tar.gz" ]; then      wget -P $DIR/zookeeper -c $ZOOKEEPER_BINARY_URL    fi    # setup zookeeper with 2182    tar -zxf $DIR/zookeeper/apache-zookeeper-$ZK_VERSION-bin.tar.gz -C $ZK_2181_DIR    cp $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo_sample.cfg $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    # mac OS    if [ "$(uname)" == "Darwin" ]    then      sed -i "_bak" "s#^clientPort=.*#clientPort=2181#g" $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg      sed -i "_bak" "s#^dataDir=.*#dataDir=$LOG_DIR/2181#g" $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    else      sed -i "s#^clientPort=.*#clientPort=2181#g" $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg      sed -i "s#^dataDir=.*#dataDir=$LOG_DIR/2181#g" $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    fi    echo "admin.serverPort=8081" >> $ZK_2181_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    # setup zookeeper with 2182    tar -zxf $DIR/zookeeper/apache-zookeeper-$ZK_VERSION-bin.tar.gz -C $ZK_2182_DIR    cp $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo_sample.cfg $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    # mac OS    if [ "$(uname)" == "Darwin" ]    then      sed -i "_bak" "s#^clientPort=.*#clientPort=2182#g" $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg      sed -i "_bak" "s#^dataDir=.*#dataDir=$LOG_DIR/2182#g" $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    else      sed -i "s#^clientPort=.*#clientPort=2182#g" $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg      sed -i "s#^dataDir=.*#dataDir=$LOG_DIR/2182#g" $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    fi    echo "admin.serverPort=8082" >> $ZK_2182_DIR/apache-zookeeper-$ZK_VERSION-bin/conf/zoo.cfg    echo "Start zookeeper instances...."    $ZK_SERVER_2181 start    $ZK_SERVER_2182 start    ;;stop)    echo "Stop zookeeper instances...."    $ZK_SERVER_2181 stop    $ZK_SERVER_2182 stop    rm -rf $DIR/zookeeper    ;;status)    echo "Get status of all zookeeper instances...."    $ZK_SERVER_2181 status    $ZK_SERVER_2182 status    ;;reset)    echo "Reset all zookeeper instances"    $ZK_CLIENT_2181 -timeout 1000  -server 127.0.0.1:2181 deleteall /dubbo    $ZK_CLIENT_2182 -timeout 1000  -server 127.0.0.1:2182 deleteall /dubbo    ;;*)    echo "./zkCmd.sh start|stop|status|reset"    exit 1    ;;esac


脚本写完以后发现,在 Mac 和 Linux 系统上运行都是正常的,但是这个脚本在 Windows 上的 git-bash 却是行不通的。主要原因是对 wget、sed 命令不支持。wget 通过 curl 命令来替换可以实现下载的功能,但是 sed 命令确实很难替换。


最后,在赫炎大佬的提示下,在 Github Workflows 中安装了 msys2 插件,msys2 可以建立一个简单易用的统一环境,也就是说可以在 Windows 上运行 bash shell,经过验证发现这一方案确实可行。


steps:      - name: "Set up msys2 if necessary"        if: ${{ startsWith( matrix.os, 'windows') }}        uses: msys2/setup-msys2@v2        with:          release: false  # support cache, see https://github.com/msys2/setup-msys2#context


优点

  • 使用全局 zookeeper 来作为注册中心和元数据中心,大大降低了注册中心的启动和销毁次数
  • 代码无侵入,写单测的时候不用维护注册中心的生命周期
  • 更接近生产环境
  • 可以支持多版本、不同类型的注册中心混用


缺点

  • 代码在本地运行时需要手动启动注册中心


一切看似很完美,但是如果在本地运行单测的时候强制要求开发者在本机启动注册中心,这貌似违背了单测的原则。为了解决这一问题,我们又尝试了新的解决方案。


3、TestExecutionListener


TestExecutionListener 是 Junit5 在运行单测的过程中对外提供的一个接口,可以通过这个接口获取所有单测启动、所有单测全部结束、单个单测启动、单个单测结束等相关的事件。


/**
 * Register a concrete implementation of this interface with a {@link Launcher}
 * to be notified of events that occur during test execution.
 *
 * <p>All methods in this interface have empty <em>default</em> implementations.
 * Concrete implementations may therefore override one or more of these methods
 * to be notified of the selected events.
 *
 * <p>JUnit provides two example implementations.
 *
 * <ul>
 * <li>{@link org.junit.platform.launcher.listeners.LoggingListener}</li>
 * <li>{@link org.junit.platform.launcher.listeners.SummaryGeneratingListener}</li>
 * </ul>
 *
 * <p>Contrary to JUnit 4, {@linkplain org.junit.platform.engine.TestEngine test engines}
 * are supposed to report events not only for {@linkplain TestIdentifier identifiers}
 * that represent executable leaves in the {@linkplain TestPlan test plan} but also
 * for all intermediate containers. However, while both the JUnit Vintage and JUnit
 * Jupiter engines comply with this contract, there is no way to guarantee this for
 * third-party engines.
 */
public interface TestExecutionListener {

    /**
     * Called when the execution of the {@link TestPlan} has started,
     * <em>before</em> any test has been executed.
     *
     * @param testPlan describes the tree of tests about to be executed
     */
    default void testPlanExecutionStarted(TestPlan testPlan) {
    }

    /**
     * Called when the execution of the {@link TestPlan} has finished,
     * <em>after</em> all tests have been executed.
     *
     * @param testPlan describes the tree of tests that have been executed
     */
    default void testPlanExecutionFinished(TestPlan testPlan) {
    }

    /**
     * Called when the execution of a leaf or subtree of the {@link TestPlan}
     * has finished, regardless of the outcome.
     *
     * <p>The {@link TestIdentifier} may represent a test or a container.
     *
     * <p>This method will only be called if the test or container has not
     * been {@linkplain #executionSkipped skipped}.
     *
     * <p>This method will be called for a container {@code TestIdentifier}
     * <em>after</em> all of its children have been
     * {@linkplain #executionSkipped skipped} or have
     * {@linkplain #executionFinished finished}.
     *
     * <p>The {@link TestExecutionResult} describes the result of the execution
     * for the supplied {@code TestIdentifier}. The result does not include or
     * aggregate the results of its children. For example, a container with a
     * failing test will be reported as {@link Status#SUCCESSFUL SUCCESSFUL} even
     * if one or more of its children are reported as {@link Status#FAILED FAILED}.
     *
     * @param testIdentifier the identifier of the finished test or container
     * @param testExecutionResult the (unaggregated) result of the execution for
     * the supplied {@code TestIdentifier}
     *
     * @see TestExecutionResult
     */
    default void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
    }

}


我们通过实现 TestExecutionListener#testPlanExecutionStarted 的接口启动注册中心,TestExecutionListener#testPlanExecutionFinished 接口用来销毁注册中心,在每个单测运行结束以后,通过 TestExecutionListener#executionFinished 的接口来重置注册中心中的数据。通过这种方式就可以解决开发者在本地运行单测的时候需要手动启动注册中心的问题了。但是实现起来却是并不是那么简单。


我们将注册中心的生命周期抽象成以下几个部分:

  • 初始化:这个过程包括下载 zookeeper 的 jar 包、解压 jar 包和修改配置文件 3 个过程
  • 启动:启动多个注册中心
  • 重置:每次单测运行结束后重置注册中心中的数据
  • 销毁:关闭多个注册中心


1、初始化下载文件、解压和修改配置这几个过程看似简单,但是想实现的比较优雅却还是要花一番工夫的。需要考虑异步下载、重复下载、重复解压、并行解压、无网络或者网络不稳定情况下的解决方案、端口占用等一系列问题。


2、启动启动 zookeeper 在 Windows 上是通过 zkServer.cmd 脚本,而在 Linux 上是通过 zkServer.sh 脚本,这两个脚本实现的方式是截然不同的。同时,我们希望注册中心是运行在单独进程中,这就需要使用 ProcessBuilder 的方式来启动 zookeeper。


在 Linux 上相对简单一点,代码如下:


protected Process doProcess(ZookeeperContext context, int clientPort) throws DubboTestException {
    logger.info(String.format("The zookeeper-%d is starting...", clientPort));
    List<String> commands = new ArrayList<>();
    Path zookeeperBin = Paths.get(context.getSourceFile().getParent().toString(),
        String.valueOf(clientPort),
        context.getUnpackedDirectory(),
        "bin");
    commands.add(Paths.get(zookeeperBin.toString(), "zkServer.sh")
        .toAbsolutePath().toString());
    commands.add("start");
    commands.add(Paths.get(zookeeperBin.getParent().toString(),
        "conf",
        "zoo.cfg").toAbsolutePath().toString());
    try {
        return new ProcessBuilder().directory(zookeeperBin.getParent().toFile())
            .command(commands).inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE).start();
    } catch (IOException e) {
        throw new DubboTestException(String.format("Failed to start zookeeper-%d", clientPort), e);
    }
}


通过检测 Process 中输出的日志来的关键词来判断是否启动成功。


在 Windows 上会比较复杂一点,因为 Windows 上运行 zkServer.cmd 脚本的时候会弹出一个黑框,需要采用后台的方式来运行。同时,当这个黑框关闭以后 zookeeper 就被关闭了。同样尝试使用 ProcessBuilder 的方式来实现发现遇到不少问题,特别是通过 start /b cmd.exe /c zkServer.cmd 命令来启动 zookeeper。最后通过 commons-exec 包中的 Executor 来实现了在 Windows 环境启动 zookeeper。


protected void doProcess(ZookeeperWindowsContext context) throws DubboTestException {
    ......
    for (int clientPort : context.getClientPorts()) {
        logger.info(String.format("The zookeeper-%d is starting...", clientPort));
        Path zookeeperBin = Paths.get(context.getSourceFile().getParent().toString(),
            String.valueOf(clientPort),
            context.getUnpackedDirectory(),
            "bin");
        Executor executor = new DefaultExecutor();
        executor.setExitValues(null);
        executor.setWatchdog(context.getWatchdog());
        CommandLine cmdLine = new CommandLine("cmd.exe");
        cmdLine.addArgument("/c");
        cmdLine.addArgument(Paths.get(zookeeperBin.toString(), "zkServer.cmd")
            .toAbsolutePath().toString());
        context.getExecutorService().submit(() -> executor.execute(cmdLine));
    }
}


这里需要提醒的是,启动 Process 以后需要保存 pid,后面销毁的时候会用到。


3、重置这个功能是最简单的,因为现在注册中心已经启动了,只需要连接上注册中心删除注册上去的节点即可。


public void process(Context context) throws DubboTestException {
    ZookeeperContext zookeeperContext = (ZookeeperContext)context;
    for (int clientPort : zookeeperContext.getClientPorts()) {
        CuratorFramework client;
        try {
            CuratorFrameworkFactory.Builder builder = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:" + clientPort)
                .retryPolicy(new RetryNTimes(1, 1000));
            client = builder.build();
            client.start();
            boolean connected = client.blockUntilConnected(1000, TimeUnit.MILLISECONDS);
            if (!connected) {
                throw new IllegalStateException("zookeeper not connected");
            }
            // 删除dubbo节点
            client.delete().deletingChildrenIfNeeded().forPath("/dubbo");
        } catch (Exception e) {
            throw new DubboTestException(e.getMessage(), e);
        }
    }
}


4、销毁通过研究 zookeeper 的 zkServer.cmd 和 zkServer.sh 脚本发现,在执行 zkServer.sh stop 命令的时候其实是找到 zookeeper 的 pid 然后再 kill 掉。


stop)
  echo -n "Stopping zookeeper ... "
    if [ ! -f "$ZOOPIDFILE" ]
    then
      echo "no zookeeper to stop (could not find file $ZOOPIDFILE)"
    else
      $KILL $(cat "$ZOOPIDFILE")
      rm "$ZOOPIDFILE"
      sleep 1
      echo STOPPED
    fi
    exit 0


在 Linux 上相对简单一点,代码如下:


protected Process doProcess(ZookeeperContext context, int clientPort) throws DubboTestException {
    logger.info(String.format("The zookeeper-%d is stopping...", clientPort));
    List<String> commands = new ArrayList<>();
    Path zookeeperBin = Paths.get(context.getSourceFile().getParent().toString(),
        String.valueOf(clientPort),
        context.getUnpackedDirectory(),
        "bin");
    commands.add(Paths.get(zookeeperBin.toString(), "zkServer.sh")
        .toAbsolutePath().toString());
    commands.add("stop");
    try {
        return new ProcessBuilder().directory(zookeeperBin.getParent().toFile())
            .command(commands).inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE).start();
    } catch (IOException e) {
        throw new DubboTestException(String.format("Failed to stop zookeeper-%d", clientPort), e);
    }
}


但是在 Windows 上却有所不同,需要将之前记录的 pid 杀掉,代码如下:


protected void doProcess(ZookeeperWindowsContext context) throws DubboTestException {
    logger.info("All of zookeeper instances are stopping...");
    // find pid and save into global context.
    this.findPidProcessor.process(context);
    // kill pid of zookeeper instance if exists
    this.killPidProcessor.process(context);
    // destroy all resources
    context.destroy();
}


在这个过程中需要考虑重重复销毁的问题,所以在销毁之前会先查一下 pid 然后再销毁。查找 pid 是通过 netstat 命令实现的,代码如下:


private void findPid(ZookeeperWindowsContext context, int clientPort) {
    logger.info(String.format("Find the pid of the zookeeper with port %d", clientPort));
    Executor executor = new DefaultExecutor();
    executor.setExitValues(null);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    ByteArrayOutputStream ins = new ByteArrayOutputStream();
    ByteArrayInputStream in = new ByteArrayInputStream(ins.toByteArray());
    executor.setStreamHandler(new PumpStreamHandler(out, null, in));
    CommandLine cmdLine = new CommandLine("cmd.exe");
    cmdLine.addArgument("/c");
    cmdLine.addArgument("netstat -ano | findstr " + clientPort);
    try {
        executor.execute(cmdLine);
        String result = out.toString();
        logger.info(String.format("Find result: %s", result));
        if (StringUtils.isNotEmpty(result)) {
            String[] values = result.split("\\r\\n");
            // values sample:
            // Protocol Local address          Foreign address        Status          PID
            //   TCP    127.0.0.1:2182         127.0.0.1:56672        ESTABLISHED     4020
            //   TCP    127.0.0.1:56672        127.0.0.1:2182         ESTABLISHED     1980
            //   TCP    127.0.0.1:56692        127.0.0.1:2182         ESTABLISHED     1980
            //   TCP    127.0.0.1:56723        127.0.0.1:2182         ESTABLISHED     1980
            //   TCP    [::]:2182              [::]:0                 LISTENING       4020
            if (values != null && values.length > 0) {
                for (int i = 0; i < values.length; i++) {
                    List<String> segments = Arrays.stream(values[i].trim().split(" "))
                        .filter(str -> !"".equals(str))
                        .collect(Collectors.toList());
                    // segments sample:
                    // TCP
                    // 127.0.0.1:2182
                    // 127.0.0.1:56672
                    // ESTABLISHED
                    // 4020
                    if (segments != null && segments.size() == 5) {
                        if (this.check(segments.get(1), clientPort)) {
                            int pid = Integer.valueOf(segments.get(segments.size() - 1).trim());
                            context.register(clientPort, pid);
                            return;
                        }
                    }
                }
            }
        }
    } catch (IOException e) {
        throw new DubboTestException(String.format("Failed to find the PID of zookeeper with port %d", clientPort), e);
    }
}


通过 killtask 命令来销毁进程,代码如下:


protected void doProcess(ZookeeperWindowsContext context) throws DubboTestException {
    for (int clientPort : context.getClientPorts()) {
        Integer pid = context.getPid(clientPort);
        if (pid == null) {
            logger.info("There is no PID of zookeeper instance with the port " + clientPort);
            continue;
        }
        logger.info(String.format("Kill the pid %d of the zookeeper with port %d", pid, clientPort));
        Executor executor = new DefaultExecutor();
        executor.setExitValues(null);
        executor.setStreamHandler(new PumpStreamHandler(null, null, null));
        CommandLine cmdLine = new CommandLine("cmd.exe");
        cmdLine.addArgument("/c");
        cmdLine.addArgument("taskkill /PID " + pid + " -t -f");
        try {
            executor.execute(cmdLine);
            // clear pid
            context.removePid(clientPort);
         } catch (IOException e) {
            throw new DubboTestException(String.format("Failed to kill the pid %d of zookeeper with port %d", pid, clientPort), e);
        }
    }
}


至此,彻底解决了单测在本地运行时需要手动启动注册中心的问题了。


优点

  • 支持多种类型注册中心的混用。如果需要支持 nacos,只要按照这个方案针对 nacos 实现一遍即可。
  • 最大限度的模拟生产环境
  • 开发者使用无感
  • 代码无倾入


缺点

  • 依赖网络环境


针对无网络或者网络不稳定的情况,可以把下载好的 zookeeper 二进制文件 copy 放到指定的目录下即可。

总结


通过代码测试优化,Dubbo 3.0 的单测运行耗时在 Ubuntu 环境下从 40 分钟下降到 24 分钟,在 Windows 环境下从 50 分钟下降到 30 分钟左右,也很好的解决了多注册中心无法覆盖的问题。


提高 Dubbo 3.0 版本的质量、稳定性和可靠性一直是 Dubbo 社区契而不舍的目标。这一次的优化仅仅只是向前迈出了一小步,后续还会朝着这个方向不断改进,也希望有更多对 Dubbo 感兴趣的同学积极参与社区贡献!在此也非常感谢赫炎和河清两位大佬的指导!

Dubbo 社区刚刚发布了 3.0.5 版本,Go 语言的首个 3.0 正式版本也已发布。欢迎更多的 Dubbo 3 用户在此登记,以便能更好的与社区交流使用问题。

https://github.com/apache/dubbo/issues/9436


对 dubbogo感兴趣的同学,可加入钉钉群 23331795 进行交流。


作者介绍

熊聘,Apache Dubbo Committer,Github 账号:pinxiong微信公众号:技术交流小屋。关注 RPC、Service Mesh 和云原生等领域。现任职于携程国际事业部研发团队,负责市场营销、云原生等相关工作。

上一篇:开发一个完整的JavaScript组件


下一篇:js之RGB->转换为十六进制