在某个时刻,每家公司都会到达一个十字路口,他们需要停下来重新评估他们一直在使用的工具。对于我们来说,那一刻到来了,我们意识到为 Web 仪表板提供支持的 API 变得难以管理、难以测试,并且不符合我们为代码库设定的标准。
Arcjet 主要是一个安全即代码 SDK,可帮助开发人员实现机器人检测、电子邮件验证和 PII 检测等安全功能。这与我们的高性能、低延迟决策 gRPC API 进行通信。
我们的网络仪表板使用单独的 REST API 主要用于管理站点连接和审查已处理的请求分析,但这还包括注册新用户和管理他们的帐户,这意味着它仍然是产品的重要组成部分。
Arcjet 仪表板的请求分析页面的屏幕截图。
因此,我们决定接受从头开始重建 API 的挑战,这次重点关注可维护性、性能和可扩展性。然而,我们不想开始一个巨大的重写项目 - 永远不会成功 - 相反,我们决定构建一个新的基础,然后从单个 API 端点开始。
在这篇文章中,我将讨论我们如何解决这个问题。
当速度是我们的首要任务时,Next.js 提供了最方便的解决方案来构建我们的前端可以使用的 API 端点。它为我们提供了在单个代码库中进行无缝全栈开发的能力,而且我们不必过多担心基础设施,因为我们部署在 Vercel 上。
我们的重点是代码 SDK 的安全性和低延迟决策 API,因此对于前端仪表板,该堆栈使我们能够轻松快速地制作功能原型。
我们的堆栈:Next.js、DrizzleORM、useSWR、NextAuth
然而,随着我们产品的发展,我们发现将所有 API 端点和前端代码组合在同一个项目中会导致混乱。
测试我们的 API 变得很麻烦(并且 无论如何使用 Next.js 都很难),并且我们需要一个能够处理 内部和 外部消费。随着我们与更多平台(如 Vercel、Fly.io 和 Netlify)集成,我们意识到仅靠开发速度是不够的。我们需要一个更强大的解决方案。
作为此项目的一部分,我们还希望解决一个挥之不去的安全问题,即 Vercel 如何要求您公开公开数据库。除非您为他们的企业“安全计算”付费,否则连接到远程数据库需要它具有公共端点。我们更喜欢锁定我们的数据库,以便只能通过专用网络访问它。纵深防御很重要,这将是另一层保护。
这导致我们决定将前端 UI 与后端 API 分离。
什么是“黄金 API”?它不是特定的技术或框架,而是定义构建良好的 API 的一组理想原则。虽然开发人员可能对语言和框架有自己的偏好,但在构建高质量 API 时,大多数人都同意某些跨技术堆栈有效的概念。
我们已经拥有交付高性能 API 的经验。我们的 Decision API 部署在靠近客户的位置,使用 Kubernetes 进行动态扩展,并针对低延迟响应进行了优化.
我们考虑了无服务器环境和其他提供商,但由于我们现有的 k8s 集群已经在运行,因此重用现有的基础设施是最有意义的:通过 Octopus Deploy 进行部署,通过 Grafana Jaeger、Loki、Prometheus 等进行监控
经过短暂的内部 Rust 与 Go 对比后,我们选择了 Go,因为它的简单性、速度以及它实现其最初目标 对构建可扩展网络服务的出色支持的效果。我们也已经将它用于 Decision API 并了解如何操作 Go API,这为我们最终确定了决策。
由于 Go 的简单性和强大工具的可用性,将后端 API 切换到 Go 非常简单。但有一个问题:我们保留了 Next.js 前端,并且不想手动编写 TypeScript 类型或为我们的新 API 维护单独的文档。
引入 OpenAPI——它非常适合我们的需求。 OpenAPI 允许我们定义前端和后端之间的契约,同时也充当我们的文档。这解决了维护应用程序两侧架构的问题。
在 Go 中集成身份验证并不太困难,这要归功于 NextAuth 在后端的模仿相对简单。 NextAuth(现在的 Auth.js)具有可用于验证会话的 API。
这意味着我们可以在前端有一个根据 OpenAPI 规范生成的 TypeScript 客户端,对后端 API 进行提取调用。凭据会自动包含在获取调用中,后端可以使用 NextAuth 验证会话。
在 Go 中编写任何类型的测试都非常简单,并且有很多示例,涵盖了测试 HTTP 处理程序的主题。
与 Next.js API 相比,为新的 Go API 端点编写测试也容易得多,特别是因为我们想要测试经过身份验证的状态和真实的数据库调用。我们能够轻松地为 Gin 路由器 编写测试,并使用 Testcontainers 针对我们的 Postgres 数据库启动真正的集成测试。
我们首先为我们的 API 编写 OpenAPI 3.0 规范。 OpenAPI 优先的方法提倡在实施之前设计 API 契约,确保所有利益相关者(开发人员、产品经理和客户)在编写任何代码之前就 API 的行为和结构达成一致。它鼓励仔细规划并产生经过深思熟虑的 API 设计,该设计是一致的并遵守既定的最佳实践。这就是为什么我们选择首先编写规范并从中生成代码,而不是相反的原因。
我选择的工具是API Fiddle,它可以帮助您快速起草和测试 OpenAPI 规范。然而,API Fiddle 仅支持 OpenAPI 3.1(我们无法使用它,因为许多库尚未采用它),因此我们坚持使用 3.0 版本并手动编写规范。
以下是我们 API 规范的示例:
openapi: 3.0.0 info: title: Arcjet Sites API description: A CRUD API to manage sites. version: 1.0.0 servers: - url: <https://api.arcjet.com/v1> description: Base URL for all API operations paths: /teams/{teamId}/sites: get: operationId: GetTeamSites summary: Get a list of sites for a team description: Returns a list of all Sites associated with a given Team. parameters: - name: teamId in: path required: true description: The ID of the team schema: type: string responses: "200": description: A list of sites content: application/json: schema: type: array items: $ref: "#/components/schemas/Site" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: Site: type: object properties: id: type: string description: The ID of the site name: type: string description: The name of the site teamId: type: string description: The ID of the team this site belongs to createdAt: type: string format: date-time description: The timestamp when the site was created updatedAt: type: string format: date-time description: The timestamp when the site was last updated deletedAt: type: string format: date-time nullable: true description: The timestamp when the site was deleted (if applicable) Error: required: - code - message - details properties: code: type: integer format: int32 description: Error code message: type: string description: Error message details: type: string description: Details that can help resolve the issue
OpenAPI 规范就位后,我们使用了 OAPI-codegen,这是一个根据 OpenAPI 规范自动生成 Go 代码的工具。它生成所有必要的类型、处理程序和错误处理结构,使开发过程更加顺利。
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../../api.yaml
输出是一组 Go 文件,一个包含服务器框架,另一个包含处理程序实现。以下是为 Site 对象生成的 Go 类型的示例:
// Site defines model for Site. type Site struct { // CreatedAt The timestamp when the site was created CreatedAt *time.Time `json:"createdAt,omitempty"` // DeletedAt The timestamp when the site was deleted (if applicable) DeletedAt *time.Time `json:"deletedAt"` // Id The ID of the site Id *string `json:"id,omitempty"` // Name The name of the site Name *string `json:"name,omitempty"` // TeamId The ID of the team this site belongs to TeamId *string `json:"teamId,omitempty"` // UpdatedAt The timestamp when the site was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty"` }
使用生成的代码,我们能够实现 API 处理程序逻辑,如下所示:
func (s Server) GetTeamSites(w http.ResponseWriter, r *http.Request, teamId string) { ctx := r.Context() // Check user has permission to access team resources isAllowed, err := s.userIsAllowed(ctx, teamId) if err != nil { slog.ErrorContext( ctx, "failed to check permissions", slogext.Err("err", err), slog.String("teamId", teamId), ) SendInternalServerError(ctx, w) return } if !isAllowed { SendForbidden(ctx, w) return } // Retrieve sites from database sites, err := s.repo.GetSitesForTeam(ctx, teamId) if err != nil { slog.ErrorContext( ctx, "list sites for team query returned an error", slogext.Err("err", err), slog.String("teamId", teamId), ) SendInternalServerError(ctx, w) return } SendOk(ctx, w, sites) }
Drizzle 对于 JS 项目来说是一个很棒的 ORM,我们会再次使用它,但是将数据库代码移出 Next.js 意味着我们需要类似的 Go 代码。
我们选择 GORM 作为我们的 ORM,并使用 存储库模式 来抽象数据库交互。这使我们能够编写干净、可测试的数据库查询。
type ApiRepo interface { GetSitesForTeam(ctx context.Context, teamId string) ([]Site, error) } type apiRepo struct { db *gorm.DB } func (r apiRepo) GetSitesForTeam(ctx context.Context, teamId string) ([]Site, error) { var sites []Site result := r.db.WithContext(ctx).Where("team_id = ?", teamId).Find(&sites) if result.Error != nil { return nil, ErrorNotFound } return sites, nil }
测试对我们来说至关重要。我们希望确保所有数据库调用都得到正确测试,因此我们使用 Testcontainers 为我们的测试启动一个真实的数据库,密切反映我们的生产设置。
openapi: 3.0.0 info: title: Arcjet Sites API description: A CRUD API to manage sites. version: 1.0.0 servers: - url: <https://api.arcjet.com/v1> description: Base URL for all API operations paths: /teams/{teamId}/sites: get: operationId: GetTeamSites summary: Get a list of sites for a team description: Returns a list of all Sites associated with a given Team. parameters: - name: teamId in: path required: true description: The ID of the team schema: type: string responses: "200": description: A list of sites content: application/json: schema: type: array items: $ref: "#/components/schemas/Site" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: Site: type: object properties: id: type: string description: The ID of the site name: type: string description: The name of the site teamId: type: string description: The ID of the team this site belongs to createdAt: type: string format: date-time description: The timestamp when the site was created updatedAt: type: string format: date-time description: The timestamp when the site was last updated deletedAt: type: string format: date-time nullable: true description: The timestamp when the site was deleted (if applicable) Error: required: - code - message - details properties: code: type: integer format: int32 description: Error code message: type: string description: Error message details: type: string description: Details that can help resolve the issue
设置测试环境后,我们像在生产中一样测试了所有 CRUD 操作,确保我们的代码行为正确。
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../../api.yaml
为了测试我们的 API 处理程序,我们使用了 Go 的 httptest 包并使用 Mockery 模拟了数据库交互。这使我们能够专注于测试 API 逻辑,而不必担心数据库问题。
// Site defines model for Site. type Site struct { // CreatedAt The timestamp when the site was created CreatedAt *time.Time `json:"createdAt,omitempty"` // DeletedAt The timestamp when the site was deleted (if applicable) DeletedAt *time.Time `json:"deletedAt"` // Id The ID of the site Id *string `json:"id,omitempty"` // Name The name of the site Name *string `json:"name,omitempty"` // TeamId The ID of the team this site belongs to TeamId *string `json:"teamId,omitempty"` // UpdatedAt The timestamp when the site was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty"` }
我们的 API 经过测试和部署后,我们就把注意力转向了前端。
我们之前的 API 调用是使用 Next.js 推荐的 fetch API 进行的,内置缓存。对于更多动态视图,一些组件在 fetch 之上使用 SWR,因此我们可以获得类型安全、自动重新加载数据获取调用。
为了在前端使用 API,我们使用了 openapi-typescript 库,它根据我们的 OpenAPI 架构生成 TypeScript 类型。这使得我们可以轻松地将后端与前端集成,而无需手动同步数据模型。它内置了 Tanstack Query,它在底层使用 fetch,但也同步到我们的架构。
我们正在逐步将 API 端点迁移到新的 Go 服务器,并在此过程中进行一些小的改进。如果您打开浏览器检查器,您将看到这些新请求发送至 api.arcjet.com
浏览器检查器屏幕截图,显示对新 Go 后端的 API 调用。
那么,我们实现了难以捉摸的 Golden API 了吗?让我们选中该框:
我们更进一步:
最后,我们对结果感到满意。我们的 API 更快、更安全且经过更好的测试。向 Go 的过渡是值得的,随着我们产品的增长,我们现在可以更好地扩展和维护我们的 API。
以上是重新思考我们的 REST API:构建黄金 API的详细内容。更多信息请关注PHP中文网其他相关文章!