首页 > 后端开发 > Golang > 重新思考我们的 REST API:构建黄金 API

重新思考我们的 REST API:构建黄金 API

Susan Sarandon
发布: 2024-12-06 22:05:23
原创
227 人浏览过

Rethinking our REST API: Building the Golden API

在某个时刻,每家公司都会到达一个十字路口,他们需要停下来重新评估他们一直在使用的工具。对于我们来说,那一刻到来了,我们意识到为 Web 仪表板提供支持的 API 变得难以管理、难以测试,并且不符合我们为代码库设定的标准。

Arcjet 主要是一个安全即代码 SDK,可帮助开发人员实现机器人检测、电子邮件验证和 PII 检测等安全功能。这与我们的高性能、低延迟决策 gRPC API 进行通信。

我们的网络仪表板使用单独的 REST API 主要用于管理站点连接和审查已处理的请求分析,但这还包括注册新用户和管理他们的帐户,这意味着它仍然是产品的重要组成部分。

Rethinking our REST API: Building the Golden API
Arcjet 仪表板的请求分析页面的屏幕截图。

因此,我们决定接受从头开始重建 API 的挑战,这次重点关注可维护性、性能和可扩展性。然而,我们不想开始一个巨大的重写项目 - 永远不会成功 - 相反,我们决定构建一个新的基础,然后从单个 API 端点开始。

在这篇文章中,我将讨论我们如何解决这个问题。

之前在 Arcjet 上

当速度是我们的首要任务时,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 时,大多数人都同意某些跨技术堆栈有效的概念。

1. 性能和可扩展性

我们已经拥有交付高性能 API 的经验。我们的 Decision API 部署在靠近客户的位置,使用 Kubernetes 进行动态扩展,并针对低延迟响应进行了优化.

我们考虑了无服务器环境和其他提供商,但由于我们现有的 k8s 集群已经在运行,因此重用现有的基础设施是最有意义的:通过 Octopus Deploy 进行部署,通过 Grafana Jaeger、Loki、Prometheus 等进行监控

经过短暂的内部 Rust 与 Go 对比后,我们选择了 Go,因为它的简单性、速度以及它实现其最初目标 对构建可扩展网络服务的出色支持的效果。我们也已经将它用于 Decision API 并了解如何操作 Go API,这为我们最终确定了决策。

2. 全面、清晰的文档

由于 Go 的简单性和强大工具的可用性,将后端 API 切换到 Go 非常简单。但有一个问题:我们保留了 Next.js 前端,并且不想手动编写 TypeScript 类型或为我们的新 API 维护单独的文档。

引入 OpenAPI——它非常适合我们的需求。 OpenAPI 允许我们定义前端和后端之间的契约,同时也充当我们的文档。这解决了维护应用程序两侧架构的问题。

3. 安全与认证

在 Go 中集成身份验证并不太困难,这要归功于 NextAuth 在后端的模仿相对简单。 NextAuth(现在的 Auth.js)具有可用于验证会话的 API。

这意味着我们可以在前端有一个根据 OpenAPI 规范生成的 TypeScript 客户端,对后端 API 进行提取调用。凭据会自动包含在获取调用中,后端可以使用 NextAuth 验证会话。

4. 可测试性

在 Go 中编写任何类型的测试都非常简单,并且有很多示例,涵盖了测试 HTTP 处理程序的主题。

与 Next.js API 相比,为新的 Go API 端点编写测试也容易得多,特别是因为我们想要测试经过身份验证的状态和真实的数据库调用。我们能够轻松地为 Gin 路由器 编写测试,并使用 Testcontainers 针对我们的 Postgres 数据库启动真正的集成测试。

把它们放在一起

编写 OpenAPI 规范

我们首先为我们的 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)
}
登录后复制

数据库:远离 DrizzleORM

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"`
}
登录后复制
登录后复制

前端消耗:OpenAPI 驱动的前端

我们的 API 经过测试和部署后,我们就把注意力转向了前端。

我们之前的 API 调用是使用 Next.js 推荐的 fetch API 进行的,内置缓存。对于更多动态视图,一些组件在 fetch 之上使用 SWR,因此我们可以获得类型安全、自动重新加载数据获取调用。

为了在前端使用 API,我们使用了 openapi-typescript 库,它根据我们的 OpenAPI 架构生成 TypeScript 类型。这使得我们可以轻松地将后端与前端集成,而无需手动同步数据模型。它内置了 Tanstack Query,它在底层使用 fetch,但也同步到我们的架构。

我们正在逐步将 API 端点迁移到新的 Go 服务器,并在此过程中进行一些小的改进。如果您打开浏览器检查器,您将看到这些新请求发送至 api.arcjet.com

Rethinking our REST API: Building the Golden API
浏览器检查器屏幕截图,显示对新 Go 后端的 API 调用。

黄金清单

那么,我们实现了难以捉摸的 Golden API 了吗?让我们选中该框:

  • 性能和可扩展性 – API 部署在我们现有的 k8s 集群上,这些集群已经针对性能进行了调整。我们有详细的指标,并可以根据需要进行扩展。
  • 全面而清晰的文档 – OpenAPI 规范提供了单一的事实来源,因为代码是从它生成的,而不是相反。使用生成的客户端意味着我们的团队可以轻松使用 API。
  • 安全和身份验证 – 我们已经在生产中部署了 Go,因此我们可以复制我们的安全实践。身份验证由 NextAuth 处理。
  • 可测试性 – 我们使用 Testcontainers 实现了处理程序的单元测试和集成测试,这是对我们的 Next.js API 的重大改进。

我们更进一步:

  • 监控 – 部署到现有的 k8s 集群意味着我们继承了已经设置的跟踪、日志记录和指标功能。向 Gin 添加 OpenTelemetry 仪器非常简单。
  • 简单性 – Go 最初是为 API 设计的,这一点也体现出来了。我们的代码更干净、更易于维护。

最后,我们对结果感到满意。我们的 API 更快、更安全且经过更好的测试。向 Go 的过渡是值得的,随着我们产品的增长,我们现在可以更好地扩展和维护我们的 API。

以上是重新思考我们的 REST API:构建黄金 API的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板