mcpField
V
vtex-io-service-apps

vtex-io-service-apps

github.com/vtex开发471.2k 次安装2 周前更新
代码仓库

关于 vtex-io-service-apps

面向在 VTEX IO 应用中实现后端逻辑的开发者技能,介绍服务入口、路由、中间件、事件处理器和注册式 API 客户端,并说明如何遵循 VTEX 基础设施规范。

主要功能

  1. 01用 node/index.ts 组织服务
  2. 02配置路由和中间件链
  3. 03创建并注册 API 客户端
  4. 04通过 ctx.clients 访问客户端
  5. 05处理解析器和事件
  6. 06检查 HTTP 调用与路由模式

适用场景

  1. 01为 VTEX IO 应用构建 REST API 路由
  2. 02连接 VTEX 或外部 API
  3. 03添加 GraphQL 解析器或事件处理器
  4. 04检查客户端注册和 HTTP 用法

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill vtex-io-service-apps

安装后在 Claude Code 中输入/vtex-io-service-apps确认已加载。

详细介绍

后端服务应用与 API 客户端

适用场景

开发需要后端逻辑的 VTEX IO 应用时使用此技能,例如 REST API 路由、GraphQL 解析器、事件处理程序、计划任务,或与 VTEX Commerce API 和外部服务集成。

  • 使用类型化的上下文、客户端和状态构建 Service 入口点(node/index.ts)
  • 创建并注册扩展 JanusClient 或 ExternalClient 的自定义客户端
  • 使用 ctx.clients 访问具有内置缓存、重试和指标功能的客户端
  • 在 service.json 中配置路由和中间件链

以下场景不要使用此技能:

  • Manifest 和 builder 配置(改用 vtex-io-app-structure)
  • GraphQL 架构定义(改用 vtex-io-graphql-api)
  • React 组件开发(改用 vtex-io-react-apps)

