用Python写一个Dubbo接口测试工具

本文实现的效果:输入服务名、方法名和参数,输出格式化后的请求结果
对dubbo和telnet有所了解的可以直接移步github

Dubbo和telnet

简介

Dubbo是阿里巴巴开源的一款RPC(Remote Procedure Call,远程过程调用)框架,用于实现分布式服务的跨服务调用,具有远程通讯、动态配置、地址路由等功能。
Dubbo基于dubbo协议,dubbo协议是TCP协议之上的协议,采用单一长连接和 NIO 异步通讯,适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况。

Telnet协议是TCP/IP协议族中的一员,是Internet远程登录服务的标准协议和主要方式。Telnet是常用的远程控制Web服务器的方法,具体介绍可以查看百度百科:Telnet_百度百科

Dubbo 2.0.5版本以上开始支持telnet命令调用,本文正是基于telnet命令来实现对Dubbo接口的调用。

使用telnet命令调用dubbo服务

编写代码之前,先简单了解一下如何使用telnet命令调用dubbo服务。
首先打开cmd,使用telnet命令连接服务器(如果提示指令不存在的话,在设置-启用或关闭Windows功能里勾选“Telnet客户端“并保存)

telnet 172.16.51.4 20885

其中172.16.51.4是dubbo服务所在的服务器地址,20885是dubbo服务指定的端口。

telnet连接服务器

连接后按回车,出现dubbo>输入提示符表示已经连上了dubbo服务


连接dubbo服务器

telnet命令

  • 输入ls并回车,列出所有可用的服务
  • 输入ls 服务名,列出服务下所有的方法
  • 输入ls -l 服务名,列出服务下的所有方法和参数
  • 输入invoke 服务名.方法名(调用参数),调用对应服务的dubbo接口
  • 其它Telnet命令参考

telnet调用示例

调用UserInfoRpcService服务的getUserState方法,参数是一个Long类型数值
如下图,接口成功响应并返回了一个json字符串(默认不是utf-8编码,所以中文会乱码)


调用接口

综上所述,使用telnet命令的方式调用dubbo接口最少需要三步:
1、查找服务的ip和端口(通常是在dubbo-admin或zookeeper查找)
2、telnet连接服务器
3、invoke命令调用接口

如果一次两次还好,如果用的多了,就会感觉很不方便:
1、每次都要查找对应服务的ip端口并手动连接
2、连接一段时间不操作,就会自动断开连接
3、invoke命令纯手输,输错了不能用光标移动到错误处修改,只能删除重新输入
4、响应不支持中文,且没有格式化,需要复制出来手动格式化才具有易读性

使用Python实现

既然手动输入telnet命令很不方便,那么使用代码实现可以做到哪些改进呢?
重复几次命令操作之后就会发现,除了调用时需要填写的服务名、方法名和参数之外,其余如查找ip端口、连接服务器、格式化响应都是重复性很高的操作,完全可以简化。
所以我们用Python实现的目标就是:只输入服务名方法名参数并执行,就得到一个易读的返回结果。

通过服务名自动查找ip端口

首先人工查找服务的ip端口的步骤是可以省略的。
系统通过dubbo提供服务,需要将服务注册到指定的注册中心(通常是使用Zookeeper作为Dubbo的注册中心),并暴露其服务器ip和端口,既然如此,就可以通过注册中心查找对应服务的注册信息,来自动获取其ip和端口了。
在Python中,可以通过kazoo库来连接Zookeeper,代码如下:

from urllib.parse import unquote

from kazoo.client import KazooClient

# zookeeper的ip和端口
zk = {
    'host': '172.16.253.21',
    'port': 2181
}

class Zookeeper:
    client = None
    service_dict = {}

    class ServiceNotAvailableError(ValueError):
        pass


    def __init__(self, timeout=100):
        # 连接zookeeper
        self.client = KazooClient('%s:%s' % (zk['host'], zk['port']), timeout=timeout)
        self.client.start()

        # 查找所有注册的dubbo服务
        service_list = self.client.get_children('dubbo')
        for service in service_list:
            name = str(service).split('.')[-1]  # 去掉包名,剩下的服务名作为key
            self.service_dict[name] = service  # 此处如果有重名的服务,会覆盖


    def get_service_address(self, service):
        """获取指定服务的注册地址信息"""
        if '.' not in service:
            # 如果传入的服务名不带包名,就从service_dict找到完整服务名
            service = self.service_dict[service]

        uri = 'dubbo/%s' % service
        if not self.client.exists(uri):
            raise ServiceNotAvailableError('服务"%s"不存在' % service)
        elif not self.client.exists('%s/providers' % uri):
            raise ServiceNotAvailableError('服务"%s"没有提供者' % service)
        else:
            providers = self.client.get_children('%s/providers' % uri)
            addrs = []
            for provider in providers:
                addr = str(unquote(provider)).split('/')[2]
                addrs.append((str(addr).split(':')[0], str(addr).split(':')[1], str(addr)))
            return addrs


    def close(self):
        self.client.stop()
        self.client.close()

