PSR 7 HTTP 消息接口 - 说明文档

1. 概要

这个提案的目的是为 RFC 7230RFC 7231 定义的 HTTP 消息和 RFC 3986 定义的 URL ( 用于 HTTP 消息上下文中 ) 提供一组通用的接口

  • RFC 7230 : http://www.ietf.org/rfc/rfc7230.txt
  • RFC 7231 : http://www.ietf.org/rfc/rfc7231.txt
  • RFC 3986 : http://www.ietf.org/rfc/rfc3986.txt

所有的 HTTP 消息都由所使用的 HTTP 协议版本,头部字段和消息内容组成

建立在 HTTP 消息基础上的请求则包含用于发出请求的 HTTP 方法以及请求要发送到的 URI

建立在 HTTP 消息基础上的响应则包括 HTTP 状态码和对状态描述的短语

PHP 中,有两种情况下会用到 HTTP 消息:

  • 发起一个 HTTP 请求,通过 ext/curl 扩展发送或者使用内置的流来发送,并处理接收到的 HTTP 响应。换句话说,使用 PHP 作为 HTTP 客户端使用 HTTP 消息

  • 处理传入到服务器的 HTTP 请求,并向发出请求的客户端返回 HTTP 响应。PHP 可以作为 服务器端应用程序 使用 HTTP 消息来完成 HTTP 请求

该提案提供了一份使用 PHP 定义的涵盖各种 HTTP 消息的所有组成部分的 API

2. PHP 中的 HTTP 消息

PHP 没有内置 HTTP 消息支持

客户端侧的 HTTP 支持

PHP 支持通过以下几种机制发送 HTTP 请求

  1. PHP 流
  2. cURL 扩展
  3. ext/http (v2 版本也提供了服务器端侧的支持)

使用 PHP 流发送 HTTP 请求是最方便也是最常见的方式,但对 SSL 支持方面却存在诸多限制,而且用于设置头部字段等的接口也是笨重无比。 cURL 提供了一个完整的扩展功能集,但由于它不是默认的扩展,通常不存在。 http 扩展有着与 cURL 相同的问题,而且使用的例子也少得多

正因为这样,大多数现代客户端库都倾向于在上述机制之上抽象实现,以确保它们可以在任何环境中运行

服务器端侧的 HTTP 支持

PHP 使用服务端 API ( SAPI ) 来解释传入的 HTTP 请求,收集输入数据,然后将处理的结果传递给脚本

最初的 SAPI 设计镜像了 通用网关接口 ( CGI ) 。而通用官网接口编整了请求数据并在将处理逻辑传递给脚本之前将其添加到环境变量中,该脚本会从环境变量中取出数据,处理请求并返回响应

PHP 的 SAPI 设计通过超全局变量 ($_COOKIE, $_GET$_POST 等等) 抽象出常见的输入源,如 cookies 信息、查询字符串参数和 URL 编码过的 POST 数据,为开发人员提供了一个便利的访问机制

而在对应的响应方面,PHP 最初是作为模板语言开发的,允许混合使用 HTMLPHP 代码;文件中的任何 HTML 代码都会被立即舒心到输出缓冲区。 这种机制可能会导致发送状态行和/或响应头部出现问题,现代应用程序和框架都在极力避免这种做法,它们倾向于汇总所有的响应头部和响应正文,并在所有其它应用程序处理时立即发送它们。 需要特别注意确保将错误报告和其它将内容发送到输出缓冲区的操作不会刷新输出缓冲区

3. 何必 ?

大量的 PHP 项目,无论是客户端还是服务器,都使用了 HTTP 消息

对于每个项目,我们都会观察以下一种或多种模式或情况:

  1. 项目直接使用 PHP 的超全局变量
  2. 项目从头开始创建实现
  3. 项目可能需要一个特定的 HTTP 客户端/服务器类库来提供 HTTP 消息的实现
  4. 项目可能会为常见的 HTTP 消息实现创建适配器

例如:

  1. 几乎任何在框架兴起之前就开始开发的应用程序 ( 其中包括许多非常流行的 CMS,论坛和购物车系统 ) 一直都在使用超全局变量

  2. Symfony 和 Zend Framework 等框架均定义了构成其 MVC 模式的基础的 HTTP 组件。即使是小型的单一用途的库,如 oauth2-server-php 也提供并使用它们自己的 HTTP 请求/响应实现。Guzzle,Buzz 和其它 HTTP 客户端实现也都创建了自己的 HTTP 消息实现

  3. 像 Silex,Stack 和 Drupal 8 这样的项目对 Symfony 的 HTTP 内核有很强的依赖性。 任何构建于 Guzzle 上的 SDK 对 Guzzle 的 HTTP 消息实现都有很强的要求

  4. Geocoder 这样的项目则创建了一个通用的类库适配器

直接使用超全局变量会出现一些问题。首先,它们是可变更的,类库和代码都可以改变这些值,从而改变应用程序的状态。其次,超全局变量使得单元测试和集成测试变得困难而脆弱,导致代码质量下降

当前实现 HTTP 消息抽象的框架生态系统中,项目往往不具备互操作性或交叉访问。为了使用另一个框架的代码,开发的第一阶段就是在 HTTP 消息的实现之间建立一个适配器。 在客户端侧,如果特定的库没有可以使用的适配器,你需要使用另一个库提供的适配器来桥接请求和响应

在当前实现HTTP消息抽象的框架生态系统中, 最终的结果是项目不具备互操作性, 异花授粉。 为了从一个框架消费代码 另一方面,业务的第一阶段是在两者之间建立一个桥梁层 HTTP消息实现

最后,我们谈谈服务器端响应,PHP 有它自己的方式:在调用 header() 函数之前发送的任何内容都将导致调用 header() 成为空操作;这取决于错误报告的设置,这通常意味着响应头部和/或响应都未正常发送。 解决此问题的一种方法是使用 PHP 的缓冲输出功能,但输出缓冲区的嵌套可能会出现问题并难以调试。 因此,框架和类库倾向于抽象响应,汇总可以一次性发送的响应头部和响应正文,通常,这些抽象又是不能互相兼容的。

因此,本提案的目标是抽象客户端和服务器端的请求和响应接口,以促进项目之间的互操作性。 如果项目实现了这些接口,那么采用不同类库中的代码时就可以假设它们是相互兼容的

应该指出的是,本提案的目的不是让已经存在的 PHP 库所使用的接口过时,而是为了描述能在不同的 PHP 包之间进行互操作的 HTTP 消息

4. 目标

4.1 目标

  • 提供描述 HTTP 消息所需的接口
  • 关注在实际应用和可用性上
  • 定义接口来模型化 HTTP 消息和 URI 规范的所有构成元素
  • 确保 API 不会对 HTTP 消息施加任何限制,例如,一些 HTTP 消息正文可能太大而不能存储在内存中,所以我们必须对此进行说明
  • 为处理传入服务器端的请求和在 HTTP 客户端发出去的请求提供有用的抽象

4.2 非目标

  • 此规范并不期待所有的 HTTP 客户端库和服务器端框架都变更它们的接口来适配,虽然意味着更强的交互性

  • 由于每个人对于实现细节的想法都各不相同,此规范不应该强加实现细节

  • 由于 RFC 7230, 7231, 和 3986 中没有强制任何特定的实现,因此在 PHP 中定义 HTTP 消息接口的时候需要作出一些特别的规范

5. 设计决策

消息设计

MessageInterface 接口为 HTTP 消息的所有通用元素提供了访问器。无论它们是用于请求还是响应。

这些元素包括:

  • HTTP 协议版本 ( HTTP protocol version ( 例如 "1.0", "1.1") )
  • HTTP 头部信息 ( HTTP headers )
  • HTTP 消息正文 ( HTTP message body )

更多更具体的接口则描述了请求和响应,更具体的说是描述了每个上下文 (客户端和服务器端)

这种分类受到现有的 PHP 使用情况的启发,也受到其它语言的启发,例如 Ruby's Rack, Python's WSGI, Go's http package, Node's http module, 等等

更具体的接口用于描述请求和响应,更具体地说是每个(客户端对服务器端)的上下文。 这些部门部分受到现有PHP使用情况的启发,也受到其他语言的启发

为什么处理头部的方法在消息中而非在额外的头部接口中 ?

消息本身就是头部信息的容器 ( 也是其它消息属性的容器 )。

如何摆放是类库实现的细节问题,但对头部信息的统一访问方式是消息的责任

为什么 URI 被定义成一个对象 ?

URI 是由各种特定的值构成的,因此应该被建模为一个值对象

此外,同一个请求可能需要多次访问 URI 中的多个段,而且因为判断的需要可能要解析 URI ( 例如,通过 parse_url() 函数 )

将 URI 建模为值对象只需要解析一次,且能够简化对各个段的访问,同时也为客户端应用程序提供了便利,允许用户在只更新部分段 ( 例如,路径) 的情况下创建一个基本的 URI 新实例

为什么请求接口中会有处理请求目标的方法和合成 URI 的方法 ?

RFC 7230 规范将请求行细化为包含 「 请求目标 」

在四种形态的请求目标中,只有一种形态符合 RFC 3986 的 URI 格式

绝大多数的请求都使用 origin-form,这种形态中的 URI 中不包含协议头 ( scheme ) 和授权信息

此外,所有的形态对于请求来说都是有效的,此提案必须兼容每种格式

因此 RequestInterface 定义了一些与请求目标相关的方法,默认的,它们通过组合 URI 作为 origin-form 的请求目标,并且在 URI 实例不存在的情况下返回字符串 /。而 withRequestTarget() 方法,则允许用指定的请求目标创建一个实例,允许用户创建其它有效请求目标形体的请求

各种原因的存在,使得 URI 的构成部分离散的存储在请求中。对于客户端和服务器,绝对 URI 通常是必须的。客户端需要使用 URI,特别是协议头 ( scheme ) 和授权信息细节,创建 TCP 连接。而服务器端应用程序,则需要完整的 URI 来验证请求或者留有到适当的处理程序

为什么使用值对象 ( value objects ) ?

此提案建议将消息 ( Messages ) 和 URI 模型化为 值对象

消息 ( Messages ) 就是消息的所有构成部分聚合而成的值,对消息任何部分的变更实际上是创建了一个新的消息。这就是值对象的定义

看起来要么是废话,要么是不可理喻,其实也很好理解,比如,睁眼和闭眼的我,就是两个不同的我,因为一个眼睛是睁开的,一个眼睛是闭上的,但你可能会说,那还是我,因为我还是那个我,是吗?从某些方面说就不是了,这就是人格分裂的由来吧...

变化导致创建新实例的做法称为 不变性, 这也是为了确保消息完整性的的一个特性

该提案还认识到,大多数客户端和服务器端应用程序需要能够轻松地更新消息方面,并且因此提供了接口方法,这些接口方法将创建具有更新的新消息实例。 这些通常以带有或不带有的措词为前缀。

此提案意识到大多数客户端和服务器端应用程序需要轻松的变更构成消息的方方面面,因此提供了一些接口方法,这些接口方法通常以 withwithout 为前缀,会创建包含了更新后的值的新消息实例

在模型化 HTTP 消息时,值对象提供了很多的好处:

  • 对 URI 的变更不能修改传入请求的 URI 实例
  • 对头部信息的变更不能修改由它们聚合而成的消息

实际上,将 HTTP 消息建模为值对象可以确保消息状态的完整性,并防止需要双向依赖关系,当然了,这往往也会导致不同步或出现调试或性能问题

对于 HTTP 客户端来说,值对象允许使用者用基本的 URI 和必须的头部信息来构建基本的请求,而无需为客户端发送的每条消息创建一个全新的请求或者重置请求状态

<?php
$uri = new Uri('http://api.example.com');
$baseRequest = new Request($uri, null, [
    'Authorization' => 'Bearer ' . $token,
    'Accept'        => 'application/json',
]);;

$request = $baseRequest->withUri($uri->withPath('/user'))->withMethod('GET');
$response = $client->send($request);

// 从 $response 变量中获取用户 id

$body = new StringStream(json_encode(['tasks' => [
    'Code',
    'Coffee',
]]));;
$request = $baseRequest
    ->withUri($uri->withPath('/tasks/user/' . $userId))
    ->withMethod('POST')
    ->withHeader('Content-Type', 'application/json')
    ->withBody($body);
$response = $client->send($request)

// 不需要重写头部或消息正文
$request = $baseRequest->withUri($uri->withPath('/tasks'))->withMethod('GET');
$response = $client->send($request);

在服务器端,开发者需要;

  • 解析请求的消息正文
  • 解析 HTTP cookies
  • 写入响应

这些操作也可以通过值对象完成,而且会带来很多好处:

  • 保存了请求的原始状态以供其它使用者获取
  • 可以使用默认的头部和消息正文创建默认的响应状态

