今天我们的主题是 单服务实战:RESTful 文章系统。今天我们将整合前四天所学的全部技能——注解、中间件、验证器、异常处理、Redis 缓存、事件机制——来构建一个功能完备的文章管理系统,包含分类、标签和文章,并严格遵循 RESTful 规范。这是你第一次在 Hyperf 中完成一个相对完整的业务模块,也是通往微服务架构的重要一步。


在这里插入图片描述

今日目标

  1. 使用 hyperf/database 连接 MySQL,创建文章、分类、标签三张核心数据表。
  2. 设计并实现符合 RESTful 风格的 CRUD 接口(创建、读取、更新、删除)。
  3. 集成请求验证器,确保数据的完整性和格式正确。
  4. 利用 @Cacheable@CacheEvict 注解实现文章详情缓存及列表缓存,提升查询性能。
  5. 实现文章列表的分页查询,体验 Hyperf 的分页组件。
  6. 用统一 JSON 结构与 HTTP 状态码规范响应,打造专业 API。

一、环境准备:添加 MySQL 与数据库组件(约 45 分钟)

我们需要在 Docker 环境中加入 MySQL 服务,并安装 Hyperf 的数据库组件。

1. 修改 docker-compose.yml 添加 MySQL

swoole-course/docker-compose.yml 中增加一个数据库服务:

services:
  swoole:
    # ... 已有配置 ...
  redis:
    # ... 已有配置 ...
  mysql:
    image: mysql:8.0
    container_name: mysql-lab
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: hyperf_blog
      MYSQL_USER: blog_user
      MYSQL_PASSWORD: blog_pass
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
volumes:
  mysql_data:

然后重启所有服务:

docker-compose up -d

等待 MySQL 初始化完成(约 30 秒),进入 Swoole 容器测试连接:

docker-compose exec swoole bash
# 安装 mysql-client 以便测试(镜像中可能已有,没有则用 apt-get update && apt-get install -y default-mysql-client)
# 或者直接用 PHP 连接测试
php -r "new PDO('mysql:host=mysql;dbname=hyperf_blog', 'blog_user', 'blog_pass'); echo 'OK';"
2. 安装 Hyperf 数据库组件
cd /var/www/hyperf-app
composer require hyperf/database hyperf/db-connection hyperf/paginator

发布数据库配置:

php bin/hyperf.php vendor:publish hyperf/database

这会在 config/autoload/ 下生成 databases.php

3. 配置数据库连接

编辑 config/autoload/databases.php(若已存在则修改):

<?php
return [
    'default' => [
        'driver' => env('DB_DRIVER', 'mysql'),
        'host' => env('DB_HOST', 'mysql'), // Docker 服务名
        'port' => env('DB_PORT', 3306),
        'database' => env('DB_DATABASE', 'hyperf_blog'),
        'username' => env('DB_USERNAME', 'blog_user'),
        'password' => env('DB_PASSWORD', 'blog_pass'),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => 'blog_',
        'pool' => [
            'min_connections' => 1,
            'max_connections' => 20,
            'connect_timeout' => 10.0,
            'wait_timeout' => 3.0,
            'heartbeat' => -1,
            'max_idle_time' => 60,
        ],
    ],
];

对应的 .env 文件确认或添加:

DB_DRIVER=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=hyperf_blog
DB_USERNAME=blog_user
DB_PASSWORD=blog_pass
4. 创建数据表迁移文件(或直接建表)

为了快速启动,我们直接用 SQL 建表。创建 database/schema.sql 并在容器内导入:

-- database/schema.sql
CREATE TABLE IF NOT EXISTS `categories` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `name` VARCHAR(50) NOT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `tags` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `name` VARCHAR(50) NOT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `articles` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `title` VARCHAR(255) NOT NULL,
    `content` TEXT NOT NULL,
    `category_id` INT UNSIGNED NOT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `article_tag` (
    `article_id` INT UNSIGNED NOT NULL,
    `tag_id` INT UNSIGNED NOT NULL,
    PRIMARY KEY (`article_id`, `tag_id`),
    FOREIGN KEY (`article_id`) REFERENCES `articles`(`id`) ON DELETE CASCADE,
    FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

在容器内执行:

mysql -h mysql -u blog_user -pblog_pass hyperf_blog < /var/www/hyperf-app/database/schema.sql

(如果容器没有 mysql 客户端,可以通过宿主机连接 3306 端口执行,或编写一个 PHP 脚本来创建表。)


二、知识核心:RESTful 设计、分页与缓存策略(约 30 分钟)

1. RESTful API 设计规范
  • 资源 URI 使用名词复数/categories/tags/articles
  • HTTP 方法对应操作:GET(获取),POST(创建),PUT/PATCH(更新),DELETE(删除)
  • 子资源关联/articles/{id}/tags 获取某篇文章的标签
  • 状态码:200 成功,201 创建成功,204 无内容(删除成功),422 验证失败,404 未找到
2. Hyperf 分页组件

hyperf/paginator 提供了类似 Laravel 的 paginate() 方法。我们可以这样使用:

$articles = Article::paginate(10); // 每页10条
return $articles->toArray();

它自动处理 page 查询参数,返回 data, current_page, last_page, total 等信息。

3. 缓存策略
  • 文章详情:高读取、低修改,使用 #[Cacheable] 注解,设置合理的 TTL(如 600 秒)。
  • 文章列表:列表变化相对频繁,可以缓存短时间(60 秒)或仅缓存特定分类下的列表。
  • 分类/标签:数据量小,变化极少,可永久缓存直到更新时清除。
  • 缓存清除:在文章创建、更新、删除时,使用 #[CacheEvict] 注解清除对应的缓存键。

三、实战:构建文章系统 CRUD(约 3 小时)

我们将创建模型、控制器,并逐步实现功能。所有代码都在 hyperf-app 项目内。

1. 创建 Eloquent 模型

新建目录 app/Model,然后创建三个模型文件:
app/Model/Category.php

<?php
namespace App\Model;

use Hyperf\DbConnection\Model\Model;

class Category extends Model
{
    protected $table = 'categories';
    protected $fillable = ['name'];
    public $timestamps = true;

    public function articles()
    {
        return $this->hasMany(Article::class);
    }
}

app/Model/Tag.php

<?php
namespace App\Model;

use Hyperf\DbConnection\Model\Model;

class Tag extends Model
{
    protected $table = 'tags';
    protected $fillable = ['name'];
    public $timestamps = true;

    public function articles()
    {
        return $this->belongsToMany(Article::class, 'article_tag', 'tag_id', 'article_id');
    }
}

app/Model/Article.php

<?php
namespace App\Model;

use Hyperf\DbConnection\Model\Model;

class Article extends Model
{
    protected $table = 'articles';
    protected $fillable = ['title', 'content', 'category_id'];
    public $timestamps = true;

    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class, 'article_tag', 'article_id', 'tag_id');
    }
}
2. 创建分类管理控制器

app/Controller/CategoryController.php

<?php
namespace App\Controller;

use App\Model\Category;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\RequestMapping;
use Hyperf\HttpServer\Annotation\Middleware;
use App\Middleware\ApiTokenMiddleware; // 可复用
use Hyperf\Cache\Annotation\Cacheable;
use Hyperf\Cache\Annotation\CacheEvict;
use Hyperf\Validation\Contract\ValidatorFactoryInterface;
use Hyperf\Validation\ValidationException;
use Hyperf\Di\Annotation\Inject;

#[Controller(prefix: '/categories')]
class CategoryController extends AbstractController
{
    #[Inject]
    protected ValidatorFactoryInterface $validatorFactory;

    // 列表(缓存 300 秒)
    #[RequestMapping(path: '', methods: 'get')]
    #[Cacheable(prefix: 'categories_list', ttl: 300)]
    public function index()
    {
        return Category::all()->toArray();
    }

    // 详情
    #[RequestMapping(path: '{id:\d+}', methods: 'get')]
    public function show(int $id)
    {
        $category = Category::find($id);
        if (!$category) {
            return $this->response->json(['code' => 404, 'message' => '分类不存在'])->withStatus(404);
        }
        return $category->load('articles')->toArray();
    }

    // 创建
    #[RequestMapping(path: '', methods: 'post')]
    public function store()
    {
        $data = $this->request->all();
        $validator = $this->validatorFactory->make($data, [
            'name' => 'required|string|max:50|unique:categories,name',
        ], [
            'name.required' => '分类名称必填',
            'name.unique' => '分类名称已存在',
        ]);
        if ($validator->fails()) throw new ValidationException($validator);
        
        $category = Category::create($validator->validated());
        // 清除列表缓存
        // 手动调用缓存清除(注解不方便动态 key,这里用容器清除)
        $cache = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Psr\SimpleCache\CacheInterface::class);
        $cache->delete('categories_list');
        
