12.3 实现一个REST服务器——WSGI和mod_wsgi

    REST是基于HTTP建立的,REST服务器是HTTP服务器的扩展。为了稳定性、高性能和安全性,通常会基于某个服务器创建REST服务,例如Apache Httpd或者nginx。这个服务器模式默认不支持Python,我们需要安装一些扩展模块,其中包含与Python应用程序交互接口。

    WSGI是常用的Web服务器和Python的接口。更多的信息可以参考http://www.wsgi.org。Python 标准库中包括了WSGI引用的实现。关于Python3中这个引用的实现是如何工作的,可以参见PEP333:http://www.python.org/dev/peps/pep-3333/

    WSGI的目的是用一个相对简单并且容易扩展的Python API来将HTTP的请求响应处理过程标准化。这让我们可以将复杂的Python解决方案结构化为相对独立的模块。我们的目标是建立一系列能够应对增量处理请求的应用程序。这就创建了一种每个环节都会向请求中添加信息的管道。

    每个WSGI应用程序必须有下面这个API。

    result = application(environ, start_response)

    environ变量是必须包含环境信息的dictstart_response函数必须用来为客户端创建响应,这个函数用来发送响应码和报头。返回值必须是一个可迭代的字符串,也就是响应的主体。

    在WSGI标准中,术语应用程序有许多不同的意义。一个单独的服务器可能有许多WSGI应用程序,这并不是因为WSGI有意地推荐或者需要在与底层WSGI兼容的应用程序中编写代码,真正的意图是使用更大更复杂的Web框架。所有的Web框架都会使用WSGI的API定义来保证兼容性。

    WSGI参考的实现不适合作为一个公开的Web服务器。这个服务器没有直接支持SSL,为了用正确的SSL加密封装套接字,我们需要做一些额外的工作。为了访问80(或者443)端口,必须用一个拥有特权的用户ID在setuid模块中运行进程。一个常用的方法是在Web服务器中安装WSGI扩展模块,或者使用支持WSGI API的Web服务器,这意味着请求会从使用标准WSGI接口的服务器转发给Python。这让Web服务器得以提供静态内容,通过WSGI接口访问的Python应用程序负责提供动态的内容。

    下面是一些用Python写的或者支持Python插件的Web服务器:https://wiki.python.org/moin/webServers。这些服务器(或者插件)适合用来提供稳定、安全、公开的Web服务器。

    另外一个选择是创建一个独立的Python服务器,然后用重定向将请求从公开的服务器上分流到各个独立的Python镜像中。当使用Apache httpd时,可以通过mod_wsgi模块创建独立的Python镜像。由于这里我们只关注Python,所以不会介绍nginx和Apache httpd的细节。

    12.3.1 创建简单的REST应用程序和服务器

    我们会编写一个非常简单的REST服务器,用于旋转轮盘(Roulette)。这是一个服务响应一个简单请求的例子。重点将放在Python中关于RESTful Web服务器的编程部分。如果想要把这个软件作为更大型的Web服务器插件,例如Apache httpd或者nginx,还需要做一些其他工作。

    首先,我们定义一个简化的轮盘。

    class Wheel:
       """Abstract, zero bins omitted."""
       def init( self ):
         self.rng= random.Random()
         self.bins= [
            {str(n): (35,1),
            self.redblack(n): (1,1),
            self.hilo(n): (1,1),
            self.evenodd(n): (1,1),
            } for n in range(1,37)
         ]
       @staticmethod
       def redblack(n):
         return "Red" if n in (1, 3, 5, 7, 9, 12, 14, 16, 18,
            19, 21, 23, 25, 27, 30, 32, 34, 36) else "Black"
       @staticmethod
       def hilo(n):
         return "Hi" if n >= 19 else "Lo"
       @staticmethod
       def evenodd(n):
         return "Even" if n % 2 == 0 else "Odd"
       def spin( self ):
         return self.rng.choice( self.bins )

    Wheel类包含了一个箱子的列表。每个箱子都是一个dict,键代表的是当球落到箱子中时会成为赢家的注,值是赔率。这里我们只定义了一个很短的下注列表,完整的轮盘下注列表非常庞大。

    我们也会忽略零或者双零的箱子。有两种常用的轮盘,这里是定义了常用轮盘的两个mixin类。

    class Zero:
       def init( self ):
         super().init()
         self.bins += [ {'0': (35,1)} ]

    class DoubleZero:
       def init( self ):
         super().init()
         self.bins += [ {'00': (35,1)} ]

    Zero mixin类将bins初始化为单零。DoubleZero mixin类将bins初始化为双零。这些是相对简单的箱子,只有定了这个号码本身才能赢得这些箱子的投注。

    这里我们使用mixin是因为会在后面的一些例子中优化Wheel。通过使用mixin,就可以确保每个对于基类Wheel的扩展都能够以一致的方式进行工作。关于更多mixin风格的设计内容,请参见第8章“装饰器和mixin——横切方面”。

    下面是定义了两种常用轮盘的子类。

    class American( Zero, DoubleZero, Wheel ):
       pass

    class European( Zero, Wheel ):
       pass

    这两个类用mixin扩展了基本的Wheel类,它们会为不同轮盘选择不同的初始化箱子的方式,这些Wheel的子类可以像下面这样使用。

    american = American()
    european = European()
    print( "SPIN", american.spin() )

    每次执行spin()都会产生一个类似下面这样的简单字典。

    {'Even': (1, 1), 'Lo': (1, 1), 'Red': (1, 1), '12': (35, 1)}

    dict中的键是投注的名称,值是包含两个元素、用于表示赔率的元组。上面例子中Red 12是赢家,它既是最小的也是偶数。如果我们在12上下注,因为赔率是35比1,所以会赢得35倍于投注的回报。其他投注的赔率是1比1:我们会赢得和投注一样的回报。

    我们定义了一个简单路径来决定使用哪种轮盘的 WSGI 应用程序。一个类似于http://localhost:8080/european/ 的URI会使用欧式轮盘。另外一种路径会使用美式轮盘。

    这里是用了Wheel实例的WSGI程序。

    import sys
    import wsgiref.util
    import json
    def wheel(environ, start_response):
       request= wsgiref.util.shift_path_info(environ) # 1. Parse.
       print( "wheel", request, file=sys.stderr ) # 2. Logging.
       if request.lower().startswith('eu'): # 3. Evaluate.
         winner= european.spin()
       else:
         winner= american.spin()
       status = '200 OK' # 4. Respond.
       headers = [('Content-type', 'application/json; charset=utf-8')]
       start_response(status, headers)
       return [ json.dumps(winner).encode('UTF-8') ]

    这里展示了WSGI应用程序中一些基本的组成部分。

    首先,我们用了 wsgiref.util.shift_path_info()函数扫描environ['PATH_INFO']的值。这会对请求中的路径信息的一个层次进行解析,它会返回找到的值,当没有提供路径时,它会返回None

    其次,写日志的那行代码说明如果我们想记录日志则必须将信息写到sys.stderr中。任何写到sys.stdout的信息都会作为WSGI应用程序响应的一部分。在调用start_response()之前,任何打印信息的尝试都会导致异常,因为状态码和报头都还没有发送。

    再次,我们根据请求计算出响应。这里使用了两个全局变量——europeanamerican来提供行为一致的随机响应序列。如果尝试为每个请求创建一个唯一的Wheel实例,那么就表明我们没有以正确的方式使用随机数生成器。

    最后,我们用合适的状态和HTTP报头组成一个响应。响应的主体是一个JSON文档,根据HTTP的要求,我们已经用UTF-8将这个文档编码为一个符合要求的字节流。

    现在可以启动这个服务器的演示版本,这个版本只有一个函数,如下所示。

    from wsgiref.simple_server import make_server
    def roulette_server(count=1):
       httpd = make_server('', 8080, wheel)
       if count is None:
         httpd.serve_forever()
       else:
         for c in range(count):
           httpd.handle_request()

    wsgiref.simple_server.make_server()函数创建了服务器对象。这个对象会调用可回调的wheel()处理每个请求。我们用了本地的主机名’’和一个非特权端口——8080。使用特权端口80需要setuid特权,而且最好让Apache httpd服务器处理这个过程。

    一旦服务器建立,它就会使用自动持续运行,这是因为使用了httpd.serve_forever()方法。但是,对于单元测试,通常更好的方法是处理一定数量的请求,然后停止服务器。

    我们可以在终端窗口的命令行中运行这个函数。一旦运行这个函数,就可以用浏览器查看当我们请求http://localhost:8080/ 时的响应。当创建某个技术原型或者调试时,这种方法非常有用。

    12.3.2 实现REST客户端

    在介绍更智能的REST服务器应用程序之前,我们会先介绍如何编写REST客户端。下面的函数会用GET方式向某个REST服务器发送一个简单的请求。

    import http.client
    import json
    def json_get(path="/"):
       rest= http.client.HTTPConnection('localhost', 8080)
       rest.request("GET", path)
       response= rest.getresponse()
       print( response.status, response.reason )
       print( response.getheaders() )
       raw= response.read().decode("utf-8")
       if response.status == 200:
         document= json.loads(raw)
         print( document )
       else:
         print( raw )

    这里展示了RESTful API的基本使用方法。http.client模块包含4个步骤的。

    • 用HTTPConnection ()建立连接。
    • 用命令和路径发送请求。
    • 获取响应。
    • 读取响应中的数据。

    请求可以包括一个附加的文档(POST需要使用)和额外的报头。在这个函数中,我们打印了响应中的某些部分。在本例中,我们读取了状态码和表示原因的文本。大多数时候,我们会期望得到状态200OK作为原因文本,同时也读取和打印了所有的报头。

    最后,我们将整个响应读到一个临时的字符串raw中。如果状态码是200,用json模块从响应字符串中加载对象,这个步骤可以恢复从服务器发送的、用JSON编码过的序列化对象。

    如果状态码不是200,我们只打印可用的文本。它有可能是一条错误信息或者其他可用于调试的信息。

    12.3.3 演示RESTful服务并创建单元测试

    演示RESTful服务器相对简单。我们可以导入服务器类和函数定义,然后从终端窗口启动服务器函数。然后,我们可以访问http://localhost:8080 查看响应信息。

    为了能够正确地完成单元测试,我们希望客户端和服务器之间的信息交换方式更为统一。对于一个可控的单元测试,我们希望启动然后停止一个服务器进程。接着,我们就可以往服务器发送请求,查看响应的内容。

    我们可以用concurrent.futures模块创建一个独立的子进程来运行服务器。下面的代码段展示了可以成为单元测试用例一部分的某种处理过程。

    import concurrent.futures
    import time
    with concurrent.futures.ProcessPoolExecutor() as executor:
       executor.submit( roulette_server, 4 )
       time.sleep(2) # Wait for the server to start
       json_get()
       json_get()
       json_get("/european/")
       json_get("/european/")

    通过创建concurrent.futures.ProcessPoolExecutor实例创建了一个独立的进程。接着,我们使用适当的参数值向这个服务器请求了一个函数。

    在本例中,我们运行了json_get()客户端函数两次,用于读取默认路径——/。然后,用GET请求了两次”/european/”

    executor.submit()函数让进程池执行roulette_server(4)函数。现在服务器会处理4个请求,然后终止。由于ProcessPoolExecutor是一个上下文管理器,因此可以保证所有的资源都会被适当地释放。单元测试输出的日志包含类似下面这样的内容。

    wheel 'european'
    127.0.0.1 - - [08/Dec/2013 09:32:08] "GET /european/ HTTP/1.1" 200 62
    200 OK
    [('Date', 'Sun, 08 Dec 2013 14:32:08 GMT'), ('Server', 'WSGIServer/0.2
    CPython/3.3.3'), ('Content-type', 'application/json; charset=utf-8'),
    ('Content-Length', '62')]
    {'20': [35, 1], 'Even': [1, 1], 'Black': [1, 1], 'Hi': [1, 1]}

    wheel 'european'wheel()WSGI应用程序输出的日志。127.0.0.1 - - [08/Dec/2013 09:32:08] "GET /european/ HTTP/1.1" 200 62 是WSGI服务器输出的默认日志,用于展示已经成功地完成了请求的处理。

    接下来的3行是json_get()的输出。200 OK是由第1个print()函数输出的。这些内容会作为服务器响应的一部分返回给客户端。最后,我们打印了经过解码的字典对象,这个对象是从服务器发到客户端的。在本例中,赢家是20 Black。

    同时,请注意,我们原本的元素在经过JSON的编码和解码之后被转变为了列表。原本的字典包含'20': (35, 1)。经过编码和解码之后,这里得到的结果是'20': [35, 1]

    请注意,我们用来测试的模块会由ProcessPool服务器导入。这个导入的过程还会定位所指定的函数,rouletteserver()。由于服务器会在测试中导入模块,因此测试中的模块必须正确地使用name == "main"来确保这个模块不会在导入过程中执行任何其他的处理过程,它必须只能被用来提供定义。我们必须确保在定义服务器的脚本中使用这种构造方法。

    if name == "main":
      roulette_server()