scribble

ottocho's blog

Home About GitHub

25 Apr 2014
API design in Python

对接系统接口设计总结

本文主要讲述在与外部系统对接、 python 编程中遇到的参数传递的问题,以及用以在多层级结构的应用代码中进行参数透传的一些简单的方法以及思路。

缘起

在与别项目进行合作时,经常需要进行接口提供或者使用。

如某系统(名为 M 系统 )提供的某 http 接口,api url 地址为 http://www.sample.com/api/url,在 post data 中把请求数据以字典格式发送而去。通常是类似这样的:

{
    "requestItem": {
        "method" : "query_something",
        "resultColumns": [
            'id', 'start_time', 'end_time', 'operator', 'status'
        ],
        "searchCondition": {
            "departmentId": 1,
            "status": [5,20,21,22],
            "assetId": [ ""],
            "serverId": 1,
            "idcName": "",
            "ip": ""
        },
    }
}

requestItem 值表示的是此次请求的具体数据,数据格式及其含义显而易见:

  • method 值表示具体请求的函数名(也就是 M 系统 提供的某接口名)
  • resultColumns 为字符串列表,表示需要获取的域名称列表
  • searchCondition 代表的是检索的条件

我的任务是以 M 系统 的这些接口为基础进行后续的工作。当然我不可能每次请求 M 系统 之时都很蠢的拼装这一坨数据再 post 数据过去。因此针对这个外部系统需要设计一个 lib,此 lib 提供接口进行对 M 系统 的访问(学术的说:此为GatewayAn object that encapsulates access to an external system or resource.)。

本文说明对此 Gateway 的设计实现。

在确定了变与不变后,明确了规则以后,即可开始进行接口设计。对于 M 系统 接口而言,规则是:接口以给出不同的 requestItem内容 确定 具体调用的接口以及调用方式(需求获取的内容、提供检索的约束条件)

本文讲述的是在此项工作中的设计思路,以及用到的一些方法的总结归纳。

注意:本文代码未经严格测试,仅供参考用。

接口设计

旧设计

在接受此项目时,原 lib 实现的接口是这样接入的(此 1700 行的 lib 中定义的其中一个函数):

class XXX():

    def query_orders(self, status=["1", "2", "5", "6"], order_type=1, ctime_gt=None, ctime_lt=None,
                     etime_gt=None, etime_lt=None, orderby='createTime desc', applicant=None,
                     department_id=None, need_page=False, page_start=0, page_size=15):
        pass

query_orders 函数实现了接口的一个 query_method,它对应的请求数据结构体如下:

"requestItem": {
    "method" : "query_orders",
    "resultColumns": xxx , # query_orders 函数体内指定了一个特定的值
    "searchCondition": {
        "status": ..,
        "order_type": ..,
        "ctime_gt": ..,
        "ctime_lt": ..,
        "orderby": ..,
        "applicant": ..,
        "need_page": ..,
        "page_start": ..,
        "page_size": ..,
    },
}

旧设计对于对接系统的各个 method 都定义了一个函数进行处理。

这样的设计缺点在于:

  • 对方变更加入一个新接口(新的 method),这边就需要再新实现一个函数对应其新接口;
  • 极其臃肿的函数参数列表、重复性极高的代码实现;
  • 懒得找借口了;

当然是有优点的:

  • 代码直接简单,(在命名和缩进正常的情况下)阅读一个接口的成本还是比较低的;
  • 代码维护直接不费脑力,直接 ctrlc ctrlv 完成;
  • 代码行数多,显示工作量大,KPI 高;
  • 等等;

新设计

接口的设计目标是清晰直接。而且,更希望在良好的设计下兼容各接口,在对方系统进行变更时,我的接口可以有最小程度的改动。因此设计实现的模块(名为 mapi)只提供的函数为 api(另外还再提供辅助的数据和参数、后续提及),设计如下:

mapi.api(methodName, resultColumns=[], **searchCondition)

接口的参数直接对应接口的 requestItem 数据结构,如下:

"requestItem": {
    "method" : methodName,
    "resultColumns": resultColumns,
    "searchCondition": searchCondition
}

因此,mapi 支持的接口调用应该是这样的:

# 获取指定条件的单列表
result = mapi.api('query_orders', ['id', 'status', 'start_time', 'end_time'.. ],
                      status=[1, 2, 3],
                      etime_gt=None,
                      etime_lt=None,
                      orderby='createTime desc',
                      applicant=None,
                      department_id=None,
                      need_page=True,
                      page_start=0,
                      page_size=15 )

# 获取设备列表
result = mapi.api('query_devices', ['id', 'status', 'positon', 'hostname'.. ],
                      status=[1, 2, 3],
                      department_id=None,
                      position=XXX,
                      need_page=True,
                      page_start=0,
                      page_size=15 )

实际上我的目的也就是:利用 mapi.api 进行透传,从而在接口名、参数等等规则完全适应接口提供方(M 系统)。在 mapi 的实现上尽可能的不参与太多的逻辑。

为了提供更强的灵活性,我也希望 mapi 能有这样的调用方式:

mapi.methodName(resultColumns, **searchCondition)

因此上面的例子对应的接口调用应该是这样的:

# 获取指定条件的单列表
result = mapi.query_orders( ['id', 'status', 'start_time', 'end_time'.. ],
                            status=[1, 2, 3],
                            time_gt=None,
                            etime_lt=None,
                            orderby='createTime desc',
                            applicant=None,
                            department_id=None,
                            need_page=True,
                            page_start=0,
                            page_size=15 )

# 获取设备列表
result = mapi.query_devices(['id', 'status', 'positon', 'hostname'.. ],
                            status=[1, 2, 3],
                            department_id=None,
                            position=XXX,
                            need_page=True,
                            page_start=0,
                            page_size=15 )

而在实践中发现,searchCondition 在我方的接口使用中经常会先取得一个 dict 再传入,因此接口又再加入了一个参数:

mapi.methodName(resultColumns, searchCondition={}, **otherSearchCondition)

searchConditionotherSearchCondition 两个参数在函数体内进行合并传入数据。

因此,调用可以这样实现:

# 单独指定检索参数
result_columns = ['id', 'status', 'positon', 'hostname'.. ]
search_condition = dict(
    status=[1, 2, 3],
    department_id=None,
    position=XXX,
    need_page=True,
    page_start=0,
    page_size=15
)
# 接口调用
result = mapi.query_devices(result_columns, search_condition)

或者:

# 接口调用
result = mapi.query_devices(result_columns, **search_condition)

概念实现

为了实现上述的概念,在下方进行了如下的 demo 演示。因此:

  • 只有在接口的命名和参数上用了小驼峰,这是为了兼容;
  • 代码没有加上很多异常处理和注释;
  • 范例里的 api 函数输出的是需求的请求数据结构,没有进行后续的 post 等等处理;

mapi.py

from pprint import pprint

class ApiClass(object):

    def api(self, methodName, resultColumns, searchCondition={}, **otherSearchCondition):

        condition_args = {}
        if isinstance(searchCondition, dict):
            condition_args.update(searchCondition)
        condition_args.update(otherSearchCondition)

        request_item = {}
        request_item['method'] = methodName
        request_item['resultColumns'] = resultColumns
        request_item['searchCondition'] = condition_args

        return request_item

    def __getattr__(self, key):
        def _fun(ags={}, **args):
            return self.api(key, ags, **args)
        return _fun

mapi = ApiClass()

ret = mapi.query_devices(['id', 'status', 'positon', 'hostname' ],
                          status=[1, 2, 3],
                          department_id=None,
                          position=1,
                          need_page=True,
                          page_start=0,
                          page_size=15 )
pprint(ret)
print
ret = mapi.query_orders(['id', 'status', 'start_time', 'end_time' ],
                          status=[1, 2, 3],
                          etime_gt=None,
                          etime_lt=None,
                          orderby='createTime desc',
                          applicant=None,
                          department_id=None,
                          need_page=True,
                          page_start=0,
                          page_size=15 )
pprint(ret)

此时这个示例代码的输出是符合预期的:

{'method': 'query_devices',
 'resultColumns': ['id', 'status', 'positon', 'hostname'],
 'searchCondition': {'department_id': None,
                     'need_page': True,
                     'page_size': 15,
                     'page_start': 0,
                     'position': 1,
                     'status': [1, 2, 3]}}

