11

综合Twitter、Github等各大网站API设计经验:RESTful API实用设计与最佳实践 - Vinay...

 4 years ago
source link: https://www.jdon.com/53510
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

综合Twitter、Github等各大网站API设计经验:RESTful API实用设计与最佳实践

如果你的数据模型已经开始稳定,并且可以为Web应用程序创建公共API了,一旦发布了API,就很难对其进行重大更改,并且想要尽可能早地获得正确的解决方案。现在,互联网上对API设计的意见不统一也不是很充足。但是,因为没有一种在所有情况下都能有效使用的标准,你可能有很多选择:应该接受哪种格式?您应该如何认证?是否应该对API进行版本控制?

在设计Enchant的API (Zendesk替代品)时,我尝试提出了这些问题的实用答案。我的目标是Enchant API对于我们自己的用户界面易于使用,易于采用,并足够灵活的内部测试

API的关键要求

在网络上找到的许多API设计意见都是学术讨论,围绕模糊标准的主观解释,而不是现实世界中有意义的解释。我的这篇文章的目的是描述为当今的Web应用程序设计的实用API的最佳实践。如果感觉不对,我不会尝试满足一个标准。为了帮助指导决策过程,我写下了API必须努力满足的一些要求:

  • 它应该在有意义的地方使用网络标准
  • 它应该对开发人员友好并且可以通过浏览器地址栏探索
  • 它应该简单,直观且一致,以使采用不仅容易而且愉快
  • 它应提供足够的灵活性以增强大部分UI
  • 它应该是有效的,同时保持与其他要求的平衡

API是开发人员的UI,就像任何UI一样,确保仔细考虑用户的体验也很重要!

使用RESTful URL和操作

REST的关键原理涉及将您的API分成逻辑资源。这些资源使用HTTP请求进行操作,其中方法(GET,POST,PUT,PATCH,DELETE)具有特定含义。

但是我可以做些什么呢?好吧,这些应该是从API使用者角度讲有意义的名词(而不是动词!)。虽然您的内部模型可以整齐地映射到资源,但不一定是一对一的映射。这里的关键是不要将无关的实现细节泄漏给您的API,例如Enchant案例中这些名词是ticket,user和group。

定义资源后,您需要确定哪些操作适用于它们以及这些操作将如何映射到您的API。RESTful原则提供了使用映射如下的HTTP方法来处理CRUD操作的策略:

  • GET /tickets-检索门票列表
  • GET /tickets/12-检索特定的票证
  • POST /ticket -创建新票证
  • PUT /ticket/ 12-更新票#12
  • PATCH /tickets12-部分更新ticket#12
  • DELETE /tickets/12-删除故障单#12

REST的优点在于,您可以利用现有的HTTP方法在单个/ tickets端点上实现重要的功能。没有要遵循的方法命名约定,URL结构清晰明了。REST FTW!

