了不起的 Deno 实战教程

一、Oak 简介

相信接触过 Node.js 的读者对 Express、Hapi、Koa 这些 Web 应用开发框架都不会陌生,在 Deno 平台中如果你也想做 Web 应用开发,可以考虑直接使用以下现成的框架:

  • deno-drash:A REST microframework for Deno with zero dependencies。
  • deno-express:Node Express way for Deno。
  • oak:A middleware framework for Deno’s net server :sauropod:
  • pogo:Server framework for Deno。
  • servest:ear_of_rice:A progressive http server for Deno​:ear_of_rice:

写作本文时,目前 Star 数最高的项目是 Oak,加上我的一个 Star,刚好 720。 下面我们来简单介绍一下 Oak

A middleware framework for Deno’s http server, including a router middleware.

This middleware framework is inspired by Koa and middleware router inspired by koa-router.

很显然 Oak 的的灵感来自于 Koa,而路由中间件的灵感来源于 koa-router 这个库。如果你以前使用过 Koa 的话,相信你会很容易上手 Oak。不信的话,我们来看个示例:

import { Application } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

app.use((ctx) => {
  ctx.response.body = "Hello Semlinker!";
});

await app.listen({ port: 8000 });

以上示例对于每个 HTTP 请求,都会响应 “Hello Semlinker!”。只有一个中间件是不是感觉太 easy 了,下面我们来看一个更复杂的示例(使用多个中间件):

import { Application } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

// Logger
app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.headers.get("X-Response-Time");
  console.log(`${ctx.request.method} ${ctx.request.url} - ${rt}`);
});

// Timing
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.response.headers.set("X-Response-Time", `${ms}ms`);
});

// Hello World!
app.use((ctx) => {
  ctx.response.body = "Hello World!";
});

await app.listen({ port: 8000 });

为了更好地理解 Oak 中间件流程控制,我们来一起回顾一下 Koa 大名鼎鼎的 “洋葱模型”:

koa-onion-model

从 “洋葱模型” 示例图中我们可以很清晰的看到一个请求从外到里一层一层的经过中间件,响应时从里到外一层一层的经过中间件。上述代码成功运行后,我们打开浏览器,然后访问 http://localhost:8000/ URL 地址,之后在控制台会输出以下结果:

➜  learn-deno deno run --allow-net oak/oak-middlewares-demo.ts
GET http://localhost:8000/ - 0ms
GET http://localhost:8000/favicon.ico - 0ms

好了,介绍完 Oak 的基本使用,接下来我们开始进入正题,即使用 Oak 开发 REST API。

二、Oak 实战

本章节我们将介绍如何使用 Oak 来开发一个 Todo REST API,它支持以下功能:

  • 添加新的 Todo
  • 显示 Todo 列表
  • 获取指定 Todo 的详情
  • 移除指定 Todo
  • 更新指定 Todo

小伙伴们,你们准备好了没?让我们一起步入 Oak 的世界!

步骤一:初始化项目结构

首先我们在 learn-deno 项目中,创建一个新的 todos 目录,然后分别创建以下子目录和 TS 文件:

  • **handlers 目录:**存放路由处理器;
  • **middlewares 目录:**存放中间件,用于处理每个请求;
  • **models 目录:**存放模型定义,在我们的示例中只包含 Todo 接口;
  • **services 目录:**存放服务层程序;
  • db 目录:作为本地数据库,存放 Todo 数据;
  • **config.ts:**包含应用的全局配置信息;
  • index.ts: 应用的入口文件;
  • **routing.ts:**包含 API 路由信息。

完成项目初始化之后,todos 项目的目录结构如下所示:

└── todos
    ├── config.ts
    ├── db
    ├── handlers
    ├── index.ts
    ├── middlewares
    ├── models
    ├── routing.ts
    └── services

如你所见,这个目录结构看起来像一个小型 Node.js Web 应用程序。下一步,我们来创建 Todo 项目的入口文件。

步骤二:创建入口文件

index.ts

import { Application } from "https://deno.land/x/oak/mod.ts";
import { APP_HOST, APP_PORT } from "./config.ts";
import router from "./routing.ts";
import notFound from "./handlers/notFound.ts";
import errorMiddleware from "./middlewares/error.ts";

const app = new Application();

app.use(errorMiddleware);
app.use(router.routes());
app.use(router.allowedMethods());
app.use(notFound);

console.log(`Listening on ${APP_PORT}...`);

