RESTful API 已是当下成熟的且被广泛使用的 API 设计理论,它的轻量、直观、易用使得前后端的沟通交流难度大大降低。

作为一个新手,用 PHP 框架搭建 RESTful API 无疑是一个非常简单的入门选项,而在一众 PHP 框架中,我选择了 Slim。

本文的安装和基本使用教程是在 Daniel Opitz 的 Slim 4 - Tutorial 基础上进行了些许修改和补充完成的。关于 Slim 4 的更多进阶用法可以访问他的博客 Daniel's Dev Blog

为什么是 Slim?

按照惯例,Google 一下“PHP 框架”,你会发现 Laravel 绝对是高频词。但是拿 Laravel 来搭一个 RESTful API 好像有点大材小用,而且由于它是全栈框架,所以提供了非常多的功能,增加了入门和学习的难度。

而我想要的只是一个单纯的 RESTful API 框架,所以再三寻找下,我发现了 Slim——一个轻量的、功能强大的 PHP 微框架。安装、配置起来简单、快速,后期还可以通过安装新的依赖项来扩展功能,Slim 无疑是入门的最佳选择。

更重要的一点,Laravel 的文档和 Slim 比起来真的是有点胃疼……看一下 Slant 网站推荐 Slim 的理由:

Slim's documentation is well organized and detailed, every concept is thoroughly explained and it is very helpful for both advanced users and beginners.

安装 Slim 4

需求

切换 Composer 镜像

由于众所周知的原因,国内 VPS 使用 Composer 速度非常感人,所以需要切换到国内镜像。这里我们选择阿里云的镜像:

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
composer selfupdate

安装

首先创建一个新目录作为项目根目录并进入,例如 /var/www/api.example.com

mkdir /var/www/api.example.com
cd /var/www/api.example.com

安装 Slim 4 的核心组件:

composer require slim/slim

安装 PSR-7 实现,这一步也可以选择同样广泛使用的 guzzlehttp/psr7Nyholm/psr7,这里我们选择 Slim 的实现 Slim-Psr7

composer require slim/psr7

安装 Slim PSR-7 对象装饰器 Slim-Http,以使用 $response->withJson() 等非常方便的功能:

composer require slim/http

安装 PSR-11 实现 PHP-DI,从而实现依赖注入和自动装配:

composer require php-di/php-di

安装 selective/config,一个强类型的应用程序配置组件:

composer require selective/config

安装 selective/array-reader,一个强类型的数组读取器:

composer require selective/array-reader

最后,如果需要进行单元测试,那么安装 phpunit,并且加上 --dev 参数

composer require phpunit/phpunit --dev

现在,Slim 4 及其基本依赖项已经安装完毕了。

注意:为了避免将 vendor 目录提交到 git,应该在根目录创建 .gitignore 文件并写入以下内容:

vendor/
.idea/

目录结构

无论是开发大型项目还是小型项目,清晰的目录结构总是一个良好的开端,所以我们先在项目的根目录创建如下的目录结构:

.
├── config/             配置文件目录
├── public/             网站根目录
│   └── .htaccess       Apache 重定向规则文件
│   └── index.php       前端控制器(应用入口)
├── templates/          模板文件目录
├── src/                PHP 源代码目录(App 命名空间)
├── tmp/                临时文件目录(缓存及日志)
├── vendor/             Composer 目录
└── .gitignore          Git 忽略规则文件

public 目录是网站根目录(DocumentRoot),所有的流量都会指向该目录,进而重定向至 index.php

其他目录不会不应该被暴露在网络中,所以本文忽略了我们将要搭建的 Slim 应用运行在子目录中(例如:www.example.com/api)的情况,并强烈建议直接运行在域名根目录下(例如:api.example.com)。

Apache 配置

URL 重写

要实现路由功能,我们需要将网站的所有流量都重定向到前端控制器(Front Controller),即 index.php 来处理,这也是我们的应用入口。

首先启用 Apache 的 Rewrite 模块:

a2enmod rewrite

然后在之前创建的 public/.htaccess 文件中写入以下内容:

# Redirect to front controller
RewriteEngine On
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

再在之前创建的 public/index.php 文件中写入以下内容:

<?php

(require __DIR__ . "/../config/bootstrap.php")->run();

虚拟主机配置

/etc/apache2/sites-available 目录中创建 api.example.com.conf 文件,并写入以下内容:

<VirtualHost *:80>
	ServerName api.example.com
	DocumentRoot /var/www/api.example.com/public/

	<Directory /var/www/api.example.com/public/>
		AllowOverride All
	</Directory>

	ErrorLog ${APACHE_LOG_DIR}/api.example.com.error.log
	CustomLog ${APACHE_LOG_DIR}/api.example.com.access.log combined
</VirtualHost>

注意:根目录(DocumentRoot)项必须指向 public 目录。否则,其他人可能会从 Web 访问内部文件。

之后启用配置文件,再重启 Apache 即可:

a2ensite api.example.com.conf
systemctl restart apache2

创建 Slim 应用

配置

在本项目中,所有的配置文件都存放在 config 目录下。

在这之前我们已经创建了 config 目录,所以现在需要创建主要配置文件 config/settings.php。这个文件很简单,引入了默认设置文件 config/defaults.php 和环境设置文件 config/env.php,内容如下:

<?php

$settings = require __DIR__ . "/defaults.php";

require __DIR__ . "/env.php";

return $settings;

然后创建 config/defaults.php,这个文件包含了一些非敏感的设置,例如时区、错误显示、数据库相关配置等:

<?php

// Error reporting
error_reporting(0);
ini_set("display_errors", "0");

// Timezone
date_default_timezone_set("Europe/Berlin");

// Settings
$settings = [];

// Path settings
$settings["root"] = dirname(__DIR__);
$settings["temp"] = $settings["root"] . "/tmp";
$settings["public"] = $settings["root"] . "/public";

// Error Handling Middleware settings
$settings["error_handler_middleware"] = [
    // Should be set to false in production
    "display_error_details" => true,

    // Parameter is passed to the default ErrorHandler
    // View in rendered output by enabling the "displayErrorDetails" setting.
    // For the console and unit tests we also disable it
    "log_errors" => true,

    // Display error details in error log
    "log_error_details" => true,
];

$settings["db"] = [
    "driver" => "mysql",
    "host" => "localhost",
    "database" => "restful-api",
    "charset" => "utf8mb4",
    "collation" => "utf8mb4_0900_ai_ci",
    "flags" => [
        // Turn off persistent connections
        PDO::ATTR_PERSISTENT => false,
        // Enable exceptions
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        // Emulate prepared statements
        PDO::ATTR_EMULATE_PREPARES => true,
        // Set default fetch mode to array
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        // Set character set
        PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci'
    ]
];

return $settings;

注意:“charset” 和 “collation” 的值 “utf8mb4” 和 "utf8mb4_0900_ai_ci" 都是 MySQL 8.0 的默认值,记得根据你的数据库的实际值填写

最后创建 config/env.php,这个文件包含了诸如数据库用户名、密码之类的敏感信息:

<?php

// Database username and password
$settings["db"]["username"] = "restful-api";
$settings["db"]["password"] = "restful-api";

以上两个文件中关于数据库的配置都是以 MySQL 为例,如果你使用的是其他数据库,记得修改相关配置项。

启动引导

这一步我们创建引导文件 config/bootstrap.php,其中包含了应用启动时执行的代码,所以它也是 Slim 应用的实际起点。

Bootstrap 文件的具体功能是引入 Composer 自动加载器、创建 PHP-DI 容器实例和 Slim 应用实例、注册路由和中间件:

<?php

use DI\ContainerBuilder;
use Slim\App;

require_once __DIR__ . "/../vendor/autoload.php";

$containerBuilder = new ContainerBuilder();

// Set up settings
$containerBuilder->addDefinitions(__DIR__ . "/container.php");

// Build PHP-DI Container instance
$container = $containerBuilder->build();

// Create App instance
$app = $container->get(App::class);

// Register routes
(require __DIR__ . "/routes.php")($app);

// Register middleware
(require __DIR__ . "/middleware.php")($app);

return $app;

路由

路由是 Slim 的核心之一。在这里我们先创建一个最简单的路由,即为网站根目录 / 创建一个 GET 路由,回调函数的内容是返回一个 “Hello, World!” 响应。

创建 config/routes.php 文件,并写入以下内容:

<?php

use Slim\Http\Response;
use Slim\Http\ServerRequest;
use Slim\App;

return function (App $app) {
    $app->get("/", function (ServerRequest $request, Response $response) {
        $response->getBody()->write("Hello, World!");

        return $response;
    });
};

中间件

什么是中间件?如果你想在 Slim 应用之前之后执行一些代码,从而根据需要操作 Request 和 Response 对象,那么这就被称为中间件。例如非常经典的身份(Token)验证中间件。

中间件如何工作
中间件如何工作

想要了解更多关于中间件的细节,可以查阅官方文档 Middleware - Slim Framework

添加基本中间件

对一个新的 Slim 应用来说,我们只需要添加最基本几个中间件,例如 Body 解析、路由和错误中间件。

创建 config/middleware.php 文件,并写入以下内容:

<?php

use Selective\Config\Configuration;
use Slim\App;

return function (App $app) {
    // Parse json, form data and xml
    $app->addBodyParsingMiddleware();

    // Add routing middleware
    $app->addRoutingMiddleware();

    $container = $app->getContainer();

    // Add error handler middleware
    $settings = $container->get(Configuration::class)->getArray("error_handler_middleware");
    $displayErrorDetails = (bool)$settings["display_error_details"];
    $logErrors = (bool)$settings["log_errors"];
    $logErrorDetails = (bool)$settings["log_error_details"];

    $app->addErrorMiddleware($displayErrorDetails, $logErrors, $logErrorDetails);
};

容器

什么是容器?

容器,即依赖注入容器,也是 Slim 的重要组成部分。

我们将依次了解这几个概念:依赖注入Dependency Injection),依赖注入容器DICDependency Injection Container)和自动装配Autowiring),从而对容器及其作用有一个基本的了解。

依赖注入

依赖注入的意思是给予调用方它所需要的事物,即将依赖项传递给其他对象。

先来看看传统的思路:

  • 我需要 A 类
  • 那么我创建 A 类
  • 然后调用 A 类的方法:
    • A 类需要 B 类
    • 那么 A 类创建 B 类
    • 然后调用 B 类的方法:
      • B 类需要 C 类
      • 那么 B 类创建 C 类
      • B 类处理其他逻辑……

而依赖注入的思路是这样的:

  • 我需要 A 类,A 类需要 B 类,B 类需要 C 类
  • 我先创建 C 类
  • 再创建 B 类并把 C 类注入 B 类
  • 再创建 A 类并把 B 类注入 A 类
  • 然后调用 A 类的方法:
    • A 类调用 B 类的方法:
      • B 类处理其他逻辑……

依赖注入是控制反转Inversion of Control)模式最为常见的一种技术:依赖关系的控制反转到调用链的起点。这样做的好处是:你可以完全控制依赖关系,通过调整不同的注入对象,来控制程序的行为。

例如把 C 类换成 D 类,传统思路中你只能修改实例化 C 类的代码,而通过依赖注入你可以直接将 D 类注入 B类。

依赖注入容器

而为了更好地准备、管理和注入应用程序依赖项,我们需要一个依赖注入容器

首先需要明确的是:依赖注入容器和依赖注入是两个独立的概念。

  • 依赖注入是为了书写更好的代码
  • 依赖注入容器是帮助完成依赖注入的工具

你完全可以不需要依赖注入容器就实现依赖注入,但是依赖注入容器能帮助你更好地实现依赖注入。

还是以上面的例子来说,使用依赖注入容器的思路是这样的:

  • 我需要 A 类
  • 那么我就从容器中获取(Get)A 类,获取的过程如下:
    • 容器创建 C 类
    • 容器创建 B 类并把 C 类注入 B 类
    • 容器创建 A 类并把 B 类注入 A 类
  • 然后调用 A 类的方法:
    • A 类调用 B 类的方法:
      • B 类处理其他逻辑……

用代码来描述这个思路,即:

// Before
$b = new B();
$a = new A($b);

// After
$a = $container->get("serviceA");

简言之,依赖注入容器接管了所有创建注入依赖项的工作。

自动装配

依赖注入容器非常方便,但是也很容易导致这样一个问题:将容器注入类中。例如在 Slim 3 中使用自带的 Pimple 容器,就可能写出这样的代码:

class UsersController
{
    // Inject Container in controller (which is bad, actually)
    public function __construct(ContainerInterface $container)
    {
        // grab instance from container
        $this->repository = $container["userRepository"];
    }

    // Handler of a route
    public function getAllUsers($request, $response)
    {
        $user = $this->repository->getAllUsers();
        return $response->withJson($users);
    }
}

这种行为是一个经典的反模式Anti-pattern),而且在大多数情况下,将容器注入类中都是反模式,有以下缺点:

  • 隐藏了类的实际依赖项
  • 违背 SOLID 原则中的依赖反转原则

不过从 Slim 4 开始,我们可以使用更加先进的 PSR-11 实现——PHP-DI,它提供了更加高级且十分便捷的功能:自动装配

自动装配的含义很简单:容器自动创建和注入依赖项的能力。而将自动装配与构造函数注入结合起来使用,意味着你只需要在构造函数中显式声明所有依赖项,而依赖注入容器(DIC)会自动帮你创建和注入所需的依赖项。

下面就是一个很好的例子:

class UserRepository
{
    // ...
}

class UserRegistrationService
{
    private UserRepository $repository;

    public function __construct(UserRepository $repository)
    {
        $this->repository = $repository;
    }

    // ...
}

当 DIC 创建 UserRegistrationService 实例时,它会检测到构造函数接受了一个 UserRepository 对象作为参数,那么无须任何配置,DIC 将自动创建一个UserRepository 实例(如果尚未创建),并将其作为构造函数的参数传递。

容器定义

除了自动装配外,PHP-DI 还支持用 PHP 定义 的方式来定义依赖项,所以一般情况下我们应该将根据情况合理使用这两种定义方式(通常用自动装配来注入我们自己编写的类)。

所以接下来,我们创建容器入口文件 config/container.php,并写入以下内容:

<?php

use Psr\Container\ContainerInterface;
use Selective\Config\Configuration;
use Slim\App;
use Slim\Factory\AppFactory;

return [
    App::class => function (ContainerInterface $container) {
        AppFactory::setContainer($container);
        $app = AppFactory::create();

        return $app;
    },

    Configuration::class => function () {
        return new Configuration(require __DIR__ . "/settings.php");
    },

    PDO::class => function (ContainerInterface $container) {
        $config = $container->get(Configuration::class);

        $host = $config->getString("db.host");
        $dbname =  $config->getString("db.database");
        $username = $config->getString("db.username");
        $password = $config->getString("db.password");
        $charset = $config->getString("db.charset");
        $flags = $config->getArray("db.flags");
        $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";

        return new PDO($dsn, $username, $password, $flags);
    },
];

在这里我们使用 PHP 数据对象PDO)代替 mysqli 等具体数据库扩展。上面的配置项以 MySQL 为例,如果你使用的不是 MySQL,记得修改相关配置。

Hello, World!

好了,到这里,一个简单的 Slim 应用就创建完成了!试着访问你的域名(例如 api.example.com),你应该会看到 Hello, World 字样。

RESTful API 开发

在上一步中,我们搭建了一个最基本的 Slim 应用,接下来就进行下一个目标:RESTful API 的开发了。

PSR-4 自动加载

为了进行之后的步骤,我们需要在 PSR-4 自动加载器中注册 App 命名空间。

将以下自动加载设置添加到 composer.json 文件中:

"autoload": {
    "psr-4": {
        "App\\": "src"
    }
},
"autoload-dev": {
    "psr-4": {
        "App\\Test\\": "tests"
    }
}

此时,composer.json 文件的完整内容应如下所示:

{
    "require": {
        "slim/slim": "^4.4",
        "slim/psr7": "^1.0",
        "slim/http": "^1.0",
        "php-di/php-di": "^6.0",
        "selective/config": "^0.1.1",
        "selective/array-reader": "^0.3.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.0.1"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Test\\": "tests"
        }
    },
    "config": {
        "process-timeout": 0,
        "sort-packages": true
    }
}

执行 composer update 命令以使改动生效。

ADR 模式

在进行实际开发之前,我们先明确一下所要使用的软件架构模式。

MVCModel–view–controller)模式是一个很经典的架构模式,诸如 Laravel 等框架都使用 MVC 模式进行开发。但是 MVC 最初是桌面图形用户界面的设计模式,并不能很好地描述 Web 应用架构下的情形。

基于此,Paul M. Jones 提出了另一个架构模式:ADRAction–domain–responder)。ADR 并不是一个全新的模式,而是对 MVC 的一种改进,从而更适合 Web 应用。

ADR 的逻辑相较于 MVC 而言,能更贴切地描述当今 Web 交互的日常实践和工作:一个请求(Request)传入并被分配给一个动作(Action);动作与域(Domain)交互得到输出;动作将域的输出传递给一个响应器(Responder)。响应(包括标头和内容)与输入收集、域逻辑完全分离。

ADR 的缺点之一是我们需要编写很多的类,但从长远来看,独立的类反而可以展现更清晰、更浅显的继承层次结构。而且 Slim 本来就是一个“面向动作Action-oriented)”的框架,即路由器(Router)不会设想一个拥有一大堆操作方法的控制器(Controller)类,而是设想一个动作闭包(Closure)或一个可调用的单动作类。

那么接下来,我们就可以很自然地使用 ADR 模式进行开发了。

动作

在之前配置路由的时候,我们设置了一个基本路由,它的第二个参数是一个闭包Closure)——由上文我们知道,它也可以是一个可调用类。我们把这个可调用的对象称为动作控制器Action Controller),简称动作Action)。它包含了这条路由的实际逻辑,即我们在 Action 中完成这条路由要做的事情。

我们刚才也提到过,每个动作都由一个单独的类或者闭包表示,且动作只应该干这些事情:

  • 从 HTTP 请求中获取输入(如果需要)
  • 调用Domain)来处理这些输入(如果需要),并保留结果
  • 构建一个 HTTP 响应(一般包含调用域产生的结果)

因而所有的其他逻辑,包括输入验证、错误处理等等,都会被转交给(对于域逻辑问题,例如操作数据库)或者响应渲染器(对于表现问题,例如渲染一个页面)来处理。

对于一个标准的 Web 请求,处理完成所产生的响应,会被渲染为 HTML;而对于 RESTful API 请求,一般会输出为 JSON。

注意:像之前配置路由时一样将闭包Closure)当作动作(Action),是一件很“昂贵”的事情,因为 PHP 必须为一个请求创建所有路由的闭包,这在大型项目中是一个性能浪费巨大的行为。更好的方法是使用类名,这样做更轻便、更有效率,而且对于大型项目来说也更好扩展。

创建 Home Action

现在,我们将之前创建的根目录 / 路由的回调函数由闭包改为类名:

  • 在之前创建的 src 目录中创建子目录 src/Action
  • src/Action 目录中,创建动作类文件 src/Action/HomeAction.php,内容如下:
<?php

namespace App\Action;

use Slim\Http\Response;
use Slim\Http\ServerRequest;

final class HomeAction
{
    public function __invoke(ServerRequest $request, Response $response): Response
    {
        $response->getBody()->write("Hello, World!");

        return $response;
    }
}

然后打开 config/routes.php,并用下面这行替换根目录 / 的路由闭包:

$app->get("/", \App\Action\HomeAction::class);

此时,config/routes.php 文件的完整内容应如下所示:

<?php

use Slim\App;

return function (App $app) {
    $app->get("/", \App\Action\HomeAction::class);
};

现在再次试着访问你的域名(例如 api.example.com),你应该还是看到 Hello, World 字样,但是你知道,这次访问比上次访问要一点。

将 JSON 写入响应

标准的 RESTful API 响应应该是 JSON 格式,然而不同于繁琐地使用 json_encode() 构造 JSON 字符串再写入响应,我们可以使用 withJson() 方法来直接构建 JSON 响应。

打开 src/Action/HomeAction.php 文件,并写入以下内容:

<?php

namespace App\Action;

use Slim\Http\Response;
use Slim\Http\ServerRequest;

final class HomeAction
{
    public function __invoke(ServerRequest $request, Response $response): Response
    {
        return $response->withJson(["success" => true]);
    }
}

再次试着访问你的域名(例如 api.example.com),这次你应该会看到 {"success":true} 字样的 JSON 响应。

以及,如果想要更改 HTTP 状态码,只需使用 $response->withStatus(code) 方法即可:

$result = ["error" => ["message" => "Validation failed"]];

return $response->withJson($result)->withStatus(422);

Domain)除了不与响应器(Responder)交互之外,几乎与 MVC 中的模型(Model)没有任何显著区别,所以二者的不同主要表现在名字上。用“”而不是“模型”,主要是为了让开发者联想到 PoEAA 的域逻辑模式(例如服务层事务脚本)和域驱动的设计模式(例如应用程序服务用例)。ADR 中的域被定义为事务脚本(Transaction Script)、服务层(Service Layer)、应用程序服务等域工作的入口。

所以不要将你的业务逻辑添加到动作(Action)当中,因为你的 API 应该反映你的业务用例Use Case)而不是一大堆数据库操作(即 CRUD)。动作应该分别调用一系列域层(Domain Layer)和应用程序服务,这样的话,如果要在另一个动作中重用相同的逻辑,那么直接调用所需要的应用程序服务即可。

现在,让我们先新建一个目录 src/Domain,用来存放域的所有模块和子模块。

服务

域用来存放复杂的业务逻辑,但不同于 MVC 将逻辑放入巨大的(而且臃肿的)模型(Model)中,我们将逻辑放入了更轻巧的、专门的服务类(即应用程序服务)中。

服务提供一个或一组特定的功能,例如检索指定的信息或执行一组操作,目的是使不同的动作可以出于不同的原因重用这些操作。一个服务可以被多个客户端重用:例如一个动作(请求),另一个服务,命令行界面(CLI),单元测试环境(PHP Unit)等。

注意:服务类并不是“管理器(Manager)”类或者“工具(Utility)”类。

每个服务类应该只完成一项任务,例如将钱从 A 账户转移到 B 账户,而不是更多。同时我们通过使用服务类和数据传输对象(DTO)将数据从行为中分离出来。

接下来,我们再新建一个目录 src/Domain/User/Service,用来存放关于用户的服务类。之后我们需要创建一个 UserCreator 服务类,所以新建文件 src/Domain/User/Service/UserCreator.php 并写入以下代码:

<?php

namespace App\Domain\User\Service;

use App\Domain\User\Data\UserCreateData;
use App\Domain\User\Repository\UserCreatorRepository;
use UnexpectedValueException;

/**
 * Service.
 */
final class UserCreator
{
    /** @var UserCreatorRepository */
    private UserCreatorRepository $repository;

    /**
     * The constructor.
     *
     * @param UserCreatorRepository $repository The repository
     */
    public function __construct(UserCreatorRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * Create a new user.
     *
     * @param UserCreateData $user The user data
     *
     * @return int The new user ID
     */
    public function createUser(UserCreateData $user): int
    {
        // Validation
        if (empty($user->username)) {
            throw new UnexpectedValueException("Username required");
        }

        // Insert user
        $userId = $this->repository->insertUser($user);

        // Logging here: User created successfully

        return $userId;
    }
}

注意构造函数:我们将 UserCreatorRepository 声明为依赖项,因为服务(Service)只能通过存储库(Repository)来与数据库进行交互;同时这也是我们在容器那一节中所提到的构造函数注入与自动装配(Autowiring)的用法——直接在构造函数中声明参数类型即可。

数据传输对象

数据传输对象DTOData Transfer Object)只包含纯粹的数据,没有业务/域逻辑,也不存在数据库访问。服务可以从存储库获取数据,并将数据填充进 DTO;动作也可以从输入中收集数据并将其填充进 DTO。总之 DTO 被用来在域内或域外传输数据,可以把它当做 C/C++ 中的结构体来使用。

我们在上面的服务中看到的 UserCreateData 即一个 DTO 类,现在让我们创建这个类。新建文件 src/Domain/User/Data/UserCreateData.php 并写入以下代码:

<?php

namespace App\Domain\User\Data;

use Selective\ArrayReader\ArrayReader;

final class UserCreateData
{
    /** @var string|null */
    public ?string $username;

    /** @var string|null */
    public ?string $firstName;

    /** @var string|null */
    public ?string $lastName;

    /** @var string|null */
    public ?string $email;

    /**
     * The constructor.
     *
     * @param array $array The array with data
     */
    public function __construct(array $array = [])
    {
        $data = new ArrayReader($array);

        $this->username = $data->findString("username");
        $this->firstName = $data->findString("first_name");
        $this->lastName = $data->findString("last_name");
        $this->email = $data->findString("email");
    }
}

利用 ArrayReader,我们可以很方便地在 DTO 内部以构造函数的形式寻找并填充数据,而动作或者服务只需要很简单地传入一个数组而已。

存储库

存储库Repository)负责数据访问、与数据库的通信。

存储库是应用程序所需的所有数据的来源,并在服务和数据库之间充当中介。存储库通过将业务逻辑数据访问逻辑分开来提高代码的可维护性、测试和可读性,并向数据源提供了集中管理的、一致的访问规则。每个存储库方法都代表着一个查询(Query),其返回值即查询结果集,可以是原始值/对象或它们的列表(数组)。

关于数据库本身的业务应该在更高一层(例如服务)中被处理,而不是在存储库中。

配置容器两小节中,我们已经完成了数据库配置和容器定义,对于本项目,我们还需要一个 users 数据表。你可以用图形化工具(例如 MySQL Workbench)来创建数据表,或者在你的测试数据库中执行以下 SQL 语句(MySQL 8.0 及以上):

CREATE TABLE `users` (
    `id` int NOT NULL AUTO_INCREMENT,
    `username` varchar(255) DEFAULT NULL,
    `email` varchar(255) DEFAULT NULL,
    `first_name` varchar(255) DEFAULT NULL,
    `last_name` varchar(255) DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

出于容器的特性,只要我们在构造函数中将 PDO 声明为依赖项,PHP-DI 就会始终注入相同的 PDO 实例,无需担心多次建立连接的问题。

接下来,我们再新建一个目录 src/Domain/User/Repository,用来存放关于用户的存储库类。同样地,我们需要创建一个 UserCreatorRepository 存储库类,所以新建文件 src/Domain/User/Repository/UserCreatorRepository.php 并写入以下代码:

<?php

namespace App\Domain\User\Repository;

use App\Domain\User\Data\UserCreateData;
use PDO;

/**
 * Repository.
 */
class UserCreatorRepository
{
    /** @var PDO The database connection */
    private PDO $connection;

    /**
     * Constructor.
     *
     * @param PDO $connection The database connection
     */
    public function __construct(PDO $connection)
    {
        $this->connection = $connection;
    }

    /**
     * Insert user row.
     *
     * @param UserCreateData $user The user
     *
     * @return int The new ID
     */
    public function insertUser(UserCreateData $user): int
    {
        $row = [
            "username" => $user->username,
            "first_name" => $user->firstName,
            "last_name" => $user->lastName,
            "email" => $user->email,
        ];

        $sql = "INSERT INTO users SET
                username=:username,
                first_name=:first_name,
                last_name=:last_name,
                email=:email;";

        $this->connection->prepare($sql)->execute($row);

        return (int)$this->connection->lastInsertId();
    }
}

注册新路由

了解了动作(Action)和域(Domain)的概念并创建了服务、DTO 和存储库之后,我们的最后一步是为创建用户这个功能注册一条新路由:/users

首先我们需要创建一个 UserCreateAction 动作类,所以新建文件 src/Action/UserCreateAction.php 并写入以下代码:

<?php

namespace App\Action;

use App\Domain\User\Data\UserCreateData;
use App\Domain\User\Service\UserCreator;
use Slim\Http\Response;
use Slim\Http\ServerRequest;

final class UserCreateAction
{
    /** @var UserCreator The user creator */
    private UserCreator $userCreator;

    public function __construct(UserCreator $userCreator)
    {
        $this->userCreator = $userCreator;
    }

    public function __invoke(ServerRequest $request, Response $response): Response
    {
        // Collect input from the HTTP request
        $data = (array)$request->getParsedBody();

        // Mapping (done by ArrayReader)
        $user = new UserCreateData($data);

        // Invoke the Domain with inputs and retain the result
        $userId = $this->userCreator->createUser($user);

        // Transform the result into the JSON representation
        $result = [
            "user_id" => $userId
        ];

        // Build the HTTP response
        return $response->withJson($result)->withStatus(201);
    }
}

再在 config/routes.php 中添加一条新路由:

$app->post("/users", \App\Action\UserCreateAction::class);

Done!

到这里,一个简单的 ADR 模式的 RESTful API 就开发完成了!

然而你可能会问,为什么我们没有提到 ADR 中的 R,即响应器Responder)呢?

为了简单起见(而且我们输出的数据确实很简单),我们省略了响应器,因为对于 RESTful API 来说,最终的输出响应就是 JSON,而 JSON 的构建直接使用 $response->withJson() 方法即可,没有必要再多此一举地创建一个响应器类,所以我们将响应器的逻辑直接写在动作里就可以了。

但是注意:如果你的响应不只是简单的 JSON,还包括标头或者复杂的内容,又或者直接输出一个 HTML 页面,那么还是建议你将这些逻辑剥离到一个响应器类中,并在其中处理你的响应逻辑或调用相应的渲染器(Renderer),例如 PHP-View

至此,我们的项目的完整结构应该如下所示:

项目结构
项目结构

现在,你可以用 Postman 测试一下 POST /users 路由,看看是不是正常运行。正常运行的结果应该如下图所示:

运行结果
运行结果

部署

要在生产环境进行部署,我们需要考虑一些重要设置和与安全性相关的事项。

你可以使用 Composer 构建应用程序的优化版本,这将删除所有开发环境的依赖项,而且 Composer 自动加载器还会针对性能进行一些优化。

在项目根目录(例如 /var/www/api.example.com)中运行以下命令:

composer install --no-dev --optimize-autoloader

同时,出于安全考虑,你应该关闭生产环境中所有错误详细信息的输出。修改 config/defaults.php 文件:

$settings["error_handler_middleware"] = [
    "display_error_details" => false,
];

再者,我们虽然非常不推荐在子目录(例如 www.example.com/api)中运行 Slim 应用,但是如果真的需要这样做,可以尝试使用这个库:selective/basepath。并且你还需要在项目根目录也创建一个 .htaccess 文件,并在容器定义中加一条 $app->setBasePath("/your-sub-directory")。详情请参阅原文章:Slim 4 - Tutorial | Daniel’s Dev Blog

再次注意:将 Apache 中的网站配置文件中的 DocumentRoot 项设置为 public 目录非常重要。否则,其他人可能会从Web访问内部文件。我们在虚拟主机配置一节中进行了这一步,请再检查一遍。

最后注意切勿将密码等敏感信息存储在 git/SVN 存储库中,注意配置 .gitignore 文件。我们在配置一节中将敏感信息存储在了 config/env.php 文件中,你可以将该文件放在 Slim 应用根目录的上级目录目录中,例如 /var/www/api.example.com/env.php

总结

记住这些关系:

  • Slim:处理路由(Routing)和调度(Dispatching)工作
  • 动作控制器:调用正确的服务方法(域)
  • 域:应用程序的核心
  • 服务:处理业务逻辑
  • DTO:传送数据(没有实质行为)
  • 存储库:执行数据库查询

常见问题

在 GitHub 中查看代码

本文的完整示例已上传到 GitHub:drsanwujiang/slim4-tutorial。需要注意的是,本文相对于 Daniel 大神的原文有一点修改,原文的示例在:odan/slim4-tutorial

同时,Daniel 的 Slim 4 完整框架可以在这里找到:odan/slim4-skeleton

如何添加 JSON Web Token(JWT)/ Bearer 身份验证?

使用 lcobucci/jwt 库即可,这是一个基于 RFC 7519 的很棒的 JSON Web Token(JWT)库。

详情参阅 Slim 4 配置 JSON Web Token,或者 Daniel 的原文 Slim 4 - OAuth 2.0 and JSON Web Token (JWT) Setup

如何添加记录器(Logger)?

odan/slim4-skeleton 中有相应示例:注入一个记录器工厂,例如 LoggerFactory。记录器的设置定义在这里

如何添加查询生成器(Query Builder)?

可以按照以下教程添加:

如何使用 Webpack 编译 Assets?

参阅这篇文章:Slim 4 - Compiling Assets with Webpack

跨域资源共享(CORS)问题

创建一个 CORS 中间件从而返回相应的标头,即可解决这个问题。

详情参阅这篇文章:Slim 4 - CORS setup

遇到 404 Not Found 错误

很大概率是子目录的问题。正文中也说了,本文为了避免不必要的错误,省略了原文中关于子目录的处理方法,建议查阅原文章:Slim 4 - Tutorial

收到错误消息:Callable (…) does not exist

应该是 Composer 的问题。执行 composer update 命令修复即可。

其他问题

参阅这篇文章:Slim 4 - Cheatsheet and FAQ