近期课余时间开发一个基于 Django 的 RESTful Web Service,需要接入短信验证发送功能,比较之后选定阿里大鱼的解决方案。

然而,选 Python 作为技术栈的悲催之处在于,虽然 Python 的第三方库和生态很强大,但是就国内的开发圈而言,Python 是一个相对小众的流派,又由于 Python 2.x 和 Python 3.x 的分化,许多第三方库并没有跟进 Python 3,导致很多时候用 Python 会有些捉襟见肘,尤其是像我这种野路子的 Python 开发。

比如阿里大鱼的短信方案,虽然相比其他厂商很良心的在 PHP 、 Java 版 SDK 之外,友情附赠了 Python 版,但集成进 Django 工程并 debug 后,真是握了棵草,它是基于 Python 2.x 开发的,翻了下开发包源码文件,署名为 “lihao” 的这位阿里同学是在 2012 年更新的源码,这竟是一份蒙尘多年的代码啊,当时我的内心是奔溃的……

自己动手,丰衣足食。——《毛选》

阿里大鱼短信开发包的源码并不复杂,来来去去无非一些 request,response 和 string 的处理,底层都是在阿里服务端 api 里实现的,短信包只是提供 api 调用、处理功能,因此迁移工作倒也不是很令人拙计。下面就把我填的坑一一道来(难免有遗忘和疏漏,见谅)——

一号坑

# 如果parameters是字典类
keys = parameters.keys()

不出所料的话,控制台会输出 >>>TypeError: 'dict_keys' object does not support indexing 这是一大坑,在 Python 3.x 里,dict.keys(), dict.values()dict.items() 是返回一个 dict_keys 对象(dict_keys 不支持 indexing),而不是 Python 2.x 那样返回一个 list。因此最简单的迁移方法就是显式调用 list() 处理,

# 如果parameters是字典类
keys = list(parameters.keys())

但实际上,这种处理办法并不是最优解,可参考 Watch out for list(dict.keys()) in Python 3

二号坑

sign = hashlib.md5(parameters).hexdigest().upper()

哈哈,控制台会报如下错 >>>Python hashlib problem “TypeError: Unicode-objects must be encoded before hashing” 言简意赅,debug 信息已经给出了解决方案,上述代码中的 parameters 参数必须先 encoded 后才能 hash。So easy!

sign = hashlib.md5(parameters.encode('utf8')).hexdigest().upper()

utf-8 转码后就没问题了,一个赛艇!

三号坑

if (isinstance(pstr, str)):
    return pstr
elif (isinstance(pstr, unicode)):
    return pstr.encode('utf-8')

运行后的错误信息: >>>NameError: global name 'unicode' is not defined Python 3.x 把 unicode 类型改名为 str,而原来 Python 2.x 下的 str 类型被 bytes 替代。所以解决办法就是按 Python 3.x 的定义来

if (isinstance(pstr, bytes)):
    return pstr
elif (isinstance(pstr, str)):
    return pstr.encode('utf-8')

四号坑

connection = httplib.HTTPConnection(self.__domain, self.__port, False, timeout)