端点名称应为单数还是复数?保持简单规则在此处适用。尽管内部语法专家会告诉您使用复数形式描述资源的单个实例是错误的,但务实的答案是保持URL格式一致并始终使用复数形式。不必处理带有复数概念的词语((person/people, goose/geese) ,使API消费者的生活更美好,是对API提供商实现最现代化的框架将在本地处理更容易。

但是您如何处理关系?如果一个关系只能存在于另一个资源中,则RESTful原则将提供有用的指导。让我们来看一个例子。Enchant中的ticket包含许多消息。可以将这些消息逻辑上映射到/ tickets端点,如下所示:

  • GET / tickets / 12 / messages-检索ticket#12的消息列表
  • GET / tickets / 12 / messages / 5-检索ticket#12的消息#5
  • POST / tickets / 12 / messages-在ticket#12中创建新消息
  • PUT / tickets / 12 / messages / 5-更新ticket#12的消息#5
  • PATCH / tickets / 12 / messages / 5-部分ticket#12的消息#5
  • DELETE / tickets / 12 / messages / 5-删除ticket#12的消息#5

或者,如果一个关系可以独立于资源而存在,则在资源的输出表示中仅包括一个标识符即可。然后,API使用者将不得不点击关系的端点。但是,如果通常随资源一起请求该关系,则API可以提供自动嵌入该关系的表示的功能,并避免对该API造成第二次打击。

那些不适合CRUD操作的操作又如何呢?

这是事情变得模糊的地方。有多种方法:

  1. 重组操作以使其看起来像资源的字段。如果操作不使用参数,则此方法有效。例如,激活动作可以映射到激活activated布尔字段,并通过PATCH更新到资源。
  2. 使用RESTful原则将其视为子资源。例如,GitHub的API可以通过PUT /gists/:id/star让打星星,DELETE /gists/:id/star去除打星。
  3. 有时候,您确实无法将操作映射到合理的RESTful结构。例如,将多资源搜索应用于特定资源的端点并没有任何意义。在这种情况下,/ search即使不是资源,也将是最有意义的。没关系-只需从API使用者的角度进行正确的操作,并确保已对其进行了清晰记录,以免造成混淆。
  4. banq注:从活动这个词语考虑,将动词操作名词化,某某活动即代表是动作,又代表名词。

随时随地使用SSL

始终使用SSL。没有例外。如今,您可以从Internet上的任何地方(例如图书馆,咖啡店,机场等)访问您的Web API。并非所有这些都是安全的。许多协议根本不加密通信,如果劫持了身份验证凭据,则可以轻松进行窃听或模拟。

始终使用SSL的另一个优势是,保证加密的通信可简化身份验证工作-您可以使用简单的访问令牌来获得成功,而不必签署每个API请求。

文档

API仅与其文档一样好。该文档应该易于查找并且可以公开访问。大多数开发人员会在尝试任何集成工作之前先签出文档。当文档隐藏在PDF文件中或需要登录时,它们不仅很难找到而且也不容易搜索。

文档应显示完整的请求/响应周期的示例。应该是可粘贴的示例-可以粘贴到浏览器中的链接或可以粘贴到终端中的curl示例。 GitHubStripe在这方面做得很好。

发布公共API后,您承诺不会在没有通知的情况下破坏内容。该文档必须包括所有弃用时间表以及有关外部可见API更新的详细信息。更新应通过博客(例如,变更日志)或邮件列表(最好同时使用两者!)进行传递。

版本控制

始终对API进行版本控制。版本控制可帮助您更快地进行迭代,并防止无效请求到达更新的端点。由于您可以在一段时间内继续提供旧的API版本,因此还可以帮助您平滑所有主要的API版本转换。

关于是否应在URL或标头中包含API版本,存在不同意见。从学术上讲,它可能应该放在标题中。但是,该版本必须位于URL中,以确保浏览器可以跨版本使用资源(还记得这篇文章顶部指定的API要求吗?)。

我非常喜欢Stripe进行API版本控制的方法-URL具有主要版本号(v1),但是API具有基于日期的子版本,可以使用自定义HTTP请求标头进行选择。在这种情况下,主要版本提供了整个API的结构稳定性,而子版本则说明了较小的更改(字段弃用,端点更改等)。

API永远不会完全稳定。变化是不可避免的。重要的是如何管理更改。有据可查并已宣布的数月弃用时间表对于许多API来说都是可以接受的做法。对于API的行业和潜在消费者,这归结为合理的选择。

结果过滤,排序和搜索

最好使基本资源URL尽可能精简。复杂的结果过滤器,排序要求和高级搜索(仅限于一种类型的资源时)都可以轻松地实现为基本URL上方的查询参数。让我们更详细地看一下这些:

过滤:对实现过滤的每个字段使用唯一的查询参数。例如,当从/ tickets端点请求票证列表时,您可能希望将其限制为仅处于打开状态的票证。这可以通过GET /tickets?state=open之类的请求来完成。这里,状态是实现过滤器的查询参数。

排序:类似于过滤,通用参数排序可用于描述排序规则。通过使sort参数接受逗号分隔的字段列表,可以满足复杂的排序要求,每个字段都有可能的一元负数以暗示降序排列。让我们看一些例子:

  • GET / tickets?sort=-priority-以优先级降序检索票证列表
  • GET / tickets?sort=-priority,created_at-按优先级从高到低的顺序检索票证列表。在特定优先级之内,优先订购较旧的门票

搜索:有时基本的过滤器还不够,您需要全文搜索的功能。也许您已经在使用ElasticSearch或其他基于Lucene的搜索技术。当全文搜索用作检索特定类型资源的资源实例的机制时,可以在API上将其公开为资源端点上的查询参数。假设 参数名为q。搜索查询应直接传递给搜索引擎,API输出应采用与普通列表结果相同的格式。

将这些结合在一起,我们可以构建查询,例如:

  • GET/tickets?sort = -updated_at-检索最近更新的票证
  • GET/tickets?state = closed&sort = -updated_at-检索最近关闭的门票
  • GET/tickets?q = return&state = open&sort = -priority,created_at-检索提及“ return”一词的优先级最高的公开票证

常见查询的别名

为了使普通用户对API的体验更加愉快,请考虑将条件集打包到易于访问的RESTful路径中。例如,上面最近关闭的票查询可以打包为GET / tickets / recently_closed

限制API返回哪些字段

API使用者并不总是需要资源的完整表示。选择和选择返回的字段的能力在让API使用方最大程度地减少网络流量并加快他们自己对API的使用方面大有帮助。

使用field作为查询参数名,该参数采用逗号分隔的字段列表。例如,以下请求将仅检索足够的信息以显示未清票的排序列表:

GET / tickets?fields = id,subject,customer_name,updated_at&state = open&sort = -updated_at

更新和创建应返回资源表示形式

PUT,POST或PATCH调用可能会对不属于所提供参数的基础资源字段进行修改(例如:created_at或updated_at时间戳)。为了避免API使用者必须再次点击API以获得更新的表示形式,请让API返回更新(或创建的)表示形式作为响应的一部分。

如果是POST导致创建,请使用HTTP 201状态代码并包含一个指向新资源URL的Location标头

你应该HATEOAS吗?

关于API使用者是否应该创建链接或是否应该向API提供链接,存在很多不同的意见。RESTful设计原则指定了HATEOAS,它粗略地指出与端点的交互应该在输出表示形式附带的元数据中定义,而不是基于边界外信息。

尽管网络通常可以使用HATEOAS类型原则(我们可以转到网站的首页并根据页面上的链接访问链接),但我认为我们还不准备在API上使用HATEOAS。浏览网站时,将在运行时决定要单击的链接。但是,使用API​​时,将在编写API集成代码时(而不是在运行时)决定发送什么请求。可以将决定推迟到运行时间吗?当然,沿着这条路线没有太多收获,因为代码仍将无法处理重要的API更改而不会中断。就是说,我认为HATEOAS很有前途,但还没有准备好迎接黄金时段。必须付出更多的努力来围绕这些原则定义标准和工具,以充分发挥其潜力。

现在,最好假设用户可以访问文档并在输出表示中包含资源标识符,API使用者在制作链接时将使用该标识符。保存标识符有两个优点-网络上的数据流被最小化,API使用者存储的数据也被最小化(因为他们存储的是小的标识符,而不是包含标识符的URL)。

同样,鉴于此文章主张在URL中使用版本号,从长远来看,对于API使用者而言,存储资源标识符而不是URL更有意义。毕竟,标识符在各个版本中都是稳定的,但表示它的URL不稳定!

仅JSON响应

现在是时候将XML留在API中了。它很冗长,很难解析,很难阅读,它的数据模型与大多数编程语言对数据建模的方式不兼容,并且当您的输出表示形式的主要需求是从内部表示形式进行序列化时,它的可扩展性优点就无关紧要。

媒体类型应该基于“Accept”标头还是基于URL进行更改? 为了确保浏览器的可浏览性,它应该在URL中。这里最明智的选择是将.json或.xml扩展名附加到端点URL。

 大小写snake_case和camel_case

如果您使用JSON(JavaScript对象表示法)作为主要表示格式,则“正确”的事情就是遵循JavaScript命名约定-这意味着字段名称为camelCase!如果您随后采用各种语言构建客户端库的方法,则最好在它们中使用惯用的命名约定-对于C#和Java,使用camelCase,对于python&ruby,使用snake_case。

值得深思:我一直觉得snake_case比JavaScript的camelCase约定更易于阅读。直到现在,我还没有任何证据可以证明我的直觉。根据对2010 年的camelCase和snake_casePDF)进行的眼动研究,snake_case比camelCase的阅读容易20%!对可读性的影响将影响API的可探索​​性和文档中的示例。