{'method': 'query_orders',
 'resultColumns': ['id', 'status', 'start_time', 'end_time'],
 'searchCondition': {'applicant': None,
                     'department_id': None,
                     'etime_gt': None,
                     'etime_lt': None,
                     'need_page': True,
                     'orderby': 'createTime desc',
                     'page_size': 15,
                     'page_start': 0,
                     'status': [1, 2, 3]}}

参数特殊处理

当然在系统对接过程中,我方是不可能完全不做任何逻辑处理的。在需要做一些特殊处理的时候,就需要在上述实现的 api 函数中添加一些我方的逻辑。

由于 api 使用了 keyworded arguments,所有参数作为一个字典传入,因此就没法对某些参数进行一些特殊处理(如某些参数需要给默认值、或者某些参数是必须给出的、等等)。此时可以用一个装饰器或者用一个过滤器对 api 函数进行逻辑强化。

当然在这里的 demo 里,只为了演示作用(其实是懒),我只是在 api 里加上了一些简单的函数处理而已。

Talk is cheap, show you the code.

class ApiClass(object):

    ''' 用以确定指定接口名必须指定的参数列表 '''
    necessary_paramters = {
        'query_devices': [ 'status', 'position', ],
        'query_orders': [ 'applicant', ]
    }

    ''' 用以指定指定接口名的指定参数的默认值 '''
    default_patamter_values = {
        'query_devices' : {
            'hostname': 'localhost',
        },
    }

    def formalize_argument(self, method_name, arguments):
        '''
            对指定接口名 ``method_name`` 的参数 ``arugments`` 进行指定的修正处理
            包括:
            * 默认参数加入
            * 确认必须给出的参数的存在
        '''
        # 确认必须给出的参数必须存在,否则 XXX
        necessary_paramter = self.necessary_paramters.get(method_name, [])
        for _p in necessary_paramter:
            if _p not in arguments:
                return None
        # 加入应该给出的默认的参数
        default_patamter_value = self.default_patamter_values.get(method_name, {})
        for _k, _v in default_patamter_value.iteritems():
            if _k not in arguments:
                arguments[_k] = _v
        return arguments

    def api(self, methodName, resultColumns, searchCondition={}, **otherSearchCondition):

        condition_args = {}
        if isinstance(searchCondition, dict):
            condition_args.update(searchCondition)
        condition_args.update(otherSearchCondition)

        condition_args = self.formalize_argument(methodName, condition_args)
        if condition_args is None:
            # TODO FIXME 此处需要进行函数缺失处理、或者进行更细节的处理
            return 'error'

        request_item = {}
        request_item['method'] = methodName
        request_item['resultColumns'] = resultColumns
        request_item['searchCondition'] = condition_args

        return request_item

    def __getattr__(self, key):
        def _fun(ags={}, **args):
            return self.api(key, ags, **args)
        return _fun

mapi = ApiClass()

而对 api 函数进行强化的时候(即上述加入的对 arguments 的一些特殊处理),这些强化规则(其实也就是 formalize_argument 函数以及两个字典 necessary_paramtersdefault_patamter_values)是可以作为一个配置文件、或者一个单独的 py 来进行维护的。

用新人接手项目的情况来衡量代码的可维护性是个很好的方式。因此:在此 lib 的功能正常稳定后,与 系统 M 对接的工作(也就是对此 lib 的维护)就是对配置文件的维护。这是非常轻量级的。考虑到新人入伍的时候,接收 系统 M 的对接的工作就可以走如下流程:

  • 通读 系统 M 的接口 api 文档;
  • 理解我方的 lib 的设计思路;
  • 通读我方的 lib 设计实现(如上,其实是非常短且易理解的);
  • 通读我方的配置文件,理解我方对此接口的逻辑;

层级处理

当然,此 lib 很可能会再提供给别人使用,也当然或许他也面临相同的问题(例如需要前端 js ajax 使用)。解决问题的思路是一样的:尽可能的透传,否则对接口的各参数、各函数名进行一对一对应的各种无趣代码的实现是非常无聊且不好维护的。

后记

当然,在最后我并没有去重构原代码,近两千行的 python 脚本,出于工作时间和工作进度的约束,我沿袭了旧风格的代码进行了设计,只是填补上了很多注释和说明,并且修复了一些或许是 bug 的 bug 。此谓之妥协。

重要的不是技巧和技术,而是思维方式。

ottozhuo

2014.04.24


Til next time,
at 20:32

scribble

Home About GitHub