服务端 API:控制器与服务(Controllers & services)
页面摘要: 就像 Strapi 核心一样,插件也可以拥有控制器和服务。插件控制器处理 HTTP 层:它们接收
ctx、调用服务并返回响应。插件服务持有可复用的业务逻辑,并通过文档服务 API 与内容类型交互。保持控制器精简,将领域逻辑放入服务中。
控制器和服务是插件服务端中处理请求和业务逻辑的 2 个构建块。它们以清晰的关注点分离协同工作:控制器负责 HTTP 层,服务负责领域层:
| Goal | Use |
|---|---|
接收 ctx、读取请求、设置响应 | Controller |
| 查询数据库或应用业务规则 | Service |
| 在多个控制器或生命周期 hook 之间复用逻辑 | Service |
| 在请求过程中调用外部 API | Service |
在深入阅读本页概念之前,请确保你已经:
- 创建了一个 Strapi 插件,
- 阅读并理解了 Server API 的基础知识。
控制器(Controllers)
控制器是一个包含操作方法的对象,每个方法对应一个路由处理程序。控制器接收包含请求和响应的 Koa 上下文对象(ctx),调用适当的服务,并为响应设置 ctx.body 或 ctx.status。
声明
控制器既可以作为接收 { strapi } 的工厂函数导出,也可以作为普通对象导出。工厂函数模式是推荐的依赖注入方式,并且与大多数文档示例保持一致。
在运行时,Strapi 同时支持这两种导出方式,并通过以 { strapi } 调用函数来解析函数导出。
在 controllers/index.js|ts 中使用的导出键必须与路由定义中使用的处理程序名称一致。
JavaScript
'use strict';
const article = require('./article');
module.exports = {
article,
};
'use strict';
module.exports = ({ strapi }) => ({
async find(ctx) {
const articles = await strapi
.plugin('my-plugin')
.service('article')
.findAll();
ctx.body = articles;
},
async findOne(ctx) {
const { documentId } = ctx.params;
const article = await strapi
.plugin('my-plugin')
.service('article')
.findOne(documentId);
if (!article) {
return ctx.notFound('Article not found');
}
ctx.body = article;
},
async create(ctx) {
const article = await strapi
.plugin('my-plugin')
.service('article')
.create(ctx.request.body);
ctx.status = 201;
ctx.body = article;
},
});
TypeScript
import article from './article';
export default {
article,
};
import type { Core } from '@strapi/strapi';
interface ArticleService {
findAll(): Promise<unknown[]>;
findOne(id: string): Promise<unknown>;
create(data: unknown): Promise<unknown>;
}
export default ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx: any) {
// Limitation: in @strapi/types, plugin services are currently typed as unknown.
const articleService = strapi.plugin('my-plugin').service('article') as ArticleService;
ctx.body = await articleService.findAll();
},
async findOne(ctx: any) {
const { documentId } = ctx.params;
const article = await (strapi.plugin('my-plugin').service('article') as ArticleService).findOne(documentId);
if (!article) {
return ctx.notFound('Article not found');
}
ctx.body = article;
},
async create(ctx: any) {
const articleService = strapi.plugin('my-plugin').service('article') as ArticleService;
ctx.status = 201;
ctx.body = await articleService.create(ctx.request.body);
},
});
清理(Sanitization)
当你的插件暴露 Content API 路由时,在返回之前清理查询参数和输出数据。这可防止泄露私有字段或绕过访问规则。
插件控制器是普通的工厂函数,并不像 Strapi 核心那样扩展 createCoreController(详见 后端自定义)。这意味着 this.sanitizeQuery 和 this.sanitizeOutput 简写不可用。请改用 strapi.contentAPI.sanitize,并显式传入内容类型 schema:
JavaScript
module.exports = ({ strapi }) => ({
async find(ctx) {
// highlight-start
const schema = strapi.contentType('plugin::my-plugin.article');
const sanitizedQuery = await strapi.contentAPI.sanitize.query(
ctx.query, schema, { auth: ctx.state.auth }
);
// highlight-end
const articles = await strapi.plugin('my-plugin').service('article').findAll(sanitizedQuery);
// highlight-next-line
ctx.body = await strapi.contentAPI.sanitize.output(articles, schema, { auth: ctx.state.auth });
},
});
TypeScript
import type { Core } from '@strapi/strapi';
export default ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx: any) {
// highlight-start
const schema = strapi.contentType('plugin::my-plugin.article');
const sanitizedQuery = await strapi.contentAPI.sanitize.query(
ctx.query, schema, { auth: ctx.state.auth }
);
// highlight-end
const articles = await (strapi.plugin('my-plugin').service('article') as any).findAll(sanitizedQuery);
// highlight-next-line
ctx.body = await strapi.contentAPI.sanitize.output(articles, schema, { auth: ctx.state.auth });
},
});
有关完整的清理与验证参考(包括 sanitizeInput、validateQuery 和 validateInput),请参阅 Controllers。
服务(Services)
服务是一个接收 { strapi } 并返回包含具名方法的对象(或普通对象)的工厂函数;与 控制器 类似,Strapi 会在运行时解析这两种导出方式。服务持有从控制器、生命周期 hook 或其他服务调用的业务逻辑。
声明
JavaScript
'use strict';
const article = require('./article');
module.exports = {
article,
};
'use strict';
module.exports = ({ strapi }) => ({
async findAll(params = {}) {
// highlight-next-line
return strapi.documents('plugin::my-plugin.article').findMany(params);
},
async findOne(documentId) {
return strapi.documents('plugin::my-plugin.article').findOne({
documentId,
});
},
async create(data) {
return strapi.documents('plugin::my-plugin.article').create({ data });
},
async update(documentId, data) {
return strapi.documents('plugin::my-plugin.article').update({
documentId,
data,
});
},
async delete(documentId) {
return strapi.documents('plugin::my-plugin.article').delete({
documentId,
});
},
});
TypeScript
import article from './article';
export default {
article,
};
import type { Core } from '@strapi/strapi';
export default ({ strapi }: { strapi: Core.Strapi }) => ({
async findAll(params: Record<string, unknown> = {}) {
// highlight-next-line
return strapi.documents('plugin::my-plugin.article').findMany(params);
},
async findOne(documentId: string) {
return strapi.documents('plugin::my-plugin.article').findOne({
documentId,
});
},
async create(data: Record<string, unknown>) {
return strapi.documents('plugin::my-plugin.article').create({ data });
},
async update(documentId: string, data: Record<string, unknown>) {
return strapi.documents('plugin::my-plugin.article').update({
documentId,
data,
});
},
async delete(documentId: string) {
return strapi.documents('plugin::my-plugin.article').delete({
documentId,
});
},
});
在当前 ServerObject TypeScript 接口(@strapi/types)中,services 被类型化为 unknown。这意味着 strapi.plugin('my-plugin').service('article') 返回 unknown,需要强制转换才能以类型安全的方式调用方法。对于完全类型化的服务调用,请显式定义并导出服务类型,并在调用处进行转换。
服务通过 文档服务 API 与内容类型交互,该 API 文档记录了可用方法和参数的完整列表。
端到端示例
以下示例展示了针对简单 article 资源,跨越路由、控制器和服务的完整请求流程。
JavaScript
'use strict';
module.exports = {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/articles',
// highlight-next-line
handler: 'article.find', // maps to controllers/article.js → find()
config: { auth: false },
},
{
method: 'POST',
path: '/articles',
// highlight-next-line
handler: 'article.create', // maps to controllers/article.js → create()
config: { auth: false },
},
],
},
};
'use strict';
module.exports = ({ strapi }) => ({
// highlight-next-line
async find(ctx) {
ctx.body = await strapi.plugin('my-plugin').service('article').findAll();
// Note: sanitize query and output in production — see the Sanitization section above
},
// highlight-next-line
async create(ctx) {
const article = await strapi
.plugin('my-plugin')
.service('article')
.create(ctx.request.body);
ctx.status = 201;
ctx.body = article;
},
});
'use strict';
module.exports = ({ strapi }) => ({
findAll() {
return strapi.documents('plugin::my-plugin.article').findMany();
},
create(data) {
return strapi.documents('plugin::my-plugin.article').create({ data });
},
});
TypeScript
export default {
'content-api': {
type: 'content-api' as const,
routes: [
{
method: 'GET' as const,
path: '/articles',
// highlight-next-line
handler: 'article.find', // maps to controllers/article.ts → find()
config: { auth: false },
},
{
method: 'POST' as const,
path: '/articles',
// highlight-next-line
handler: 'article.create', // maps to controllers/article.ts → create()
config: { auth: false },
},
],
},
};
import type { Core } from '@strapi/strapi';
interface ArticleService {
findAll(): Promise<unknown[]>;
create(data: unknown): Promise<unknown>;
}
export default ({ strapi }: { strapi: Core.Strapi }) => ({
// highlight-next-line
async find(ctx: any) {
ctx.body = await (strapi.plugin('my-plugin').service('article') as ArticleService).findAll();
// Note: sanitize query and output in production — see the Sanitization section above
},
// highlight-next-line
async create(ctx: any) {
const article = await (strapi.plugin('my-plugin').service('article') as ArticleService)
.create(ctx.request.body);
ctx.status = 201;
ctx.body = article;
},
});
import type { Core } from '@strapi/strapi';
export default ({ strapi }: { strapi: Core.Strapi }) => ({
findAll() {
// highlight-next-line
return strapi.documents('plugin::my-plugin.article').findMany();
},
create(data: Record<string, unknown>) {
return strapi.documents('plugin::my-plugin.article').create({ data });
},
});
最佳实践
-
保持控制器精简。 控制器操作应做 3 件事:接收
ctx、委托给服务、设置响应。业务逻辑、数据库调用和条件分支都应放在服务中。 -
每个资源一个服务。 按所管理的资源(例如
article、comment、settings)而不是按操作类型来组织服务。这使每个文件聚焦且易于测试。 -
在服务中使用文档服务 API,而不是在控制器中。 在控制器中直接调用
strapi.documents(...)会绕过服务层,并使逻辑更难复用。将所有文档服务调用放入服务中。 -
清理 Content API 响应。 当暴露 Content API 路由时,在返回数据之前使用
strapi.contentAPI.sanitize.output()。跳过清理可能会将私有字段泄露给最终用户。管理面板路由不受相同的内容类型字段可见性规则约束,但同样清理它们也无害。 -
在 TypeScript 中显式转换服务类型。 在
@strapi/types对services进行强类型化之前,请在每次调用处将strapi.plugin('my-plugin').service('my-service')的返回值转换为服务接口。避免在代码库中到处使用any。