一、基础篇
1.1 开篇说明

dubbo是一个分布式服务框架,致力于提供高性能透明化RPC远程调用方案,提供SOA服务治理解决方案。本文旨在将对dubbo的使用和学习总结起来,深入源码探究原理,以备今后可以作为借鉴用于工作之中。

由于dubbo各个分层都是很多扩展,比如注册中心有redis、zookeeper选项,通信模块有netty、mina,序列化有hession、hession2、java序列化等,本文不能面面俱到,重点阐述主线流程,注册中心选择zookeeper(client选择curator),通信选择netty,协议选择dubbo,序列化选择hession2,容器选择Spring。本文不会照搬官网文档,而是由浅入深阐述dubbo框架

1.2 为什么要服务化

为什么要做服务化?

随着业务发展,应用的功能和涵盖的业务越来越大,造成复杂度越来越高,代码量跟着加大,开发人员在发布环节会遇到前后端协调和代码冲突导致发布失败,在开发过程中由于代码的臃肿而不得不背负较大的负担降低开发效率,每个开发人员没有具体分工不能够做到业务模块责任到人,单个应用包含了不同业务一方业务出现问题影响其他业务的正常服务,大量业务柔和在一起无法有效做到容量规划,造成数据库连接和分布式缓存的浪费。
因此,将应用拆分,并抽取出核心服务来解决上述问题,还要考虑负载均衡、服务监控、高可用性、服务隔离与降级、路由策略、完善的容错机制、序列化方案的选择、通信框架的选择、开发人员对底层细节无感知、服务升级兼容性等问题。Dubbo满足了以上所有需求。

1.3 深入浅出dubbo

本节从零基础服务调用到dubbo演进过程,如果对dubbo功能已经很熟悉,可以忽略。

  • 1)初期一个简单的服务调用方式

调用方与服务方约定请求参数字段和请求结果字段,服务方启动一个tomcat+springmvc,监听80端口,调用方通过httpclient发起http请求,服务方返回json或xml数据结果,调用方拿到http响应结果解析结果数据,一次服务调用结束。
那么问题来了,过了几个月,核心服务越来越多,业务被拆的越来越精细,配置文件中由服务方提供的url地址越来越多,运维忙于架设负载均衡设备,部署新的软负载服务nginx,lvs,haproxy,甚至每个服务的内部域名的变化或url路径变化都要增加运维人员的人工成本。

  • 2)通过注册中心发现服务、client端完成负载均衡

服务方在启动tomcat后,向注册中心注册自己的服务列表,包括服务器ip、port,以及代表服务的唯一标识,比如以格式/a_service/ip_port,/b_service/ip_port存储在注册中心。
调用方在启动后,去注册中心寻找a服务的地址列表,并且订阅/a_service,当a服务列表变更就会将变更消息推给调用方。接下来地址列表得到了,调用方创建多个httpClient实例,每个实例对应一个服务器ip_port,每次发起调用,从httpclient实例列表中随机选择一个,发起调用请求。当服务方某台服务器出现宕机或者网络故障,调用方会从收到由注册中心推送过来的通知消息,进而将出现故障的ip_port对应的httpclient从列表中移出;当服务方新增加服务器时,调用方同样会收到通知消息,进而新建httpclient实例,加入httpclient列表。由此我们增加了注册中心集群,在服务方调用方加入注册中心客户端,解决了1)提出的问题。

那么问题来了?服务方提供的服务器硬件配置不一样,性能也不一样,希望通过设置服务器权重的方式,权重高的希望收到更多的请求,希望有轮询的负载均衡方式,于是有了负载均衡策略的需求。

  • 3) 负载均衡策略模块

服务方将权重信息写入注册中心,调用方取到后根据自己或者服务方建议的负载均衡策略从httpclient列表中选择一个实例,进而发起http请求。
那么问题来了?负载均衡虽然解决了均衡压力的问题,但如果服务方与调用方之间网络出现闪断造成请求失败怎么办,如果能够重试就好了,但又不能对所有的请求都开启重试机制,有些写请求比如账户充值,肯定不能重试多次,于是需要一个集群容错模块。

  • 4)集群容错模块
    对于不同服务选择不同的容错机制,比如非幂等的写操作选择failfast--失败立即报错,对于响应快速的读操作选择failover—重试其它服务器,如果调用方无法容忍因为服务调用的阻塞也可以选择failfast,对于消息通知可以选择failback---失败定时重发,对于审计日志可以选择failsafe—失败直接忽略。
    那么问题来了?如何解决不同机房调用的问题,读写分离的问题,当线上服务方某个网段服务器出现问题,需要立即隔离掉。于是需要一个路由策略模块。

  • 5)路由策略

通过web界面管理端可以直接操作注册中心,管理员添加路由规则,调用方订阅路由规则节点,当发生变更时调用方收到通知修改本地路由策略。

那么问题来了?添加这些路由规则需要一个管理端,这个管理端由于与注册中心建立连接,还可以方便的进行权重修改、负载均衡策略变更、容错机制变更等

  • 6)管理端
    具有良好web界面的管理端。
    dubbo管理端

那么问题来了?只有管理端,但不能看到服务调用情况,无法做容量规划,比如调用次数,响应时间,QPS,服务依赖关系,服务方有几台,调用方有几台机器,同时提供哪些服务,我总不能每次都跳到注册中心集群敲命令看吧,何况以上服务统计信息敲命令也看不到。那么因此需要一个监控中心集群,调用方和服务端定期上报监控中心服务的请求与返回数据,监控中心通过计算用曲线图展示出来。

  • 7)监控中心
    监控中心
    调用方和服务方引入监控中心client,client定期将服务数据上报到监控中心集群,监控中心提供web界面,使应用负责人可以登录查看。监控中心因为也是提供统计数据收集服务,所以同样可以作为服务方接收来自普通服务方和普通调用方的统计上报请求。在代码实现上,我们可以在发起调用和接收到结果之间做一层拦截比如monitorFilter,在发起调用前纪录下时间戳,在收到响应结果纪录下时间戳,然后算出时间差发送到监控中心。

