菜单

nameko微服务框架实现原理解析

2017年7月6日 - 开源项目, 架构

目录

 

微服务概念

nameko介绍

认识关键概念

服务启动流程

服务交互方式

系统监控方案

结尾

相关链接

 

微服务概念

随着云计算大数据技术的兴起,微服务也是近几年炒得比较火的一种应用架构,这种架构倡导一个应用由一系列的微型服务组成,每个微服务完成一些特定的业务逻辑,独立发布独立运行,这样一个大型应用就变成由多个微服务组成的松耦合架构。相对于传统的单体架构,微服务框架因为松耦合,在持续集成持续部署方面有天然的优势,同样的,在开发过程中,不同服务可以采用不同的编程语言和开发环境,不同服务更容易分派给不同的开发人员进行开发,只要他们定义好了接口规则,就可以做到并行开发。此外,一个微服务可以被多个其他的微服务调用,使得微服务架构也能更好地促进功能复用和模块化。

 

nameko介绍

nameko­是基于微服务架构理念的一个开源框架,使用Python编写,框架的目的是让开发者更多的聚焦在业务逻辑和功能的实现上,而底层的通信机制则由框架本身来解决,内建的通信机制如下:

  1. RPC over AMQP,基于AMQP的RPC方式;
  2. Asynchronous events (pub-sub) over AMQP,基于AMQP的异步事件发布-订阅;
  3. Simple HTTP GET and POST,简单的HTTP请求,GET和POST;
  4. Websocket RPC and subscriptions (experimental),WebSocket RPC;

同时,nameko是可扩展的,用户还可以定义自己的通信机制融入到框架中。nameko框架在设计的时候同样考虑到了可测试性,在接下来的分析中我们也将会谈到这一点。

 

认识关键概念

 

Service

在nameko中,Service就是服务,对应到代码中就是一个Python类,每个类有个name属性表示这个类所对应服务的名称,并通过成员函数封装应用逻辑,以类属性形式声明应用逻辑需要依赖的其他服务,同时通过入口点(EntryPoint)修饰器将接口暴露出来供其它服务调用。一个典型的服务类结构如下图:

 

EntryPoints

EntryPoints即入口点,是它所修饰的函数的网关,它一般监听一些外部实体,如消息队列。当收到对应的消息时,入口点被触发,其所修饰函数就会在Worker中被执行。

 

Dependencies

Dependencies即依赖者,因为拆分成微服务以后,很多服务都是需要调用其他服务的功能来完成某项业务,而其他服务协助完成的功能又不是本服务核心业务逻辑的一部分,这时候就将这部分非核心业务功能抽出来写成Dependency的形式,如果有另外一些服务也需要以依赖者的形式使用该Dependency,Dependency的代码就得到了复用,而不需要每个服务都去实现一遍类似于这个Dependency的功能。和EntryPoints类似,Dependencies也可以看作是网关,它是本服务与其他外部实体(如其他服务,外部API等)之间的网关。

 

Workers

Workers即工作者,是当一个EntryPoints触发时被创建。一个工作者就是Service类的一个实例,并且Service类所声明的Dependency这个时候就被注入到Worker,协同完成业务功能。Worker的生命周期在Service函数调用完毕就结束了,一个Service可以同时运行多个Workers,具体的Workers数目可自定义。

 

Dependency Injection

依赖注入,是以类属性的形式在Service类中声明,比如上图中的代码:

other_rpc = RpcProxy(“another_service”)

RpcProxy是一个依赖提供(Dependency Provider)类,继承自DependencyProvider类,RpcProxy类实现了一个叫“get_dependency”的接口,这个接口返回一个对象并注入到Worker中。Worker的生命周期如下:

  1. EntryPoint触发;
  2. 从Service类实例化Worker;
  3. Dependencies注入到Worker;
  4. Method方法执行;
  5. Worker被销毁;

以下是Worker生命周期的伪代码:

worker = Service()

worker.other_rpc = worker.other_rpc.get_dependency()