至今为止的大多数框架仍然允许变更 HTTP 消息,对于处理值对象来说,主要的改变是

  • 不通过调用设置方法或者设置公开的属性,而是调用相应的衍生方法来赋值
  • 开发人员必须通知应用程序状态已变更

例如,在 Zend Framework 2 中,不是使用

<?php
function (MvcEvent $e)
{
    $response = $e->getResponse();
    $response->setHeaderLine('x-foo', 'bar');
}

而是使用

<?php
function (MvcEvent $e)
{
    $response = $e->getResponse();
    $e->setResponse(
        $response->withHeader('x-foo', 'bar')
    );
}

上面的代码把赋值和通知都放在了同一个调用中

这种做法有一个好处,就是使得应用程序状态的变更变得更加清晰明确

新的实例还是返回 $this

对于各种 with*() 方法,如果传递的参数不会变更任何值,一些意见更愿意使用 return $this,因为这样更安全也不会消耗性能 ( 因为这不会导致克隆操作 )

之前已经实现的大量的接口都被要求 必须 保持不可变更的,但也仅仅是指示必须返回包含新状态的 「 实例 」。因为具有相同值的实例被认为是相等的,所以返回 $this 从功能上来说也是等同的,因此也是被允许的

使用流代替 X

MessageInterface 使用消息正文时 必须 实现 StreamInterface

作出这样的设计决定,是为了开发人员可以发送和接收 ( 和/或接收或发送 ) 比实际存储在内存中的更多的 HTTP 消息,同时又可以转换为字符串从而与消息正文进行便利的交互

虽然 PHP 对流进行了抽象的封装,但封装后的流对资源是不友好的:

  1. 流的资源只能通过 stream_get_contents() 函数转换为字符串或手动读取字符串的剩余部分

  2. 给流添加自定义的读写操作需要先注册为流的过滤器,只有在向 PHP 注册过滤器之后才能将流过滤器添加到流中,即不存在流过滤器的自动加载机制

良好定义的流接口可以灵活的使用流装饰器,这些流装饰器可以预先添加到请求或响应中,提供加密、压缩、确保下载的字节数匹配响应头 Content-Length 等等。装饰流是一种非常成熟的模式,例如 pattern in the JavaNode,可以增进流的灵活性

StreamInterface 接口中大部分的 API 都是基于 Python3 的 io 模块。相比通过实现 WritableStreamInterfaceReadableStreamInterface 接口来提供流的功能,提供 isReadable()isWritable() 等方法显然更加易用且使用。这些方法也被 PythonC#C ++RubyNode 及其它语言所实现

要怎么返回一个文件 ?

在某些情况下,你可能更想要返回一个文件而不是一个流

PHP 中通常可以这么做

<?php
readfile($filename);

stream_copy_to_stream(fopen($filename, 'r'), fopen('php://output', 'w'));

注意: 上面的代码没有发送 Content-TypeContent-Length 响应头。 开发者需要先发送这两个响应头再调用上面的代码

HTTP 消息中与上面代码等价的方式是:使用能够接受文件名和/或流资源的 StreamInterface 实现类, 并将其传递给响应实例

下面的代码为一个完整的例子,包括发送适当的响应头

<?php 
// Stream 实现了 StreamInterface 接口
$stream   = new Stream($filename);
$finfo    = new finfo(FILEINFO_MIME);
$response = $response
    ->withHeader('Content-Type', $finfo->file($filename))
    ->withHeader('Content-Length', (string) filesize($filename))
    ->withBody($stream);

返回这个响应将会把文件发送给客户端

怎样直接发送输出 ?

