12.4 使用可回调类创建WSGI应用程序
我们将WSGI应用程序实现为Callable对象而不是孤立的函数。这让我们可以在WSGI服务器上使用有状态的处理过程,这样的过程不会有潜在的全局变量导致的混乱。在之前的例子中,get_spin()WSGI应用程序依赖于两个全局变量,american和european。应用程序和全局变量之间的绑定可能是非常神秘的。
定义一个类的目的是将处理过程和数据都封装进一个单独的包中,可以用Callable对象更好地封装我们的应用程序。这个可以让有状态的Wheel和WSGI应用程序间绑定更清晰。下面的类将Wheel扩展为可回调WSGI应用程序。
from collections.abc import Callable
class Wheel2( Wheel, Callable ):
def call(self, environ, start_response):
winner= self.spin() # 3. Evaluate.
status = '200 OK' # 4. Respond.
headers = [('Content-type', 'application/json;
charset=utf-8')]
start_response(status, headers)
return [ json.dumps(winner).encode('UTF-8') ]
我们扩展了基类Wheel,这个扩展类包含了WSGI接口。这个类中没有对请求做任何解析,WSGI处理过程被削减为只剩两个步骤:运行和响应。我们会在更高层的封装应用程序中处理解析和日志。Wheel2应用程序只是简单地读取某个结果然后将它编码为另一个结果。
请注意,我们在Wheel2类中添加了一个特别的特性。这是定义了一个不属于Wheel中is-a定义的部分例子,这更偏向于一个acts-as特性。或许,这应该被定义为mixin或者一个装饰器,而不是类的一等特性。
下面是实现了欧洲式轮盘和美国式轮盘的两个子类。
class American2( Zero, DoubleZero, Wheel2 ):
pass
class European2( Zero, Wheel2 ):
pass
这两个子类依赖于基类中的call()方法函数。正如前面的例子所示,我们使用mixin向wheel中添加合适的零bins。
我们将wheel从一个简单对象变为了一个WSGI应用程序。这意味着更高层的封装应用程序就可以变得稍微简单一些。相比于算出其他对象的值,更高层的应用程序现在只用简单地将请求委托给对象本身。以下是一个修改后的封装应用程序,它选择要旋转的轮盘并且将请求委托给对应对象。
class Wheel3( Callable ):
def init( self ):
self.am = American2()
self.eu = European2()
def call(self, environ, start_response):
request= wsgiref.util.shift_path_info(environ) # 1. Parse
print( "Wheel3", request, file=sys.stderr ) # 2. Logging
if request.lower().startswith('eu'): # 3. Evaluate
response= self.eu(environ,start_response)
else:
response= self.am(environ,start_response)
return response # 4. Respond
当创建Wheel3类的实例时,它会创建两个wheels。每个wheel都是一个WSGI应用程序。
当请求到达时,Wheel3 WSGI应用程序会解析请求。然后,它会将两个参数(environ和startresponse函数)传递给另外一个应用程序执行真正的处理过程并且创建响应结果。在许多情况下,这个委托操作也会包含用从请求路径或者报头中解析而来的参数更新environ变量。最后,Wheel3._call()函数会调用其他应用程序所得的结果作为响应返回给客户端。
这种风格的委托是WSGI应用程序所特有的。这是WSGI应用程序互相之间可以非常优雅地结合的原因。注意,在封装程序中,有两个位置可以注入处理逻辑。
- 在调用另一个应用给程序之前,它会修改环境来添加一些信息。
- 在调用了另外的应用程序之后,它可以修改响应文档。
通常,我们会专注于在封装程序中修改环境。但是,在本例中,由于请求非常简单,因此不必用任何额外的信息更新环境。
12.4.1 设计RESTful对象标识符
序列化对象的过程涉及为每个对象定义某种标识符。对于shelve和sqlite,我们需要为每个对象定义一个字符串类型的键。RESTful的Web服务器有相同的需求,它要求必须定义一个合理的键,这个键可以被用于明确地追踪每个对象。
一个简单的代理键也可以作为RESTful的Web服务的标识符。我们可以很容易地将这个键用于shelve或sqlite。
这里重要的是“cool URIs don't change”,参见http://www.w3.org/Provider/ Style/URI.html。
对于我们而言,定义一个永远都不变的URI非常重要。重要的是,永远不要把对象有状态的部分作为URI的一部分。例如,一个博客程序可能需要支持多个作者。如果基于作者组织博客中文章的文件夹,那么对于多个作者共同创作的文章,就无法正确表示。同时,当一个作者接管了另一个作者的文章时,我们就会遇到更大的问题。当纯粹的管理功能(例如,拥有权)改变时,我们不希望改变URI。
一个RESTful的应用程序可能会提供许多索引或者搜索条件。但是,区别某个资源或者对象的基本标识应该永远不会随着索引的改变或者重组而改变。
对于相对简单的对象,我们总是能够找到某种标识符——例如,数据库的代理键常被用做标识符。在博客的例子中,通常会使用发布日期(因为不会改变)和标题作为标识符。其中,会将标题中的标点和空格用“_”替换。这样做的目的是创建一个无论网站的结构如何改变都不会变化的标识符。增加或者改变索引不会改变博文的基本标识符。
对于更复杂的容器对象,我们必须根据用来引用这些更复杂对象的粒度确定标识符。继续看博客的例子,我们将博客作为一个整体,它包含了若干单独的文章。
博客的URI可能类似下面这样。
/microblog/blog/bid/
最高层的名字(microblog)代表整个应用程序。接下来是资源的类型(blog),最后是某个实例的ID。
但是,一篇文章的URI可能有几种不同的选择。
/microblog/post/title_string/
/microblog/post/bid/title_string/
/microblog/blog/bid/post/title_string/
当不同的博客中有标题相同的文章时,第1个URI就无法使用了。在这种情况下,为了让标题唯一,某个作者可能会看到他们的标题后追加了“_2”或者其他的一些可以让标题唯一的装饰。不过,我们一般不推荐这样做。
第2个URI中用了博客ID(bid)作为上下文或者命名空间来确保Post的标题在当前博客的上下文中是唯一的。这种技术经常被扩展为包括其他分支,例如日期,这样可以进一步缩小搜索范围。
第3个例子中显式使用了两层class/object的名称:blog/bid和post/title_string。这种做法的缺点是路径更长,但是这让一个复杂的容器可以内置包含多个元素的不同集合。
请注意,REST服务会影响为持久化存储而定义的API。实际上,定义URI和定义接口的方法名类似。我们必须为它们选择清晰、有意义并且可以长久使用的名字。
12.4.2 多层REST服务
下面是一个更智能的多层REST服务器应用程序。我们会分成不同部分进行讲解。首先,需要为我们的Wheel类提供一个轮盘的桌子。
from collections import defaultdict
class Table:
def init( self, stake=100 ):
self.bets= defaultdict(int)
self.stake= stake
def place_bet( self, name, amount ):
self.bets[name] += amount
def clear_bets( self, name ):
self.bets= defaultdict(int)
def resolve( self, spin ):
"""spin is a dict with bet:(x:y)."""
details= []
while self.bets:
bet, amount= self.bets.popitem()
if bet in spin:
x, y = spin[bet]
self.stake += amount*x/y
details.append( (bet, amount, 'win') )
else:
self.stake -= amount
details.append( (bet, amount, 'lose') )
return details
Table类追踪单独匿名玩家的bets。每个bet由轮盘桌上的一个区域的名称和一个整型金额组成。当解析bet时,Wheel类会提供一个旋转一圈的结果给reslove()方法。接下来,会将投入的注和旋转所赢得的注比较,并且根据是否赢得了更多的注调整玩家的筹码。
我们会定义一个RESTful的轮盘服务器,这个服务器会向我们展示用HTTP_ POST实现的一个有状态的事务。我们把轮盘的游戏分成下面3个URI。
- /player/
- 用GET方法请求这个URI会获得一个用JSON编码的dict,这个dict中包含了所有用户的信息,包括他们的筹码和当前已经玩的局数。将来可以定义一个合适的Player对象并且返回一个序列化的实例来扩展这个方法。
- 一个可能的扩展方式是处理POST请求,用于创建下注的其他用户。
- /bet/
- 用POST请求这个URI需要包含用于创建投注的一个用JSON编码的dict或者一个dict的列表。每个投注的字典都包含两个键:bet和amount。
- 用GET请求这个URI会得到一个JSON编码的dict,这个dict用于向我们展示当前的投注和到目前为止的总金额。
- /wheel/
- 用不带参数的POST请求这个URI会旋转轮盘并且计算支出。我们用POST实现它是为了强调这个URI会改变可用的筹码和用户的状态。
- 用GET方法请求这个URI可能会返回之前的结果,向我们展示上次旋转的结果、上次的支出和上次用户的筹码。这可以作为不可以否认性模式的一部分,它为上次的旋转返回了一个额外的副本。
下面为WSGI应用程序家族定义的两个帮助类。
class WSGI( Callable ):
def call( self, environ, start_response ):
raise NotImplementedError
class RESTException( Exception ):
pass
我们简单地扩展了 Callable,在这个类中我们显式地声明将会定义一个 WSGI应用程序类。我们也定义了一个异常类,在WSGI应用程序中可以用它发送错误状态码,这些状态码和 wsgiref 中表示为 Python 一段错误的通用错误码 500 不同。下面是Roulette服务器的顶层。
class Roulette( WSGI ):
def init( self, wheel ):
self.table= Table(100)
self.rounds= 0
self.wheel= wheel
def call( self, environ, start_response ):
#print( environ, file=sys.stderr )
app= wsgiref.util.shift_path_info(environ)
try:
if app.lower() == "player":
return self.player_app( environ, start_response )
elif app.lower() == "bet":
return self.bet_app( environ, start_response )
elif app.lower() == "wheel":
return self.wheel_app( environ, start_response )
else:
raise RESTException("404 NOT_FOUND",
"Unknown app in {SCRIPT_NAME}/{PATH_INFO}".format_map(environ))
except RESTException as e:
status= e.args[0]
headers = [('Content-type', 'text/plain; charset=utf-8')]
start_response( status, headers, sys.exc_info() )
return [ repr(e.args).encode("UTF-8") ]
我们定义了一个封装了其他应用程序的WSGI应用程序。wsgiref.util.shift_ path_info()函数会解析路径并且根据“/”取出第1个单词。基于这个单词的值,我们会调用3个WSGI应用程序中对应的那个。在本例中,每个应用程序都是类中的一个方法函数。
我们还定义了一个全局的异常处理器,它会将任何RESTException实例转化成一个合适的RESTful响应。我们没有处理的异常会被转化为wsgiref提供的通用状态码500。以下是player_app方法函数。
def player_app( self, environ, start_response ):
if environ['REQUEST_METHOD'] == 'GET':
details= dict( stake= self.table.stake, rounds= self.
rounds )
status = '200 OK'
headers = [('Content-type', 'application/json;
charset=utf-8')]
start_response(status, headers)
return [ json.dumps( details ).encode('UTF-8') ]
else:
raise RESTException("405 METHOD_NOT_ALLOWED",
"Method '{REQUEST_METHOD}' not allowed".format_map(environ))
我们创建了一个响应对象——details。然后,我们将这个对象序列化为一个JSON字符串,然后再用UTF-8将这个字符串编码为字节。
如果不小心使用Post(或者Put或Delete)请求/player/路径,程序会抛出一个异常。顶层的call()方法会捕获这个异常并且将它转化为一个错误响应。
下面是bet_app()函数。
def bet_app( self, environ, start_response ):
if environ['REQUEST_METHOD'] == 'GET':
details = dict( self.table.bets )
elif environ['REQUEST_METHOD'] == 'POST':
size= int(environ['CONTENT_LENGTH'])
raw= environ['wsgi.input'].read(size).decode("UTF-8")
try:
data = json.loads( raw )
if isinstance(data,dict): data= [data]
for detail in data:
self.table.place_bet( detail['bet'],
int(detail['amount']) )
except Exception as e:
raise RESTException("403 FORBIDDEN",
Bet {raw!r}".format(raw=raw))
details = dict( self.table.bets )
else:
raise RESTException("405 METHOD_NOT_ALLOWED",
"Method '{REQUEST_METHOD}' not allowed".format_map(environ))
status = '200 OK'
headers = [('Content-type', 'application/json; charset=utf-8')]
start_response(status, headers)
return [ json.dumps(details).encode('UTF-8') ]
这段代码根据不同的请求方法会执行两个不同的处理过程。当使用GET方法时,结果是当前投注的字典。当使用POST请求时,必须同时传进一些用于定义投注的数据。当尝试使用其他的HTTP方法请求时,返回一个错误。
当使用 POST 时,投注的信息会以数据流的方式附加在请求中。我们必须通过一些步骤来读取和处理这些数据。第1步是用environ['CONTENT_LENGTH']的值确定需要读取多少字节的数据。第2步是解码读取的字节流从而获得客户端发送的原始字符串。
我们用JSON编码请求。这里需要强调的是,这不是浏览器或者Web服务器处理HTML表单用POST方法发送的数据的方式。当使用浏览器从HTML表单中POST数据时,所谓编码只是简单地用urllib.parse模块实现的一系列转义操作。urllib.parse. parse_=qs()模块函数会用HTML发送的数据解析编码过的查询字符串(query string)。
对于RESTful服务,有时候会使用与POST兼容的数据,这样处理HTML表单的过程就会和RESTful的处理过程非常类似。在其他情况下,会使用一个单独的编码方式,例如JSON,来创建比Web表单生成的引用数据更容易使用的数据结构。
一旦我们获得了raw字符串,就可以用json.loads()获取这个字符串所表示的对象。本例中,我们期望获得两个类中的一个、一个定义投注的简单dict对象,一系列定义了多个投注的dict对象。作为一种简单的泛化,我们让单个的dict对象成为一个只包含一个元素的序列。这样就可以用一个通用的dict实例序列表示需要的投注。
注意,我们的异常处理方法会留下一些投注,但是返回全局的403 Forbidden消息。更好的设计方式是使用备忘录(Memento)模式。当放置投注时,我们可以同时创建一个可以用来撤销任何投注的memento对象。实现备忘录模式的一种方法是用Before Image设计模式。备忘录可以包含在应用某个改变之前所有投注的副本。当异常发生时,我们可以删除已损坏的版本,然后恢复到前一个状态。当使用内嵌的可变对象容器时,这个过程可能会非常复杂,因为我们必须确保复制了所有可变对象。由于这个程序只用于不可变的字符串和整数,为table.bets做一份浅拷贝就足够了。
对于POST和GET方法,响应是相同的。我们将table.bets字典序列化为JSON,然后将它发回给REST客户端。这个响应用于确认投注已经成功地放置到台面上。
这个类的最后一个部分是wheel_app()方法。
def wheelapp( self, environ, start_response ):
if environ['REQUEST_METHOD'] == 'POST':
size= environ['CONTENT_LENGTH']
if size != '':
raw= environ['wsgi.input'].read(int(size))
raise RESTException("403 FORBIDDEN",
"Data '{raw!r}' not allowed".format(raw=raw))
spin= self.wheel.spin()
payout = self.table.resolve( spin )
self.rounds += 1
details = dict( spin=spin, payout=payout,
stake= self.table.stake, rounds= self.rounds )
status = '200 OK'
headers = [('Content-type', 'application/json;
charset=utf-8')]
start_response(status, headers)
return [ json.dumps( details ).encode('UTF-8') ]
else:
raise RESTException("405 METHOD_NOT_ALLOWED",
"Method '{REQUEST_METHOD}' not allowed".format
map(environ))
这个方法首先确认客户端是通过一个无参的POST请求调用它的。这样做是为了确保已经正确地关闭了套接字,并且已经读取并忽略了所有数据。这样做可以避免一个写得很差的客户端在遇到套接字中仍然有未读的数据时崩溃的问题。
一旦处理完这些问题,那么剩下的就是旋转轮盘、解析不同的投注和生成一个包含旋转的结果、支出、玩家的筹码和目前进行轮次的响应。这个结果会存储在dict对象中。然后,我们会用JSON来序列化这个对象、编码为UTF-8并且将它发回给客户端。
请注意,我们避免了处理多个玩家的问题。这会在/player/路径下增加一个类和另外一个 POST 方法。它会添加一些定义,并且有一些额外的信息需要记录。创建新玩家的POST 处理过程和放置投注的处理过程类似。这是一个很有趣的练习,但是不会涉及任何新的编程技术。
12.4.3 创建roulette服务器
一旦我们创建了可回调的Roulette类,就可以用下面的方式创建一个WSGI服务器。
def roulette_server_3(count=1):
from wsgiref.simple_server import make_server
from wsgiref.validate import validator
wheel= American()
roulette= Roulette(wheel)
debug= validator(roulette)
httpd = make_server('', 8080, debug)
if count is None:
httpd.serve_forever()
else:
for c in range(count):
httpd.handle_request()
这个函数创建了轮盘 WSGI 应用程序——roulette。它用 simple_server. make_server()创建了一个服务器,这个服务器会用roulette可回调对象处理每个请求。
在本例中,我们也包含了wsgiref.validate.validator() WSGI应用程序。这个应用程序用于验证roulette应用程序使用的接口,它会用assert语句装饰各种API来提供一些诊断信息。万一需要考虑WSGI应用程序中更严重的编程问题时,它也提供了一些更易读的错误信息。
12.4.4 创建roulette客户端
用RESTful客户端API来定义模块是一种常见的做法。通常,客户端的API中会包含一些专门为需要请求的服务定制的函数。
我们会定义一个和各种RESTful服务器兼容的通用客户端,而不是去定义一些专用的客户端。这个客户端可以作为专门为轮盘定制的客户端的基本组成。下面是一个可以使用Roulette服务器的通用客户端。
def roulette_client(method="GET", path="/", data=None):
rest= http.client.HTTPConnection('localhost', 8080)
if data:
header= {"Content-type": "application/json; charset=utf-8'"}
params= json.dumps( data ).encode('UTF-8')
rest.request(method, path, params, header)
else:
rest.request(method, path)
response= rest.getresponse()
raw= response.read().decode("utf-8")
if 200 <= response.status < 300:
document= json.loads(raw)
return document
else:
print( response.status, response.reason )
print( response.getheaders() )
print( raw )
客户端会发起GET或者POST请求,并且它会将POST请求的数据编码为JSON文档。请注意,用JSON编码请求数据绝对不是浏览器处理HTML表单POST数据的方式。浏览器使用的是urllib.parse.urlencode()模块函数实现的编码方式。
我们的客户端函数将JSON文档解码,如果状态码处于[200,300)半开区间,它会将解码后的数据返回。这个区间中的状态码是成功状态码。我们可以用下面的方式试验客户端和服务器。
with concurrent.futures.ProcessPoolExecutor() as executor:
executor.submit( roulette_server_3, 4 )
time.sleep(3) # Wait for the server to start
print( roulette_client("GET", "/player/" ) )
print( roulette_client("POST", "/bet/", {'bet':'Black', 'amount':2}) )
print( roulette_client("GET", "/bet/" ) )
print( roulette_client("POST", "/wheel/" ) )
首先,我们创建ProcessPoo l作为我们试验的上下文。我们向服务器提交了一个请求,实际上,请求是 roulette_server_3(4)。一旦服务器启动,我们就可以尝试和服务器交互了。
在本例中,我们请求了4次。首先查看玩家的状态。接着,放置一个投注,然后,查看投注的状态。最后,转动轮盘。每个步骤中,我们都打印了响应的JSON文档。
日志看起来类似下面这样。
127.0.0.1 - - [09/Dec/2013 08:21:34] "GET /player/ HTTP/1.1" 200 27
{'stake': 100, 'rounds': 0}
127.0.0.1 - - [09/Dec/2013 08:21:34] "POST /bet/ HTTP/1.1" 200 12
{'Black': 2}
127.0.0.1 - - [09/Dec/2013 08:21:34] "GET /bet/ HTTP/1.1" 200 12
{'Black': 2}
127.0.0.1 - - [09/Dec/2013 08:21:34] "POST /wheel/ HTTP/1.1" 200 129
{'stake': 98, 'payout': [['Black', 2, 'lose']], 'rounds': 1, 'spin':
{'27': [35, 1], 'Odd': [1, 1], 'Red': [1, 1], 'Hi': [1, 1]}}
这段日志展示了服务器响应请求的内容,在台面上创建投注、随机转动轮盘并用结果适当地更新用户状态。
