ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Deno + YugabyteDB:从零构建现代化API的完整实操

Deno + YugabyteDB:从零构建现代化API的完整实操 1. 为什么用 Deno YugabyteDB 这套组合来现代化 API先聊一个很现实的问题很多团队说“现代化”其实就是把 Node.js 换成了 Deno把单体数据库换成了分布式数据库。实际上如果只做表面替换不解决核心矛盾那迁移完照样得天天加班。我之所以推荐 Deno YugabyteDB 这套组合核心就一句话Deno 解决的是运行时和工程体验的问题YugabyteDB 解决的是数据层的扩展与容错问题两者合在一起API 的开发和运维模型都会变得简单很多。先说 Deno。JavaScript 生态在服务端这么多年Node.js 功不可没但它的历史包袱也越来越重node_modules动辄几个 G、模块解析粒度太细、权限模型基本是摆设任何一个包都能读你磁盘、package.json配置冗余。Deno 相当于用 Rust 重写了整个运行时并且从设计层面就把这些问题打掉了。它原生支持 TypeScript开箱即用不需要tsconfig.json和ts-node那堆东西模块通过 URL 直接导入不依赖node_modules启动时通过--allow-net、--allow-read这类白名单权限明确授予能力API 进程就算被人打了洞也要不到你的文件系统权限。再说 YugabyteDB。我们用 PostgreSQL 协议跑分布式数据库这在 API 现代化里是极大的红利。很多后端团队不敢换数据库是因为怕 SQL 语义不一致、怕迁移复杂。YugabyteDB 的 YSQL 接口在绝大多数场景下是 PostgreSQL 兼容的你现有基于 PG 的 ORM、驱动、SQL 语句基本都能直接搬过去。它底层是分布式架构多节点数据自动分片、复制、故障自动切换不再需要手动做主从切换或者依赖外部中间件。而这些能力对于一个要快速发展的 API 服务来说恰恰是 Node.js 生态里最难搞的那部分。所以这套组合最适合谁我是这么看的如果你正在维护一个传统 Node.js PostgreSQL 单体 API想在不重写业务代码的前提下把运行时和数据库都升级一版或者你是一个小团队要从零做一个可能快速增长的 SaaS 产品 API不想一开始就背上 Kubernetes 和微服务的运维成本——那 Deno YugabyteDB 就是一条非常务实的路径。下面是完整的实操过程从搭环境到跑通 CRUD每一步我都会讲清楚为什么这么做。2. 项目初始化与数据库环境准备2.1 安装 Deno 并启动 YugabyteDBDeno 的安装方式各平台不一样macOS可以用 HomebrewLinux 可以用官方安装脚本Windows 直接下载可执行文件就行。我这边用 macOS 举例跑两条命令brew install deno deno --version执行完你会在终端看到类似deno 1.38.0的版本号。这里有个细节Deno 的版本迭代非常快API 偶尔会有变化建议先关注大版本不要追最新等社区验证过再升级。比如 OakDeno 的 Web 框架在 12.x 版本之后 API 比较稳定我下面的代码基于oak12.6.2写你如果装的是 13 或 14个别地方可能需要微调。YugabyteDB 这一步稍微复杂一点。最简单的方案是用 Docker 起单节点docker run -d --name yugabyte \ -p 7000:7000 -p 9000:9000 -p 5433:5433 \ yugabytedb/yugabyte:2.19.2.0 \ bin/yugabyted start --daemonfalse注意端口号5433是 YSQL 接口默认端口不是 PostgreSQL 常用的 5432。如果你用习惯的 psql 或者数据库客户端连接填 5433 才会通。另外7000是 web 管理页面YugabyteDB Anywhere 的 UI9000是 YCQL 接口Cassandra 兼容不过我们全程只走 YSQL用不到 9000。等容器启动完成后可以在宿主机上用psql连接验证psql -h 127.0.0.1 -p 5433 -U yugabyte -d yugabyte默认用户名是yugabyte密码默认也是yugabyte数据库名yugabyte。连接成功之后建一个独立数据库给我们这个项目用CREATE DATABASE deno_yb;2.2 设计数据模型从 API 资源到表结构有了数据库环境我先设计数据模型。这里我选了一个最常见的例子文章管理 API。它有创建、查询列表、查详情、更新、删除五个端点基本覆盖了 CRUD 的典型路径而且数据之间有排序、过滤需求能顺便演示 YugabyteDB 对数组类型和全文查询的支持。表结构设计如下-- 在 deno_yb 数据库中执行 CREATE TABLE posts ( id UUID PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, tags TEXT[] NOT NULL DEFAULT {}, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_posts_created_at ON posts (created_at DESC);这个表有几个地方值得展开说说。id字段我用 UUID 而不是自增整数这是分布式数据库里的一个重要考量。自增整数在单机 PostgreSQL 里没有问题但在分布式架构下多个节点同时生成自增 ID 容易产生冲突或者需要全局协调器反而成为性能瓶颈。UUID 由客户端也就是我们的 Deno 进程用crypto.randomUUID()生成天然跨节点唯一不需要数据库做任何协同。tags字段用了 PostgreSQL 的数组类型TEXT[]。这个在实际业务中非常实用比如给文章打标签传统做法可能会拆一张post_tags关联表但如果你只是做简单的标签过滤数组类型加操作符就够了查询和写入都轻量很多。时间字段用TIMESTAMPTZ带时区的时间戳。这是一个很多新手容易踩坑的地方TIMESTAMP不带时区信息存进去是什么就返回什么TIMESTAMPTZ是以 UTC 存储的查询时会被转成客户端的时区。API 要面向全球用户时间字段一定要用带时区的类型否则跨时区访问时会出现诡异的 8 小时偏差。3. 用 Deno 构建 CRUD API 的完整实操3.1 项目目录结构与依赖管理Deno 的项目结构比 Node.js 干净很多不需要node_modules也允许你把依赖集中到一个deps.ts文件里统一管理。这个习惯强烈建议一上来就养成因为 Deno 的模块通过 URL 导入如果你的代码里到处都是远程 URL将来升级依赖版本会非常痛苦而且每次拉取也有网络开销。我的项目目录长这样deno-yb-api/ ├── deps.ts ├── db.ts ├── models/ │ └── post.ts ├── handlers/ │ └── postHandler.ts ├── server.ts └── test/ └── api_test.tsdeps.ts的核心就是集中对外暴露框架和数据库驱动// deps.ts export { Application, Router, Status } from https://deno.land/x/oak12.6.2/mod.ts; export type { Context, RouterContext } from https://deno.land/x/oak12.6.2/mod.ts; export { Client } from https://deno.land/x/postgresv0.17.0/mod.ts;用 Oak 而不是 Deno 原生serve理由很简单Oak 提供了中间件、路由、上下文、错误处理这些 Web 框架该有的能力代码组织起来更规整。deno-postgres是社区最常用的 PostgreSQL 驱动和 Node.js 生态中的pg使用体验几乎一致而且它底层用的是 Rust 写的tokio-postgres性能表现不错。你应该注意到了这里故意没有用package.json锁版本之类的概念——Deno 直接锁定 URL 里的版本号就够了。如果后续要升级只需要改deps.ts这一个文件。3.2 数据库连接和直连说再见把配置交给环境变量数据库连接是最容易出问题的一层我建议从一开始就做一个独立的连接模块。先看代码// db.ts import { Client } from ./deps.ts; const connection { hostname: Deno.env.get(DB_HOST) || 127.0.0.1, port: Number(Deno.env.get(DB_PORT) || 5433), database: Deno.env.get(DB_NAME) || deno_yb, user: Deno.env.get(DB_USER) || yugabyte, password: Deno.env.get(DB_PASSWORD) || yugabyte, }; const client new Client(connection); export async function connectDB() { await client.connect(); console.log(Connected to YugabyteDB at ${connection.hostname}:${connection.port}); } export function getClient() { return client; }这里我用了Deno.env来读取环境变量好处是部署到云上时不需要改代码只要在容器或编排平台里配置对应的环境变量就行。Deno 默认不会让进程访问环境变量所以启动时务必带上--allow-env权限否则运行时会报PermissionDenied。连接参数里端口默认写的 5433这和我们前面说的一致YugabyteDB 的 YSQL 端口默认不是 5432。如果你本地是用 Docker 映射了端口那么连本机的 5433 就能通。如果你连的是远程的 YugabyteDB 集群还需要注意sslmode这种参数——deno-postgres底层是支持 TLS 的只需在连接配置里加一个ssl: true选项即可。单独抽一个db.ts模块还有一个好处将来如果想把Client换成连接池只需要改这一个文件。YugabyteDB 单连接在开发环境够用但高并发下必须用连接池。deno-postgres也提供了Pool类用法也差不多等会儿在常见问题里我再展开。3.3 路由与处理器这样组织代码改起来才不心累Web API 最常见的组织方式是把路由、处理器、数据访问拆开。我先定义路由再写处理器最后写数据访问函数。// server.ts import { Application } from ./deps.ts; import { router } from ./handlers/postHandler.ts; import { connectDB } from ./db.ts; const app new Application(); // 中间件JSON 解析 app.use(async (ctx, next) { try { await next(); } catch (err) { ctx.response.status err.status || 500; ctx.response.body { error: err.message || Internal Server Error, }; } }); app.use(router.routes()); app.use(router.allowedMethods()); await connectDB(); await app.listen({ port: 8000 }); console.log(API listening on http://localhost:8000);路由模块我是这样注册的// handlers/postHandler.ts import { Router } from ../deps.ts; import { createPost, getPost, updatePost, deletePost, listPosts, } from ../models/post.ts; export const router new Router(); router .get(/api/posts, listPosts) .get(/api/posts/:id, getPost) .post(/api/posts, createPost) .put(/api/posts/:id, updatePost) .delete(/api/posts/:id, deletePost);在写处理器之前我想强调一个组织原则路由只做“请求分发”处理器只做“HTTP 相关的事”真正的数据访问逻辑一定要下沉到 model 层。这样做的好处是你的 SQL 语句不会散落在路由文件里将来做测试、换库、加缓存都方便。对应模型层// models/post.ts import { getClient } from ../db.ts; export interface Post { id: string; title: string; content: string; tags: string[]; created_at: Date; updated_at: Date; }3.4 处理器实现CRUD 中的关键代码与查询细节查询列表接口我加上了分页和标签过滤。这里要特别强调 SQL 参数化的正确方式import type { RouterContext } from ../deps.ts; export async function listPosts(ctx: RouterContext/api/posts) { const url new URL(ctx.request.url); const tag url.searchParams.get(tag) || ; const page Number(url.searchParams.get(page) || 1); const pageSize Number(url.searchParams.get(pageSize) || 20); const offset (page - 1) * pageSize; const client getClient(); const result await client.queryObjectPost( SELECT id, title, content, tags, created_at, updated_at FROM posts WHERE ($1 ) OR ($1 ANY(tags)) ORDER BY created_at DESC LIMIT $2 OFFSET $3, tag, pageSize, offset, ); ctx.response.body { data: result.rows, page, pageSize, total: result.rows.length, }; }这里我把LIMIT和OFFSET都作为参数传入而不是直接拼接字符串这是防 SQL 注入的最基本要求。deno-postgres的参数占位符和 Node 的pg一样都是$1、$2。还有一个值得注意的细节$1 可以直接处理“没有 tag 参数”的情况如果是空字符串就不过滤省得写两套 SQL。再看创建接口。因为应用端生成 UUID所以INSERT语句看起来特别简单export async function createPost(ctx: RouterContext/api/posts) { const body ctx.request.body; if (body.type ! json) { ctx.response.status 400; ctx.response.body { error: Content-Type must be application/json }; return; } const { title, content, tags [] } await body.value; if (!title || !content) { ctx.response.status 400; ctx.response.body { error: title and content are required }; return; } const id crypto.randomUUID(); const client getClient(); const result await client.queryObjectPost( INSERT INTO posts (id, title, content, tags) VALUES ($1, $2, $3, $4) RETURNING id, title, content, tags, created_at, updated_at, id, title, content, tags, ); ctx.response.status 201; ctx.response.body { data: result.rows[0] }; }有一个细节body.value是一个 Promise它解析出来的对象属性名取决于请求体里 JSON 的字段名。如果你希望前后端用camelCase比如createdAt那这里拿到的是createdAt而不是created_at需要做一个映射转换。为了演示简单我这个接口直接用的snake_case但你心里要有这根弦真实项目里该转换就转换。crypto.randomUUID()是 Deno 全局自带的方法不需要额外 import 任何第三方库。这一点在 Node.js 里其实也原生支持但对 Deno 来说这个能力默认就存在非常顺手。更新和删除接口类似更新接口我只展示关键片段export async function updatePost(ctx: RouterContext/api/posts/:id) { const { id } ctx.params; const body await ctx.request.body().value; const result await getClient().queryObjectPost( UPDATE posts SET title COALESCE($2, title), content COALESCE($3, content), tags COALESCE($4, tags), updated_at now() WHERE id $1 RETURNING id, title, content, tags, created_at, updated_at, id, body.title ?? null, body.content ?? null, body.tags ?? null, ); if (result.rows.length 0) { ctx.response.status 404; ctx.response.body { error: Post not found }; return; } ctx.response.body { data: result.rows[0] }; }COALESCE($2, title)是一个很有用的技巧如果请求体里没有传某个字段就用数据库里已有的值不用先查一遍再回填。这样既保证了部分更新的能力又少了一次查询。注意body.title ?? null这能区分“字段没传”和“字段传了 null”两种场景。3.5 启动服务并用 curl 验证完整流程现在所有代码就位启动命令比 Node.js 稍微多几个权限参数deno run --allow-net --allow-env --allow-read server.ts--allow-net允许访问网络包括监听 8000 端口和连接 YugabyteDB--allow-env允许读取环境变量--allow-read允许读取当前项目里的文件有些场景不需要但某些依赖会读本地证书加上保险启动成功后终端会输出Connected to YugabyteDB at 127.0.0.1:5433和API listening on http://localhost:8000。接下来用 curl 验证完整的 CRUD 流程。创建一个资源curl -X POST http://localhost:8000/api/posts \ -H Content-Type: application/json \ -d {title:Hello Deno,content:My first API with Deno and YugabyteDB,tags:[deno,database]}预期返回一个 JSON里面带上了id字段。拿到这个 ID再测试查详情、更新、删除、列表这些接口curl http://localhost:8000/api/posts/返回的id curl http://localhost:8000/api/posts?tagdeno curl -X PUT http://localhost:8000/api/posts/返回的id \ -H Content-Type: application/json \ -d {title:Updated Title} curl -X DELETE http://localhost:8000/api/posts/返回的id实测下来整个过程耗时非常短单机模式下 YugabyteDB 的响应延迟和普通 PostgreSQL 几乎一致。原因也很简单数据在本地节点缓存了分布式复制是异步发生在后台的。4. 安全加固、性能调优和自动化测试4.1 输入校验绝不能省上面的示例代码里我做了最基础的title和content非空校验但在真实项目里这远远不够。至少还要考虑tags是不是数组如果客户端传了个字符串数据库那边会直接报错title和content的长度限制防止恶意超长数据打爆数据库page和pageSize的范围约束不能允许pageSize1000000这种值Deno 生态里有一个很成熟的校验库叫zod通过 URL 导入后可以定义 schemaimport { z } from https://deno.land/x/zodv3.22.4/mod.ts; const createPostSchema z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(10000), tags: z.array(z.string()).max(20).optional(), });我更推荐这种 schema 驱动的方式不仅校验逻辑清晰而且可以把同一个 schema 用在 OpenAPI 文档生成上前后端契约直接对齐。注意zod也支持类型推导配合 TypeScript 非常好用。4.2 连接池与索引性能问题的两大杠杆前面代码里用的是单Client开发环境下没问题但一旦上线并发请求一上来就必然出现“连接获取超时”或者“Cannot acquire a connection”这类错误。原因很简单单连接同一时间只能处理一个查询。改成连接池是第一步。deno-postgres的Pool用法如下import { Pool } from ./deps.ts; const pool new Pool({ hostname: 127.0.0.1, port: 5433, database: deno_yb, user: yugabyte, password: yugabyte, }, 10);连接池大小10是一个相对保守的值。你不需要上来就设 100连接池过大反而会撑爆数据库端的连接数。一般实时估算如果你的 API 平均响应时间是 50ms单连接每秒最多处理 20 个请求10 个连接能扛到每秒 200 个请求对大多数业务场景已经足够。性能问题的第二个杠杆是数据库索引。拿我们的列表接口举例ORDER BY created_at DESC在有数据量之后必须依赖索引所以我建表时就已经执行了CREATE INDEX idx_posts_created_at ON posts (created_at DESC);。如果你在生产环境发现列表查询越来越慢第一件事不是加机器而是用EXPLAIN ANALYZE看一下执行计划看有没有走全表扫描。YugabyteDB 还有一点和单机 PG 不一样分布式的执行计划在某些情况下会更复杂尤其是表特别大时索引的选择和分区键的分布会直接影响延迟。不过对于中小规模的数据绝大多数场景下它表现得和单机 PG 一致不需要过度优化。4.3 用 Deno 内置测试框架覆盖核心路径Deno 的标准测试框架不需要额外安装写起来也有天然的清爽感。我们在test/api_test.ts里写一个针对数据访问层的测试import { assert, assertEquals } from https://deno.land/std0.200.0/testing/asserts.ts; import { createPost, getPost, deletePost } from ../models/post.ts; Deno.test(create and delete a post, async () { const result await createPost({ title: Test Title, content: Test Content, tags: [test], }); assertEquals(result.title, Test Title); assert(result.id.length 0); const fetched await getPost(result.id); assertEquals(fetched?.content, Test Content); await deletePost(result.id); });然后运行deno test --allow-net --allow-env --allow-read注意测试也需要显式权限因为测试代码里连了数据库。如果你希望测试跑得更快可以在本地用内存数据库或者 mock 数据访问层。但对于刚起步的项目我更推荐直接连真实的 YugabyteDB 测试这样才能真正覆盖 SQL 写法的问题。5. 常见问题与排查技巧实录5.1 连接失败5433 端口为什么连不上这是初学者最容易踩的坑。很多人用习惯 PostgreSQL 之后默认就是 5432结果连 YugabyteDB 报Connection refused。先在宿主机上验证端口是否监听nc -zv 127.0.0.1 5433如果是 Docker 部署还要确认端口映射的是5433:5433而不是5433:5432。另外一个问题是服务启动顺序YugabyteDB 容器有时启动较慢如果 API 进程先启动了连接失败后不会自动重试。我在connectDB()里加一个简单的重试逻辑更稳妥这个你可以根据实际情况调整。5.2 Deno 权限报错PermissionDenied 怎么解决error: Uncaught PermissionDenied: network access to 127.0.0.1:5433, run again with the --allow-net flag这个报错其实体现了 Deno 的安全设计它默认跑在沙箱里任何网络访问都要显式授权。解决办法就是启动时带上权限参数。但也有个细节值得注意如果你要访问外部 API比如云上的数据库--allow-net得写域名或 IP 白名单比如--allow-net127.0.0.1:5433,db.example.com:5433这样可以缩小权限面。5.3 数组类型和 JSON 类型在驱动里映射不一致YugabyteDB 返回的tags在deno-postgres里默认可能是 JSON 字符串而不是数组。我在开发中就遇到过这个问题查询结果里tags字段返回的是[deno]的字符串表示不是真正的数组。解决办法是在查询时类型转换或者在应用层做额外解析SELECT id, title, content, array_to_json(tags) AS tags FROM posts;这样tags就会以 JSON 数组的文本形式返回应用端再JSON.parse一下即可。如果你的接口上层还要直接对接前端这个细节会影响你是否需要额外的序列化处理。5.4 连不上云上的 YugabyteDBTLS 证书报错上生产环境连托管 YugabyteDB 时通常都会要求启用 TLS。deno-postgres的配置里加一项ssl: true就行。如果不想验证服务端证书开发环境可以加sslmode: disable但生产环境务必完整校验证书。Deno 的权限标志还要加上--allow-read/path/to/ca.pem否则读取证书文件也会被拦。5.5 类型定义与 SQL 结果集对不上TypeScript 的静态类型在编译期管不到 SQL 运行时的结果。比如查询返回的created_at可能是字符串而不是Date取决于驱动的解析逻辑。我用queryObjectPost其实就是让驱动做了一层行到对象的映射但里面的日期字段你得自己确认。如果发现类型不匹配最直接的处理方式是在应用层做映射把 ISO 字符串转成Date或者保持字符串直接返回给前端让前端自己格式化。5.6 热更新与开发体验的小建议Deno 有一个你一定会爱上的功能deno run --watch。只要代码文件有变动进程会自动重启不用像 Node.js 那样装nodemondeno run --watch --allow-net --allow-env --allow-read server.ts不过有个坑--watch重启后数据库连接也会断开重连如果这个时候有正在进行的请求会被中断。开发环境无所谓生产环境可不要用--watch。最后分享一点实际感受这个组合我用了几个项目后最大的感受是 Deno 把“工程体验”这一层做得非常舒服。不需要配构建工具不需要折腾ts-node写 TypeScript 就是写原生几乎没有依赖地狱。YugabyteDB 则把数据库这层最让人焦虑的扩展问题提前解决了业务量上来之后最先需要横向扩展的通常是数据库而它的分布式架构天然支持这个方向。不能说这套组合适合所有场景但中小团队、快速迭代的 API 服务它真的可以帮你省下很多运维层面的烦恼。如果你也想试试就从今天这个 demo 开始把 Node.js 换成 Deno把单机数据库换成 YugabyteDB跑通的那一刻你会体验到一种“现代化”的真实速递。
返回列表