Python Tornado介绍
Tornado 介绍
Tornado是一个 Python Web 框架和异步网络库,最初是在FriendFeed开发的。通过使用非阻塞网络 I/O,Tornado 可以扩展到数万个打开的连接,使其成为 long
polling、 WebSocket和其他需要与每个用户长期连接的应用程序的理想选择。
Tornado大致可分为四个主要组成部分:
一个 Web 框架(包括RequestHandler创建 Web 应用程序的子类,以及各种支持类)
HTTPServerHTTP(和 AsyncHTTPClient)的客户端和服务器端实现
一个异步网络库,包括类IOLoop 和IOStream,它们充当 HTTP 组件的构建块,也可用于实现其他协议
一个协程库 ( tornado.gen),它允许以比链接回调更直接的方式编写异步代码。这类似于Python 3.5 ( ) 中引入的原生协程功能。如果可用,建议使用本机协程代替模块。async deftornado.gen
Tornado Web 框架和 HTTP 服务器一起提供了WSGI的全栈替代方案。WSGIContainer虽然可以将Tornado HTTP 服务器用作其他 WSGI 框架的容器
Tornado 安装
使用pip命令进行安装
pip install tornado
创建一个“Hello,world”示例Web应用程序
import tornado.ioloop
import tornado.web
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello, world")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
])
if __name__ == "__main__":
app = make_app()
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
此时在浏览器地址栏输入127.0.0.1:8888访问即可显示如下结果
Tornado 异步和非阻塞 I/O
异步和非阻塞 I/O
实时 Web 功能需要每个用户的长期空闲连接。在传统的同步 Web 服务器中,这意味着为每个用户分配一个线程,这可能非常昂贵。
为了最小化并发连接的成本,Tornado 使用单线程事件循环。这意味着所有应用程序代码都应该以异步和非阻塞为目标,因为一次只能激活一个操作。
术语异步和非阻塞密切相关,经常互换使用,但它们并不完全相同。
阻塞
当一个函数在返回之前等待某事发生时会阻塞。一个函数可能会因为多种原因而阻塞:网络 I/O、磁盘 I/O、互斥锁等。事实上,每个函数在运行和使用 CPU 时都会阻塞,至少有一点点阻塞(举一个极端的例子来演示为什么 CPU 阻塞必须像其他类型的阻塞一样受到重视,请考虑密码散列函数,如 bcrypt,其设计使用数百毫秒的 CPU 时间,远远超过典型的网络或磁盘访问)。
一个函数在某些方面可以是阻塞的,而在其他方面可以是非阻塞的。在 Tornado 的上下文中,我们通常在网络 I/O 的上下文中讨论阻塞,尽管所有类型的阻塞都将被最小化。
异步
异步函数在完成之前返回,并且通常会在触发应用程序中的某些未来操作之前在后台发生一些工作(与正常的 同步函数相反,它们在返回之前完成它们将要做的所有事情)。异步接口有多种风格:
回调参数
返回占位符 ( Future, Promise, Deferred) 交付到队列
回调注册表(例如 POSIX 信号)
无论使用哪种类型的接口, 根据定义,异步函数与其调用者的交互方式不同;没有免费的方法可以以对其调用者透明的方式使同步函数异步(像gevent 之类的系统使用轻量级线程来提供与异步系统相当的性能,但它们实际上并没有使事情异步)。
Tornado 中的异步操作通常返回占位符对象 ( Futures),但一些低级组件(例如IOLoop使用回调的组件)除外。Futures通常使用awaitoryield 关键字转换成它们的结果。
演示例子
这是一个同步函数:
from tornado.httpclient import HTTPClient
def synchronous_fetch(url):
http_client = HTTPClient()
response = http_client.fetch(url)
return response.body
这是作为原生协程异步重写的相同函数:
from tornado.httpclient import AsyncHTTPClient
async def asynchronous_fetch(url):
http_client = AsyncHTTPClient()
response = await http_client.fetch(url)
return response.body
或者为了与旧版本Python兼容,请使用以下tornado.gen模块
from tornado.httpclient import AsyncHTTPClient
from tornado import gen
@gen.coroutine
def async_fetch_gen(url):
http_client = AsyncHTTPClient()
response = yield http_client.fetch(url)
raise gen.Return(response.body)
**协程有点神奇,但它们在内部做的事情是这样的:**
from tornado.concurrent import Future
def async_fetch_manual(url):
http_client = AsyncHTTPClient()
my_future = Future()
fetch_future = http_client.fetch(url)
def on_fetch(f):
my_future.set_result(f.result().body)
fetch_future.add_done_callback(on_fetch)
return my_future
请注意:协程Future在 fetch 完成之前返回它。这就是使协同程序异步的原因。
任何你可以用协程做的事情,你也可以通过传递回调对象来做,但是协程提供了一个重要的简化,它让你以与同步时相同的方式组织你的代码。这对于错误处理尤其重要,因为try/except块的工作方式与您在协程中所期望的一样,而这很难通过回调实现。本指南的下一部分将深入讨论协程
Tornado 协程协程
协程是在 Tornado 中编写异步代码的推荐方式。协程使用 Python的await或yield关键字来暂停和恢复执行,而不是一连串的回调(在gevent等框架中看到的协作轻量级线程有时也称为协程,但在 Tornado 中,所有协程都使用显式上下文切换并被称为异步函数)协程几乎和同步代码一样简单,但没有线程的开销。它们还通过减少可能发生上下文切换的位置数量,使并发更容易推理。
例子:
async def fetch_coroutine(url):
http_client = AsyncHTTPClient()
response = await http_client.fetch(url)
return response.body
``
原生协程与装饰协程
Python 3.5 引入了async和await关键字(使用这些关键字的函数也称为“本机协程”)。为了与旧版本的 Python 兼容,您可以使用装饰器使用“装饰”或“基于产量”的协程tornado.gen.coroutine
尽可能推荐使用原生协程。仅在需要与旧版本的 Python 兼容时才使用修饰的协程。Tornado 文档中的示例通常会使用原生形式。
两种形式之间的转换通常很简单:
# Decorated: # Native:
# Normal function declaration
# with decorator # "async def" keywords
@gen.coroutine
def a(): async def a():
# "yield" all async funcs # "await" all async funcs
b = yield c() b = await c()
# "return" and "yield"
# cannot be mixed in
# Python 2, so raise a
# special exception. # Return normally
raise gen.Return(b) return b
下面概述了两种形式的协程之间的其他差异:
原生协程:
通常更快。
可以使用async for和async with语句使某些模式更简单。 除非您知道yield和await他们,否则根本不要运行。
装饰协程一旦被调用就可以开始“在后台”运行。请注意,对于这两种协程,使用await或yield是很重要的,这样任何异常都有可能发生。
装饰协程:
包有额外的集成 concurrent.futures,允许executor.submit直接产生结果。对于本机协程,请IOLoop.run_in_executor改用
通过产生一个列表或字典来支持一些等待多个对象的简写。用于tornado.gen.multi在本机协程中执行此操作
可以通过转换函数注册表支持与其他包的集成,包括 Twisted。要在本机协程中访问此功能,请使用
tornado.gen.convert_yielded 总是返回一个Future对象。本机协程返回一个不是Future. 在 Tornado 中,两者大多可以互换。
如何运作
本节解释装饰协程的操作。原生协程在概念上相似,但由于与 Python 运行时的额外集成而稍微复杂一些
包含的函数yield是生成器。所有生成器都是异步的;当被调用时,它们返回一个生成器对象,而不是运行到完成。装饰器@gen.coroutine通过yield表达式与生成器通信,并通过返回一个Future
这是协程装饰器内部循环的简化版本:
# Simplified inner loop of tornado.gen.Runner
def run(self):
# send(x) makes the current yield return x.
# It returns when the next yield is reached
future = self.gen.send(self.next)
def callback(f):
self.next = f.result()
self.run()
future.add_done_callback(callback)
装饰器Future从生成器接收 a,等待(不阻塞)Future完成,然后“解包”并将结果作为表达式Future 的结果发送回生成器 。yield大多数异步代码从不直接接触类,除非立即将Future异步函数返回的值传递给yield表达式。
如何调用协程
协程不会以正常方式引发异常:它们引发的任何异常都将被困在等待对象中,直到它被产生。这意味着以正确的方式调用协程很重要,否则您可能会遇到未被注意到的错误:
async def divide(x, y):
return x / y
def bad_call():
# This should raise a ZeroDivisionError, but it won't because
# the coroutine is called incorrectly.
divide(1, 0)
在几乎所有情况下,任何调用协程的函数都必须是协程本身,并且在调用中使用await或者yield关键字。当您覆盖类中定义的方法时,请查阅文档以查看是否允许使用协程(文档应说明该方法“可能是协程”或“可能返回一个Future”):
async def good_call():
# await will unwrap the object returned by divide() and raise
# the exception.
await divide(1, 0)
有时你可能想“触发并忘记”一个协程而不等待它的结果。在这种情况下,建议使用IOLoop.spawn_callback
,这使得IOLoop负责呼叫。如果失败,IOLoop将记录堆栈跟踪:
# The IOLoop will catch the exception and print a stack trace in
# the logs. Note that this doesn't look like a normal call, since
# we pass the function object to be called by the IOLoop.
IOLoop.current().spawn_callback(divide, 1, 0)
对于使用IOLoop.spawn_callback的函数,建议以这种方式使用@gen.coroutine,但对于使用async def的函数,则需要以这种方式使用(否则,协程运行程序将无法启动)。
最后,在程序的顶层,如果 IOLoop 尚未运行
,您可以启动IOLoop,运行协程,然后IOLoop使用IOLoop.run_sync方法停止
。这通常用于启动main面向批处理的程序的功能:
# run_sync() doesn't take arguments, so we must wrap the
# call in a lambda.
IOLoop.current().run_sync(lambda: divide(1, 0))
协程模式
调用阻塞函数
从协程调用阻塞函数的最简单方法是使用IOLoop.run_in_executor
,它的返回值 Futures与协程兼容:
async def call_blocking():
await IOLoop.current().run_in_executor(None, blocking_func, args)
并行性
该multi函数接受值为列表和字典,并等待所有这些Futures:
from tornado.gen import multi
async def parallel_fetch(url1, url2):
resp1, resp2 = await multi([http_client.fetch(url1),
http_client.fetch(url2)])
async def parallel_fetch_many(urls):
responses = await multi ([http_client.fetch(url) for url in urls])
# responses is a list of HTTPResponses in the same order
async def parallel_fetch_dict(urls):
responses = await multi({url: http_client.fetch(url)
for url in urls})
# responses is a dict {url: HTTPResponse}
在装饰协程中,可以yield直接生成使用list 或dict:
@gen.coroutine
def parallel_fetch_decorated(url1, url2):
resp1, resp2 = yield [http_client.fetch(url1),
http_client.fetch(url2)]
交错
有时,保存一个Future而不是立即放弃它是有用的,这样你就可以在等待之前开始另一个操作。
from tornado.gen import convert_yielded
async def get(self):
# convert_yielded() starts the native coroutine in the background.
# This is equivalent to asyncio.ensure_future() (both work in Tornado).
fetch_future = convert_yielded(self.fetch_next_chunk())
while True:
chunk = yield fetch_future
if chunk is None: break
self.write(chunk)
fetch_future = convert_yielded(self.fetch_next_chunk())
yield self.flush()
这对装饰协程来说更容易一些,因为它们在调用时立即启动:
@gen.coroutine
def get(self):
fetch_future = self.fetch_next_chunk()
while True:
chunk = yield fetch_future
if chunk is None: break
self.write(chunk)
fetch_future = self.fetch_next_chunk()
yield self.flush()
循环
在本地协同程序中,可以使用async for。在较旧版本的Python中,使用协同路由进行循环是很棘手的,因为在for或while循环的每次迭代中都无法找到yield并捕获结果。相反,您需要将循环条件与访问结果分开,如本例中的Motor:
import motor
db = motor.MotorClient().test
@gen.coroutine
def loop_example(collection):
cursor = db.collection.find()
while (yield cursor.fetch_next):
doc = cursor.next_object()
在后台运行
PeriodicCallback通常不与coroutines一起使用。相反,协同程序可以包含while True:循环并使用tornado.gen.sleep:
async def minute_loop():
while True:
await do_something()
await gen.sleep(60)
# Coroutines that loop forever are generally started with
# spawn_callback().
IOLoop.current().spawn_callback(minute_loop)
有时可能需要一个更复杂的循环。例如,前一个循环每60+N秒运行一次,其中N是do_something
()的运行时间。要准确地每60秒运行一次,请使用上面的交错模式:
async def minute_loop2():
while True:
nxt = gen.sleep(60) # Start the clock.
await do_something() # Run while the clock is ticking.
await nxt # Wait for the timer to run out.
Tornado Query
Query示例-并发网络蜘蛛
Tornado 的tornado.queues模块为协程实现了异步生产者/消费者模式,类似于 Python 标准库的queue模块为线程实现的模式。
产生Queue.get暂停的协程直到队列中有一个项目。如果队列设置了最大大小,则产生的协程会Queue.put暂停,直到有空间容纳另一个项目。
一个Queue维护未完成任务的计数,该计数从零开始。 put增加计数;task_done递减它。
在此处的 web-spider 示例中,队列开始时仅包含 base_url。当工作人员获取页面时,它会解析链接并将新链接放入队列中,然后调用task_done以减少计数器一次。最终,一个工作人员获取了一个之前所有 URL 都已看到的页面,并且队列中也没有剩余工作。因此,该工人的调用task_done 将计数器减至零。正在等待加入的主协同程序未暂停并完成。
#!/usr/bin/env python3
import time
from datetime import timedelta
from html.parser import HTMLParser
from urllib.parse import urljoin, urldefrag
from tornado import gen, httpclient, ioloop, queues
base_url = "http://www.tornadoweb.org/en/stable/"
concurrency = 10
async def get_links_from_url(url):
"""Download the page at `url` and parse it for links.
Returned links have had the fragment after `#` removed, and have been made
absolute so, e.g. the URL 'gen.html#tornado.gen.coroutine' becomes
'http://www.tornadoweb.org/en/stable/gen.html'.
"""
response = await httpclient.AsyncHTTPClient().fetch(url)
print("fetched %s" % url)
html = response.body.decode(errors="ignore")
return [urljoin(url, remove_fragment(new_url)) for new_url in get_links(html)]
def remove_fragment(url):
pure_url, frag = urldefrag(url)
return pure_url
def get_links(html):
class URLSeeker(HTMLParser):
def __init__(self):
HTMLParser.__init__(self)
self.urls = []
def handle_starttag(self, tag, attrs):
href = dict(attrs).get("href")
if href and tag == "a":
self.urls.append(href)
url_seeker = URLSeeker()
url_seeker.feed(html)
return url_seeker.urls
async def main():
q = queues.Queue()
start = time.time()
fetching, fetched, dead = set(), set(), set()
async def fetch_url(current_url):
if current_url in fetching:
return
print("fetching %s" % current_url)
fetching.add(current_url)
urls = await get_links_from_url(current_url)
fetched.add(current_url)
for new_url in urls:
# Only follow links beneath the base URL
if new_url.startswith(base_url):
await q.put(new_url)
async def worker():
async for url in q:
if url is None:
return
try:
await fetch_url(url)
except Exception as e:
print("Exception: %s %s" % (e, url))
dead.add(url)
finally:
q.task_done()
await q.put(base_url)
# Start workers, then wait for the work queue to be empty.
workers = gen.multi([worker() for _ in range(concurrency)])
await q.join(timeout=timedelta(seconds=300))
assert fetching == (fetched | dead)
print("Done in %d seconds, fetched %s URLs." % (time.time() - start, len(fetched)))
print("Unable to fetch %s URLS." % len(dead))
# Signal all the workers to exit.
for _ in range(concurrency):
await q.put(None)
await workers
if __name__ == "__main__":
io_loop = ioloop.IOLoop.current()
io_loop.run_sync(main)
Tornado Web应用程序的结构
Tornado Web 应用程序通常由一个或多个 RequestHandler子类,一个Application将传入请求路由到处理程序的对象和一个main()启动服务器的函数组成。
一个最小的“hello world”示例如下所示:
import tornado.ioloop
import tornado.web
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello, world")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
])
if __name__ == "__main__":
app = make_app()
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
Application对象
该Application对象负责全局配置,包括将请求映射到处理程序的路由表。
路由表是URLSpec对象(或元组)的列表,每个对象(至少)包含一个正则表达式和一个处理程序类。订单事项;使用第一个匹配规则。如果正则表达式包含捕获组,这些组是路径参数,并将传递给处理程序的 HTTP 方法。如果字典作为 的第三个元素传递URLSpec,它提供将传递给 的初始化参数RequestHandler.initialize。最后,URLSpec可能有一个名称,这将允许它与 一起使用 RequestHandler.reverse_url。
例如,在这段代码中,根URL/被映射到MainHandler,格式为/story/后跟数字的URL被映射到StoryHandler。该数字(作为字符串)传递给StoryHandler.get
class MainHandler(RequestHandler):
def get(self):
self.write('<a href="%s">link to story 1</a>' %
self.reverse_url("story", "1"))
class StoryHandler(RequestHandler):
def initialize(self, db):
self.db = db
def get(self, story_id):
self.write("this is story %s" % story_id)
app = Application([
url(r"/", MainHandler),
url(r"/story/([0-9]+)", StoryHandler, dict(db=db), name="story")
])
Application构造函数接受许多关键字参数,这些参数可用于自定义应用程序的行为并启用可选功能;查看Application.settings的完整列表。
RequestHandler子类
Tornado Web 应用程序的大部分工作都是在RequestHandler. 处理程序子类的主要入口点是一个以正在处理的 HTTP 方法命名的方法:get()、 post()等。每个处理程序可以定义一个或多个这些方法来处理不同的 HTTP 操作。如上所述,将使用与匹配的路由规则的捕获组对应的参数调用这些方法。
在处理程序中,调用RequestHandler.render或RequestHandler.write等方法以生成响应。render()按名称加载Template,并用给定的参数呈现它。write()用于非基于模板的输出;它接受字符串、字节和字典(dict将被编码为JSON)。
RequestHandler中的许多方法被设计为在子类中重写,并在整个应用程序中使用。通常会定义一个BaseHandler类来覆盖write_error和get_current_user等方法,然后为所有特定的处理程序子类化自己的BaseHandler而不是RequestHandler。
处理请求输入
请求处理程序可以通过self.request访问表示当前请求的对象。有关属性的完整列表,请参见HTTPServerRequest的类定义。
HTML表单使用的格式的请求数据将被解析,并通过get_query_argument和get_body_argument等方法提供。
class MyFormHandler(tornado.web.RequestHandler):
def get(self):
self.write('<html><body><form action="/myform" method="POST">'
'<input type="text" name="message">'
'<input type="submit" value="Submit">'
'</form></body></html>')
def post(self):
self.set_header("Content-Type", "text/plain")
self.write("You wrote " + self.get_body_argument("message"))
由于HTML表单编码对于参数是单个值还是包含一个元素的列表是不明确的,RequestHandler有不同的方法来允许应用程序指示它是否需要列表。对于列表,请使用get_query_arguments和get_body_arguments,而不是它们的单数对应项。
通过表单上传的文件在self.request.files中可用,它将名称(HTML 元素的名称)映射到文件列表。每个文件都是{“filename”:…,“content_type”:…,“body”:…}格式的字典。files对象仅在使用表单包装器(即multipart/form-data内容类型)上载文件时存在;如果未使用此格式,则self.request.body中可获得原始上传数据。默认情况下,上传的文件在内存中完全缓冲;如果需要处理太大而无法轻松保存在内存中的文件,请参阅stream_request_body类装饰器。
在demos目录中,file_receiver.py显示了接收文件上传的两种方法。
由于HTML表单编码的特殊性(例如,单数和复数参数之间的模糊性),Tornado不尝试将表单参数与其他类型的输入统一起来。特别是,我们不解析JSON请求体。希望使用JSON而不是表单编码的应用程序可能会重写prepare来解析其请求:
def prepare(self):
if self.request.headers.get("Content-Type", "").startswith("application/json"):
self.json_args = json.loads(self.request.body)
else:
self.json_args = None
重写RequestHandler方法
除了get()/post()等,RequestHandler中的某些其他方法被设计为在必要时被子类重写。每次请求时,都会进行以下顺序的调用:
每个请求都会创建一个新的RequestHandler对象
initialize()使用配置中的初始化参数调用Application。initialize 通常应该只保存传递给成员变量的参数;它可能不会产生任何输出或调用方法,例如 send_error
调用prepare()。这在所有处理程序子类共享的基类中最有用,因为无论使用哪个HTTP方法,都会调用prepare。prepare可以产生输出;如果它调用finish或redirect等等方法,处理将在此停止
其中一个HTTP方法被调用:get()、post()、put()等等。如果URL正则表达式包含捕获组,则将它们作为参数传递给此方法
当请求完成时,on_finish()被调用。这通常是在get()或另一个 HTTP 方法返回之后。
RequestHandler文件中记录了所有设计为覆盖的方法。一些最常用的重写方法包括:
write_error- 输出HTML以在错误页面上使用
on_connection_close- 当客户端断开连接时调用;应用程序可以选择检测这种情况并停止进一步处理。请注意,不能保证可以立即检测到关闭的连接
get_current_user- 请参阅用户身份验证
get_user_locale- 返回Locale当前用户使用的对象
set_default_headers- 可用于在响应上设置附加表头(例如自定义Server表头)
错误处理
如果处理程序引发异常,Tornado将调用RequestHandler.write_error生成错误页面。tornado.web.HTTPError可用于生成指定的状态代码;所有其他异常都返回500状态。
默认错误页面包括调试模式下的堆栈跟踪和错误的单行描述(例如“500:内部服务器错误”)。要生成自定义错误页,请重写RequestHandler.write_error(可能在所有处理程序共享的基类中)。这种方法通常可以通过write和render等方法产生输出。如果错误是由异常引起的,则exc_info 将作为关键字参数传递(请注意,此异常不能保证是sys.exc_info中的当前异常,因此write_error必须使用例如traceback.format_exception而不是traceback.format_exc)
也可以从常规处理程序方法生成错误页面,而不是write_error通过调用 set_status、编写响应和返回。在简单返回不方便的情况下,tornado.web.Finish可能会引发特殊异常以终止处理程序而不调用write_error
对于404错误,使用default_handler_class应用程序设置。这个处理程序应该覆盖prepare,而不是像get()这样更具体的方法,这样它就可以与任何HTTP方法一起工作。它应该如上所述生成错误页面:通过引发HTTPError(404)并覆盖write_error,或者调用self.set_status(404)并直接在prepare()中生成响应。
重定向
在Tornado中,有两种主要的重定向请求的方法:RequestHandler.redirect和RedirectHandler
可以在self.redirect()方法中使用RequestHandler将用户重定向到其他地方。还有一个可选参数permanent,可用于指示重定向被视为永久性的。permanent的默认值为False,这将生成一个302 Found的HTTP响应代码,适用于在成功的POST请求后重定向用户。如果permanent为True,则使用301 Moved permanuallyHTTP响应代码,这有助于以SEO友好的方式重定向到页面的规范URL
RedirectHandler允许您直接在Application路由表中配置重定向。例如,要配置单个静态重定向:
app = tornado.web.Application([
url(r"/app", tornado.web.RedirectHandler,
dict(url="http://itunes.apple.com/my-app-id")),
])
RedirectHandler还支持正则表达式替换。以下规则将所有以/pictures开头的请求重定向到前缀/photos:
app = tornado.web.Application([
url(r"/photos/(.*)", MyPhotoHandler),
url(r"/pictures/(.*)", tornado.web.RedirectHandler,
dict(url=r"/photos/{0}")),
])
与RequestHandler.redirect不同,RedirectHandler默认使用永久重定向。这是因为路由表在运行时不会更改,并且被认为是永久的,而在处理程序中找到的重定向可能是其他可能更改的逻辑的结果。要使用RedirectHandler发送临时重定向,请在RedirectHandler初始化参数中添加permanent=False
异步处理程序
某些处理程序方法(包括prepare()和HTTP的get()/post()方法等)可能会被重写为协同路由,以使处理程序异步。
例如,下面是一个使用协同程序的简单处理程序:
class MainHandler(tornado.web.RequestHandler):
async def get(self):
http = tornado.httpclient.AsyncHTTPClient()
response = await http.fetch("http://friendfeed-api.com/v2/feed/bret")
json = tornado.escape.json_decode(response.body)
self.write("Fetched " + str(len(json["entries"])) + " entries "
"from the FriendFeed API")
作者:马里亚纳海沟网