那么问题来了?注册中心、监控中心、负载均衡策略、容错机制、路由规则都有了,但是现在每次调用服务都要写一堆httpclient相关代码,调用前要组织httpclient要求的请求对象request,结果返回后要解析httpclient封装的response对象,除此之外还要负载均衡策略、容错的代码封装在外面,这不仅没有减少开发成本反而增加了,如果每次服务调用都像本地调用一样,服务化对开发者无感知就好了。于是需要一个代理对象,来封装底层细节,让通信细节、路由、负载均衡对开发者不可见。

  • 8)代理对象
    上面说到了我们需要一个服务调用做一个代理,代码看起来应该是酱紫的:
    清单1.DemoService.java
    package Test;
    public interface DemoService{
    String sayHello(String name);
    }

清单2.DemoService$Proxy.java

Public class DemoService$Proxy implements DemoService{
   Private java.lang.reflect.InvocationHandler handler;
   Public static java.lang.reflect.Method[] methods;
   Public java.lang.String interfaceName;
   Public DemoService$Proxy(String interfaceName,java.lang.reflect.InvocationHandlerarg1){
          this.handler=arg1;
          this.interfaceName=interfaceName;
   }

   Public java.lang.String sayHello(java.lang.String arg0){
          Object[]args=new Object[1];
          args[0]= arg0;
          Object ret =handler.invoke(interfaceName, methods[0],args);
                 return(java.lang.String)ret;
   }
}

DemoService$Proxy即为代理类,但需要传递一些必须字段比如类名、方法名、方法参数类型、参数值(这里的实现是与dubbo的代理类有出入的,比如handler里面invoker属性已经有了interfaceName属性)。其中handler隐藏了所有远程调用细节,包括负载均衡、路由、容错、通信。动态代理可以借助很多开源类库都可以实现比如javassist,asm,cglib,jdk自带的,那到底要选择哪个呢,当然是哪个性能好易用性好选哪个,为了保证高性能就要避免反射,首先jdk自带的方案排除了,dubbo推荐使用javassit,一方面是它性能好比cglib好,另一方面它可以拼接java源码动态编译生成字节码而asm需要框架开发人员熟悉class字节码结构开发成本较高,关于几种动态代理的方案对比可以看dubbo作者的博客—动态代理方案性能对比(http://javatar.iteye.com/blog/814426).
问题又来了?现在调用方通过代理对象来调用远程服务,不需要关注通信协议,已经可以作为一个RPC框架来使用了,但是传输参数是一个复杂对象而不是一个个基本类型参数该怎么办?这就需要引入序列化,业界流行的序列化协议有java序列化,hession,hession2,json序列化,protobuf,thrift。除此之外,我们需要一个高性能的通信框架,比如netty、mina。

  • 9)通信与序列化模块

有了NIO通信框架,不再是httpclient和tomcat,性能得到了很大的提升;但同时带来连接管理的工作量,第一,NIO没有BIO那样可以方便设置读超时时间,超时管理是必不可少的(不然堆内存溢出);第二,client-server建立长连接,server端要定期扫描所有连接,关闭空闲连接;第三,为了维持长连接,client会定期发送心跳给server,发心跳也能及时检测与server的连接状态(当网络断开而FIN消息未能发出,client不知道连接关闭导致操作失败;通过定期传输接收数据,在遇到IO异常比如ClosedChannelException时就可以判断连接失效,发起关闭连接操作).
server端由tomcat改为netty,接收到调用方发过来的类名、方法名、参数等数据,一般情况下需要通过反射调用最终服务代码,但是反射性能很差,我们需要对每个服务都动态生成一个Wrapper类(通过拼接源码,借助javassist动态编译),避免反射,代码看起来是酱紫的:
清单 Wrapper.java

  Public class Wrapper0{    
    public Object invokeMethod(Object object, String method, Class[]parameterTypes,Object[]parameterValues)throwsjava.lang.reflect.InvocationTargetException{
com.test.DemoServiceImpl w; 
     try{ 
          w =(com.test.DemoServiceImpl)object;  
       }catch(Throwable e){ 
          throw new IllegalArgumentException(e);
}   
      try{if("sayHello".equals( method )&¶meterTypes.length==1){    
          return w.sayHello((java.lang.String)parameterValues[0]);}}
      catch(Throwable e){   
         throw newjava.lang.reflect.InvocationTargetException(e);
    }
}

以上代码与dubbo真实代码有些出入,这么写为的是更能方便易懂。

那么问题又来了?之前用http协议传输的,client只需要在url中指定路径,spring mvc通过url path找到方法反射调用。现在server使用netty,而调用方需要多种服务,服务方又暴露了多种服务不同服务还有不同方法,调用方应该怎么传数据才能让服务方知道它需要的服务呢?这就需要约定一种协议,协议规定了发出何种控制信息,接收方收到信息做出什么样的动作做出什么响应。

  • 10)协议

既然是远程过程调用,肯定方法名、类名、参数类型、参数值这些少不了,通过这些信息,服务方就可以很容易映射到具体方法。除此之外,我通过传递某些头信息,可以控制服务端不返回结果,比如消息通知。

总结:以上即是dubbo几大核心组件:按照角色来划分分为

  • provider(服务提供方,对应前文的服务方)
  • consumer(服务消费方,对应前文的调用方)
  • monitor center(监控中心)
  • registry center(注册中心,接下来我们以zookeeper为例子说明)
  • admin web console(管理端,用于修改路由、修改配置,最终作用于注册中心)

图片描述
更细致的组件关系图:按功能来划分

  • directory (负责从zookeeper中心生成的provider列表)
  • router (路由)
  • fault-tolerantStrategy(容错策略)
  • loadBalance(负载均衡)
  • monitorFilter(监控拦截)
  • zookeeperClient(Zoookeeper客户端,我们使用zookeeper做例子)
  • proxy(代理对象)
  • nettyClient(我们以netty作为通信框架)
  • nettyServer(我们以netty作为通信框架)
  • Hession2Serialization(我们选hession2作为序列化方案)

图片描述

1.4dubbo SPI扩展框架

dubbo作为一个开源RPC框架,实现的功能也比较多,而不同的组件有各种不一样的方案,
用户会根据自己的情况来选择合适的方案。比如序列化、通信框架、注册中心都有不同方案可选,负载均衡,路由规则都有不同的策略,dubbo采用微内核+插件式扩展体系,获得极佳的扩展性。
dubbo没有借助spring,guice等IOC框架来管理,而是运用JDK SPI思路自己实现了一个IOC框架,减少了对外部框架的依赖,更多dubbo框架设计原则可以看dubbo作者的分享《框架设计原则》

  • dubbo扩展框架特性
    1). 内嵌在dubbo中
    2). 支持通过SPI文件声明扩展实现(interfce必须有@SPI注解),格式为extensionName=extensionClassName,extensionName类似于spring的beanName
    3). 支持通过配置指定extensionName来从SPI文件中选出对应实现
    ExtensionLoader.getExtensionLoader(RegistryFactory.class).getExtension("Zookeeper")

