用 Deno 构建 GraphQL 服务器:从 Hello World 到 PostgreSQL

原作者:Andy Jiang;原文发表于 2023 年 1 月 11 日,来源:Deno Blog — How to Build a GraphQL Server with Deno。本文按授权翻译整理,原示例与编者修订分别标明。

GraphQL 是构建 API 的常见方式。客户端可以只请求需要的字段,减少多余数据传输,并在适合的数据模型中减少连续等待的请求,从而改善客户端性能。本文将用 Deno 构建一个 GraphQL API 服务器。完整示例在 denoland/deno-graphql。原文也提到,可以通过 npm specifier 使用 Apollo;那是另一种实现选择,本教程使用下面列出的 URL 导入依赖。

GraphQL 请求流:客户端选择字段,经 Deno 的 /graphql 路由进入模式和解析器,再以参数化 SQL 访问 PostgreSQL,返回所选字段。
原创技术示意图:未完纪编辑整理,依据本文接口流程绘制;不是运行截图。

一、先让服务器回答 Hello World

新建 hello_world_server.ts,导入四个依赖:Server 创建 HTTP 服务器;GraphQLHTTP 根据模式创建 HTTP 中间件;makeExecutableSchema 合并类型定义和解析器;gql 用于书写类型定义。

import { Server } from "https://deno.land/std@0.166.0/http/server.ts";
import { GraphQLHTTP } from "https://deno.land/x/gql@1.1.2/mod.ts";
import { makeExecutableSchema } from "https://deno.land/x/graphql_tools@0.0.2/mod.ts";
import { gql } from "https://deno.land/x/graphql_tag@0.0.1/mod.ts";

const typeDefs = gql`
  type Query {
    hello: String
  }
`;

const resolvers = {
  Query: {
    hello: () => `Hello, World!`,
  },
};

const schema = makeExecutableSchema({ resolvers, typeDefs });

const server = new Server({
  handler: async (req) => {
    const { pathname } = new URL(req.url);
    return pathname === "/graphql"
      ? await GraphQLHTTP<Request>({ schema, graphiql: true })(req)
      : new Response("Not Found", { status: 404 });
  },
  port: 3000,
});

server.listenAndServe();

Query 的 hello 字段返回字符串;对应解析器决定这个字段实际得到什么值。查询和变更的行为都由解析器实现。makeExecutableSchema 把类型与实现合成可执行模式,再交给 GraphQLHTTP。

HTTP 处理函数从请求 URL 取出路径,仅在路径严格等于 /graphql 时调用 GraphQL 中间件;其他路径返回 404。graphiql: true 打开 GraphiQL 交互界面。也可以关闭该界面,改用 Postman、Insomnia 或 curl 发送请求。最后一行让服务监听 3000 端口。

deno run --allow-net hello_world_server.ts

--allow-net 授予网络访问权限。按原文运行后,在浏览器访问 http://localhost:3000/graphql,可以在 GraphiQL 中输入:

query {
  hello
}

按上述解析器,预期响应如下。原文响应片段写成了 Hello World!,少了逗号;这里按代码修正了这处展示不一致,并非实测输出。

{
  "data": {
    "hello": "Hello, World!"
  }
}

二、把查询连接到真实数据

GraphQL 的价值不止在返回固定字符串。接下来扩展示例,让客户端可以查询数据、挑选返回字段,并往数据库中插入记录。原文选择 PostgreSQL,原文推荐的数据库搭建资料是其 Postgres 连接教程(该旧链接在本次核对时返回 404);示例使用仓库中的 dinosaurs.json 恐龙数据。表需要能够存储 name 和 description。原文没有在本页给出建表与导入命令,因此这里也不把未核验的建库脚本当作原文步骤。

除了新增用于写入的 mutation,整体结构与 Hello World 相同。为了让文件更清晰,先把类型定义与 gql 导入移到 typedefs.ts:

import { gql } from "https://deno.land/x/graphql_tag@0.0.1/mod.ts";

export const typeDefs = gql`
  type Query {
    allDinosaurs: [Dinosaur]
    oneDinosaur(name: String): Dinosaur
  }

  type Dinosaur {
    name: String
    description: String
  }

  type Mutation {
    addDinosaur(name: String, description: String): Dinosaur
  }
`;

allDinosaurs 返回恐龙列表,方括号表示列表类型;oneDinosaur 按名称返回一只恐龙。Dinosaur 包含名称和描述两个字符串字段。addDinosaur 是添加记录的变更。GraphQL 名称区分大小写,原文解释段落里的 OneDinosaur 应以代码中的 oneDinosaur 为准。没有感叹号的字段与参数可以为空,这是此示例的模式设计,不等于所有业务都应该接受空值。

三、拆出解析器与数据库操作

新建 resolvers.ts,把查询、变更和 PostgreSQL 连接放在这里。下面保留原文的完整逻辑,便于对照其思路;紧接着说明静态审核发现的问题。

import * as postgres from "https://deno.land/x/postgres@v0.14.2/mod.ts";

const connect = async () => {
  const databaseUrl = Deno.env.get("DATABASE_URL")!;
  const pool = new postgres.Pool(databaseUrl, 3, true);
  const connection = await pool.connect();
  return connection;
};

const allDinosaurs = async () => {
  const connection = await connect();
  const result = await connection.queryObject`
    SELECT name, description FROM dinosaurs
  `;
  return result.rows;
};

const oneDinosaur = async (args: any) => {
  const connection = await connect();
  const result = await connection.queryObject`
    SELECT name, description FROM dinosaurs WHERE name = ${args.name}
  `;
  return result.rows;
};