worker.method()

del worker

依赖提供者RpcProxy的生命周期和Service服务一样,而实际的get_dependency返回的注入对象的生命周期取决于其所注入的Worker实例。

 

Concurrency

nameko框架基于eventlet库,eventlet通过greenthreads来提供并发,这种并发就是协程,而不是操作系统级别的多线程。关于协程,可参考我之前写的一篇文章,这里。Greenthreads翻译过来就是绿色线程,也就是协程,每个Worker运行在一个协程中。

 

Extensions

Extension即扩展,在nameko中,所有的EntryPoints和Dependency Provider都以扩展的形式提供,每个扩展类都继承自基类nameko.extensions.Extension,这个类定义了一个Extension类的基本结构。

 

服务启动流程

在Linux系统中运行nameko服务,命令行格式如下:

nameko run module:[ServiceClass]

如果不指定类名ServiceClass,则模块中所有的类都会被检查是否包含某些EntryPoints,包含有EntryPoint的类将被当作Service类并保存其相应的信息供后面使用,如果都没有发现包含EntryPoints的类则报错。检查是否有EntryPoints的代码位于run.py文件,这部分代码如下图:

假设我们编写了一个test.py模块,模块中只有一个类Compute,类中有@rpc修饰的函数,那要运行这个Compute服务,只需要执行以下命令:

nameko run test

接下来我们跟踪执行一下这个命令,看看服务运行的顺序是怎样的。

从上面的命令很容易看出,nameko是可执行的命令,后面的“run test”都是参数,所以我们先找到nameko这个文件。在命令行中执行:“which nameko”,输出结果如下:

[root@centos-7 ~]# which nameko

/usr/bin/nameko

根据输出我们知道nameko可执行文件的全路径是/usr/bin/nameko,然后我们在命令行中执行“file /usr/bin/nameko”,输出结果如下:

[root@centos-7 ~]# file /usr/bin/nameko

/usr/bin/nameko: Python script, ASCII text executable

可以看出,这个是一个Python脚本文件,我们可以查看该文件内容,如下:

[root@centos-7 ~]# cat /usr/bin/nameko

#!/usr/bin/python

# EASY-INSTALL-ENTRY-SCRIPT: ‘nameko==2.5.2′,’console_scripts’,’nameko’

__requires__ = ‘nameko==2.5.2’

import re

import sys

from pkg_resources import load_entry_point

if __name__ == ‘__main__’:

sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$’, ”, sys.argv[0])

sys.exit(

load_entry_point(‘nameko==2.5.2’, ‘console_scripts’, ‘nameko’)()

)

为了跟踪调试,我在该文件中import pdb模块,修改后文件内容大致如下:

……此处省略部分代码

if __name__ == ‘__main__’:

import pdb

pdb.set_trace()

sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$’, ”, sys.argv[0])

sys.exit(

load_entry_point(‘nameko==2.5.2’, ‘console_scripts’, ‘nameko’)()

)

然后运行命令“nameko run test”,界面如下:

[root@centos-7 ~]# nameko run test

> /usr/bin/nameko(11)<module>()

-> sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$’, ”, sys.argv[0])

(Pdb)

可见代码在pdb.set_trace()这个语句之后停了下来,接着我们就可以进入调试器跟踪代码执行流程。

首先我们逐条语句运行到代码

sys.exit(

load_entry_point(‘nameko==2.5.2’, ‘console_scripts’, ‘nameko’)()

)

处,然后执行“s”单步进入函数load_entry_point,经过一系列的执行后,界面如下:

可以看到,前面的代码路径都是了来自Python库的pkg_resource模块,功能主要就是从已经安装的nameko的安装信息中找到入口函数,后面终于进入到了cli/main.py/main()函数,这就是nameko框架提供的一个入口代码。那么这个入口代码位置是哪里定义的呢?我们看看load_entry_point()这个函数的参数,再看看nameko所提供的setup.py文件里面的内容,部分内容如下:

就算我们不熟悉Python安装方式,不熟悉pkg_resource功能,也大概能猜出来,图中画蓝线部分的内容是load_entry_point()函数的参数,这个函数通过这三个参数定位到了红线所示内容,即代码入口处nameko.cli.main:main()。接下来的分析,就是nameko框架自身的调用流程,即我们想弄明白自己编写的test模块的服务类Compute是如何被运行起来的。

光看静态代码有时候代码流程进入了多个不同文件之后,我们往往会忘记参数的传递链是怎样,也就是不知道刚开始的一些参数是怎么一步步传到当前文件被调用的函数里面,而动态调试则可以避免这个问题。我们在命令行执行“nameko run test”,因为我在上面的代码中引入了pdb模块,因此程序运行后在指定的地方停了下来,接着我们就可以进行单步调试,可以打印参数值等。

程序在cli/main.py文件的main函数入口处停下来,我们可以打印args这个变量,其值如下图所示:

broker就是我们RPC使用的rabbitmq服务的地址,services列表只含有一个值,名称是test,是我们在从命令传入的模块名称,main指向的是commands.py文件第85行代码中的main函数,如下图所示:

commands中的main函数又调用了run.py文件中第181行的main函数,后者调用了一个run函数,顾名思义我们知道这里是我们自己编写的服务test模块运行的入口。在进入run函数之前,我们打印传过来的参数,看到services的类型是我们在test模块中编写的Compute类,config是rabbitmq服务地址,如上图所示。

再经过一系列的运行,我们来到run函数中的“service_runner = ServiceRunner(config)”代码行,执行s命令则进入ServiceRunner的构造函数__init__,从图中可以看到这个类是在runner.py文件中声明的,继续跟踪执行知道__init__函数中的代码行“self.container_cls = container_cls”处,container_cls是ServiceRunner类的一个属性,我们打印一下container_cls如下图,看到它的类型是ServiceContainer类,这个类在container.py文件中声明。

上图中我们进入到了ServiceRunner的构造函数中,位于runners.py文件,之后我们调出来回到run.py继续执行其中的run函数,顺便看一下这个run函数后面部分的代码,如下图:

在run函数里边,遍历了services变量,service_runner对其中每个值调用了add_service函数。从之前我们打印参数的图中可以知道,这个例子中services只包含有一个值,这个值就是test.Compute这个class本身。在service_runner调用add_service后,接着调用service_runner.start()真正启动服务。我们顺便也看下add_service这个函数的代码,如下图:

可以看到,add_service的参数cls是一个类型(Python中的类型也是一个对象),我们这个例子中就是test.Compute这个类,这个函数首先取得服务名称“compute”,然后创建一个容器对象,容器类型为前面打印出来的ServiceContainer类,并将test.Compute类作为一个参数传递进来,之后在self.service_map键值对中保存服务名称和容器信息,内容如下图中所示:

我们跟踪进入service_runner.start()函数,来到代码行“SpawningProxy(self.containers).start()”处。我们先看下self.containers是什么,看下图:

图中看到self.containers会返回self.service_map键值对中值的部分,即全部的ServiceContainer对象,在这个例子中我们只有一个包装了test.Compute类的ServiceContainer对象。继续执行,看下SpawningProxy的start调用是什么回事,如下图:

可以看到这时候直接进入了SpawningProxy类的__getattr__()函数,传递进来的参数name值为“start”,如上图所示。我们干脆看下SpawningProxy这个类的代码,在utils.py中,如下图:

可看到SpawningProxy类并没有start函数,只有构造函数__init__()和__getattr__()函数。当调用start函数时,实际上是调用了__getattr__()函数,start作为参数值传递,具体的语法细节可参考相关Python资料。从函数代码可以看出,__getattr__()返回的也是一个函数类型,然后这个叫spawning_method的函数被调用。继续跟踪这个函数执行,如下图,看到首先使用eventlet创建了一个GreenPool对象,然后调用GreenPool的imap接口。根据打印出来的信息,可以知道针对self._items每个值,画线的call函数被调用,并以这个值为参数。call函数又调用了getattr,实际上真正的调用变成了调用ServiceContainer的start函数。