类似于spring的beanFactory.getBean(“xxx”);
ExtensionLoader是扩展机制能够实现的核心类,类似于spring的beanFactory,只不过ExtensionLoader里面只存放单一类型的所有单例实现,存放dubbo扩展的”bean”容器是有多个ExtensionFactory组成的。
5) 支持依赖注入,注入来源(ExtensionFactory)可以自己定义,比如可以来自于SPI,也可以来自于spring容器,ExtensionFactory也是一个扩展,可以自己扩展。查找方式是通过set${ExtName}方法名(ExtName可以替换为任意扩展名称)来注入相关类型对应extName的扩展,找不到就不注入。
6) 可以指定或动态生成自适应扩展类,通过interface方法里@Adaptive注解指定的value值作为key,从配置中(com.alibaba.dubbo.common.URL)获取key对应的extName值,找到对应扩展再调用具体方法实现方法调用自适应

6) 对于拥有构造方法参数为interface类型的扩展,按照顺序依次包装最终扩展实现类,比如ProtocolListenerWrapper-->ProtocolFilterWrapper—->DubboProtocol

7) 可以通过对同一类型不同扩展类名添加@Activate注解,基于@Activate属性group和value获取指定group、指定参数名的扩展

  • dubbo扩展框架实现
    1) 扩展bean类型划分
    每个扩展类似于spring的bean概念,所以我们姑且将dubbo扩展称为extension bean和spring一样,spirng也有不同类型的bean,比如BeanFactoryPostProcessor,BeanPostProceesor,dubbo也不例外也有各种类型的bean
    • ExtensionFactory:用做依赖注入bean的查找,默认实现有SPIExtensionFactory和SpringExtensionFactory
    • Wrapper bean:对普通bean做包装,比如ProtocolFilterWrapper用于应用于请求拦截,如果有多个wrapper bean会依次包装,比如ProtocolListenerWraaper包装ProtocolFilterWrapper,ProtocolFilterWrapper包装DubboProtocol
    • Activate Bean :可以通过给bean添加@Activate注解,达到通过group、value搜索bean的目的,比如希望Filter类型bean在consumer端和provider端使用不同的组合,provider只使用注解了@Activate(group = Constants.PROVIDER)的bean,consumer只使用注解了@Activate(group = Constants.CONSUMER)的bean。
    • Adaptive Bean :自适应bean,如果有bean在类上添加了Adaptive注解可以通过注解查找;如果找不到会通过动态代理生成一个,SPIExtensionFactory如果找到有通过SPI配置的bean,那么它就注入Adaptive Bean。下面是一个Adaptive Bean自动生成的例子
      比如我们以CacheFactory的Adaptive Bean为例
      清单1 CacheFactory.java
      @SPI("lru")
      Public interface CacheFactory{
      @Adaptive("cache")
      Cache getCache(URL url);
      }

清单2 CacheFactory@Adaptive.java

Package com.alibaba.dubbo.cache;
Import com.alibaba.dubbo.common.extension.ExtensionLoader;

Public class CacheFactory$Adpativeimplementscom.alibaba.dubbo.cache.CacheFactory{

Public com.alibaba.dubbo.cache.CachegetCache(com.alibaba.dubbo.common.URL arg0){
    if(arg0 ==null)thrownewIllegalArgumentException("url == null");
    com.alibaba.dubbo.common.URLurl= arg0;
     String extName=url.getParameter("cache","lru");①
     if(extName==null)throw
         new IllegalStateException("Fail to get extension........");
     com.alibaba.dubbo.cache.CacheFactory extension =
(com.alibaba.dubbo.cache.CacheFactory)ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.cache.CacheFactory.class).getExtension(extName);
       return extension.getCache(arg0);②
}
}

清单1中CacheFactory Interface方法@Adaptive注解value属性是“cache”,清单2 CacheFactory@Adaptive即是生成Adaptive bean源码,它首先从URL配置中获取key为cache的value值(①所示),比
如”dubbo://192.168.1.1:2080/xxxx?cache=jcache”如果没有配置”cache”,那么就取extName等于lru的cache bean,取到bean之后再调用对应方法,比如②所示。
2) SPI 格式
dubbo使用的SPI文件格式和JDK SPI(spring、log4j都有用过)有些不同,JDK-SPI文件仅仅是class列表,而dubbo使用的SPI文件是key-value结构,extName=className格式,文件名规则一样,都是寻求实现的interface类全名。还是以cacheFactory为例,cacheFactory的SPI文件是以com.alibaba.dubbo.cache.CacheFactory为文件名,放在目录/META-INFO/dubbo/internal classpath之下的
图片描述
文件内容如下

threadlocal=com.alibaba.dubbo.cache.support.threadlocal.ThreadLocalCacheFactory
lru=com.alibaba.dubbo.cache.support.lru.LruCacheFactory
jcache=com.alibaba.dubbo.cache.support.jcache.JCacheFactory

ExtensionLoader查找步骤:

  • 找到classpath下文件名com.alibaba.dubbo.cache.CacheFactory
  • 以extName为key,Class对象为value放入extensionClasses缓存

3) ExtensionLoader有几个最常用方法,首先构造方法接收一个拥有@SPI注解的Class参数.

Private ExtensionLoader(Class<?> type){
this.type= type;
objectFactory=(type ==ExtensionFactory.class?null:ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension());
}

构造方法是私有的,需要通过getExtensiooader(Class<T> type)来生成和得到ExtensionLoader实例。接下来就可以借助ExtensionLoader根据扩展名找到扩展实例。

public T getExtension(String name)//根据extName获取扩展bean

第一次构造扩展实例成功后会放入ExtensionLoader实例变量中缓存起来,保证单一实例。

public T getAdaptiveExtension() //获取或者生成自适应bean

获得Activate bean可以通过如下方法

//获取Activate bean
public List<T> getActivateExtension(URL url, String key, String group)
  • dubbo扩展框架思维导图
    图片描述
1.5 扩展组件脑图