直接发送输出 ( 例如通过 echoprintf 或写入 php://output 流 ) 通常只用于性能优化或发布大型数据集

如果确实需要这么做且想要在 HTTP 消息中完成,一种方式是使用基于回调的 StreamInterface 实现。

例如这个 范例,将输出代码直接放在回调中,将其传递给 StreamInterface 实现,最后提供给消息正文

<?php
$output = new CallbackStream(function () use ($request) {
    printf("The requested URI was: %s<br>\n", $request->getUri());
    return '';
});
return (new Response())
    ->withHeader('Content-Type', 'text/html')
    ->withBody($output);

如何在内容上使用迭代器

Ruby's Rack 实现使用了基于迭代器的方式为服务器端的响应提供消息正文。

这可以通过使用包含了基于迭代器的 StreamInterface 的 HTTP 消息来模拟,就像 detailed in the psr7examples repository 中的那样

为什么流是可变更的 ?

StreamInterface 接口中的相关方法,如 write() 可以修改消息的内容,这直接与消息的不可变更性矛盾

这个问题主要基于这样的事实:接口想要封装 PHP 的流或类似的操作。因此写入操作就是将数据写入到流。即使我们把 StreamInterface 设定为不可变更的,一旦更新了流的内容,包含该流的任何实例也将同步更新,也就是无法确保不可变更性

我们推荐的做法是:实现接口时为服务器端接收到的请求和客户端接收到的响应提供只读流

ServerRequestInterface 存在的理由

遵循 RFC 7230 规范,RequestInterfaceResponseInterface 接口与请求和响应消息是 1:1 的关系

它们提供的操作数据的接口针对着特定的 HTTP 消息类型

对于服务器端应用程序来说,还需要考虑传入的请求的其它因素:

  • 访问服务器端参数 ( 可能从请求中衍生而来,也可能是服务器的配置参数,一般存储在全局变量 $_SERVER 中,它们是 PHP 服务器 API ( SAPI ) 的一部分 )
  • 访问查询字符串参数 ( 通常封装在 PHP 的全局变量 $_GET 中 )
  • 访问解析后的消息正文 (例如,从传入请求的消息正文中反序列化得到的,在 PHP 中,通常是一个使用 application/x-www-form-urlencoded 内容类型的 POST 请求,封装在 PHP 的全局变量 $_POST 中的, 对于非表单编码和非 POST 请求的数据,一般是一个数据或对象 )
  • 访问上传的文件 ( 封装在 PHP 的全局变量 $_FIELS 中 )
  • 访问 cookies 信息 (封装在 PHP 的全局变量 $_COOKIE 中)
  • 访问从请求衍生出来的属性 ( 通常,但不限于,URL 路径匹配结果 )

在框架和库之间用统一的方式访问这些参数可以提高互操作的可能性,因为它们可以预见到,对于实现了 ServerRequestInterface 的请求,它们可以获取到这些值。这也同时解决了 PHP 语言本身的很多问题:

  • 直到 PHP 5.6.0 ,php://input 只能被读取一次,因此,导致了多个框架/类库实例化多个请求实例可能会导致状态不一致。因为只有首先访问 php://input 的能读取到输入的数据

  • 对超全局变量 ( 例如 $_GET$_FILES 等等 ) 进行单元测试是困难的并且通常很脆弱,将它们封装在 ServerRequestInterface 接口中则简化了测试注意事项

为什么 ServerRequestInterface 接口的正文是的是解析过的?

争论的焦点在于术语 「 BodyParams 」 和要求值必须为一个数组

理由如下:

  • 与其它服务器端参数有着一致的访问方式
  • $_POST 全局变量也是一个数组,而且 80% 的操作都针对全局变量
  • 单类型更简约也更简单

主要的原因是,如果正文参数是一个数组,开发者对于要访问的值是可预测的

<?php
$foo = isset($request->getBodyParams()['foo'])
    ? $request->getBodyParams()['foo']
    : null;

支持使用 「 parsed body 」的另一个原因是要适配各种情况。消息正文可以是字面上的任何内容。 传统的 Web 应用程序使用 POST 一个表单的形式提交数据,这种用法在当前的 Web开发趋势中很快就受到了挑战,这些趋势通常以 API 为中心,使用替代请求方法 ( 特别是 PUT 和 PATCH ),并且使用非表单编码的内容,通常是 JSONXML,这些编码格式多数情况下都可以强制转换为数组,另一些情况下也不能也不应该

如果强制消息正文解析后的内容只能保存为一个数组,那么开发人员就需要一个关于在哪里存储解析结果的共同约定,有几种方案:

  1. 消息正文参数内的一个特殊的键,比如 __parsed__
  2. 一个特殊的明确的属性,比如 __body__

但不管是哪种,开发人员都必须多次调用才能获取解析后的结果

<?php 
$data = $request->getBodyParams();
if (isset($data['__parsed__']) && is_object($data['__parsed__'])) {
    $data = $data['__parsed__'];
}

// or:
$data = $request->getBodyParams();
if ($request->hasAttribute('__body__')) {
    $data = $request->getAttribute('__body__');
}

因此,解决方案就是使用术语 「 ParsedBody 」,也就是说存储的值是消息正文解析后的结果,同时也意味着返回的值是模糊的,但又因为是在特定的环境中,所以结果也是可预料的,因此,用法将变为

<?php
$data = $request->getParsedBody();
if (! $data instanceof \stdClass) {
    // 抛出一个异常 exception!
}
// 其它的代码

这种方式移除了强制使用数组的限制,而代价则是返回值不明确,当然了,把解析的结果保存在消息正文参数内的一个特殊键或一个特殊的明确的属性中也存在不明确性。

这种方案的优点是更简单,因为不需要添加结果规范,虽然结果不明确,但也更灵活

为什么没有一个用于获取基础路径 ( base path ) 的方法 ?

许多框架都提供了获取「 基础路径 」的方法

基础路径通常被认为是前端控制器的路径,例如,假设提供服务的应用程序为 http://example.com/b2b/index.php,且请求的 URL 为 http://example.com/b2b/index.php/customer/register, 那么用于获取基础路径的函数将返回 /b2b/index.php

基础路径常用于路由器中,在尝试匹配路径之前去掉该路径段

基础路径通常也用于在应用程序内的 URL 生成: 将参数传递给路由器,路由器将生成路径,并添加基础路径作为前缀,返回完全限定的 URI

其它的工具,比如视图助手,模板过滤器或模板函数,会创建相对于基础路径的路径,以生成连接到资源 ( 例如静态文件 ) 的 URI

查阅了几种不同的实现后,我们注意到以下几点:

  • 确定基础路径的实现逻辑差异很大,例如 logic in ZF2logic in Symfony 2
  • 大多数实现允许手动将基础路径注入到路由器和/或用于 URI 生成的任何工具中
  • 主要的用途 - 路由或 URI 生成 - 通常也是基础路径的唯一使用的地方,开发人员通常不需要知道基本路径的概念,因为其它的对象会为他们处理这些实现的细节,例如

    • 路由器会自动移除基础路径,我们并不需要手动传递去除了基础路径的路径给路由器
    • 视图助手,模板过滤器等,会在被调用之前就被注入基础路径,有时候是手动完成的,更多的时候是框架自动注入
    • 需要处理基础路径的资源,通过服务器端变量或者 URI 实例,早已包含在 RequestInterface 接口的实例中

我们的立场是,检查基础路径是框架和/或应用程序的事情,何况检测结果可以很容易的注入到需要它的对象中,和/或可以根据需要,使用来自 RequestInterface 实例提供的函数或类来计算

为什么 getUploadedFiles() 返回的是对象而非数组 ?

getUploadedFiles() 以结构树的形式返回 Psr\Http\Message\UploadedFileInterface 的实例

这主要是为了简化接口:我们指定一个接口,而不是长篇大论的去讨论如何实现一个上传文件数组

此外, UploadedFileInterface 中的数据已标准化,可以在 SAPI 和非 SAPI 环境中使用,这允许使用一个程序来手动解析消息正文,可以在不需要先写入文件系统的情况下将内容分配给流,同时仍然允许在 SAPI 环境中正确处理文件上传

什么是 「 特殊 」 的头部值 ?

许多请求头都具有独一无二的格式,这为生成和使用它们造成了困难,特别是 cookies 信息和 Accept 请求头

此规范不提供对任何请求头的特殊处理,MessageInterface 接口提供了获取和设置请求头的通用方法,并且,所有请求头的值都是字符串类型

我们鼓励开发人员编写用于处理特殊请求头的商业类库,无论用于是生成还是解析,用户如果需要处理这些特殊的请求头可以使用这些类库。很多类库都提供了这种做法,如 willdurand/NegotiationAura.Accept

只要一个对象具有将值转换为字符串的功能,那么这个对象就可以作为 HTTP 消息的请求头

6. 参与人员

6.1 编撰者

  • Matthew Weier O'Phinney

6.2 发起者

  • Paul M. Jones
  • Beau Simensen (coordinator)

6.3 贡献者

  • Michael Dowling
  • Larry Garfield
  • Evert Pot
  • Tobias Schultze
  • Bernhard Schussek
  • Anton Serdyuk
  • Phil Sturgeon
  • Chris Wilkinson

PHP 标准规范

关于   |   FAQ   |   我们的愿景   |   广告投放   |  博客

  简单教程,简单编程 - IT 入门首选站

Copyright © 2013-2022 简单教程 twle.cn All Rights Reserved.