通过实例化Zookeeper对象,并调用get_service_address方法,就可以获得指定服务的ip地址和端口了。

实现telnet命令调用并格式化返回值

完成服务的ip端口查找后,开始进行接口调用,并将接口响应格式化成易读的形式。
在Python中,使用telnetlib库来完成telnet命令操作,代码如下:

import json
import telnetlib
...  # 此处省略zk的代码

class DubboTester(telnetlib.Telnet):
    class Args:

        def __init__(self, service, method, params, host=None, port=0, index=0):
            self.service = service
            self.method = method
            self.params = params
            self.host = host
            self.port = port
            self.index = index

    prompt = 'dubbo>'
    coding = 'utf-8'
    zk = Zookeeper()
    args = None

    def __init__(self, args: Args or dict):
        """
        实例化DubboTester,这一步会连接到指定服务的服务器
        :param args: 可以传Args对象实例,也可以传字典数据,字典最少要包含service、method、params,
            params必须是list类型,list中的元素就是方法所需的参数
        """
        # dict解析成Args对象
        if isinstance(args, dict):
            args = self.__init_args_from_dict(args) if isinstance(args, dict) else args
        address_list = self.zk.get_service_address(args.service)
        if len(address_list) > 1:
            # 对于多节点服务,默认连接第一个节点,可用index指定
            print('——' * 43)
            print('|%s服务有多个地址,使用index参数指定请求地址,默认index=0:|' % str(args.service).center(30, ' '))
            print('-' * 86)
            for i, address in enumerate(address_list):
                print('| %d ==> %s:%s |' % (i, address[0], str(address[1]).ljust(80 - len(address[2]), ' ')))
            print('——' * 43)

        args.host = address_list[args.index][0]
        args.port = address_list[args.index][1]
        print('当前连接地址: %s:%s' % (args.host, args.port))
        self.args = args
        super(DubboTester, self).__init__(host=args.host, port=args.port)
        self.write(b'\n')


    @staticmethod
    def __init_args_from_dict(d):
        service = d.get('service')
        method = d.get('method')
        params = d.get('params', [])
        host = d.get('host')
        port = d.get('port')
        index = d.get('index', 0)
        if port is not None and not isinstance(port, int):
            raise TypeError('port必须是数值类型')
        elif params is not None and not isinstance(params, list):
            raise TypeError('params必须是list类型')
        return DubboTester.Args(service, method, params, port, index)


    @staticmethod
    def __parse_args(args):
        """将参数解析成tenlet命令行调用的字符串格式"""
        if isinstance(args, str) or isinstance(args, dict):
            args = json.dumps(args)
        elif isinstance(args, list):
            tmp = ''
            for arg in args:
                tmp += json.dumps(arg) + ','
            args = tmp[:-1]
        return args



    def command(self, flag, str_=""):
        data = self.read_until(flag.encode())
        self.write(str_.encode() + b'\n')
        return data


    def invoke(self):
        arg = self.__parse_args(self.args.params)
        command_str = "invoke {0}.{1}({2})".format(self.args.service, self.args.method, arg)
        print(self.prompt, command_str)
        self.command(self.prompt, command_str)
        data = self.command(self.prompt, "")
        # [:-6] 截取掉返回结果末尾的'dubbo>'
        data = data.decode(self.coding, errors='ignore')[:-6].strip()
        # 截取掉elapsed及之后的部分
        if 'elapsed' in data:
            data = data[:data.index('elapsed')].strip()
        # 双换行符替换为单换行符
        data = data.replace('\r\n', '\n')
        return data


    def close(self):
        if self.zk:
            self.zk.close()