二级主题是按功能分类,三级主题是接口名,四级主题是扩展名称。
这里不会做一一组件介绍,后文会穿插着介绍。
图片描述

1.6 功能示例说明

官网上已经很详细,这里只作为补充;需要结合官网http://dubbo.io/来看

功能 场景 实现 哪端
启动时检查   Spring容器启动时,需要完成初始化bean,有一个bean初始化失败都会导致spring容器启动失败。每个ReferenceConfig在init的时候会从注册中心寻找provider并建立连接,寻找不到可用provider(建立连接失败,lazy连接除外)就将抛异常导致初始化失败。 Consumer
集群容错&负载均衡   通过注册中心发现和收到的通知得到invoker list,可以将一个invoker理解成封装了与某个provider(比如192.168.100.1:20880)建立的一组连接的抽象。从invoker列表中先筛选出符合路由规则(从注册中心得到)的invoker list,通过负载均衡策略从invoker list得到一个invoker,这就是provider路由选择过程。如果调用失败,可以根据容错策略,比如failover,可以再重新选择新的invoker去远程调用。注意:如果启用了粘滞连接,除非发生失败重选,否则负载均衡策略会失效 Consumer
线程模型 以netty为例,netty会起固定的一些IO线程取做底层网络接口读、写建立连接、关闭连接操作,当数据读完需要处理的时候如果不将处理派发到线程池就会造成网络事件(读时间)长时间得不到处理。PS:netty使用OrderedMemoryAware ThreadPoolExecutor来解决这个问题 Dubbo有两个扩展专门为解决IO线程占用的问题,一个是Dispatcher定义什么事件派发到线程池,一个是threadpool定义线程池类型,建议fixed Consumer & provider
直连提供者   当配置了url,consumer不会从注册中心获取provider Consumer
注册不订阅&订阅不注册 注册:将自己的ip:port和所拥有的服务写入注册中心节点订阅:当注册中心某个节点变更时收到通知比如,provider列表变更会通知consumer Registry client,不订阅就不做subscribe操作不注册就是不做register操作 Consumer & Provider
静态服务   比如zookeeper注册中心,设置dynamic=false,那么在注册的时候,创建的节点就是永久节点,session失效时,节点不会释放,因此consumer也收不到通知,不建议将dynamic设为false,只在需要人工管理和调试时才设置 Consumer & provider
多协议 1不同服务不同协议:根据服务性能的实用性 2同一服务多种协议:满足不同consumer的要求 对于provider每种协议都是单独的sever,支持多个协议需要多个server,比如同时支持dubbo和rmi,需要起两个端口,dubbo起netty server,rmi协议启rmi server  
多注册中心   1、 以zookeeper为例,在多个zookeeper集群中创建节点,使得来自不同注册中心的consumer都可以发现我的服务2、 不同的服务注册到不同的zookeeper集群中 3、 在consumer端,两个reference bean,同一接口引用,可以订阅不同的注册中心 Consumer & provider
服务分组&多版本 同一实现,有不同组,比如场景灰度发布,abtest,推荐算法对比。版本升级时,为了兼容老版本,可以注册多个服务但是版本不一样,服务实现也不一样 Consumer在从注册中心抓取provider信息时,会对信息进行匹配,group和version匹配才会引用 Consumer & provider
分组聚合   Consumer
参数验证   Consumer
结果缓存   通过CacheFilter实现 Consumer
泛化引用 框架集成,可以用于测试,甚至快速写程序,验证provider端是否正常 深入浅出10协议中说了,可以通过接口名、方法名、参数来进行远程调用,既然如此,我们就可以使用万能的GenericService替换任何服务引用达到迅速发起服务的目的。 Consumer
泛化实现 框架集成 Provider
上下文信息    
隐式传参    
异步调用&事件通知 Consumer远程调用有两种方式 1是发送数据同步等待结果返回 2发送完数据(甚至不等数据发出)直接返回,等需要结果时通过get方法等待响应结果,或者不通过get方法而是设置回调方法来处理影响 实现实际上是用户线程将java对象序列化成bytebuffer塞入队列,通信框架中IO线程从队里取出bytebuffer发出。同样接收到数据后,IO线程将bytebuffer decode成request对象,如果用户线程有对结果数据的wait操作,就唤醒用户线程。最高效的方式是不通过get操作而是通过onreturn真正达到无阻塞的目的 Consumer
本地调用    
参数回调    
本地存根 做缓存、预处理、参数验证 Stub类包装service类 Consumer 但是需要通过provider配置,来使consumer启用存根的执行
本地伪装     Provider
并发控制 executes、actives 通过executeLimitFilter和ActiveLimitFilter实现,在调用前计数器加1,调用后计算器减1 Provider & consumer
连接控制 accpets,connections 去收到连接请求时,判断连接数是否大于配置阀值,大于则关闭对于connection,是consumer在refer provider时,对一个服务建立的连接数 Provider &Consumers
延迟连接    
粘滞连接   使用粘滞连接,只有在调用失败的情况下才会启用负载均衡策略,否则每次都是同一个provider Consumer
令牌验证   通过tokenFilter实现 Consumer provider
路由规则   通过管理端向注册中心,/xxx/routers节点下添加路由规则,consumer收到通知更新本地路由规则 Consumer
配置规则   同样通过管理端向注册中心 /xxx/configurators 添加配置信息,provider和consumer收到通知后重新export或者refer.Provider收到通知后不需要重启server,只需要动态修改配置即可,包括acceptes,心跳间隔,派发线程池最大最小线程数量等等。而consumer没有那么幸运,每次收到修改通知哪怕一个参数都需要重建连接。 Consumer & provider
延迟暴露     Provider
服务容器     Consumer & provider
服务降级    
优雅停机    
主机绑定    
日志适配    
服务容器     Consumer & provider
访问日志   通过accessFilter实现 Provider
相关标签: JAVA

本文原创发布于慕课网 ,转载请注明出处,谢谢合作!

21  推荐

相关阅读

  • 最简单的Dubbo框架+SOA服务入门教程(附源码)

  • 从motan看RPC框架设计

  • Dubbo+zookeeper实现分布式服务框架

  • Spring Cloud(一)服务的注册与发现(Eureka)

  • 学习微服务首先要了解为什么使用微服务

一、基础篇
1.1 开篇说明

