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

今日目标
- 使用
hyperf/database连接 MySQL,创建文章、分类、标签三张核心数据表。 - 设计并实现符合 RESTful 风格的 CRUD 接口(创建、读取、更新、删除)。
- 集成请求验证器,确保数据的完整性和格式正确。
- 利用
@Cacheable、@CacheEvict注解实现文章详情缓存及列表缓存,提升查询性能。 - 实现文章列表的分页查询,体验 Hyperf 的分页组件。
- 用统一 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 | /categories | 200,返回所有分类数组 |
| 创建分类 | POST | /categories | 201,返回分类对象,重复名报 422 |
| 获取文章列表 | GET | /articles?page=1&category_id=1 | 200,分页数据,关联分类 |
| 获取文章详情 | GET | /articles/1 | 200,包含分类和标签,第一次查询后缓存 Redis |
| 更新文章 | PUT | /articles/1 | 200,缓存被清除 |
| 删除文章 | DELETE | /articles/1 | 204,缓存被清除 |
验证缓存:
# 第一次请求详情
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 重新写入)。
五、今日作业与学习产出
- 提交代码:将模型、控制器、数据库迁移文件(或 SQL)提交到 Git,附上 README 说明如何配置环境。
- 完善功能:
- 完成 TagController 的 CRUD(参照 CategoryController)。
- 为文章添加按标签筛选的功能:
GET /articles?tag_id=1(需在控制器中处理 whereHas)。 - 为文章列表增加排序参数(如按创建时间倒序)。
- 学习笔记:
- 总结今天用到的注解
#[Cacheable],#[CacheEvict]的原理和局限性(如 Evict 无法基于表达式清除多个 key)。 - 画出文章系统各接口与数据库、缓存、验证器交互的完整数据流图。
- 总结今天用到的注解
- 挑战任务:
- 使用事件机制,在文章创建后异步同步到搜索引擎或记录操作日志(可结合周四的事件监听器)。
- 将文章内容的 HTML 转换为纯文本摘要存入缓存,并用于列表展示(需要借助字符串处理库)。
- 使用 Hyperf 的模型事件(
created,updated)自动清除相关缓存,替代控制器中手动清除。
通过今天的高强度实战,你已成功将 Hyperf 的多种特性融为一体,构建了一个结构清晰、性能优秀的 RESTful 文章系统。这标志着你已经具备了在单服务内部进行专业级开发的能力。从下周开始,我们将跨越服务边界,进入微服务通信与治理的精彩世界。
更多推荐
所有评论(0)