这里需要注意的地方有两处,Python 2.x 的 httplib 包的 import 路径为 import httplib 在 Python 3.x 里的 import 路径则是: import http.client as httplib 还有一处注意点是,在更改了包路径后,httplib.HTTPConnection(httplib.HTTPConnection() 的参数也发生了变化, Python 2.x 中该接口为 httplib.HTTPConnection(host[, port[, strict[, timeout[, source_address]]]]) 其中,strict 默认为 False,而 Python 3.x 里该接口则修改成 httplib.HTTPConnection(self, host, port=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None) 很显然,阿里源码里的传参是按照 Python 2.x 的写法,对照 Python 3.x 接口改过来便是

connection = httplib.HTTPConnection(self.__domain, self.__port, timeout)

五号坑

P_TIMESTAMP: str(long(time.time() * 1000))

>>>NameError: name 'long' is not defined Python 3.x 已经将 Python 2.x 中的 intlong 型合并为 int,因此在 Python 3.x 下,已经不存在 long 类型了。修改成int()即可

P_TIMESTAMP: str(int(time.time() * 1000))

六号坑

from urllib import urlencode
body = urllib.urlencode(application_parameter)

Python 3.x 把 Python 2.x 下 urlencode() 函数转移到了 urllib.parse 里。修改为

from urllib.parse import urlencode
body = urllib.parse.urlencode(application_parameter)

七号坑

result = response.read()
jsonobj = json.loads(result)

Python 3.x 里 JSON 只接收 unicode,因此 result 需要转码为 unicode

result = response.read().decode('utf-8')
jsonobj = json.loads(result)

八号坑

for key, value in application_parameter.iteritems():
    pass

控制台报错: >>>AttributeError: 'dict' object has no attribute 'iteritems' 字典没有 iteritems 属性?原来是 Python 3.x 已经把 iteritems() 给移除掉了,因此不能再调用该方法。 参考 Python Wiki:

Remove dict.iteritems(), dict.iterkeys(), and dict.itervalues(). Instead: use dict.items(), dict.keys(), and dict.values() respectively.

for key, value in application_parameter.items():
    pass

Done!

Python 3.x 版本的 base.py

# base.py
# -*- coding: utf-8 -*-
"""
Created on 2012-7-3

@author: lihao
"""

try:
    import httplib
except ImportError:
    import http.client as httplib
import urllib
import time
import hashlib
import json
import top
import itertools
import mimetypes
from urllib.parse import urlencode

'''
定义一些系统变量
'''

SYSTEM_GENERATE_VERSION = "taobao-sdk-python-20151217"

P_APPKEY = "app_key"
P_API = "method"
P_SESSION = "session"
P_ACCESS_TOKEN = "access_token"
P_VERSION = "v"
P_FORMAT = "format"
P_TIMESTAMP = "timestamp"
P_SIGN = "sign"
P_SIGN_METHOD = "sign_method"
P_PARTNER_ID = "partner_id"

P_CODE = 'code'
P_SUB_CODE = 'sub_code'
P_MSG = 'msg'
P_SUB_MSG = 'sub_msg'

N_REST = '/router/rest'


def sign(secret, parameters):
    # ===========================================================================
    # '''签名方法
    # @param secret: 签名需要的密钥
    # @param parameters: 支持字典和string两种
    # '''
    # ===========================================================================
    # 如果parameters 是字典类的话
    if hasattr(parameters, "items"):
        # keys = parameters.keys()
        keys = list(parameters.keys())  # sudoz: Py3
        keys.sort()

        parameters = "%s%s%s" % (secret,
                                 str().join(
                                         '%s%s' % (key, parameters[key]) for key
                                         in keys),
                                 secret)
    # sign = hashlib.md5(parameters).hexdigest().upper()
    sign = hashlib.md5(parameters.encode('utf8')).hexdigest().upper()   # sudoz: Py3
    return sign


def mixStr(pstr):
    if (isinstance(pstr, str)):
        return pstr
    # elif(isinstance(pstr, unicode)):
    elif (isinstance(pstr, str)):  # sudoz: Py3
        return pstr.encode('utf-8')
    else:
        return str(pstr)


class FileItem(object):
    def __init__(self, filename=None, content=None):
        self.filename = filename
        self.content = content


class MultiPartForm(object):
    """Accumulate the data to be used when posting a form."""

    def __init__(self):
        self.form_fields = []
        self.files = []
        self.boundary = "PYTHON_SDK_BOUNDARY"
        return

    def get_content_type(self):
        return 'multipart/form-data; boundary=%s' % self.boundary

    def add_field(self, name, value):
        """Add a simple field to the form data."""
        self.form_fields.append((name, str(value)))
        return

    def add_file(self, fieldname, filename, fileHandle, mimetype=None):
        """Add a file to be uploaded."""
        body = fileHandle.read()
        if mimetype is None:
            mimetype = mimetypes.guess_type(filename)[
                           0] or 'application/octet-stream'
        self.files.append((
                          mixStr(fieldname), mixStr(filename), mixStr(mimetype),
                          mixStr(body)))
        return

    def __str__(self):
        """Return a string representing the form data, including attached files."""
        # Build a list of lists, each containing "lines" of the
        # request.  Each part is separated by a boundary string.
        # Once the list is built, return a string where each
        # line is separated by '\r\n'.  
        parts = []
        part_boundary = '--' + self.boundary

        # Add the form fields
        parts.extend(
                [part_boundary,
                 'Content-Disposition: form-data; name="%s"' % name,
                 'Content-Type: text/plain; charset=UTF-8',
                 '',
                 value,
                 ]
                for name, value in self.form_fields
        )

        # Add the files to upload
        parts.extend(
                [part_boundary,
                 'Content-Disposition: file; name="%s"; filename="%s"' % \
                 (field_name, filename),
                 'Content-Type: %s' % content_type,
                 'Content-Transfer-Encoding: binary',
                 '',
                 body,
                 ]
                for field_name, filename, content_type, body in self.files
        )

        # Flatten the list and add closing boundary marker,
        # then return CR+LF separated data
        flattened = list(itertools.chain(*parts))
        flattened.append('--' + self.boundary + '--')
        flattened.append('')
        return '\r\n'.join(flattened)


class TopException(Exception):
    # ===========================================================================
    # 业务异常类
    # ===========================================================================
    def __init__(self):
        self.errorcode = None
        self.message = None
        self.subcode = None
        self.submsg = None
        self.application_host = None
        self.service_host = None

    def __str__(self, *args, **kwargs):
        sb = "errorcode=" + mixStr(self.errorcode) + \
             " message=" + mixStr(self.message) + \
             " subcode=" + mixStr(self.subcode) + \
             " submsg=" + mixStr(self.submsg) + \
             " application_host=" + mixStr(self.application_host) + \
             " service_host=" + mixStr(self.service_host)
        return sb


class RequestException(Exception):
    # ===========================================================================
    # 请求连接异常类
    # ===========================================================================
    pass


class RestApi(object):
    # ===========================================================================
    # Rest api的基类
    # ===========================================================================

    def __init__(self, domain='gw.api.taobao.com', port=80):
        # =======================================================================
        # 初始化基类
        # Args @param domain: 请求的域名或者ip
        #      @param port: 请求的端口
        # =======================================================================
        self.__domain = domain
        self.__port = port
        self.__httpmethod = "POST"
        if (top.getDefaultAppInfo()):
            self.__app_key = top.getDefaultAppInfo().appkey
            self.__secret = top.getDefaultAppInfo().secret

    def get_request_header(self):
        return {
            'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
            "Cache-Control": "no-cache",
            "Connection": "Keep-Alive",
        }

    def set_app_info(self, appinfo):
        # =======================================================================
        # 设置请求的app信息
        # @param appinfo: import top
        #                 appinfo top.appinfo(appkey,secret)
        # =======================================================================
        self.__app_key = appinfo.appkey
        self.__secret = appinfo.secret

    def getapiname(self):
        return ""

    def getMultipartParas(self):
        return []

    def getTranslateParas(self):
        return {}

    def _check_requst(self):
        pass

    def getResponse(self, authrize=None, timeout=30):
        # =======================================================================
        # 获取response结果
        # =======================================================================
        # connection = httplib.HTTPConnection(self.__domain, self.__port, False,
        #                                     timeout)
        connection = httplib.HTTPConnection(self.__domain, self.__port, timeout)    # sudoz: Py3
        sys_parameters = {
            P_FORMAT: 'json',
            P_APPKEY: self.__app_key,
            P_SIGN_METHOD: "md5",
            P_VERSION: '2.0',
            # P_TIMESTAMP: str(long(time.time() * 1000)),
            P_TIMESTAMP: str(int(time.time() * 1000)),  # sudoz: Py3
            P_PARTNER_ID: SYSTEM_GENERATE_VERSION,
            P_API: self.getapiname(),
        }
        if authrize is not None:
            sys_parameters[P_SESSION] = authrize
        application_parameter = self.getApplicationParameters()
        sign_parameter = sys_parameters.copy()
        sign_parameter.update(application_parameter)
        sys_parameters[P_SIGN] = sign(self.__secret, sign_parameter)
        connection.connect()

        header = self.get_request_header()
        if self.getMultipartParas():
            form = MultiPartForm()
            for key, value in application_parameter.items():
                form.add_field(key, value)
            for key in self.getMultipartParas():
                fileitem = getattr(self, key)
                if fileitem and isinstance(fileitem, FileItem):
                    form.add_file(key, fileitem.filename, fileitem.content)
            body = str(form)
            header['Content-type'] = form.get_content_type()
        else:
            # body = urllib.urlencode(application_parameter)
            body = urllib.parse.urlencode(application_parameter)    # sudoz: Py3

        # url = N_REST + "?" + urllib.urlencode(sys_parameters)
        url = N_REST + "?" + urllib.parse.urlencode(sys_parameters)   # sudoz: Py3
        connection.request(self.__httpmethod, url, body=body, headers=header)
        response = connection.getresponse()
        if response.status is not 200:
            raise RequestException('invalid http status ' + str(
                response.status) + ',detail body:' + response.read())
        # result = response.read()
        result = response.read().decode('utf-8')    # sudoz: Py3里JSON只接收unicode
        jsonobj = json.loads(result)
        return jsonobj

    def getApplicationParameters(self):
        application_parameter = {}
        # for key, value in self.__dict__.iteritems():
        for key, value in self.__dict__.items():
            if not key.startswith(
                    "__") and not key in self.getMultipartParas() and not key.startswith(
                    "_RestApi__") and value is not None:
                if (key.startswith("_")):
                    application_parameter[key[1:]] = value
                else:
                    application_parameter[key] = value
        # 查询翻译字典来规避一些关键字属性
        translate_parameter = self.getTranslateParas()
        # for key, value in application_parameter.iteritems():
        for key, value in application_parameter.items():  # sudoz: Py3
            if key in translate_parameter:
                application_parameter[translate_parameter[key]] = \
                application_parameter[key]
                del application_parameter[key]
        return application_parameter