dubbo是一个分布式服务框架,致力于提供高性能透明化RPC远程调用方案,提供SOA服务治理解决方案。本文旨在将对dubbo的使用和学习总结起来,深入源码探究原理,以备今后可以作为借鉴用于工作之中。

由于dubbo各个分层都是很多扩展,比如注册中心有redis、zookeeper选项,通信模块有netty、mina,序列化有hession、hession2、java序列化等,本文不能面面俱到,重点阐述主线流程,注册中心选择zookeeper(client选择curator),通信选择netty,协议选择dubbo,序列化选择hession2,容器选择Spring。本文不会照搬官网文档,而是由浅入深阐述dubbo框架

1.2 为什么要服务化

为什么要做服务化?

随着业务发展,应用的功能和涵盖的业务越来越大,造成复杂度越来越高,代码量跟着加大,开发人员在发布环节会遇到前后端协调和代码冲突导致发布失败,在开发过程中由于代码的臃肿而不得不背负较大的负担降低开发效率,每个开发人员没有具体分工不能够做到业务模块责任到人,单个应用包含了不同业务一方业务出现问题影响其他业务的正常服务,大量业务柔和在一起无法有效做到容量规划,造成数据库连接和分布式缓存的浪费。
因此,将应用拆分,并抽取出核心服务来解决上述问题,还要考虑负载均衡、服务监控、高可用性、服务隔离与降级、路由策略、完善的容错机制、序列化方案的选择、通信框架的选择、开发人员对底层细节无感知、服务升级兼容性等问题。Dubbo满足了以上所有需求。

1.3 深入浅出dubbo

本节从零基础服务调用到dubbo演进过程,如果对dubbo功能已经很熟悉,可以忽略。

  • 1)初期一个简单的服务调用方式

调用方与服务方约定请求参数字段和请求结果字段,服务方启动一个tomcat+springmvc,监听80端口,调用方通过httpclient发起http请求,服务方返回json或xml数据结果,调用方拿到http响应结果解析结果数据,一次服务调用结束。
那么问题来了,过了几个月,核心服务越来越多,业务被拆的越来越精细,配置文件中由服务方提供的url地址越来越多,运维忙于架设负载均衡设备,部署新的软负载服务nginx,lvs,haproxy,甚至每个服务的内部域名的变化或url路径变化都要增加运维人员的人工成本。

  • 2)通过注册中心发现服务、client端完成负载均衡

服务方在启动tomcat后,向注册中心注册自己的服务列表,包括服务器ip、port,以及代表服务的唯一标识,比如以格式/a_service/ip_port,/b_service/ip_port存储在注册中心。
调用方在启动后,去注册中心寻找a服务的地址列表,并且订阅/a_service,当a服务列表变更就会将变更消息推给调用方。接下来地址列表得到了,调用方创建多个httpClient实例,每个实例对应一个服务器ip_port,每次发起调用,从httpclient实例列表中随机选择一个,发起调用请求。当服务方某台服务器出现宕机或者网络故障,调用方会从收到由注册中心推送过来的通知消息,进而将出现故障的ip_port对应的httpclient从列表中移出;当服务方新增加服务器时,调用方同样会收到通知消息,进而新建httpclient实例,加入httpclient列表。由此我们增加了注册中心集群,在服务方调用方加入注册中心客户端,解决了1)提出的问题。

那么问题来了?服务方提供的服务器硬件配置不一样,性能也不一样,希望通过设置服务器权重的方式,权重高的希望收到更多的请求,希望有轮询的负载均衡方式,于是有了负载均衡策略的需求。

  • 3) 负载均衡策略模块

服务方将权重信息写入注册中心,调用方取到后根据自己或者服务方建议的负载均衡策略从httpclient列表中选择一个实例,进而发起http请求。
那么问题来了?负载均衡虽然解决了均衡压力的问题,但如果服务方与调用方之间网络出现闪断造成请求失败怎么办,如果能够重试就好了,但又不能对所有的请求都开启重试机制,有些写请求比如账户充值,肯定不能重试多次,于是需要一个集群容错模块。

  • 4)集群容错模块
    对于不同服务选择不同的容错机制,比如非幂等的写操作选择failfast--失败立即报错,对于响应快速的读操作选择failover—重试其它服务器,如果调用方无法容忍因为服务调用的阻塞也可以选择failfast,对于消息通知可以选择failback---失败定时重发,对于审计日志可以选择failsafe—失败直接忽略。
    那么问题来了?如何解决不同机房调用的问题,读写分离的问题,当线上服务方某个网段服务器出现问题,需要立即隔离掉。于是需要一个路由策略模块。

  • 5)路由策略

通过web界面管理端可以直接操作注册中心,管理员添加路由规则,调用方订阅路由规则节点,当发生变更时调用方收到通知修改本地路由策略。

那么问题来了?添加这些路由规则需要一个管理端,这个管理端由于与注册中心建立连接,还可以方便的进行权重修改、负载均衡策略变更、容错机制变更等

  • 6)管理端
    具有良好web界面的管理端。
    dubbo管理端

那么问题来了?只有管理端,但不能看到服务调用情况,无法做容量规划,比如调用次数,响应时间,QPS,服务依赖关系,服务方有几台,调用方有几台机器,同时提供哪些服务,我总不能每次都跳到注册中心集群敲命令看吧,何况以上服务统计信息敲命令也看不到。那么因此需要一个监控中心集群,调用方和服务端定期上报监控中心服务的请求与返回数据,监控中心通过计算用曲线图展示出来。

  • 7)监控中心
    监控中心
    调用方和服务方引入监控中心client,client定期将服务数据上报到监控中心集群,监控中心提供web界面,使应用负责人可以登录查看。监控中心因为也是提供统计数据收集服务,所以同样可以作为服务方接收来自普通服务方和普通调用方的统计上报请求。在代码实现上,我们可以在发起调用和接收到结果之间做一层拦截比如monitorFilter,在发起调用前纪录下时间戳,在收到响应结果纪录下时间戳,然后算出时间差发送到监控中心。

那么问题来了?注册中心、监控中心、负载均衡策略、容错机制、路由规则都有了,但是现在每次调用服务都要写一堆httpclient相关代码,调用前要组织httpclient要求的请求对象request,结果返回后要解析httpclient封装的response对象,除此之外还要负载均衡策略、容错的代码封装在外面,这不仅没有减少开发成本反而增加了,如果每次服务调用都像本地调用一样,服务化对开发者无感知就好了。于是需要一个代理对象,来封装底层细节,让通信细节、路由、负载均衡对开发者不可见。

  • 8)代理对象
    上面说到了我们需要一个服务调用做一个代理,代码看起来应该是酱紫的:
    清单1.DemoService.java
    package Test;
    public interface DemoService{
    String sayHello(String name);
    }