许多流行的JSON API使用snake_case。我怀疑这是由于序列化库遵循了它们所使用的基础语言的命名约定。也许我们需要让JSON序列化库处理命名约定转换。

驼峰:johnSmith;蛇形:john_smith

默认情况下漂亮打印并确保支持gzip

从浏览器看,提供空格压缩输出的API并不是很有趣。尽管可以提供某种查询参数(例如?pretty = true)来启用漂亮打印,但是默认情况下漂亮打印的API更加易于使用。额外数据传输的成本可以忽略不计,尤其是与不实施gzip的成本相比时。

考虑一些用例:如果API使用者正在调试并且将其代码打印出从API接收到的数据,该怎么办-默认情况下可读。或者,如果使用者获取了他们的代码生成的URL并直接从浏览器中将其命中-默认情况下它将可读。这些都是小事。使API易于使用的小东西!

但是,所有额外的数据传输又如何呢?

让我们以一个真实的例子来看一下。我从GitHub的API中提取了一些数据,该API默认情况下使用漂亮的打印。我还进行一些gzip比较:

$ curl https://api.github.com/users/veesahni > with-whitespace.txt
$ ruby -r json -e 'puts JSON JSON.parse(STDIN.read)' < with-whitespace.txt > without-whitespace.txt
$ gzip -c with-whitespace.txt > with-whitespace.txt.gz
$ gzip -c without-whitespace.txt > without-whitespace.txt.gz