def run(case: dict):
    try:
        tester = DubboTester(case)
        result = tester.invoke()
        try:
            # 解析结果,结果缩进,支持中文
            result = json.dumps(json.loads(result), ensure_ascii=False, sort_keys=True, indent=4)
        except json.JSONDecodeError as e:
            print(e)
        print('请求结果:\n%s ' % result)
        tester.close()
    except TimeoutError:
        print('连接超时!')

在run函数中,首先实例化一个DubboTester对象并调用invoke方法,执行telnet命令,得到返回的结果后,再尝试json解析并格式化,最后得到一个易读的返回结果。

执行

通过上述代码的实现,现在只需要传入一个包含service、method、params字段的字典类型数据,就可以调用对应的dubbo接口了:

if __name__ == '__main__':
    # 输入服务名、方法名和参数
    case = {
        'service': 'UserInfoRpcService',
        'method': 'getUserState',
        'params': [600001]
    }
    # 执行
    run(case)

执行结果如下:


代码执行结果

完整代码:

#!/usr/bin/python3
import json
import telnetlib
from urllib.parse import unquote

from kazoo.client import KazooClient

test_env = {
    'zk': '{ip}:{端口}'
}

env = test_env


class Zookeeper:
    address = None
    timeout = 100
    client = None
    service_dict = {}

    def __init__(self, address=None, timeout=100):
        self.address = address if address else env['zk']
        self.timeout = timeout
        self.client = KazooClient(hosts=self.address, timeout=timeout)
        self.client.start()
        service_list = self.client.get_children('dubbo')
        for service in service_list:
            name = str(service).split('.')[-1]
            self.service_dict[name] = service

    def get_service_address(self, service):
        if '.' not in service:
            service = self.service_dict[service]
        uri = 'dubbo/%s' % service
        if not self.client.exists(uri):
            raise ValueError('服务%s不存在' % service)
        elif not self.client.exists(uri + '/providers'):
            raise ValueError('服务%s没有提供者' % service)
        else:
            providers = self.client.get_children(uri + '/providers')
            addrs = []
            for provider in providers:
                addr = str(unquote(provider)).split('/')[2]
                addrs.append((str(addr).split(':')[0], str(addr).split(':')[1], str(addr)))
            return addrs

    def close(self):
        self.client.stop()
        self.client.close()