清单2.DemoService$Proxy.java

Public class DemoService$Proxy implements DemoService{
   Private java.lang.reflect.InvocationHandler handler;
   Public static java.lang.reflect.Method[] methods;
   Public java.lang.String interfaceName;
   Public DemoService$Proxy(String interfaceName,java.lang.reflect.InvocationHandlerarg1){
          this.handler=arg1;
          this.interfaceName=interfaceName;
   }

   Public java.lang.String sayHello(java.lang.String arg0){
          Object[]args=new Object[1];
          args[0]= arg0;
          Object ret =handler.invoke(interfaceName, methods[0],args);
                 return(java.lang.String)ret;
   }
}

DemoService$Proxy即为代理类,但需要传递一些必须字段比如类名、方法名、方法参数类型、参数值(这里的实现是与dubbo的代理类有出入的,比如handler里面invoker属性已经有了interfaceName属性)。其中handler隐藏了所有远程调用细节,包括负载均衡、路由、容错、通信。动态代理可以借助很多开源类库都可以实现比如javassist,asm,cglib,jdk自带的,那到底要选择哪个呢,当然是哪个性能好易用性好选哪个,为了保证高性能就要避免反射,首先jdk自带的方案排除了,dubbo推荐使用javassit,一方面是它性能好比cglib好,另一方面它可以拼接java源码动态编译生成字节码而asm需要框架开发人员熟悉class字节码结构开发成本较高,关于几种动态代理的方案对比可以看dubbo作者的博客—动态代理方案性能对比(http://javatar.iteye.com/blog/814426).
问题又来了?现在调用方通过代理对象来调用远程服务,不需要关注通信协议,已经可以作为一个RPC框架来使用了,但是传输参数是一个复杂对象而不是一个个基本类型参数该怎么办?这就需要引入序列化,业界流行的序列化协议有java序列化,hession,hession2,json序列化,protobuf,thrift。除此之外,我们需要一个高性能的通信框架,比如netty、mina。

  • 9)通信与序列化模块

有了NIO通信框架,不再是httpclient和tomcat,性能得到了很大的提升;但同时带来连接管理的工作量,第一,NIO没有BIO那样可以方便设置读超时时间,超时管理是必不可少的(不然堆内存溢出);第二,client-server建立长连接,server端要定期扫描所有连接,关闭空闲连接;第三,为了维持长连接,client会定期发送心跳给server,发心跳也能及时检测与server的连接状态(当网络断开而FIN消息未能发出,client不知道连接关闭导致操作失败;通过定期传输接收数据,在遇到IO异常比如ClosedChannelException时就可以判断连接失效,发起关闭连接操作).
server端由tomcat改为netty,接收到调用方发过来的类名、方法名、参数等数据,一般情况下需要通过反射调用最终服务代码,但是反射性能很差,我们需要对每个服务都动态生成一个Wrapper类(通过拼接源码,借助javassist动态编译),避免反射,代码看起来是酱紫的:
清单 Wrapper.java

  Public class Wrapper0{    
    public Object invokeMethod(Object object, String method, Class[]parameterTypes,Object[]parameterValues)throwsjava.lang.reflect.InvocationTargetException{
com.test.DemoServiceImpl w; 
     try{ 
          w =(com.test.DemoServiceImpl)object;  
       }catch(Throwable e){ 
          throw new IllegalArgumentException(e);
}   
      try{if("sayHello".equals( method )&¶meterTypes.length==1){    
          return w.sayHello((java.lang.String)parameterValues[0]);}}
      catch(Throwable e){   
         throw newjava.lang.reflect.InvocationTargetException(e);
    }
}

以上代码与dubbo真实代码有些出入,这么写为的是更能方便易懂。

那么问题又来了?之前用http协议传输的,client只需要在url中指定路径,spring mvc通过url path找到方法反射调用。现在server使用netty,而调用方需要多种服务,服务方又暴露了多种服务不同服务还有不同方法,调用方应该怎么传数据才能让服务方知道它需要的服务呢?这就需要约定一种协议,协议规定了发出何种控制信息,接收方收到信息做出什么样的动作做出什么响应。

  • 10)协议

既然是远程过程调用,肯定方法名、类名、参数类型、参数值这些少不了,通过这些信息,服务方就可以很容易映射到具体方法。除此之外,我通过传递某些头信息,可以控制服务端不返回结果,比如消息通知。

总结:以上即是dubbo几大核心组件:按照角色来划分分为

  • provider(服务提供方,对应前文的服务方)
  • consumer(服务消费方,对应前文的调用方)
  • monitor center(监控中心)
  • registry center(注册中心,接下来我们以zookeeper为例子说明)
  • admin web console(管理端,用于修改路由、修改配置,最终作用于注册中心)

图片描述
更细致的组件关系图:按功能来划分

  • directory (负责从zookeeper中心生成的provider列表)
  • router (路由)
  • fault-tolerantStrategy(容错策略)
  • loadBalance(负载均衡)
  • monitorFilter(监控拦截)
  • zookeeperClient(Zoookeeper客户端,我们使用zookeeper做例子)
  • proxy(代理对象)
  • nettyClient(我们以netty作为通信框架)
  • nettyServer(我们以netty作为通信框架)
  • Hession2Serialization(我们选hession2作为序列化方案)

图片描述

1.4dubbo SPI扩展框架

dubbo作为一个开源RPC框架,实现的功能也比较多,而不同的组件有各种不一样的方案,
用户会根据自己的情况来选择合适的方案。比如序列化、通信框架、注册中心都有不同方案可选,负载均衡,路由规则都有不同的策略,dubbo采用微内核+插件式扩展体系,获得极佳的扩展性。
dubbo没有借助spring,guice等IOC框架来管理,而是运用JDK SPI思路自己实现了一个IOC框架,减少了对外部框架的依赖,更多dubbo框架设计原则可以看dubbo作者的分享《框架设计原则》

  • dubbo扩展框架特性
    1). 内嵌在dubbo中
    2). 支持通过SPI文件声明扩展实现(interfce必须有@SPI注解),格式为extensionName=extensionClassName,extensionName类似于spring的beanName
    3). 支持通过配置指定extensionName来从SPI文件中选出对应实现
    ExtensionLoader.getExtensionLoader(RegistryFactory.class).getExtension("Zookeeper")

