关于dubbo:Dubbogo-Server端开启服务过程

4次阅读

共计 13001 个字符,预计需要花费 33 分钟才能阅读完成。

导读

导读:随着微服务架构的风行,许多高性能 rpc 框架应运而生,由阿里开源的 dubbo 框架 go 语言版本的 dubbo-go 也成为了泛滥开发者不错的抉择。本文将介绍 dubbo-go 框架的根本应用办法,以及从 export 调用链的角度进行 server 端源码导读,心愿能疏导读者进一步意识这款框架。下周将发表本文的姊妹篇:《从 client 端源码导读 dubbo-go 框架》。

序言

近日浏览了局部 dubbo-go 源码
https://github.com/dubbogo/dubbo-go

当拿到一款框架之后,一种不错的源码浏览形式大抵如下:从运行最根底的 helloworld demo 源码开始,再查看配置文件,开启各种依赖服务(比方 zk、consul),开启服务端,再到通过 client 调用服务端,打印残缺申请日志和回包。调用胜利之后,再依据框架的设计模型,从配置文件解析开始,自顶向下递浏览整个框架的调用栈。
对于 C / S 模式的 rpc 申请来说,整个调用栈被拆成了 client 和 server 两局部,所以能够别离从 server 端的配置文件解析浏览到 server 端的监听启动,从 client 端的配置文件解析浏览到一次 invoker Call 调用。这样一次残缺申请就清晰了起来。

1. 运行官网提供的 helloworld-demo

官网 demo

1.1 dubbo-go 2.7 版本 QuickStart

1. 开启一个 go-server 服务
  • 将仓库 clone 到本地

$ git clone https://github.com/dubbogo/dubbo-samples.git

  • 进入 dubbo 目录

$ cd dubbo-samples/golang/helloworld/dubbo

进入目录后可看到四个文件夹,别离反对 go 和 java 的 client 以及 server,咱们尝试运行一个 go 的 server
进入 app 子文件夹内,能够看到外面保留了 go 文件。

$ cd go-server/app

  • sample 文件构造:

可在 go-server 外面看到三个文件夹:app、assembly、profiles
其中 app 文件夹下保留 go 源码,assembly 文件夹下保留可选的针对特定环境的 build 脚本,profiles 下保留配置文件。对于 dubbo-go 框架,配置文件十分重要,没有文件将导致服务无奈启动。

  • 设置指向配置文件的环境变量

因为 dubbo-go 框架依赖配置文件启动,让框架定位到配置文件的形式就是通过环境变量来找。对于 server 端须要两个必须配置的环境变量:CONF_PROVIDER_FILE_PATH、APP_LOG_CONF_FILE,别离应该指向服务端配置文件、日志配置文件。
在 sample 外面,咱们能够应用 dev 环境,即 profiles/dev/log.yml profiles/dev/server.yml 两个文件。
在 app/ 下,通过命令行中指定好这两个文件:

$ export CONF_PROVIDER_FILE_PATH=”../profiles/dev/server.yml”
$ export APP_LOG_CONF_FILE=”../profiles/dev/log.yml”

  • 设置 go 代理并运行服务

$ go run .

如果提醒 timeout,则须要设置 goproxy 代理

$ export GOPROXY=”http://goproxy.io”

再运行 go run 即可开启服务

2. 运行 zookeeper

装置 zookeeper,并运行 zkServer, 默认为 2181 端口

3. 运行 go-client 调用 server 服务
  • 进入 go-client 的源码目录

$ cd go-client/app

  • 同理,在 /app 下配置环境变量

$ export CONF_CONSUMER_FILE_PATH=”../profiles/dev/client.yml”
$ export APP_LOG_CONF_FILE=”../profiles/dev/log.yml”

配置 go 代理

$ export GOPROXY=”http://goproxy.io”

  • 运行程序

$ go run .

即可在日志中找到打印出的申请后果

response result: &{A001 Alex Stocks 18 2020-10-28 14:52:49.131 +0800 CST}

同样,在运行的 server 中,也能够在日志中找到打印出的申请:

req:[]interface {}{“A001”}
rsp:main.User{Id:”A001″, Name:”Alex Stocks”, Age:18, Time:time.Time{…}

祝贺!一次基于 dubbo-go 的 rpc 调用胜利。

4. 常见问题
    1. 当日志开始局部呈现 profiderInit 和 ConsumerInit 均失败的日志。查看环境变量中配置门路是否正确,配置文件是否正确。
    1. 当日志中呈现 register 失败的状况,个别为向注册核心注册失败,查看注册核心是否开启,查看配置文件中对于 register 的端口是否正确。
    1. sample 的默认开启端口为 20000,确保启动前无占用

1.2 配置环境变量

export APP_LOG_CONF_FILE="../profiles/dev/log.yml"
export CONF_CONSUMER_FILE_PATH="../profiles/dev/client.yml"

1.3 服务端源码

1. 目录构造

dubbo-go 框架的 example 提供的目录如下:

  • app/ 文件夹下寄存源码,能够本人编写环境变量配置脚本 buliddev.sh
  • assembly/ 文件夹下寄存不同平台的构建脚本
  • profiles/ 文件夹下寄存不同环境的配置文件
  • target/ 文件夹下寄存可执行文件
2. 要害源码

源码搁置在 app/ 文件夹下,次要蕴含 server.go 和 user.go 两个文件,顾名思义,server.go 用于应用框架开启服务以及注册传输协定,user.go 则定义了 rpc-service 构造体,以及传输协定的构造。
user.go

func init() {config.SetProviderService(new(UserProvider))
    // ------for hessian2------
    hessian.RegisterPOJO(&User{})
}
type User struct {
    Id   string
    Name string
    Age  int32
    Time time.Time
}
type UserProvider struct {
}
func (u *UserProvider) GetUser(ctx context.Context, req []interface{}) (*User, error) {

能够看到,user.go 中存在 init 函数,是服务端代码中最先被执行的局部。User 为用户自定义的传输构造体,UserProvider 为用户自定义的 rpc_service。
蕴含一个 rpc 函数,GetUser。
当然,用户能够自定义其余的 rpc 性能函数。
在 init 函数中,调用 config 的 SetProviderService 函数,将以后 rpc_service 注册在框架 config 上。
能够查看 dubbo 官网文档提供的设计图

service 层上面就是 config 层,用户服务会逐层向下注册,最终实现服务端的裸露。
rpc-service 注册结束之后,调用 hessian 接口注册传输构造体 User。
至此 init 函数执行结束
server.go

// they are necessary:
//      export CONF_PROVIDER_FILE_PATH="xxx"
//      export APP_LOG_CONF_FILE="xxx"
func main() {hessian.RegisterPOJO(&User{})
    config.Load()
    initSignal()}
func initSignal() {signals := make(chan os.Signal, 1)
    ...

之后执行 main 函数
main 函数中只进行了两个操作,首先应用 hessian 注册组件将 User 构造体注册(与之前略有反复),从而能够在接下来应用 getty 打解包。
之后调用 config.Load 函数,该函数位于框架 config/config_loader.go 内,这个函数是整个框架服务的启动点,上面会具体讲这个函数内重要的配置处理过程 。执行完 Load() 函数之后,配置文件会读入框架,之后依据配置文件的内容,将注册的 service 实现到配置构造里,再调用 Export 裸露给特定的 registry,进而开启特定的 service 进行对应端口的 tcp 监听,胜利启动并且裸露服务。
最终开启信号监听 initSignal()优雅地完结一个服务的启动过程。

1.4 客户端源码

客户端蕴含 client.go 和 user.go 两个文件,其中 user.go 与服务端完全一致,不再赘述。
client.go

// they are necessary:
//      export CONF_CONSUMER_FILE_PATH="xxx"
//      export APP_LOG_CONF_FILE="xxx"
func main() {hessian.RegisterPOJO(&User{})
    config.Load()
    time.Sleep(3e9)
    println("\n\n\nstart to test dubbo")
    user := &User{}
    err := userProvider.GetUser(context.TODO(), []interface{}{"A001"}, user)
    if err != nil {panic(err)
    }
    println("response result: %v\n", user)
    initSignal()}

main 函数和服务端也相似,首先将传输构造注册到 hessian 上,再调用 config.Load()函数。在下文会介绍,客户端和服务端会依据配置类型执行 config.Load()中特定的函数 loadConsumerConfig()和 loadProviderConfig(),从而达到“开启服务”、“调用服务”的目标。
加载完配置之后,还是通过实现服务,减少函数 proxy,申请 registry,reloadInvoker 指向服务端 ip 等操作,重写了客户端实例 userProvider 的对应函数,这时再通过调用 GetUser 函数,能够间接通过 invoker,调用到曾经开启的服务端,实现 rpc 过程。
上面会从 server 端和 client 端两个角度,具体解说服务启动、registry 注册、调用过程:

1.5 自定义配置文件(非环境变量)办法

1.5.1 服务端自定义配置文件
  1. var providerConfigStr = xxxxx// 配置文件内容,能够参考 log 和 client

    • 在这里你能够定义配置文件的获取形式,比方配置核心,本地文件读取
  2. config.Load() 之前设置配置,例如:
func main() {hessian.RegisterPOJO(&User{})
    providerConfig := config.ProviderConfig{}
    yaml.Unmarshal([]byte(providerConfigStr), &providerConfig)
    config.SetProviderConfig(providerConfig)
    defaultServerConfig := dubbo.GetDefaultServerConfig()
    dubbo.SetServerConfig(defaultServerConfig)
    logger.SetLoggerLevel("warn") // info,warn
    config.Load()
    select {}}
1.5.2 客户端自定义配置文件
  1. var consumerConfigStr = xxxxx// 配置文件内容,能够参考 log 和 client

    • 在这里你能够定义配置文件的获取形式,比方配置核心,本地文件读取
  2. config.Load() 之前设置配置,例如:
func main() {p := config.ConsumerConfig{}
     yaml.Unmarshal([]byte(consumerConfigStr), &p)
     config.SetConsumerConfig(p)
     defaultClientConfig := dubbo.GetDefaultClientConfig()
     dubbo.SetClientConf(defaultClientConfig)
     logger.SetLoggerLevel("warn") // info,warn
     config.Load()

     user := &User{}
     err := userProvider.GetUser(context.TODO(), []interface{}{"A001"}, user)
     if err != nil {log.Print(err)
         return
     }
  log.Print(user)
}

2. server 端:

服务裸露过程波及到屡次原始 rpcService 的封装、裸露,网上其余文章的图感觉太过抽象,我简要的绘制了一个用户定义服务的数据流图

2.1 加载配置

2.1.1 框架初始化

在加载配置之前,框架提供了很多已定义好的协定、工厂等组件,都会在对应模块 init 函数内注册到 extension 模块上,以供接下来配置文件中进行选用。
其中重要的有:

  1. 默认函数代理工厂
    common/proxy/proxy_factory/default.go
func init() {extension.SetProxyFactory("default", NewDefaultProxyFactory)
}

他的作用是将原始 rpc-service 进行封装,造成 proxy_invoker,更易于实现近程 call 调用,详情可见其 invoke 函数。

  1. 注册核心注册协定
    registry/protocol/protocol.go
func init() {extension.SetProtocol("registry", GetProtocol)
}

他负责在将 invoker 裸露给对应注册核心,比方 zk 注册核心。

  1. zookeeper 注册协定
    registry/zookeeper/zookeeper.go
func init() {extension.SetRegistry("zookeeper", newZkRegistry)
}

他合并了 base_resiger,负责在服务裸露过程中,将服务注册在 zookeeper 注册器上,从而为调用者提供调用办法。

  1. dubbo 传输协定
    protocol/dubbo/dubbo.go
func init() {extension.SetProtocol(DUBBO, GetProtocol)
}

他负责监听对应端口,将具体的服务裸露,并启动对应的事件 handler,将近程调用的 event 事件传递到 invoker 外部,调用本地 invoker 并取得执行后果返回。

  1. filter 包装调用链协定
    protocol/protocolwrapper/protocol_filter_wrapper.go
func init() {extension.SetProtocol(FILTER, GetProtocol)
}

他负责在服务裸露过程中,将代理 invoker 打包,通过配置好的 filter 造成调用链,并交付给 dubbo 协定进行裸露。
上述提前注册好的框架已实现的组件,在整个服务裸露调用链中都会用到,会依据配置取其所需。

2.1.2 配置文件

服务端须要的重要配置有三个字段:services、protocols、registries
profiles/dev/server.yaml:

registries :
  "demoZk":
    protocol: "zookeeper"
    timeout    : "3s"
    address: "127.0.0.1:2181"
services:
  "UserProvider":
    # 能够指定多个 registry,应用逗号隔开; 不指定默认向所有注册核心注册
    registry: "demoZk"
    protocol : "dubbo"
    # 相当于 dubbo.xml 中的 interface
    interface : "com.ikurento.user.UserProvider"
    loadbalance: "random"
    warmup: "100"
    cluster: "failover"
    methods:
    - name: "GetUser"
      retries: 1
      loadbalance: "random"
protocols:
  "dubbo":
    name: "dubbo"
    port: 20000

其中 service 指定了要裸露的 rpc-service 名(”UserProvider),裸露的协定名(”dubbo”),注册的协定名 (“demoZk”),裸露的服务所处的 interface,负载平衡策略,集群失败策略,调用的办法等等。
其中两头服务的协定名须要和 registries 下的 mapkey 对应,裸露的协定名须要和 protocols 下的 mapkey 对应
能够看到上述例子中,应用了 dubbo 作为裸露协定,应用了 zookeeper 作为两头注册协定,并且给定了端口。如果 zk 须要设置用户名和明码,也能够在配置中写好。

2.1.3 配置文件的读入和查看

config/config_loader.go:: Load()
在上述 example 的 main 函数中,有 config.Load()函数的间接调用,该函数执行细节如下:

// Load Dubbo Init
func Load() {
    // init router
    initRouter()
    // init the global event dispatcher
    extension.SetAndInitGlobalDispatcher(GetBaseConfig().EventDispatcherType)
    // start the metadata report if config set
    if err := startMetadataReport(GetApplicationConfig().MetadataType, GetBaseConfig().MetadataReportConfig); err != nil {logger.Errorf("Provider starts metadata report error, and the error is {%#v}", err)
  return
    }
    // reference config
    loadConsumerConfig()
    // service config
    loadProviderConfig()
    // init the shutdown callback
    GracefulShutdownInit()}

在本文中,咱们重点关怀 loadConsumerConfig()和 loadProviderConfig()两个函数
对于 provider 端,能够看到 loadProviderConfig()函数代码如下:

前半部分是配置的读入和查看,进入 for 循环后,是单个 service 的裸露起始点。
后面提到,在配置文件中曾经写好了要裸露的 service 的种种信息,比方服务名、interface 名、method 名等等。在图中 for 循环内,会将所有 service 的服务顺次实现。
for 循环的第一行,依据 key 调用 GetProviderService 函数,拿到注册的 rpcService 实例,这里对应上述提到的 init 函数中用户手动注册的本人实现的 rpc-service 实例:

这个对象也就成为了 for 循环中的 rpcService 变量,将这个对象注册通过 Implement 函数写到 sys(ServiceConfig 类型)上,设置好 sys 的 key 和协定组,最终调用了 sys 的 Export 办法。
此处对应流程图的局部:

至此,框架配置构造体曾经拿到了所有 service 无关的配置,以及用户定义好的 rpc-service 实例,他触发了 Export 办法,旨在将本人的实例裸露进来。这是 Export 调用链的起始点。

2.2 原始 service 封装入 proxy_invoker

config/service_config.go :: Export()
接下来进入 ServiceConfig.Export()函数:
这个函数进行了一些细碎的操作,比方为不同的协定调配随机端口,如果指定了多个核心注册协定,则会将服务通过多个核心注册协定的 registryProtocol 裸露进来,咱们只关怀对于一个注册协定是如何操作的。还有一些操作比方生成调用 url 和注册 url,用于为裸露做筹备。

2.2.1 首先通过配置生成对应 registryUrl 和 serviceUrl

registryUrl 是用来向核心注册组件发动注册申请的,对于 zookeeper 的话,会传入其 ip 和端口号,以及附加的用户名明码等信息
这个 regUrl 目前只存有注册(zk)相干信息,后续会补写入 ServiceIvk,即服务调用相干信息,外面蕴含了办法名,参数等 …

2.2.2 对于一个注册协定,将传入的 rpc-service 实例注册在 common.ServiceMap

这个 Register 函数将服务实例注册了两次,一次是以 Interface 为 key 写入接口服务组内,一次是以 interface 和 proto 为 key 写入特定的一个惟一的服务。
后续会从 common.Map 外面取出来这个实例。

2.2.3 获取默认代理工厂,将实例封装入代理 invoker
// 拿到一个 proxyInvoker,这个 invoker 的 url 是传入的 regUrl,这个中央将下面注册的 service 实例封装成了 invoker
// 这个 GetProxyFactory 返回的默认是 common/proxy/proxy_factory/default.go
// 这个默认工厂调用 GetInvoker 取得默认的 proxyInvoker,保留了以后注册 url
invoker := extension.GetProxyFactory(providerConfig.ProxyFactory).GetInvoker(*regUrl)
// 裸露进去 生成 exporter, 开启 tcp 监听
// 这里就该跳到 registry/protocol/protocol.go registryProtocol 调用的 Export,将以后 proxyInvoker 导出
exporter = c.cacheProtocol.Export(invoker)

这一步的 GetProxyFactory(“default”)办法获取默认代理工厂,通过传入上述结构的 regUrl,将 url 封装入代理 invoker。
能够进入 common/proxy/proxy_factory/default.go::ProxyInvoker.Invoke()函数里,看到对于 common.Map 取用为 svc 的局部,以及对于 svc 对应 Method 的理论调用 Call 的函数如下:

到这里,下面 GetInvoker(*regUrl)返回的 invoker 即为 proxy_invoker,他封装好了用户定义的 rpc_service,并将具体的调用逻辑封装入了 Invoke 函数内。

为什么应用 Proxy_invoker 来调用?
我认为,通过这个 proxy_invoke 调用用户的性能函数,调用形式将更加抽象化,能够在代码中看到,通过 ins 和 outs 来定义入参和出参,将整个调用逻辑抽象化为 invocation 构造体,而将具体的函数名的抉择,参数向下传递,reflect 反射过程封装在 invoke 函数内,这样的设计更有利于之后近程调用。我认为这是 dubbo Invoke 调用链的设计思维。

至此,实现了图中对应的局部:

2.3 registry 协定在 zkRegistry 上裸露下面的 proxy_invoker

下面,咱们执行到了 exporter = c.cacheProtocol.Export(invoker)
这里的 cacheProtocol 为一层缓存设计,对应到原始的 demo 上,这里是默认实现好的 registryProtocol
registry/protocol/protocol.go:: Export()
这个函数内结构了多个 EventListener,十分有 java 的设计感。
咱们只关怀服务裸露的过程,先疏忽这些监听器。

2.3.1 获取注册 url 和服务 url

2.3.2 获取注册核心实例 zkRegistry

一层缓存操作,如果 cache 没有须要从 common 外面从新拿 zkRegistry

2.3.3 zkRegistry 调用 Registry 办法,在 zookeeper 上注册 dubboPath

上述拿到了具体的 zkRegistry 实例,该实例的定义在
registry/zookeeper/registry.go

该构造体组合了 registry.BaseRegistry 构造,base 构造定义了注册器根底的性能函数,比方 Registry、Subscribe 等,但在这些默认定义的函数外部,还是会调用 facade 层(zkRegistry 层)的具体实现函数,这一设计模型能在保障已有性能函数不须要反复定义的同时,引入外层函数的实现,相似于构造体继承却又复用了代码。
这一设计模式我认为值得学习。
咱们查看上述 registry/protocol/protocol.go:: Export()函数,间接调用了:

// 1. 通过 zk 注册器,调用 Register()函数,将已有 @root@rawurl 注册到 zk 上
    err := reg.Register(*registeredProviderUrl)

将已有 RegistryUrl 注册到了 zkRegistry 上。
这一步调用了 baseRegistry 的 Register 函数,进而调用 zkRegister 的 DoRegister 函数,进而调用:

在这个函数里,将对应 root 发明一个新的节点

并且写入具体 node 信息,node 为 url 通过 encode 的后果,蕴含了服务端的调用形式。
这部分的代码较为简单,具体能够看 baseRegistry 的 processURL()函数。
至此,将服务端调用 url 注册到了 zookeeper 上,而客户端如果想获取到这个 url,只须要传入特定的 dubboPath,向 zk 申请即可。目前 client 是能够获取到拜访形式了,但服务端的特定服务还没有启动,还没有开启特定协定端口的监听,这也是 registry/protocol/protocol.go:: Export()函数接下来要做的事件:

2.3.4 proxy_invoker 封装入 wrapped_invoker,失去 filter 调用链
// invoker 封装入 warppedInvoker
wrappedInvoker := newWrappedInvoker(invoker, providerUrl)
// 通过为 invoker 减少 filter 调用链,再应用 dubbo 协定 Export,开启 service 并且返回了 Exporter。// export_1
cachedExporter = extension.GetProtocol(protocolwrapper.FILTER).Export(wrappedInvoker)

新建一个 WrappedInvoker,用于之后链式调用
拿到提前实现并注册好的 ProtocolFilterWrapper,调用 Export 办法,进一步裸露。
protocol/protocolwrapped/protocol_filter_wrapper.go:Export()

protocol/protocolwrapped/protocol_filter_wrapper.go:buildInvokerChain

可见,依据配置的内容,通过链式调用的结构,层层将 proxy_invoker 包裹在调用链的最底部,最终返回一个调用链 invoker。
对应图中局部:

至此,咱们曾经拿到 filter 调用链,期待将这个 chain 裸露到特定端口,用于相应申请事件

2.3.5 通过 dubbo 协定裸露 wrapped_invoker

protocol/protocolwrapped/protocol_filter_wrapper.go:Export()

// 通过 dubbo 协定 Export  dubbo_protocol 调用的 export_2
    return pfw.protocol.Export(invoker)

回到上述 Export 函数的最初一行,调用了 dubboProtocol 的 Export 办法,将上述 chain 真正裸露。
该 Export 办法的具体实现在
protocol/dubbo/dubbo_protocol.go: Export()

这一函数做了两个事件:结构触发器、启动服务

  1. 将传入的 Invoker 调用 chain 进一步封装,封装成一个 exporter,再将这个 export 放入 map 保留。留神!这里昂 exporter 放入了 SetExporterMap 中,在上面服务启动的时候,会以注册事件监听器的模式将这个 exporter 取出!
  2. 调用 dubboProtocol 的 openServer 办法,开启一个针对特定端口的监听

如上图所示,一个 Session 被传入,开启对应端口的事件监听。
至此结构出了 exporter,实现图中局部:

2.4 注册触发动作

上述只是启动了服务,但还没有看到触发事件的细节,点进下面的 s.newSession 能够看到,dubbo 协定为一个 getty 的 session 默认应用了如下配置

其中很重要的一个配置是 EventListener,传入的是 dubboServer 的默认 rpcHandler
protocol/dubbo/listener.go:OnMessage()
rpcHandler 有一个实现好的 OnMessage 函数,依据 getty 的 API,当 client 调用该端口时,会触发 OnMessage

// OnMessage notified when RPC server session got any message in connection
func (h *RpcServerHandler) OnMessage(session getty.Session, pkg interface{}) {

这一函数实现了在 getty session 接管到 rpc 调用后的一系列解决:

  • 传入包的解析

  • 依据申请包结构申请 url

  • 拿到对应申请 key,找到要被调用的 exporter

  • 拿到对应的 Invoker

  • 结构 invocation

  • 调用

  • 返回

整个被调过程零打碎敲。实现了从 getty.Session 的调用事件,到通过层层封装的 invoker 的调用。
至此,一次 rpc 调用得以正确返回。

3. 小结

  • 对于 Invoker 的层层封装
    能把一次调用形象成一次 invoke,能把一个协定形象成针对 invoke 的封装,能把针对一次 invoke 所做出的特定扭转封装到 invoke 函数外部,能够升高模块之间的耦合性。层层封装逻辑更加清晰
  • 对于 URL 的形象
    对于 dubbo 的统一化申请对象 URL 的极度形象是我之前没有见过的 … 我认为这样封装能保障申请参数列表的简化和统一。但我认为在开发的过程中,滥用极度形象的接口可能造成 …debug 的艰难?以及不晓得哪些字段是以后曾经封装好的,哪些字段是无用的。
  • 对于协定的了解
    之前了解的协定还是太过具体化了,而对于 dubbo-go 对于 dubboProtocol 的协定,我认为是基于 getty 的进一步封装,他定义了客户端和服务端,对于 getty 的 session 应该有哪些特定的操作,从而保障主和谐被调的协定一致性,而这种保障也是一种协定的体现,是由 dubbo 协定来标准的。

如果你有任何疑难,欢送钉钉扫码退出交换群【钉钉群号 23331795】:

作者简介

李志信 (GitHubID LaurenceLiZhixin),中山大学软件工程业余在校学生,善于应用 Java/Go 语言,专一于云原生和微服务等技术方向。

正文完
 0