await app.listen(`${APP_HOST}:${APP_PORT}`);

在第一行代码中,我们使用了 Deno 所提供的功能特性,即直接从网络上导入模块。除此之外,这里没有什么特别的。我们创建一个应用程序,添加中间件,路由,最后启动服务器。整个流程就像开发普通的 Express/Koa 应用程序一样。

步骤三:创建配置文件

config.ts

const env = Deno.env.toObject();
export const APP_HOST = env.APP_HOST || "127.0.0.1";
export const APP_PORT = env.APP_PORT || 3000;
export const DB_PATH = env.DB_PATH || "./db/todos.json";

为了提高项目的灵活性,我们支持从环境中读取配置信息,同时我们也为每个配置项都提供了相应的默认值。其中 Deno.env() 相当于Node.js 平台中的 process.env

步骤四:添加 Todo 模型

models/todo.ts

export interface Todo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

在 Todo 模型中,我们定义了 id、userId、title 和 completed 四个属性,分别表示 todo 编号、用户编号、todo 标题和 todo 完成状态。

步骤五:添加路由

routing.ts

import { Router } from "https://deno.land/x/oak/mod.ts";

import getTodos from "./handlers/getTodos.ts";
import getTodoDetail from "./handlers/getTodoDetail.ts";
import createTodo from "./handlers/createTodo.ts";
import updateTodo from "./handlers/updateTodo.ts";
import deleteTodo from "./handlers/deleteTodo.ts";

const router = new Router();

router
  .get("/todos", getTodos)
  .get("/todos/:id", getTodoDetail)
  .post("/todos", createTodo)
  .put("/todos/:id", updateTodo)
  .delete("/todos/:id", deleteTodo);

export default router;

同样,没有什么特别的,我们创建一个 router 并添加 routes。它看起来几乎与 Express.js 应用程序一模一样。

步骤六:添加路由处理器

handlers/getTodos.ts

import { Response } from "https://deno.land/x/oak/mod.ts";
import { getTodos } from "../services/todos.ts";

export default async ({ response }: { response: Response }) => {
  response.body = await getTodos();
};

getTodos 处理器用于返回所有的 Todo。如果你从未使用过 Koa,则 response 对象类似于 Express 中的 res 对象。在 Express 应用中我们会调用 res 对象的 json 或 send 方法来返回响应。而在 Koa/Oak 中,我们需要将响应值赋给 response.body 属性。


handlers/getTodoDetail.ts

import { Response, RouteParams } from "https://deno.land/x/oak/mod.ts";
import { getTodo } from "../services/todos.ts";

export default async ({
  params,
  response,
}: {
  params: RouteParams;
  response: Response;
}) => {
  const todoId = params.id;

  if (!todoId) {
    response.status = 400;
    response.body = { msg: "Invalid todo id" };
    return;
  }

  const foundedTodo = await getTodo(todoId);
  if (!foundedTodo) {
    response.status = 404;
    response.body = { msg: `Todo with ID ${todoId} not found` };
    return;
  }

  response.body = foundedTodo;
};

getTodoDetail 处理器用于返回指定 id 的 Todo,如果找不到指定 id 对应的 Todo,会返回 404 和相应的错误消息。


handlers/createTodo.ts

import { Request, Response } from "https://deno.land/x/oak/mod.ts";
import { createTodo } from "../services/todos.ts";

export default async ({
  request,
  response,
}: {
  request: Request;
  response: Response;
}) => {
  if (!request.hasBody) {
    response.status = 400;
    response.body = { msg: "Invalid todo data" };
    return;
  }

  const {
    value: { userId, title, completed = false },
  } = await request.body();

  if (!userId || !title) {
    response.status = 422;
    response.body = {
      msg: "Incorrect todo data. userId and title are required",
    };
    return;
  }

  const todoId = await createTodo({ userId, title, completed });

  response.body = { msg: "Todo created", todoId };
};

createTodo 处理器用于创建新的 Todo,在执行新增操作前,会验证是否缺少 userIdtitle 必填项。


handlers/updateTodo.ts

import { Request, Response } from "https://deno.land/x/oak/mod.ts";
import { updateTodo } from "../services/todos.ts";

export default async ({
  params,
  request,
  response,
}: {
  params: any;
  request: Request;
  response: Response;
}) => {
  const todoId = params.id;

  if (!todoId) {
    response.status = 400;
    response.body = { msg: "Invalid todo id" };
    return;
  }

  if (!request.hasBody) {
    response.status = 400;
    response.body = { msg: "Invalid todo data" };
    return;
  }

  const {
    value: { title, completed, userId },
  } = await request.body();

  await updateTodo(todoId, { userId, title, completed });

  response.body = { msg: "Todo updated" };
};