类似于spring的beanFactory.getBean(“xxx”);
ExtensionLoader是扩展机制能够实现的核心类,类似于spring的beanFactory,只不过ExtensionLoader里面只存放单一类型的所有单例实现,存放dubbo扩展的”bean”容器是有多个ExtensionFactory组成的。
5) 支持依赖注入,注入来源(ExtensionFactory)可以自己定义,比如可以来自于SPI,也可以来自于spring容器,ExtensionFactory也是一个扩展,可以自己扩展。查找方式是通过set${ExtName}方法名(ExtName可以替换为任意扩展名称)来注入相关类型对应extName的扩展,找不到就不注入。
6) 可以指定或动态生成自适应扩展类,通过interface方法里@Adaptive注解指定的value值作为key,从配置中(com.alibaba.dubbo.common.URL)获取key对应的extName值,找到对应扩展再调用具体方法实现方法调用自适应

6) 对于拥有构造方法参数为interface类型的扩展,按照顺序依次包装最终扩展实现类,比如ProtocolListenerWrapper-->ProtocolFilterWrapper—->DubboProtocol

7) 可以通过对同一类型不同扩展类名添加@Activate注解,基于@Activate属性group和value获取指定group、指定参数名的扩展

  • dubbo扩展框架实现
    1) 扩展bean类型划分
    每个扩展类似于spring的bean概念,所以我们姑且将dubbo扩展称为extension bean和spring一样,spirng也有不同类型的bean,比如BeanFactoryPostProcessor,BeanPostProceesor,dubbo也不例外也有各种类型的bean
    • ExtensionFactory:用做依赖注入bean的查找,默认实现有SPIExtensionFactory和SpringExtensionFactory
    • Wrapper bean:对普通bean做包装,比如ProtocolFilterWrapper用于应用于请求拦截,如果有多个wrapper bean会依次包装,比如ProtocolListenerWraaper包装ProtocolFilterWrapper,ProtocolFilterWrapper包装DubboProtocol
    • Activate Bean :可以通过给bean添加@Activate注解,达到通过group、value搜索bean的目的,比如希望Filter类型bean在consumer端和provider端使用不同的组合,provider只使用注解了@Activate(group = Constants.PROVIDER)的bean,consumer只使用注解了@Activate(group = Constants.CONSUMER)的bean。
    • Adaptive Bean :自适应bean,如果有bean在类上添加了Adaptive注解可以通过注解查找;如果找不到会通过动态代理生成一个,SPIExtensionFactory如果找到有通过SPI配置的bean,那么它就注入Adaptive Bean。下面是一个Adaptive Bean自动生成的例子
      比如我们以CacheFactory的Adaptive Bean为例
      清单1 CacheFactory.java
      @SPI("lru")
      Public interface CacheFactory{
      @Adaptive("cache")
      Cache getCache(URL url);
      }

清单2 CacheFactory@Adaptive.java

Package com.alibaba.dubbo.cache;
Import com.alibaba.dubbo.common.extension.ExtensionLoader;

Public class CacheFactory$Adpativeimplementscom.alibaba.dubbo.cache.CacheFactory{

Public com.alibaba.dubbo.cache.CachegetCache(com.alibaba.dubbo.common.URL arg0){
    if(arg0 ==null)thrownewIllegalArgumentException("url == null");
    com.alibaba.dubbo.common.URLurl= arg0;
     String extName=url.getParameter("cache","lru");①
     if(extName==null)throw
         new IllegalStateException("Fail to get extension........");
     com.alibaba.dubbo.cache.CacheFactory extension =
(com.alibaba.dubbo.cache.CacheFactory)ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.cache.CacheFactory.class).getExtension(extName);
       return extension.getCache(arg0);②
}
}

清单1中CacheFactory Interface方法@Adaptive注解value属性是“cache”,清单2 CacheFactory@Adaptive即是生成Adaptive bean源码,它首先从URL配置中获取key为cache的value值(①所示),比
如”dubbo://192.168.1.1:2080/xxxx?cache=jcache”如果没有配置”cache”,那么就取extName等于lru的cache bean,取到bean之后再调用对应方法,比如②所示。
2) SPI 格式
dubbo使用的SPI文件格式和JDK SPI(spring、log4j都有用过)有些不同,JDK-SPI文件仅仅是class列表,而dubbo使用的SPI文件是key-value结构,extName=className格式,文件名规则一样,都是寻求实现的interface类全名。还是以cacheFactory为例,cacheFactory的SPI文件是以com.alibaba.dubbo.cache.CacheFactory为文件名,放在目录/META-INFO/dubbo/internal classpath之下的
图片描述
文件内容如下

threadlocal=com.alibaba.dubbo.cache.support.threadlocal.ThreadLocalCacheFactory
lru=com.alibaba.dubbo.cache.support.lru.LruCacheFactory
jcache=com.alibaba.dubbo.cache.support.jcache.JCacheFactory

ExtensionLoader查找步骤:

  • 找到classpath下文件名com.alibaba.dubbo.cache.CacheFactory
  • 以extName为key,Class对象为value放入extensionClasses缓存

3) ExtensionLoader有几个最常用方法,首先构造方法接收一个拥有@SPI注解的Class参数.

Private ExtensionLoader(Class<?> type){
this.type= type;
objectFactory=(type ==ExtensionFactory.class?null:ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension());
}

构造方法是私有的,需要通过getExtensiooader(Class<T> type)来生成和得到ExtensionLoader实例。接下来就可以借助ExtensionLoader根据扩展名找到扩展实例。

public T getExtension(String name)//根据extName获取扩展bean

第一次构造扩展实例成功后会放入ExtensionLoader实例变量中缓存起来,保证单一实例。

public T getAdaptiveExtension() //获取或者生成自适应bean

获得Activate bean可以通过如下方法

//获取Activate bean
public List<T> getActivateExtension(URL url, String key, String group)
  • dubbo扩展框架思维导图
    图片描述
1.5 扩展组件脑图

二级主题是按功能分类,三级主题是接口名,四级主题是扩展名称。
这里不会做一一组件介绍,后文会穿插着介绍。
图片描述

1.6 功能示例说明