const addDinosaur = async (args: any) => {
  const connection = await connect();
  const result = await connection.queryObject`
    INSERT INTO dinosaurs(name, description)
    VALUES(${args.name}, ${args.description})
    RETURNING name, description
  `;
  return result.rows[0];
};

export const resolvers = {
  Query: {
    allDinosaurs: () => allDinosaurs(),
    oneDinosaur: (_: any, args: any) => oneDinosaur(args),
  },
  Mutation: {
    addDinosaur: (_: any, args: any) => addDinosaur(args),
  },
};

Deno.env.get("DATABASE_URL") 从环境变量读取 PostgreSQL 连接串。原例用 new postgres.Pool(databaseUrl, 3, true) 建立最多三个连接的惰性连接池,再取得一个连接。访问环境变量时需要另授予 --allow-env。

allDinosaurs 查询表中的名称和描述;oneDinosaur 接收名称参数并加入 WHERE 条件;addDinosaur 插入名称和描述,通过 RETURNING 返回刚插入的记录。解析器再把 GraphQL 参数转交给这些函数。这里的 SQL 是驱动提供的带标签模板,插值作为查询参数处理,不是手工拼接 SQL 文本;不要为了“简化”而改成字符串拼接。

以下是与原例明确区分的修订骨架:在模块级共享连接池,启动时检查配置,并在 finally 中归还连接。单条查询改为返回第一条记录或 null。其他两个操作同样需要在 finally 中调用 release()。这段只经过静态检查,未验证旧依赖与当前运行时的组合。

const databaseUrl = Deno.env.get("DATABASE_URL");
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const pool = new postgres.Pool(databaseUrl, 3, true);

type Dinosaur = { name: string; description: string };

const oneDinosaur = async (args: { name?: string }) => {
  if (typeof args.name !== "string" || args.name.length === 0) {
    throw new Error("A non-empty name is required");
  }
  const connection = await pool.connect();
  try {
    const result = await connection.queryObject<Dinosaur>`
      SELECT name, description FROM dinosaurs WHERE name = ${args.name}
    `;
    return result.rows[0] ?? null;
  } finally {
    connection.release();
  }
};

如果名称在业务上应当唯一,需要在数据库层建立相应约束;否则“取第一条”不能定义稳定的业务含义。添加记录也应校验字段长度和必填条件。服务退出时还需要处理连接池关闭,这超出了原文的演示范围。

四、组装服务器并发送查询与变更

将主文件改名为 server.ts。类型和解析器移出去之后,主文件只需改变导入,其他结构仍然一样:

import { Server } from "https://deno.land/std@0.166.0/http/server.ts";
import { GraphQLHTTP } from "https://deno.land/x/gql@1.1.2/mod.ts";
import { makeExecutableSchema } from "https://deno.land/x/graphql_tools@0.0.2/mod.ts";
import { resolvers } from "./resolvers.ts";
import { typeDefs } from "./typedefs.ts";

const schema = makeExecutableSchema({ resolvers, typeDefs });
const server = new Server({
  handler: async (req) => {
    const { pathname } = new URL(req.url);
    return pathname === "/graphql"
      ? await GraphQLHTTP<Request>({ schema, graphiql: true })(req)
      : new Response("Not Found", { status: 404 });
  },
  port: 3000,
});
server.listenAndServe();
deno run --allow-net --allow-env server.ts

重新访问 http://localhost:3000/graphql,按原文过程,GraphiQL 的自动补全会出现新增查询与变更。以下查询请求所有恐龙的名称和描述:

query {
  allDinosaurs {
    name
    description
  }
}

GraphQL 的客户端可以选择需要的字段。只需名称时,删掉 description:

query {
  allDinosaurs {
    name
  }
}

要查询 Aardonyx,则把名称传给 oneDinosaur。原例的单条返回值问题需要先按上文修正:

query {
  oneDinosaur(name: "Aardonyx") {
    name
    description
  }
}

添加一只名为 Deno 的恐龙,可以执行以下变更,同时要求 API 返回插入后的字段:

mutation {
  addDinosaur(name: "Deno", description: "the fastest Deno in the world") {
    name
    description
  }
}

原文接着再次调用 oneDinosaur(name: "Deno") 查看记录,作为交互演示中的核对步骤。本文没有实际执行该写入,也不把原文截图或以上预期行为视为本次测试结果。

五、继续扩展之前要明确的边界

这个服务器展示了 Deno 中 GraphQL 的基本实现,还可以增加更多类型、查询与数据关系。例如原来的每只恐龙互相独立,加入 Clade(演化支)类型后,就能表达 Aardonyx、Seitaad 和 Sefapanosaurus 的关系。也可以为这些数据建立前端。原文推荐其 Shopify GraphQL 商店案例 How to Build an E-commerce Site with a Perfect Lighthouse Score(原链接当前显示 404),并邀请读者到 Deno 社区交流。

在对外提供服务前,需要为查询与变更增加身份认证、授权、限流和查询复杂度限制。原示例的 mutation 可以写数据库,不能未经保护地暴露公网;GraphiQL 也是调试入口。原命令的网络和环境权限范围较大,应按实际监听地址、数据库端点与变量名缩小权限。连接串应由部署环境注入,不应硬编码进源码或提交仓库。TLS、数据库账户的最小权限、分页和错误信息处理也需要另外设计。

原文网页版权归 Deno Land Inc.;代码的具体授权以关联仓库的许可文件为准,网页版权声明不自动等同于代码许可证。原创示意图与审核注释由未完纪编辑整理。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容