updateTodo 处理器用于更新指定的 Todo,在执行更新前,会判断指定的 Todo 是否存在,当存在的时候才会执行更新操作。


handlers/deleteTodo.ts

import { Response, RouteParams } from "https://deno.land/x/oak/mod.ts";
import { deleteTodo, getTodo } from "../services/todos.ts";

export default async ({
  params,
  response
}: {
  params: RouteParams;
  response: Response;
}) => {
  const todoId = params.id;

  if (!todoId) {
    response.status = 400;
    response.body = { msg: "Invalid todo id" };
    return;
  }

  const foundTodo = await getTodo(todoId);
  if (!foundTodo) {
    response.status = 404;
    response.body = { msg: `Todo with ID ${todoId} not found` };
    return;
  }

  await deleteTodo(todoId);
  response.body = { msg: "Todo deleted" };
};

deleteTodo 处理器用于删除指定的 Todo,在执行删除前会校验 todoId 是否为空和对应 Todo 是否存在。


除了上面已经定义的处理器,我们还需要处理不存在的路由并返回一条错误消息。

handlers/notFound.ts

import { Response } from "https://deno.land/x/oak/mod.ts";

export default ({ response }: { response: Response }) => {
  response.status = 404;
  response.body = { msg: "Not Found" };
};

步骤七:添加服务

在创建 Todo 服务前,我们先来创建两个小的 helper(辅助)服务。

services/util.ts

import { v4 as uuid } from "https://deno.land/std/uuid/mod.ts";

export const createId = () => uuid.generate();

util.ts 文件中,我们使用 Deno 标准库的 uuid 模块来为新建的 Todo 生成一个唯一的 id。


services/db.ts

import { DB_PATH } from "../config.ts";
import { Todo } from "../models/todo.ts";

export const fetchData = async (): Promise<Todo[]> => {
  const data = await Deno.readFile(DB_PATH);

  const decoder = new TextDecoder();
  const decodedData = decoder.decode(data);

  return JSON.parse(decodedData);
};

export const persistData = async (data: Todo[]): Promise<void> => {
  const encoder = new TextEncoder();
  await Deno.writeFile(DB_PATH, encoder.encode(JSON.stringify(data)));
};

在我们的示例中,db.ts 文件用于实现数据的管理,数据持久化方式使用的是本地的 JSON 文件。为了获取所有的 Todo,我们根据 DB_PATH 设置的路径,读取对应的文件内容。 readFile 函数返回一个 Uint8Array 对象,该对象在解析为 JSON 对象之前需要转换为字符串。 Uint8Array 和 TextDecoder 都来自核心 JavaScript API。同样,在存储数据时,需要先把字符串转换为 Uint8Array。

为了让大家更好地理解上面表述的内容,我们来分别看一下 Deno 命名空间下 readFilewriteFile 这两个方法的定义:

1. Deno.readFile

 export function readFile(path: string): Promise<Uint8Array>;

Deno.readFile 使用示例:

const decoder = new TextDecoder("utf-8");
const data = await Deno.readFile("hello.txt");
console.log(decoder.decode(data));

2. Deno.writeFile

export function writeFile(
    path: string,
    data: Uint8Array,
    options?: WriteFileOptions
): Promise<void>;

Deno.writeFile 使用示例:

const encoder = new TextEncoder();
const data = encoder.encode("Hello world\n");
// overwrite "hello1.txt" or create it
await Deno.writeFile("hello1.txt", data);
// only works if "hello2.txt" exists
await Deno.writeFile("hello2.txt", data, {create: false});  
// set permissions on new file
await Deno.writeFile("hello3.txt", data, {mode: 0o777});  
// add data to the end of the file
await Deno.writeFile("hello4.txt", data, {append: true});  

接着我们来定义最核心的 todos.ts 服务,该服务用于实现 Todo 的增删改查。

services/todos.ts

import { fetchData, persistData } from "./db.ts";
import { Todo } from "../models/todo.ts";
import { createId } from "../services/util.ts";

type TodoData = Pick<Todo, "userId" | "title" | "completed">;

// 获取Todo列表
export const getTodos = async (): Promise<Todo[]> => {
  const todos = await fetchData();
  return todos.sort((a, b) => a.title.localeCompare(b.title));
};