决策规则

  • Service 类(node/index.ts)是每个 VTEX IO 后端应用的入口点。它接收客户端、路由(及其中间件链)、GraphQL 解析器和事件处理程序。
  • 每个中间件、解析器和事件处理程序都会接收 ctx,其中包含:ctx.clients(已注册的客户端)、ctx.state(每个请求可变的状态)、ctx.vtex(身份验证令牌、账户信息)、ctx.body(请求/响应正文)。
  • 使用 JanusClient 访问 VTEX 内部 API(基础 URL:https://{account}.vtexcommercestable.com.br)。
  • 使用 ExternalClient 访问非 VTEX API(可指定任意 URL)。
  • 使用 AppClient 访问其他 VTEX IO 应用公开的路由。
  • 使用 MasterDataClient 执行 Master Data v2 CRUD 操作。
  • 通过扩展 IOClients 注册自定义客户端——每个客户端都会在首次访问时通过 this.getOrSet() 延迟实例化。
  • 客户端应作为精简的数据访问封装层。将业务逻辑放在中间件或服务函数中。

客户端层级:

类用途基础 URL
JanusClient访问 VTEX 内部 API(Janus 网关)https://{account}.vtexcommercestable.com.br
ExternalClient访问外部(非 VTEX)API可指定任意 URL
AppClient访问其他 VTEX IO 应用公开的路由https://{workspace}--{account}.myvtex.com
InfraClient访问 VTEX IO 基础设施服务内部
MasterDataClient执行 Master Data v2 CRUD 操作VTEX API

架构:

Request → VTEX IO Runtime → Service
  ├── routes → middleware chain → ctx.clients.{name}.method()
  ├── graphql → resolvers → ctx.clients.{name}.method()
  └── events → handlers → ctx.clients.{name}.method()
                               │
                               ▼
                         Client (JanusClient / ExternalClient)
                               │
                               ▼
                    External Service / VTEX API

硬性约束

约束:使用 @vtex/api 客户端——绝不使用原始 HTTP 库

VTEX IO 服务应用中的所有 HTTP 通信都必须通过 @vtex/api 客户端(JanusClient、ExternalClient、AppClient 或来自 @vtex/clients 的原生客户端)进行。绝不能使用 axios、fetch、got、node-fetch 或任何其他原始 HTTP 库。

原因

VTEX IO 客户端提供自动注入身份验证标头、内置缓存(磁盘和内存)、指数退避重试、超时管理、原生指标与计费跟踪,以及妥善的错误处理。原始 HTTP 库会绕过所有这些功能。此外,VTEX IO 的出站流量受防火墙限制——只有 @vtex/api 客户端才能通过基础设施正确路由。

检测

如果在 VTEX IO 服务应用中看到 import axios from 'axios'、import fetch from 'node-fetch'、import got from 'got'、require('node-fetch') 或任何直接的 fetch() 调用,请立即停止。改用扩展 JanusClient 或 ExternalClient 的适当客户端。

正确

import type { InstanceOptions, IOContext } from '@vtex/api'
import { ExternalClient } from '@vtex/api'

export class WeatherClient extends ExternalClient {
  constructor(context: IOContext, options?: InstanceOptions) {
    super('https://api.weather.com', context, {
      ...options,
      headers: {
        'X-Api-Key': 'my-key',
        ...options?.headers,
      },
    })
  }

  public async getForecast(city: string): Promise<Forecast> {
    return this.http.get(`/v1/forecast/${city}`, {
      metric: 'weather-forecast',
    })
  }
}

错误

import axios from 'axios'

// This bypasses VTEX IO infrastructure entirely.
// No caching, no retries, no metrics, no auth token injection.
// Outbound requests may be blocked by the firewall.
export async function getForecast(city: string): Promise<Forecast> {
  const response = await axios.get(`https://api.weather.com/v1/forecast/${city}`, {
    headers: { 'X-Api-Key': 'my-key' },
  })
  return response.data
}

约束:通过 ctx.clients 访问客户端 — 切勿直接实例化

在中间件、解析器和事件处理程序中,必须始终通过 ctx.clients.{clientName} 访问客户端。切勿直接使用 new 实例化客户端类。

为何重要

IOClients 注册表负责管理客户端生命周期,确保客户端使用当前请求的 IOContext(账户、工作区、身份验证令牌)正确初始化。直接实例化会创建没有身份验证上下文、没有缓存配置,也没有连接到指标管道的客户端。

检测方法

如果在中间件或解析器中看到 new MyClient(...) 或 new ExternalClient(...),请立即停止。应在 Clients 类中注册客户端,并通过 ctx.clients 访问。

正确

// node/clients/index.ts
import { IOClients } from '@vtex/api'
import { CatalogClient } from './catalogClient'

export class Clients extends IOClients {
  public get catalog() {
    return this.getOrSet('catalog', CatalogClient)
  }
}

// node/middlewares/getProduct.ts
export async function getProduct(ctx: Context, next: () => Promise<void>) {
  const { clients: { catalog } } = ctx
  const product = await catalog.getProductById(ctx.query.id)
  ctx.body = product
  ctx.status = 200
  await next()
}

错误

// node/middlewares/getProduct.ts
import { CatalogClient } from '../clients/catalogClient'

export async function getProduct(ctx: Context, next: () => Promise<void>) {
  // Direct instantiation — no auth context, no caching, no metrics
  const catalog = new CatalogClient(ctx.vtex, {})
  const product = await catalog.getProductById(ctx.query.id)
  ctx.body = product
  ctx.status = 200
  await next()
}

约束:避免单体服务应用

单个服务应用不应定义超过 10 个 HTTP 路由。如果需要更多路由,请考虑拆分为职责明确的微服务应用。

为何重要

VTEX IO 应用运行在内存有限(最高 512MB)的容器中。包含大量路由的单体应用会增加内存使用量、冷启动时间和故障影响范围。VTEX IO 平台旨在让小型、职责明确的应用组合协作。

检测方法

如果 service.json 定义了超过 10 个路由,请提醒开发者考虑将应用拆分为更小的服务。这是宽松限制——某些情况下可能存在合理例外。

正确

{
  "memory": 256,
  "timeout": 30,
  "routes": {
    "get-reviews": { "path": "/_v/api/reviews", "public": false },
    "get-review": { "path": "/_v/api/reviews/:id", "public": false },
    "create-review": { "path": "/_v/api/reviews", "public": false },
    "moderate-review": { "path": "/_v/api/reviews/:id/moderate", "public": false }
  }
}

错误

{
  "memory": 512,
  "timeout": 60,
  "routes": {
    "route1": { "path": "/_v/api/reviews" },
    "route2": { "path": "/_v/api/reviews/:id" },
    "route3": { "path": "/_v/api/products" },
    "route4": { "path": "/_v/api/products/:id" },
    "route5": { "path": "/_v/api/orders" },
    "route6": { "path": "/_v/api/orders/:id" },
    "route7": { "path": "/_v/api/users" },
    "route8": { "path": "/_v/api/users/:id" },
    "route9": { "path": "/_v/api/categories" },
    "route10": { "path": "/_v/api/categories/:id" },
    "route11": { "path": "/_v/api/brands" },
    "route12": { "path": "/_v/api/inventory" }
  }
}

涵盖评价、商品、订单、用户、类别、品牌和库存的 12 个路由——这应该拆分为 3-4 个独立应用。

推荐模式

定义自定义客户端:

// node/clients/catalogClient.ts
import type { InstanceOptions, IOContext } from '@vtex/api'
import { JanusClient } from '@vtex/api'

export class CatalogClient extends JanusClient {
  constructor(context: IOContext, options?: InstanceOptions) {
    super(context, {
      ...options,
      headers: {
        VtexIdclientAutCookie: context.authToken,
        ...options?.headers,
      },
    })
  }

  public async getProduct(productId: string): Promise<Product> {
    return this.http.get(`/api/catalog/pvt/product/${productId}`, {
      metric: 'catalog-get-product',
    })
  }

  public async listSkusByProduct(productId: string): Promise<Sku[]> {
    return this.http.get(`/api/catalog_system/pvt/sku/stockkeepingunitByProductId/${productId}`, {
      metric: 'catalog-list-skus',
    })
  }
}

在 IOClients 中注册客户端:

// node/clients/index.ts
import { IOClients } from '@vtex/api'
import { CatalogClient } from './catalogClient'
import { ReviewStorageClient } from './reviewStorageClient'

export class Clients extends IOClients {
  public get catalog() {
    return this.getOrSet('catalog', CatalogClient)
  }

  public get reviewStorage() {
    return this.getOrSet('reviewStorage', ReviewStorageClient)
  }
}

使用 ctx.clients 和 ctx.state 创建中间件:

// node/middlewares/getReviews.ts
import type { ServiceContext } from '@vtex/api'
import type { Clients } from '../clients'

type Context = ServiceContext<Clients>

export async function validateParams(ctx: Context, next: () => Promise<void>) {
  const { productId } = ctx.query

  if (!productId || typeof productId !== 'string') {
    ctx.status = 400
    ctx.body = { error: 'productId query parameter is required' }
    return
  }

  ctx.state.productId = productId
  await next()
}

export async function getReviews(ctx: Context, next: () => Promise<void>) {
  const { productId } = ctx.state
  const reviews = await ctx.clients.reviewStorage.getByProduct(productId)

  ctx.status = 200
  ctx.body = reviews
  await next()
}

在 Service 入口点中连接所有组件:

// node/index.ts
import type { ParamsContext, RecorderState } from '@vtex/api'
import { Service, method } from '@vtex/api'

import { Clients } from './clients'
import { validateParams, getReviews } from './middlewares/getReviews'
import { createReview } from './middlewares/createReview'
import { resolvers } from './resolvers'

export default new Service<Clients, RecorderState, ParamsContext>({
  clients: {
    implementation: Clients,
    options: {
      default: {
        retries: 2,
        timeout: 5000,
      },
      catalog: {
        retries: 3,
        timeout: 10000,
      },
    },
  },
  routes: {
    reviews: method({
      GET: [validateParams, getReviews],
      POST: [createReview],
    }),
  },
  graphql: {
    resolvers: {
      Query: resolvers.queries,
      Mutation: resolvers.mutations,
    },
  },
})

常见故障模式

  • 使用 axios/fetch/got/node-fetch 进行 HTTP 调用:这些库会绕过整个 VTEX IO 基础设施——不会自动注入身份验证令牌、不会缓存、不会执行重试逻辑,也不会记录指标。出站请求还可能被防火墙阻止。请改为创建继承自 ExternalClient 或 JanusClient 的合适客户端。
  • 在客户端中编写业务逻辑:客户端会变得臃肿且难以测试。客户端应作为 HTTP 调用的轻量封装。业务逻辑应放在中间件或专用服务函数中。
  • 直接实例化客户端:在中间件中使用 new MyClient(...) 会创建没有身份验证上下文、缓存或指标的客户端。始终通过 ctx.clients 访问。

审查清单

  • 所有 HTTP 调用是否都通过 @vtex/api 客户端发出(没有使用 axios、fetch、got)?
  • 是否通过 ctx.clients 访问所有客户端,而不是使用 new 实例化?
  • 自定义客户端是否已在 IOClients 类中注册?
  • Service 入口点是否正确连接了客户端、路由、解析器和事件?
  • 业务逻辑是否放在中间件/解析器中,而非客户端类中?
  • service.json 中的路由数量是否合理(≤10)?
  • 客户端选项(重试、超时)是否配置得当?

参考资料

评论

支持 Markdown