服务端 API:控制器与服务(Controllers & services)

页面摘要: 就像 Strapi 核心一样,插件也可以拥有控制器和服务。插件控制器处理 HTTP 层:它们接收 ctx、调用服务并返回响应。插件服务持有可复用的业务逻辑,并通过文档服务 API 与内容类型交互。保持控制器精简,将领域逻辑放入服务中。

控制器和服务是插件服务端中处理请求和业务逻辑的 2 个构建块。它们以清晰的关注点分离协同工作:控制器负责 HTTP 层,服务负责领域层:

GoalUse
接收 ctx、读取请求、设置响应Controller
查询数据库或应用业务规则Service
在多个控制器或生命周期 hook 之间复用逻辑Service
在请求过程中调用外部 APIService
WARNING

在深入阅读本页概念之前,请确保你已经:

控制器(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,
    });
  },
});
TypeScript 服务类型

在当前 ServerObject TypeScript 接口(@strapi/types)中,services 被类型化为 unknown。这意味着 strapi.plugin('my-plugin').service('article') 返回 unknown,需要强制转换才能以类型安全的方式调用方法。对于完全类型化的服务调用,请显式定义并导出服务类型,并在调用处进行转换。

文档服务 API

服务通过 文档服务 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。