官网上已经很详细,这里只作为补充;需要结合官网http://dubbo.io/来看

功能 场景 实现 哪端
启动时检查   Spring容器启动时,需要完成初始化bean,有一个bean初始化失败都会导致spring容器启动失败。每个ReferenceConfig在init的时候会从注册中心寻找provider并建立连接,寻找不到可用provider(建立连接失败,lazy连接除外)就将抛异常导致初始化失败。 Consumer
集群容错&负载均衡   通过注册中心发现和收到的通知得到invoker list,可以将一个invoker理解成封装了与某个provider(比如192.168.100.1:20880)建立的一组连接的抽象。从invoker列表中先筛选出符合路由规则(从注册中心得到)的invoker list,通过负载均衡策略从invoker list得到一个invoker,这就是provider路由选择过程。如果调用失败,可以根据容错策略,比如failover,可以再重新选择新的invoker去远程调用。注意:如果启用了粘滞连接,除非发生失败重选,否则负载均衡策略会失效 Consumer
线程模型 以netty为例,netty会起固定的一些IO线程取做底层网络接口读、写建立连接、关闭连接操作,当数据读完需要处理的时候如果不将处理派发到线程池就会造成网络事件(读时间)长时间得不到处理。PS:netty使用OrderedMemoryAware ThreadPoolExecutor来解决这个问题 Dubbo有两个扩展专门为解决IO线程占用的问题,一个是Dispatcher定义什么事件派发到线程池,一个是threadpool定义线程池类型,建议fixed Consumer & provider
直连提供者   当配置了url,consumer不会从注册中心获取provider Consumer
注册不订阅&订阅不注册 注册:将自己的ip:port和所拥有的服务写入注册中心节点订阅:当注册中心某个节点变更时收到通知比如,provider列表变更会通知consumer Registry client,不订阅就不做subscribe操作不注册就是不做register操作 Consumer & Provider
静态服务   比如zookeeper注册中心,设置dynamic=false,那么在注册的时候,创建的节点就是永久节点,session失效时,节点不会释放,因此consumer也收不到通知,不建议将dynamic设为false,只在需要人工管理和调试时才设置 Consumer & provider
多协议 1不同服务不同协议:根据服务性能的实用性 2同一服务多种协议:满足不同consumer的要求 对于provider每种协议都是单独的sever,支持多个协议需要多个server,比如同时支持dubbo和rmi,需要起两个端口,dubbo起netty server,rmi协议启rmi server  
多注册中心   1、 以zookeeper为例,在多个zookeeper集群中创建节点,使得来自不同注册中心的consumer都可以发现我的服务2、 不同的服务注册到不同的zookeeper集群中 3、 在consumer端,两个reference bean,同一接口引用,可以订阅不同的注册中心 Consumer & provider
服务分组&多版本 同一实现,有不同组,比如场景灰度发布,abtest,推荐算法对比。版本升级时,为了兼容老版本,可以注册多个服务但是版本不一样,服务实现也不一样 Consumer在从注册中心抓取provider信息时,会对信息进行匹配,group和version匹配才会引用 Consumer & provider
分组聚合   Consumer
参数验证   Consumer
结果缓存   通过CacheFilter实现 Consumer
泛化引用 框架集成,可以用于测试,甚至快速写程序,验证provider端是否正常 深入浅出10协议中说了,可以通过接口名、方法名、参数来进行远程调用,既然如此,我们就可以使用万能的GenericService替换任何服务引用达到迅速发起服务的目的。 Consumer
泛化实现 框架集成 Provider
上下文信息    
隐式传参    
异步调用&事件通知 Consumer远程调用有两种方式 1是发送数据同步等待结果返回 2发送完数据(甚至不等数据发出)直接返回,等需要结果时通过get方法等待响应结果,或者不通过get方法而是设置回调方法来处理影响 实现实际上是用户线程将java对象序列化成bytebuffer塞入队列,通信框架中IO线程从队里取出bytebuffer发出。同样接收到数据后,IO线程将bytebuffer decode成request对象,如果用户线程有对结果数据的wait操作,就唤醒用户线程。最高效的方式是不通过get操作而是通过onreturn真正达到无阻塞的目的 Consumer
本地调用    
参数回调    
本地存根 做缓存、预处理、参数验证 Stub类包装service类 Consumer 但是需要通过provider配置,来使consumer启用存根的执行
本地伪装     Provider
并发控制 executes、actives 通过executeLimitFilter和ActiveLimitFilter实现,在调用前计数器加1,调用后计算器减1 Provider & consumer
连接控制 accpets,connections 去收到连接请求时,判断连接数是否大于配置阀值,大于则关闭对于connection,是consumer在refer provider时,对一个服务建立的连接数 Provider &Consumers
延迟连接    
粘滞连接   使用粘滞连接,只有在调用失败的情况下才会启用负载均衡策略,否则每次都是同一个provider Consumer
令牌验证   通过tokenFilter实现 Consumer provider
路由规则   通过管理端向注册中心,/xxx/routers节点下添加路由规则,consumer收到通知更新本地路由规则 Consumer
配置规则   同样通过管理端向注册中心 /xxx/configurators 添加配置信息,provider和consumer收到通知后重新export或者refer.Provider收到通知后不需要重启server,只需要动态修改配置即可,包括acceptes,心跳间隔,派发线程池最大最小线程数量等等。而consumer没有那么幸运,每次收到修改通知哪怕一个参数都需要重建连接。 Consumer & provider
延迟暴露     Provider
服务容器     Consumer & provider
服务降级    
优雅停机    
主机绑定    
日志适配    
服务容器     Consumer & provider
访问日志   通过accessFilter实现 Provider
相关标签: JAVA

本文原创发布于慕课网 ,转载请注明出处,谢谢合作!

21  推荐

相关阅读

  • 最简单的Dubbo框架+SOA服务入门教程(附源码)

  • 从motan看RPC框架设计

  • Dubbo+zookeeper实现分布式服务框架

  • Spring Cloud(一)服务的注册与发现(Eureka)

  • 学习微服务首先要了解为什么使用微服务


作者: 格_鲁 
链接:https://www.imooc.com/article/22585
来源:慕课网
本文原创发布于慕课网 ,转载请注明出处,谢谢合作!

作者: 格_鲁 
链接:https://www.imooc.com/article/22585
来源:慕课网
本文原创发布于慕课网 ,转载请注明出处,谢谢合作!
Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