        return $this->response->json(['code' => 201, 'message' => '创建成功', 'data' => $category])->withStatus(201);
    }

    // 更新
    #[RequestMapping(path: '{id:\d+}', methods: 'put')]
    public function update(int $id)
    {
        $category = Category::find($id);
        if (!$category) {
            return $this->response->json(['code' => 404, 'message' => '分类不存在'])->withStatus(404);
        }
        $data = $this->request->all();
        $validator = $this->validatorFactory->make($data, [
            'name' => 'required|string|max:50|unique:categories,name,' . $id,
        ]);
        if ($validator->fails()) throw new ValidationException($validator);
        
        $category->update($validator->validated());
        // 清除列表缓存
        $cache = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Psr\SimpleCache\CacheInterface::class);
        $cache->delete('categories_list');
        return ['code' => 200, 'message' => '更新成功', 'data' => $category];
    }

    // 删除
    #[RequestMapping(path: '{id:\d+}', methods: 'delete')]
    public function destroy(int $id)
    {
        $category = Category::find($id);
        if (!$category) return $this->response->json(['code' => 404, 'message' => '分类不存在'])->withStatus(404);
        $category->delete();
        // 清除缓存
        $cache = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Psr\SimpleCache\CacheInterface::class);
        $cache->delete('categories_list');
        return $this->response->json(['code' => 204, 'message' => '删除成功'])->withStatus(204);
    }
}
3. 创建标签管理控制器(结构类似)

app/Controller/TagController.php(代码与分类类似,表换为 tags,模型为 Tag,验证 unique:tags,name,不再赘述,请参照分类实现)。

4. 创建文章管理控制器(集成缓存、分页)

app/Controller/ArticleController.php

<?php
namespace App\Controller;

use App\Model\Article;
use App\Model\Category;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\RequestMapping;
use Hyperf\Cache\Annotation\Cacheable;
use Hyperf\Cache\Annotation\CacheEvict;
use Hyperf\Validation\Contract\ValidatorFactoryInterface;
use Hyperf\Validation\ValidationException;
use Hyperf\Di\Annotation\Inject;
use Psr\SimpleCache\CacheInterface;

#[Controller(prefix: '/articles')]
class ArticleController extends AbstractController
{
    #[Inject]
    protected ValidatorFactoryInterface $validatorFactory;

    // 文章列表(分页),带可选分类筛选
    #[RequestMapping(path: '', methods: 'get')]
    public function index()
    {
        $perPage = $this->request->input('per_page', 10);
        $categoryId = $this->request->input('category_id');
        
        $query = Article::with('category');
        if ($categoryId) {
            $query->where('category_id', $categoryId);
        }
        $articles = $query->orderBy('id', 'desc')->paginate((int) $perPage);
        return $articles->toArray();
    }

    // 文章详情(带缓存)
    #[RequestMapping(path: '{id:\d+}', methods: 'get')]
    #[Cacheable(prefix: 'article', ttl: 600)]
    public function show(int $id)
    {
        $article = Article::with(['category', 'tags'])->find($id);
        if (!$article) {
            return $this->response->json(['code' => 404, 'message' => '文章不存在'])->withStatus(404);
        }
        return $article->toArray();
    }

    // 创建文章
    #[RequestMapping(path: '', methods: 'post')]
    public function store()
    {
        $data = $this->request->all();
        $validator = $this->validatorFactory->make($data, [
            'title' => 'required|string|max:255',
            'content' => 'required|string',
            'category_id' => 'required|integer|exists:categories,id',
            'tags' => 'array',
            'tags.*' => 'integer|exists:tags,id',
        ]);
        if ($validator->fails()) throw new ValidationException($validator);

        $validated = $validator->validated();
        $article = Article::create([
            'title' => $validated['title'],
            'content' => $validated['content'],
            'category_id' => $validated['category_id'],
        ]);
        if (!empty($validated['tags'])) {
            $article->tags()->sync($validated['tags']);
        }
        // 清除相关缓存(详情缓存键 article:{id} 会被自动清除吗?注解 Evict 无法基于返回值,这里手动清除)
        $cache = \Hyperf\Utils\ApplicationContext::getContainer()->get(CacheInterface::class);
        // 注意:文章列表没有强缓存,但如果有分类列表等可清除
        
        return $this->response->json(['code' => 201, 'message' => '创建成功', 'data' => $article->load(['category', 'tags'])])->withStatus(201);
    }

    // 更新文章
    #[RequestMapping(path: '{id:\d+}', methods: 'put')]
    #[CacheEvict(prefix: 'article', value: '#{id}')] // 清除该文章详情缓存
    public function update(int $id)
    {
        $article = Article::find($id);
        if (!$article) return $this->response->json(['code' => 404, 'message' => '文章不存在'])->withStatus(404);
        
        $data = $this->request->all();
        $validator = $this->validatorFactory->make($data, [
            'title' => 'sometimes|required|string|max:255',
            'content' => 'sometimes|required|string',
            'category_id' => 'sometimes|required|integer|exists:categories,id',
            'tags' => 'sometimes|array',
            'tags.*' => 'integer|exists:tags,id',
        ]);
        if ($validator->fails()) throw new ValidationException($validator);
        
        $validated = $validator->validated();
        $article->update($validated);
        if (isset($validated['tags'])) {
            $article->tags()->sync($validated['tags']);
        }
        return ['code' => 200, 'message' => '更新成功', 'data' => $article->fresh(['category', 'tags'])];
    }