// 获取Todo详情
export const getTodo = async (todoId: string): Promise<Todo | undefined> => {
  const todos = await fetchData();

  return todos.find(({ id }) => id === todoId);
};

// 新建Todo
export const createTodo = async (todoData: TodoData): Promise<string> => {
  const todos = await fetchData();

  const newTodo: Todo = {
    ...todoData,
    id: createId(),
  };

  await persistData([...todos, newTodo]);

  return newTodo.id;
};

// 更新Todo
export const updateTodo = async (
  todoId: string,
  todoData: TodoData
): Promise<void> => {
  const todo = await getTodo(todoId);

  if (!todo) {
    throw new Error("Todo not found");
  }

  const updatedTodo = {
    ...todo,
    ...todoData,
  };

  const todos = await fetchData();
  const filteredTodos = todos.filter((todo) => todo.id !== todoId);

  persistData([...filteredTodos, updatedTodo]);
};

// 删除Todo
export const deleteTodo = async (todoId: string): Promise<void> => {
  const todos = await getTodos();
  const filteredTodos = todos.filter((todo) => todo.id !== todoId);

  persistData(filteredTodos);
};

步骤八:添加异常处理中间件

如果用户服务出现错误,会发生什么情况?这将可能导致整个应用程序奔溃。为了避免出现这种情况,我们可以在每个处理程序中添加 try/catch 块,但其实还有一个更好的解决方案,即在所有路由之前添加异常处理中间件,在该中间件内部来捕获所有异常。

middlewares/error.ts

import { Response } from "https://deno.land/x/oak/mod.ts";

export default async (
  { response }: { response: Response },
  next: () => Promise<void>
) => {
  try {
    await next();
  } catch (err) {
    response.status = 500;
    response.body = { msg: err.message };
  }
};

步骤九:功能验证

Todo 功能开发完成后,我们可以使用 HTTP 客户端来进行接口测试,这里我使用的是 VSCode IDE 下的 REST Client 扩展,首先我们在项目根目录下新建一个 todo.http 文件,然后复制以下内容:

### 获取Todo列表
GET http://localhost:3000/todos HTTP/1.1

### 获取Todo详情

GET http://localhost:3000/todos/${todoId}

### 新增Todo

POST http://localhost:3000/todos HTTP/1.1
content-type: application/json

{
    "userId": 666,
    "title": "Learn Deno"
}

### 更新Todo
PUT http://localhost:3000/todos/${todoId} HTTP/1.1
content-type: application/json

{
    "userId": 666,
    "title": "Learn Deno",
    "completed": true  
}

### 删除Todo
DELETE  http://localhost:3000/todos/${todoId} HTTP/1.1

友情提示:需要注意的是 todo.http 文件中的 ${todoId} 需要替换为实际的 Todo 编号,该编号可以先通过新增 Todo,然后从 db/todos.json 文件中获取。

万事具备只欠东风,接下来就是启动我们的 Todo 应用了,进入 Todo 项目的根目录,然后在命令行中运行 deno run -A index.ts 命令:

$ deno run -A index.ts
Listening on 3000...

在以上命令中的 -A 标志,与 --allow-all 标志是等价的,表示允许所有权限。

-A, --allow-all
        Allow all permissions
        --allow-env
            Allow environment access
        --allow-hrtime
            Allow high resolution time measurement
        --allow-net=<allow-net>
            Allow network access
        --allow-plugin
            Allow loading plugins
        --allow-read=<allow-read>
            Allow file system read access
        --allow-run
            Allow running subprocesses
        --allow-write=<allow-write>
            Allow file system write access

可能有一些读者还没使用过 REST Client 扩展,这里我来演示一下如何新增 Todo:

从返回的 HTTP 响应报文,我们可以知道 Learn Deno 的 Todo 已经新增成功了,安全起见让我们来打开 Todo 根目录下的 db 目录中的 todos.json 文件,验证一下是否 “入库” 成功,具体如下图所示:

从图可知 Learn Deno 的 Todo 的确新增成功了,对于其他的接口有兴趣的读者可以自行测试一下。

Deno 实战之 Todo 项目源码:https://github.com/semlinker/deno-todos-api

在 Deno 1.0.0 发布之后,本人才开始踏上学习 Deno 的征程,有写得不好的地方,欢迎小伙伴们指出,对 Deno 感兴趣的小伙伴可以加我个人微信号 semlinker 一起学习与交流哈。

三、参考资源

2赞