输出文件具有以下大小:

  • 不带whitespace.txt -1252字节
  • with-whitespace.txt -1369字节
  • 不带whitespace.txt.gz -496个字节
  • with-whitespace.txt.gz -509字节

在此示例中,当不使用gzip时,空白将输出大小增加8.5%,当使用gzip时,空白将输出大小增加2.6%。另一方面,gzip压缩本身可以节省60%以上的带宽。由于漂亮打印的成本相对较小,因此默认情况下最好进行漂亮打印,并确保支持gzip压缩!

为了进一步说明这一点,Twitter发现,在其Streaming API上启用gzip压缩后,在某些情况下节省80%。Stack Exchange甚至做到了永不返回未经压缩的响应

默认情况下不使用envelope信封,但在需要时可以使用信封

许多API如下将其响应包装在信封中:

{
  "data" : {
    "id" : 123,
    "name" : "John"
  }
}

这样做有两个理由-它使得添加其他元数据或分页信息变得容易,某些REST客户端不允许轻松访问HTTP标头,而JSONP请求无法访问HTTP标头。但是,随着诸如CORSRFC 5988中Link标头之类的标准被迅速采用,信封变得不再必要。

我们可以通过默认情况下不使用信封并仅在特殊情况下进行封装来证明API的未来。

在特殊情况下应如何使用信封?

在两种情况下,确实需要使用信封-如果API需要通过JSONP支持跨域请求,或者客户端无法使用HTTP标头。

JSONP请求带有附加的查询参数(通常称为callback或jsonp),该参数代表回调函数的名称。如果存在此参数,则API应切换到信封模式,在该模式下,API始终以200 HTTP状态代码进行响应,并在JSON有效负载中传递实际状态代码。应与响应一起传递的所有其他HTTP标头都应映射到JSON字段,如下所示:

callback_function({
  status_code: 200,
  next_page: "https://..",
  response: {
    ... actual JSON response body ... 
  }
})

类似地,要支持有限的HTTP客户端,请允许一个特殊的查询参数?envelope = true,它将触发完全信封包装(不使用JSONP回调函数)。

JSON编码的POST,PUT和PATCH正文

如果您遵循本文中的方法,那么您已经将JSON用于所有API输出。让我们考虑使用JSON作为API输入。

许多API在其API请求正文中使用URL编码。URL编码确实听起来像是-请求正文,其中键值对的编码约定与URL编码参数中用于编码数据的约定相同。这很简单,得到了广泛的支持,并且可以完成工作。

但是,URL编码存在一些使其成为问题的问题。它没有数据类型的概念。这会强制API从字符串中解析整数和布尔值。此外,它没有层次结构的实际概念。尽管有一些约定可以从键值对中构建某些结构(例如将附加到表示数组的键上),但这与JSON的本机分层结构没有可比性。