我们从utils.py的SpawningProxy类的代码中返回,再从runners.py的ServiceRunner代码中返回,又回到了run.py中的代码,继续看这个run函数,如下图:

其start完后,eventlet调用了spawn函数,参数是service_runner.wait,我们打印一下这个参数,是ServiceRunner类的wait函数,接着这个函数在while循环中被调用,服务启动完成处于待命状态。

 

ServiceContainer类

每个Service类被import后都会由一个ServiceContainer类包装,在这个类的构造函数中,会做各种初始化动作。它通过使用inspect模块获取Service类的成员属性和函数,并判断它们是否是Dependency和EntryPoint,是则加入到对应的列表中,代码如下图:

判断的方法如下图,即这个类是否继承自DependencyProvider类或EntryPoint类。

 

ServiceRunner类

这个类用来封装ServiceContainer类,即功能是管理一系列ServiceContainer对象的启动停止。还记得吧,他的start函数里面有一行“SpawningProxy(self.containers).start()”代码,这行代码最后调用到了ServiceContainer的start函数,后者的代码如下图:

如上图所示,ServiceContainer的start函数启动了所有的extensions(扩展),包括EntryPoints,Dependencies等。我们的test.Compute类的某个函数使用了@rpc修饰器,则Rpc这个类的setup函数被执行,和rabbitmq相关的动作也开始了,如下图:

 

当一个Service类的某个@rpc修饰的函数被调用时,ServiceContainer类的spawn_worker函数被调用,生成GreenThread来完成请求任务,函数声明/注释如下图:

 

SpawningProxy类

这个类主要用来进行eventlet相关的调用,它先生产一个GreenPool,然后遍历所有的ServiceContainers对象,针对每个对象调用start函数,运行在一个green thread中。

 

服务交互方式

在这个例子中,我们选择RPC的通讯方式,因此我们在系统中安装好rabbitmq服务。在CentOS中,执行命令“yum –y install rabbitmq-server”完成安装,然后执行“service rabbitmq-server start”运行服务。

执行“nameko run test”运行我们的Compute服务,界面输出如下:

nameko提供了一个可以用来测试我们服务RPC调用的Python Shell,通过执行以下命令:“nameko shell”,得到如下界面:

我们在里输入“n.rpc.compute.compute(3,2)”,回车后界面如下:

我们在test.py中的Compute类实现了一个RPC修饰的函数叫“compute”,类的name属性也是“compute”,因此上面的调用就是调用到了compute服务对应的compute函数,这个函数的功能只是简单的将两个参数相乘就返回,如上图所示,我们编写的RPC函数已经可以成功地被另外的服务调用了。

Compute类的compute函数如下图,是使用了@rpc修饰器修饰了的,否则上面的远程调用不会成功。

 

系统监控方案

关于这个问题github上已经有人开了个Issue,nameko的作者mattbennett作了比较详细的回答,他表示目前文档中没有涉及这方面但后面会加上。DependencyProvider扩展会在Worker执行任务之前和完成任务之后得到通知,因此一个用于日志的Dependency Provider可以简单写成:

然后在Service服务类中以依赖注入的方式使用:

 

结尾

一篇篇幅不算很长的文章,只能把大致的框架原理阐述出来,还有很多细枝末节甚至会是比较关键的内容也会被遗漏。因此,本文的读者如果对某方面的内容还是理解不清楚或者是文章没有提及,可在本博评论区留言,我将尽量追加修改,如果自己恰好也要用到这一套框架并对其有自己的理解,欢迎成文后投稿到本博。

 

相关链接

nameko Github地址:https://github.com/nameko/nameko

使用nameko的一个示例,译文:用 Python、 RabbitMQ 和 Nameko 实现微服务

发表评论