class DubboTester(telnetlib.Telnet):
    class Args:
        host: str
        port: int
        service: str
        index: int
        method: str
        param: list

        def __init__(self, host=None, port=0, service=None, index=None, method=None, param=None):
            self.host = host
            self.port = port
            self.service = service
            self.index = index
            self.method = method
            self.param = param

    prompt = 'dubbo>'
    coding = 'utf-8'
    zk = None
    args: Args

    def __init__(self, args: Args or dict or str):
        # json字符串解析成dict
        args = json.loads(args) if isinstance(args, str) else args
        # dict解析成Args对象
        args = self.__init_args_from_dict(args) if isinstance(args, dict) else args
        if args.host and args.port:
            print('当前模式:直连')
        elif args.service is not None and args.service != '':
            print('当前模式:zookeeper')
            self.zk = Zookeeper()
            address_list = self.zk.get_service_address(args.service)
            if len(address_list) > 1 and args.index is None:
                index = 0
                print('——' * 43)
                print('|%s服务有多个地址,使用index参数指定请求地址,默认index=0:|' % str(args.service).center(30, ' '))
                print('-' * 86)
                for i, address in enumerate(address_list):
                    print('| %d ==> %s:%s |' % (i, address[0], str(address[1]).ljust(80 - len(address[2]), ' ')))
                print('——' * 43)
            else:
                index = 0 if args.index is None else args.index
                print('当前使用地址:%s:%s' % (address_list[index][0], address_list[index][1]))
            args.host = address_list[index][0]
            args.port = address_list[index][1]
        else:
            raise KeyError('参数错误')
        self.args = args
        super(DubboTester, self).__init__(host=args.host, port=args.port)
        self.write(b'\r\n')

    def command(self, str_=""):
        data = self.read_until(self.prompt.encode())
        self.write(str_.encode() + b"\n")
        return data

    @staticmethod
    def __init_args_from_dict(d: dict):
        host = d.get('host')
        port = d.get('port')
        service = d.get('service')
        index = d.get('index')
        method = d.get('method')
        param = d.get('param')
        if port is not None and not isinstance(port, int):
            raise TypeError('port参数类型错误,需要int类型,传入了%s类型' % type(port))
        if param is not None and not isinstance(param, list):
            raise TypeError('param参数类型错误,需要list类型,传入了%s类型' % type(param))
        port = port if port else 0
        return DubboTester.Args(host, port, service, index, method, param)

    @staticmethod
    def __parse_args(args):
        if isinstance(args, str) or isinstance(args, dict):
            args = json.dumps(args)
        elif isinstance(args, list):
            tmp = ''
            for param in args:
                tmp += json.dumps(param) + ','
            args = tmp[:-1]
        return args

    def invoke(self, parse=True):
        """
        调用dubbo接口
        :param parse: 是否解析参数,如果为false,则直接使用self.args.param来请求接口,如果为True,则先将参数解析为json形式再请求
        :return: 返回请求结果字符串
        """
        if self.args.method is None:
            raise KeyError('缺少method参数')
        elif self.args.param is None:
            raise KeyError('缺少param参数')
        elif not isinstance(self.args.param, list):
            raise TypeError('param参数必须是list类型')
        arg = self.__parse_args(self.args.param) if parse else self.args.param
        command_str = "invoke {0}.{1}({2})".format(self.args.service, self.args.method, arg)
        # print('\033[33m' + self.prompt, command_str)
        print(self.prompt, command_str)
        self.command(command_str)
        data = self.command('')
        # [:-6]去掉返回结果末尾的'dubbo>'
        data = data.decode(self.coding, errors='ignore')[:-6].strip()
        # 去掉elapsed(如果返回结果中包含elapsed,会返回不完整的结果)
        if 'elapsed' in data:
            data = data[:data.index('elapsed')].strip()
        # 双换行符替换为单换行符
        data = data.replace('\r\n', '\n')
        return data

    def do(self, arg):
        command_str = arg
        self.command(command_str)
        data = self.command('')
        return data.decode(self.coding, errors='ignore').split('\r\n')[:-1]

    def close(self):
        if self.get_socket():
            super(DubboTester, self).close()
        if self.zk:
            self.zk.close()

    def get_service_address(self):
        return self.host, self.port

    def get_method_list(self, service_name, detail=False):
        if detail:
            method_list = self.do('ls -l %s' % service_name)
        else:
            method_list = self.do('ls %s' % service_name)
        return [method for method in method_list]

    def get_method_info(self, method='default'):
        method = self.args.method if method == 'default' else method
        if method is None or method not in self.get_method_list(self.args.service):
            raise KeyError('%s不是%s的方法' % (method, self.args.service))
        for item in self.get_method_list(self.args.service, True):
            if method == str(item).split(' ')[1].split('(')[0]:
                return item


def print_method_list(tester, package=None):
    if package:
        title = '* 服务%s的方法列表:' % package.split('.')[-1]
        result = tester.get_method_list(package, True)
    else:
        title = '* %s:%s 的服务列表:' % tester.get_service_address()
        result = [service for service in tester.do('ls')]
    print('=' * 80)
    print(title)
    print('-' * 80)
    for item in result:
        print('*', item)
    print('=' * 80)


def run(case: dict, parse=True, invoke=True, print_service=False, print_method=False, print_req_json=False):
    try:
        # 不调用接口时,默认打印服务或接口信息
        if not invoke:
            print_service = print_service if case['service'] else False
            print_method = not print_service

        tester = DubboTester(case)
        print_method_list(tester) if print_service else None
        print_method_list(tester, case['service']) if print_method else None
        if print_req_json:
            print(json.dumps('%s.%s::%s' % (case['service'], case['method'], json.dumps(case['param']))))
        if invoke:
            result = tester.invoke(parse)
            try:
                # 解析结果,结果缩进、支持中文
                result = json.dumps(json.loads(result), ensure_ascii=False, sort_keys=True, indent=4)
                # print('\033[32m')
            except json.JSONDecodeError:
                # print('\033[31m')
                pass
            if str(result).startswith('No such method') and case['method'] in tester.get_method_list(case['service']):
                result = '方法参数错误:%s\n你的传参:%s' % (tester.get_method_info(), [type(param) for param in case['param']])
            print('请求结果:\n%s' % result)
        tester.close()
    except TimeoutError:
        print('连接超时,请检查ip和端口!')
    except (KeyError, ValueError, TypeError) as e:
        print(e)

你可能感兴趣的:(用Python写一个Dubbo接口测试工具)