    // 删除文章
    #[RequestMapping(path: '{id:\d+}', methods: 'delete')]
    #[CacheEvict(prefix: 'article', value: '#{id}')]
    public function destroy(int $id)
    {
        $article = Article::find($id);
        if (!$article) return $this->response->json(['code' => 404, 'message' => '文章不存在'])->withStatus(404);
        $article->delete();
        return $this->response->json(['code' => 204, 'message' => '删除成功'])->withStatus(204);
    }
}
5. 注册路由

config/routes.php 中,我们使用了注解路由,因此无需手动注册。只需确保控制器上有 #[Controller] 注解。为了访问,默认 Hyperf 扫描 app/Controller 目录。

6. 测试数据初始化

为了方便测试,我们可以创建一个数据填充脚本。在容器内执行:

php bin/hyperf.php start
# 另起终端进入容器
curl -X POST http://localhost:9501/categories -H "Content-Type: application/json" -d '{"name":"PHP"}'
curl -X POST http://localhost:9501/categories -H "Content-Type: application/json" -d '{"name":"Swoole"}'
curl -X POST http://localhost:9501/tags -H "Content-Type: application/json" -d '{"name":"微服务"}'
curl -X POST http://localhost:9501/tags -H "Content-Type: application/json" -d '{"name":"高性能"}'
# 创建一篇文章
curl -X POST http://localhost:9501/articles -H "Content-Type: application/json" -d '{
  "title":"Hello Hyperf",
  "content":"这是第一篇文章内容...",
  "category_id":1,
  "tags":[1,2]
}'

四、成果测试与性能观察(约 1 小时)

1. API 全流程测试(可用 Postman 或 curl)
接口方法路径预期结果
获取分类列表GET/categories200,返回所有分类数组
创建分类POST/categories201,返回分类对象,重复名报 422
获取文章列表GET/articles?page=1&category_id=1200,分页数据,关联分类
获取文章详情GET/articles/1200,包含分类和标签,第一次查询后缓存 Redis
更新文章PUT/articles/1200,缓存被清除
删除文章DELETE/articles/1204,缓存被清除

验证缓存

# 第一次请求详情
curl http://localhost:9501/articles/1
# 检查 Redis
redis-cli -h redis keys "*"
# 应该看到 'c:article:1' (假设前缀为 c:)
# 第二次请求,观察应用日志(如果控制器没有打印,可以临时加 echo 看是否进入方法体),如果走了缓存,方法体不会执行。

验证分页

curl "http://localhost:9501/articles?page=1&per_page=5"

返回的 JSON 应包含 data, current_page, total 等字段。

2. 验证器与异常测试
  • 发送缺少 title 的文章创建请求,应返回 422,并列出错误 {"code":422,"message":"请求参数验证失败","errors":{"title":["文章标题必填"]}}
  • 发送不存在的 category_id,返回错误。
  • 更新不存在文章返回 404。
3. 缓存清除测试

更新文章标题后,再次获取文章详情,应看到新标题,且 Redis 键被更新或删除后重建(由 #[CacheEvict] 清除,下次 get 重新写入)。


五、今日作业与学习产出

  1. 提交代码:将模型、控制器、数据库迁移文件(或 SQL)提交到 Git,附上 README 说明如何配置环境。
  2. 完善功能
    • 完成 TagController 的 CRUD(参照 CategoryController)。
    • 为文章添加按标签筛选的功能:GET /articles?tag_id=1(需在控制器中处理 whereHas)。
    • 为文章列表增加排序参数(如按创建时间倒序)。
  3. 学习笔记
    • 总结今天用到的注解 #[Cacheable], #[CacheEvict] 的原理和局限性(如 Evict 无法基于表达式清除多个 key)。
    • 画出文章系统各接口与数据库、缓存、验证器交互的完整数据流图。
  4. 挑战任务
    • 使用事件机制,在文章创建后异步同步到搜索引擎或记录操作日志(可结合周四的事件监听器)。
    • 将文章内容的 HTML 转换为纯文本摘要存入缓存,并用于列表展示(需要借助字符串处理库)。
    • 使用 Hyperf 的模型事件(created, updated)自动清除相关缓存,替代控制器中手动清除。

通过今天的高强度实战,你已成功将 Hyperf 的多种特性融为一体,构建了一个结构清晰、性能优秀的 RESTful 文章系统。这标志着你已经具备了在单服务内部进行专业级开发的能力。从下周开始,我们将跨越服务边界,进入微服务通信与治理的精彩世界。

更多推荐