Dubbo服务自动Web化之路

outside_default.png

outside_default.png

本文字数:6047字

预计阅读时间:40分钟


01

故障出现

事情起源于一次故障,2023年12月14日14点26分,大量Dubbo服务报出异常,无法链接zookeeper集群:

Session 0x0 for server dubboZk.xxx.com/10.x.x.x:2181, Closing socket connection. Attempting reconnect except it is a SessionExpiredException.

登录Zookeeper节点发现,集群整体处于不可用状态,且抛出如下异常:

2023-12-14 14:26:15,255 [myid:9] - WARN  [NIOWorkerThread-2:NIOServerCnxn@373] - Close of session 0x0
java.io.IOException: ZooKeeperServer not running
    at org.apache.zookeeper.server.NIOServerCnxn.readLength(NIOServerCnxn.java:544)
    at org.apache.zookeeper.server.NIOServerCnxn.doIO(NIOServerCnxn.java:332)
    at org.apache.zookeeper.server.NIOServerCnxnFactory$IOWorkRequest.doWork(NIOServerCnxnFactory.java:522)
    at org.apache.zookeeper.server.WorkerService$ScheduledWorkRequest.run(WorkerService.java:154)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)

随即将Zookeeper节点一一重启,结果重启失败。

经过查看重启日志,发现Zookeeper在重启过程中,恢复数据阶段报出如下错误:

Caused by: java.io.IOException: Unreasonable length = 10053968
    at org.apache.jute.BinaryInputArchive.checkLength(BinaryInputArchive.java:166)
    at org.apache.jute.BinaryInputArchive.readBuffer(BinaryInputArchive.java:127)
    at org.apache.zookeeper.server.persistence.Util.readTxnBytes(Util.java:159)
    at org.apache.zookeeper.server.persistence.FileTxnLog$FileTxnIterator.next(FileTxnLog.java:768)
    at org.apache.zookeeper.server.persistence.FileTxnSnapLog.fastForwardFromEdits(FileTxnSnapLog.java:352)
    at org.apache.zookeeper.server.persistence.FileTxnSnapLog.lambda$restore$0(FileTxnSnapLog.java:258)
    at org.apache.zookeeper.server.persistence.FileTxnSnapLog.restore(FileTxnSnapLog.java:303)
    at org.apache.zookeeper.server.ZKDatabase.loadDataBase(ZKDatabase.java:285)
    at org.apache.zookeeper.server.quorum.QuorumPeer.loadDataBase(QuorumPeer.java:1094)

根据上述日志,既然重启恢复数据失败,那么将follower节点的快照日志和事务日志删除,这样重启时就不需要恢复数据了,启动后数据可以重新从leader同步。

然而,事与愿违,删除数据后,重启集群,集群短暂恢复了一会,但很快又进入故障状态,查看日志发现了与之前类似的错误:

java.io.IOException: Unreasonable length = 10053968
    at org.apache.jute.BinaryInputArchive.checkLength(BinaryInputArchive.java:166)
    at org.apache.jute.BinaryInputArchive.readBuffer(BinaryInputArchive.java:127)
    at org.apache.zookeeper.server.quorum.QuorumPacket.deserialize(QuorumPacket.java:85)
    at org.apache.jute.BinaryInputArchive.readRecord(BinaryInputArchive.java:108)
    at org.apache.zookeeper.server.quorum.Learner.readPacket(Learner.java:152)
    at org.apache.zookeeper.server.quorum.Follower.followLeader(Follower.java:85)
    at org.apache.zookeeper.server.quorum.QuorumPeer.run(QuorumPeer.java:740)

反复出现的Unreasonable length = 10053968引起了大家的警觉,该异常对应的源码如下:

org.apache.jute.BinaryInputArchive.java
private void checkLength(int len) throws IOException {
    if (len < 0 || len > maxBufferSize + extraMaxBufferSize) {
        throw new IOException("Unreasonable length = " + len);
    }
}

上述代码中的maxBufferSize + extraMaxBufferSize默认大小为2M,而异常中的Unreasonable length = 10053968已经达到了9.58M,远超2M,故抛出异常。

经过搜索,发现该值由系统变量jute.maxbuffer配置,它指定了znode中可以存储的数据的最大大小。