如果API很简单,则URL编码就足够了。但是,复杂的API应该坚持使用JSON作为其API输入。无论哪种方式,请选择一个并在整个API中保持一致。

接受JSON编码的POST,PUT和PATCH请求的API还应要求将Content-Type标头设置为application / json或抛出415不支持的媒体类型HTTP状态代码。

分页

喜爱信封的API通常在信封本身中包含分页数据。而且我不怪他们-直到最近,还没有很多更好的选择。如今包括分页详细信息的正确方法是使用RFC 5988引入Link标头

使用Link标头的API可以返回一组现成的链接,因此API使用者不必自己构造链接。当分页基于光标时,这尤其重要。这是从GitHub的文档中获取的正确使用Link标头的示例:

Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next", <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"

但这不是一个完整的解决方案,因为许多API都想返回其他分页信息,例如对可用结果总数的计数。需要发送计数的API可以使用自定义HTTP标头,例如X-Total-Count。

自动加载相关的资源表示

在许多情况下,API使用者需要从请求的资源中加载与(或引用)相关的数据。与其要求消费者反复访问API来获取此信息,不如让相关数据随需返回并与原始资源一起加载,将会显着提高效率。

但是,由于这确实违反了一些RESTful原则,因此我们仅基于embed(或expand)查询参数就可以使偏差最小。

在这种情况下,embed是要用逗号分隔的要插入字段的列表。点符号可以用来指代子字段。例如:

GET /tickets/12?embed=customer.name,assigned_user

这将返回包含其他详细信息的票证,例如:

{
  "id" : 12,
  "subject" : "I have a question!",
  "summary" : "Hi, ....",
  "customer" : {
    "name" : "Bob"
  },
  assigned_user: {
   "id" : 42,
   "name" : "Jim",
  }
}

当然,实现类似功能的能力实际上取决于内部复杂性。这种嵌入很容易导致N + 1选择问题

覆盖HTTP方法

某些HTTP客户端只能使用简单的GET和POST请求。为了增加对这些受限客户端的访问能力,API需要一种方法来覆盖HTTP方法。尽管这里没有硬性标准,但流行的约定是接受带有包含PUT,PATCH或DELETE之一的字符串值的请求标头X-HTTP-Method-Override。

请注意,重写标头仅应在POST请求中接受。GET请求永远不要更改服务器上的数据

限速

为了防止滥用,通常的做法是向API添加某种速率限制。 RFC 6585引入了HTTP状态代码429 Too Many Requests To请求太多

但是,在实际达到限制之前通知消费者其限制可能非常有用。这个区域目前缺乏标准,但是使用HTTP响应标头有许多流行的约定

至少包括以下标头(使用Twitter的命名约定,因为标头通常没有中间单词大写):

  • X-Rate-Limit-Limit-当前期间允许的请求数
  • X-Rate-Limit-Remaining-当前期间的剩余请求数
  • X-Rate-Limit-Reset-当前时段剩余的秒数

为什么使用剩余的秒数代替X-Rate-Limit-Reset的时间戳?

时间戳包含各种有用但不必要的信息,例如日期和可能的时区。API使用者实际上只是想知道何时可以再次发送请求,而秒数可以用最少的额外处理来回答此问题。它还避免了与时钟偏斜有关的问题。

某些API使用UNIX时间戳实现X-Rate-Limit-Reset。不要这样做!

为什么将UNIX时间戳用于X-Rate-Limit-Reset是不好的做法?

HTTP规范已经指定使用RFC 1123的日期格式(目前正在使用的日期如果-Modified-Since的上次修改的HTTP头)。如果我们要指定一个采用某种时间戳的新HTTP标头,则应遵循RFC 1123约定,而不要使用UNIX时间戳。

认证方式

RESTful API应该是无状态的。这意味着请求身份验证不应依赖Cookie或会话。相反,每个请求都应带有某种身份验证凭据。

通过始终使用SSL,可以将身份验证凭据简化为随机生成的访问令牌,该令牌在HTTP基本身份验证的用户名字段中提供。这样做的好处是,它完全可以被浏览器使用-如果浏览器从服务器接收到401未经授权的状态代码,则浏览器将弹出一个询问凭据的提示。