随即将该值调整至20M后,重启所有节点,集群恢复。

那么,这个9.58M大小的数据到底是什么?

02

故障定位

由于该问题发生时,异常信息只打印了长度,没有相关内容,所以无法定位原因,故按如下步骤来诊断原因:

首先,将Zookeeper的事务日志和快照日志下载到本地。

其次,获取Zookeeper源码,修改异常堆栈中的org.apache.zookeeper.server.persistence.FileTxnLog.next()方法代码,在Zookeeper启动恢复数据的过程中,将超长的内容打印到日志中。

最后,发现了这个超大的数据,在/dubbo-test/com.sohu.xxxService/consumers/下,注册了4096个node,node的path类似如下:

consumer%253A%252F%252F10.x.x.x%252Fcom.sohu.xxxService%253Fapplication%253Dspaces-videos-read-service-test...timestamp%253D1702534957419

此node是Dubbo消费者启动后注册到Zookeeper一个临时node,当Dubbo消费者关闭后,临时node会被Zookeeper自动删除,但是正常情况下不会注册这么多的node。

这些node的path几乎都一样,只是最后的timestamp不同,一个node的path近2450个字节,4096个差不多9.58M,那么这个node是谁注册进去的呢?

由于此node的path中携带了ip,经过询问相关业务人员,原来当时业务方在进行压测,而压测代码由于书写不规范导致创建了大量消费者实例,从而注册到Zookeeper大量的临时node,代码类似如下:

public void test() {
    for (long i = 0; i < Long.MAX_VALUE; i++) {
        VideoInfoReadService service = getDubboService(VideoInfoReadService.class);
        ... ...
    }
}

public  T getDubboService(Class clazz) {
    ReferenceConfig reference = new ReferenceConfig<>();
    reference.setRegistry(DubboRegistry);
    reference.setInterface(clazz);
    reference.setCheck(false);
    reference.setApplication(DubboApplication);
    reference.setTimeout(timeout);
    return reference.get();
}

问题找到了,但是Zookeeper默认已经通过jute.maxbuffer限制了node数据大小为2M,为啥还能产生这么大的数据呢?

对于本次故障来说,并不是一个node中的data超过了2M,而是node下的子node合起来超过了2M,而Zookeeper并没有限制一个node下有多少个子node。

可见Zookeeper可能并不认为一个node下有大量的子node会有什么问题。

那么真实情况确实如此吗?故障到底是如何触发的呢?

03

故障重现

既然知道一个node下存在大量临时子node会触发这种故障,那么就模拟这种情况。

搭建一个测试的Zookeeper集群,通过如下简单的模拟代码,直接重现了故障:

zooKeeper.create("/test", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
// 构建临时节点路径,大小100k
byte[] bytes = new byte[1024 * 100];
Arrays.fill(bytes, (byte) 'a');
String str = new String(bytes);
// 这里需要创建21个临时节点,总大小大于2M
for (int i = 0; i < 21; i++) {
    zooKeeper.create("/test/" + str + "-" + i, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
}

上面的代码会在/test节点下创建21个临时节点,每个临时节点的path长度达100K,那么/test节点下的所有临时节点路径长度总和就超过了2M,执行完上述代码,整个Zookeeper集群直接故障了。

经过分析Zookeeper的源码,结合故障时的堆栈,找到了本次故障的本质原因:

  • 当Zookeeper客户端关闭时,leader会清除session,此时会删除本次session中创建的临时节点,对应Zookeeper的PrepRequestProcessor的如下代码(超大的数据包就在此产生):

    protected void pRequest2Txn(...) {
        ......
        case OpCode.closeSession:
            long startTime = Time.currentElapsedTime();
            synchronized (zks.outstandingChanges) {
                Set es = zks.getZKDatabase().getEphemerals(request.sessionId);
                ...
                if (ZooKeeperServer.isCloseSessionTxnEnabled()) {
                    // 这里会将一个session创建的所有子节点放到一个request里了,超大的数据包在这产生
                    request.setTxn(new CloseSessionTxn(new ArrayList(es)));
                }
                ......
    }
  • 接着,leader发起提议,注意此时提议的Request对象中包含了超大数据包,对应ProposalRequestProcessor的如下代码:

    public void processRequest(Request request) {
       zks.getLeader().propose(request);
    }
  • 在提议逻辑中,会遍历所有的follower,将提议包发送给follower,参见如下leader代码:

    void sendPacket(QuorumPacket qp) {
        synchronized (forwardingFollowers) {
            for (LearnerHandler f : forwardingFollowers) {
                f.queuePacket(qp);
            }
        }
    }
  • 所有的follower接到这个超大的数据包后,由于长度检测抛出异常,导致无法处理。

由于Zookeeper是CP的系统,它要求强一致性,如果一致性无法达到,则进入不可用状态,导致了本文开头的故障。

04

故障影响及思考

本次故障导致的影响:

  • 依赖Zookeeper做定时调度的应用无法执行任务;

  • 部分Dubbo服务调用失败。

    其实Dubbo本地缓存了路由信息,按理说Zookeeper短暂不可用时,只会影响新启动的Dubbo服务,而运行中的Dubbo服务应该不会受影响。但是由于某些业务使用Dubbo版本过低,存在某些Bug,在Zookeeper集群不可用时,会导致服务注册和发现出现异常,从而导致Dubbo服务调用失败。

本次故障带来的思考:

1. Zookeeper相关

a. 本次故障通过调大jute.maxbuffer临时解决了,但是Zookeeper官方文档中,并不建议将该属性调整过大,原因如下:

  • 过大的znode会导致不必要的延迟,从而降低吞出量;

  • 过大的znode会使leader和follower之间的同步时间不可预测,甚至超时,导致仲裁不稳定。

由此可见,调大该值为系统的稳定性埋下了隐患。

另外,如果真有在node下存储大量子node的需求,Zookeeper该如何支持?

b. 故障发生时,大量的异常Unreasonable length = 10053968毫无意义,无法定位问题节点,最起码需要在检测出数据包过大,抛出异常前,打印部分数据包内容,以便能够定位。

c. Zookeeper不同于Web服务,不支持打印访问日志,如果能加上访问日志,记录每次请求的ip、node、进出包的大小等等关键信息,那么对于定位此种问题就不会手忙脚乱了。

2. Dubbo相关

a. Dubbo诞生之初就采用Zookeeper作为服务注册和发现中心,在单体应用演变到SOA(面向服务的架构)的年代,可能没有其他更好的选择。但是在今天看来,作为CP架构的Zookeeper是否适合作为服务注册和发现中心?

对于互联网服务来说,其实更在意的是服务是否是高可用的,比如在某一时刻,服务调用者并不关心注册和发现中心获取到了2个服务提供者,还是3个服务提供者,因为它更关心的是能否调用成功,甚至在网络状况不好时,照样能从注册和发现中心获取到服务提供者。

在微服务盛行的今天,显然AP架构更适合作为服务注册和发现中心。

b. 由于团队内部已经采用了SpringCloud作为微服务治理平台,并在此基础上做了大量的基础工作,比如日志,指标,监控,追踪等等。而对于Dubbo服务,由于其采用私有协议,这些基础的工作需要单独定制开发。

c. 业务端采用的Dubbo版本长久不升级,存在某些bug和漏洞,安全隐患较大。如果升级则需要考虑兼容性问题以及潜在的风险。

综上:

  • Zookeeper为了实现强一致性,牺牲了可用性,不适合作为服务注册和发现中心;

  • 由于团队内部主流项目都接入了SpringCloud微服务平台,即使是Dubbo项目,也集成了SpringBoot进行部署,所以,在权衡开发运维成本,技术积累,付出和收益等后,确定Dubbo服务逐渐迁移到SpringCloud服务才是最终方向。

在确定了方向后,如何才能保障用最小的成本和最稳定的方案将Dubbo服务迁移到SpringCloud服务呢?

05

Dubbo服务Web化调研

1. 首先需要制定一个明确的目标,以便能够进行方案评估。

在将Dubbo服务迁移到SpringCloud服务的过程中,需要保障如下几点:

  • 对业务端代码无侵入,业务端尽量不用修改现有代码;

  • 能够和Dubbo框架共存,方便逐步的、稳妥的迁移;

  • 能够兼容现存的接口,避免修改接口带来不稳定性;

  • 不能依赖Dubbo,方便后续移除Dubbo。

2. 为了保证目标的实现,首先,先看一下Dubbo的架构:

outside_default.png

从上图可以看出,Dubbo架构核心有三大组件,分别是Provider(服务提供者),Consumer(服务消费者)和Registry(Zookeeper)。

如果业务端想提供服务,只要提供Provider的接口和实现,再进行简单的配置即可。

如果业务端想调用服务,只需要依赖服务方提供的接口包,进行简单配置即可。

3. 这里,为了后续更方便的阐述,这里举一个简单的例子:

a. 首先,假设服务方提供了一个视频服务:

public interface VideoService {
    Video getVideo(long id);
}

服务方需要将该接口打包成videoService.jar,提供给调用方。

b. 其次,服务方需要实现上述接口,假设实现如下:

@Service
public class UgcVideoService implements VideoService {

    private VideoDao videoDao;

    @Override
    public Video getVideo(long id) {
        return videoDao.queryById(id);
    }
}

接着按照Dubbo规范进行配置并部署即可。

c. 最后,调用方依赖上videoService.jar,按照如下方式调用即可:

@Reference
private VideoService videoService;

public Video getVideo(long id) {
    return videoService.getVideo(id);
}

实际中,Dubbo框架会自动生成代理,完成服务调用:

outside_default.png

好了,到这里,我们可以清晰的看出业务端使用Dubbo框架开发的整体流程,这里简单描述一下涉及到业务代码的部分:

  • Provider提供一个接口,并打成jar包;

  • Provider实现该接口,完成业务逻辑,并按照Dubbo规范配置和部署服务;

  • Consumer依赖Provider提供的jar包,像直接调用本地方法一样调用Provider的Dubbo服务。

上述业务代码如果改造成SpringCloud的服务提供和调用方式,需要做哪些工作?

4. 众所周知,SpringCloud的服务交互采用HTTP协议,其实将上述接口转换为HTTP协议很简单,因为Spring MVC已经提供了一套标准的规范,只需要参照规范实现即可。

例如上面服务方的业务里的接口VideoService,可以通过增加几个简单的注解,即可暴露HTTP协议端点,类似如下:

@RestController
@RequestMapping("/video")
public class VideoController {

    @Autowired
    private VideoService videoService;

    @RequestMapping("/getVideo")
    public Video getVideo(long id) {
        return videoService.getVideo(id);
    }
}

此时,服务方便可完成HTTP协议端点的暴露:/video/getVideo?id=xx

上面的修改方式非常的简单清晰,因为只是新增了标准的Spring MVC的注解,不会影响原有的Dubbo项目。

但是上面的修改方式违反了之前规定的目标:

  • 对业务端代码无侵入;

  • 能够兼容现存的接口。

首先,这里仅仅是一个业务接口,如果涉及到几十个,甚至几百个,手工修改工作量极大。

其次,调用方是通过本地方法调用的方式videoService.getVideo(id)使用的,为了保障兼容现存接口,依然需要支持这种方式的调用,而改成HTTP协议后,显然这种方式行不通了。

鉴于上述修改方案简单清晰,可调试,并且支持团队内的指标、追踪等,又是Spring标准的Web暴露方式,那么能否为业务端自动生成相关的代码呢?

06

Dubbo服务自动Web化方案

经过调研和实践,自动Web化是可行的,由于Dubbo服务的特点是Provider和Consumer都依赖一个共同的接口,那么可以根据接口做些文章。

1. 首先,根据接口定义出HTTP协议端点的规则

这里还拿上面示例代码作说明,例如下面的接口:

public interface VideoService {
    Video getVideo(long id);
}

可以定义出HTTP协议的端点:/类名/方法名?参数名=参数,也就是/VideoService/getVideo?id=123。

2. 其次,根据HTTP协议端点规则,通过反射方式,将服务方接口代码自动生成提供方的Controller代码,类似如下:

@RestController
@RequestMapping("/VideoService")
public class VideoController {

    @Autowired
    private VideoService videoService;

    @RequestMapping("/getVideo")
    public Video getVideo(long id) {
        return videoService.getVideo(id);
    }
}

3. 最后,根据HTTP协议端点规则,通过反射方式,将调用方的代码自动生成Consumer端的代理代码,此代理代码会生成到业务方接口代码所在的jar包中,这样便可以在Consumer项目中使用,类似如下:

@AutoWebClientProxy
public class VideoServiceProxy implements VideoService {
  private AutoWebClient autoWebClient;

  @Override
  public Video getVideo(long id) {
    AutoWebRequest autoWebRequest = new AutoWebRequest();
    autoWebRequest.setUri("/VideoService/getVideo/long");
    autoWebRequest.setReturnType(Video.class);
    autoWebRequest.addRequestBody("id", String.valueOf(id));
    return autoWebClient.invoke(autoWebRequest);
  }
}

上述代码中的autoWebClient.invoke()会通过HTTP协议调用Controller,底层可以是任意的实现,比如HttpClient,OkHttp等。

由于入参和返回值跟Provider接口一致,Consumer端调用时就可以做到无缝替换了。

如上所示,仅仅通过业务接口的定义,便可以生成一套支持HTTP协议的SpringCloud的标准的Web代码,那么该在什么时机进行代码生成呢?

4. 为了方便业务端调试&追踪代码,计划生成静态代码,即class文件。由于团队内部的项目都使用Maven管理,在Maven编译期间,通过自定义的Maven的插件,扫描业务的接口,按照事先制定的统一的HTTP协议端点规则,自动生成Controller和Consumer代理代码即可,生成的class文件可以自动打包部署,免于纳入到源码管理中,这样就可以不对业务端代码造成侵入了,如下所示:

outside_default.png

另外,关于代码生成,采用了javapoet,使生成代码更易实现。

最终,该方案满足了之前的所有目标,下图中的AutoWebService即为自动Web化方案的整体逻辑架构:

outside_default.png

如上图所示,上半部分的Dubbo项目整体运行于SpringCloud平台中,同时,下半部分为自动生成的Controller和Consumer代理,它们通过Eureka做服务的注册和发现,与Dubbo项目共存,互不影响。

07

Dubbo服务自动Web化实践的典型问题

1 性能相关

从Dubbo切换到SpringCloud,相信大家都有一个疑问,就是Dubbo服务之间调用时,底层通信采用Netty,而Netty以高性能著称。而SpringCloud服务之间采用Http协议通信,那么切换后服务性能是否会有影响呢?

为了满足业务端对性能的需求,进行了性能比对压测,相关压测环境和参数如下:

 a. 整体测试流程如下图:

outside_default.png

如上图所示,压测工具采用Apache Bench - ab,调用流程如下:

ab -> Dubbo Consumer -> Dubbo Provider

ab -> AutoWebProxy -> AutoWebService

b. 物理部署配置如下:

outside_default.png

c. 应用版本及逻辑部署:

  • SpringCloud-2021.0.8,SpringBoot-2.6.15,Dubbo-2.6.13

  • Dubbo Provider和AutoWebService的Controller共存

  • Dubbo Consumer和AutoWebService的代理共存

  • Dubbo的Consumer调用Provider

  • AutoWebService的代理调用Controller

  • 压测工具采用Apache Bench

d. 部分参数说明:

  • Dubbo采用默认配置,底层通信为Netty,即一个链接处理所有请求。

  • 由于AutoWebService依赖SpringCloud,默认内置容器为Tomcat,由于Tomcat为每个请求采用一个线程,故将最大线程数调整至400,并开启长连配置:

    server:
      tomcat:
        threads:
          max: 400
        max-keep-alive-requests: -1
  • AutoWebService的客户端代理的Http实现类采用OkHttp,链接池最小空闲数配置为100(官方建议根据qps或调用服务的线程数设置)。

e. 所有测试用例经过预热,跑3次后取最高值,测试期间物理资源cpu、内存、网络等指标正常,最终得到如下结果:

  • 收发为字符串压测数据(纵坐标为每秒的吞吐量,横坐标为并发量和收发字节大小的组合坐标):

    outside_default.png

    根据上图压测数据,可以看出,在收发为字符串时,大小为10字节,AutoWebService的性能低于Dubbo。

    其余字节时AutoWebService的性能与Dubbo旗鼓相当,吞吐可达每秒1万~1.6万。

  • 收发为对象的压测数据(纵坐标为每秒的吞吐量,横坐标为并发量和收发字节大小的组合坐标):

    outside_default.png

    根据上图压测数据,可以看出,在收发为对象时,在较小的对象时,与字符串测试结果相当。

    但是当收发对象较大时,AutoWebService吞吐高于Dubbo,尤其当收发对象大于1K时,Dubbo性能急剧下降,这与Dubbo官方说的Dubbo设计的目的是为了满足高并发小数据量调用,在大数据量下性能表现并不好不谋而合了。

f. 压测总结:由于日常开发时基本收发都用对象的方式,AutoWebService的性能会略高于Dubbo,尤其涉及到大量对象传输时。

以上压测结果仅代表特定环境下的两个框架的对比情况,由于版本,环境,配置,压测参数等等不同,压测结果会不尽相同。

2 多对象入参问题

某些接口存在入参为多个对象的方法,例如如下代码:

public interface VideoService {
   List

而在自动转换为Controller时,需要为对象参数添加@RequestBody,但是该注解在一个方法中只支持一个,如果添加多个就会报错。

【解决方案】:客户端HTTP调用时,采用Post请求,将多个对象分别序列化为类似k1=v1,k2=v2的形式,其中值为json,当做字符串传递。

在Controller端,检测到参数为对象时,分别解析类似参数k1,k2,将对应的值反序列化为对象即可。

3 返回值问题

  • 返回值为泛型类型

    【解决方案】:反射时检测并支持泛型类型。

  • 业务代码抛出自定义异常

    【解决方案】:捕获自定义异常,将异常序列化,传输至Consumer端,再抛出。

4 异步调用问题

例如如下代码:

Video video = videoService.getVideo(123);

此代码为同步调用,即调用方法返回即可获取到Video对象。

业务端需要支持异步调用,但是不能修改业务的接口签名,接口代码类似如下:

public interface VideoService {
    Video getVideo(long id);
}

而众所周知的是,异步调用后,结果会异步返回,一般是通过回调通知或Future获取结果,不修改上述接口签名定义的前提下,该如何做?

【解决方案】:采用动态代理机制,在发生videoService.getVideo(123)调用时进行拦截,拦截后底层进行异步调用,并将Future对象返回给业务端。

这里有一点需要说明一下,即动态代理并不能修改方法的返回值类型,即Video getVideo(long id)方法返回为Video,代理后的方法依然需要返回为Video,但是这里需要将Future返回,所以采用了暂存到ThreadLocal的方法,简化代码如下:

/**
 * 代理调用
 */
public class AsyncInvocationHandler implements InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 异步调用
        CompletableFuture future = (CompletableFuture) client.invoke(request);
        // 调用的Future放入ThreadLocal
        FUTURE_THREADLOCAL.set(future);
        // 返回值处理
    }
}

/**
 * 支持同步转异步调用
 */
public interface AutoWebSupplier {
    T get() throws Exception;

    static  CompletableFuture supplyAsync(AutoWebSupplier supplier) {
        try {
            supplier.get();
            return FUTURE_THREADLOCAL.get();
        } catch (Exception e) {
            throw new AutoWebException("unexpected", e);
        } finally {
            FUTURE_THREADLOCAL.remove();
        }
    }
}

// 业务端异步调用
CompletableFuture

如上代码所示,在不修改业务接口定义的情况下,实现了异步调用的支持。

相关的问题还有很多,这里不再一一列举,由于自动化生成的代码的特性,不可能一开始就100%满足所有业务端需求,需要根据业务端的实际情况进行不断地完善。

08

总结

1. 为了强化Zookeeper运维,已经对如下方面进行了开发和优化:

  • 支持输出请求日志,并支持输出指令和请求包大小;

  • 传输包过大时打印部分信息,便于故障定位;

  • 支持动态封禁客户端IP等等。

2. 由于Dubbo依赖CP架构的Zookeeper作为服务注册和发现组件,无法保障高可用,并且某些低版本的Dubbo存在的BUG,会导致故障,综合了成本和效益后,提供了一套自动Web化的方案,目前各个项目已经陆续接入AutoWebService,反应良好。

你可能感兴趣的:(dubbo,前端)