但是,只有在切实可行的情况下,让用户将令牌从管理界面复制到API使用者环境时,这种身份验证方法才可以接受。如果无法做到这一点,则应使用OAuth 2向第三方提供安全的令牌传输。OAuth 2使用Bearer令牌,并且还依赖SSL进行基础传输加密。

需要支持JSONP的API将需要第三种身份验证方法,因为JSONP请求无法发送HTTP Basic Auth凭证或Bearer令牌。在这种情况下,可以使用特殊的查询参数access_token。注意:使用令牌的查询参数存在一个固有的安全问题,因为大多数Web服务器将查询参数存储在服务器日志中。

值得一提的是,以上所有三种方法只是跨API边界传输令牌的方法。实际的基础令牌本身可以是相同的。

缓存

HTTP提供了一个内置的缓存框架!您要做的就是包括一些其他出站响应标头,并在收到一些入站请求标头时进行一些验证。

有2种方法:ETagLast-Modified

ETag:生成响应时,包括HTTP标头ETag,其中包含表示形式的哈希或校验和。每当输出表示更改时,此值应更改。现在,如果入站HTTP请求包含具有匹配的ETag值的If-None-Match标头,则API应该返回304 Not Modified状态码,而不是资源的输出表示。

Last-Modified:除了使用时间戳外,此方法基本上类似于ETag。响应标头Last-Modified包含RFC 1123格式的时间戳,该时间戳已针对If-Modified-Since进行了验证。请注意,HTTP规范具有3种不同的可接受日期格式,服务器应准备接受其中任何一种。

错误信息

就像HTML错误页面向访问者显示有用的错误消息一样,API应该以已知的消耗格式提供有用的错误消息。错误的表示形式应与任何资源的表示形式相同,只是具有其自己的字段集。

API应该始终返回明智的HTTP状态代码。API错误通常分为两种类型:用于客户端问题的400系列状态代码和用于服务器问题的500系列状态代码。该API至少应标准化所有400系列错误以及可消耗的JSON错误表示形式。如果可能(例如,如果负载平衡器和反向代理可以创建自定义错误主体),则该范围应扩展到500系列状态代码。

JSON错误主体应为开发人员提供一些帮助-有用的错误消息,唯一的错误代码(可以在文档中查找更多详细信息)以及可能的详细说明。JSON输出表示如下所示:

{
  "code" : 1234,
  "message" : "Something bad happened :(",
  "description" : "More details about the error here"
}

PUT,PATCH和POST请求的验证错误将需要进行字段细分。通过使用固定的顶级错误代码进行验证失败并在其他error字段中提供详细的错误,可以最好地对此建模,例如:

{
  "code" : 1024,
  "message" : "Validation Failed",
  "errors" : [
    {
      "code" : 5432,
      "field" : "first_name",
      "message" : "First name cannot have fancy characters"
    },
    {
       "code" : 5622,
       "field" : "password",
       "message" : "Password cannot be blank"
    }
  ]
}

HTTP状态码

HTTP定义了可以从您的API返回的一堆有意义的状态代码。可以利用这些来帮助API使用者相应地路由其响应。我整理了一些您绝对应该使用的列表:

  • 200 OK-响应成功的GET,PUT,PATCH或DELETE。也可以用于不创建的POST。
  • 201已创建 -对导致创建的POST的响应。应与指向新资源位置的Location标头结合使用
  • 204无内容 -对不会返回正文的成功请求的响应(如DELETE请求)
  • 304 Not Modified-在播放HTTP缓存头时使用
  • 400错误的请求 -请求格式错误,例如正文没有解析
  • 401未经授权 -不提供或提供无效的身份验证详细信息时。如果从浏览器使用API​​,则也可用于触发auth弹出窗口
  • 403禁止 -身份验证成功但经过身份验证的用户无权访问资源时
  • 404 Not Found-当请求不存在的资源时
  • 405不允许的方法 -当请求通过身份验证的用户不允许的HTTP方法时
  • 410 Gone-指示此端点上的资源不再可用。可用作旧API版本的总括响应
  • 415不支持的媒体类型 -如果作为请求的一部分提供了错误的内容类型
  • 422无法处理的实体 -用于验证错误
  • 429请求太多 -由于速率限制而拒绝请求时

API设计反面教材:哪些API最佳实践表示您很讨厌客户?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK