Deno开发REST API实战

Deno开发REST API实战_第1张图片
说明:Deno正处于快速发展阶段,版本发布较为频繁,标准库尚不稳定,本文会不定期更新以保证其可用性。完整源码已上传到GitHub https://github.com/sunjc/heroes-deno。

Deno简介

Deno与Node.js

2009年11月8日,在JavaScript社区欧洲 JSConf 大会上,Node.js之父Ryan Dahl首度发布了Node。Node把浏览器端的 JavaScript 技术带入了服务端应用领域。

2012年Ryan Dahl开始淡出 Node.js 社区,进入 Go、Rust社区,重回他擅长的数学应用领域。后来,Ryan Dahl使用Node.js设计一款类似 IPython 的互动式数据科学分析命令行工具时,发现Node现在太难用了。Ryan Dahl认为自从他把项目移交出去后,Node的走向越来越背离他的初衷,存在很多无法解决的问题,因此他决定重新开发一个新项目Deno,目标是Destroy-node,单词Deno的英文意思则为恐龙。

2018年6月,在Node.js迈入第 10 年之前,JSConf 欧洲大会再次邀请 Ryan Dahl进行开场演讲。在这次引发全球开发社区热烈讨论的演讲中,Ryan Dahl分享了“我在 Node.js 最悔恨的10件事”,并介绍了开发中的Deno。

Node.js十大恨

  1. 没采用 JavaScript 异步处理的 Promise 对象
  2. 低估了安全的重要性
  3. 采用 gyp 设计Build系统
  4. 没有听从社区建议改用 FFI 而继续用 gyp
  5. 过度依赖 npm(内建package.json支持是个错误)
  6. 太容易造成 require(“任意模块”)
  7. package.json容易造成错误的模块概念
  8. 臃肿复杂的 node_module 设计和下载黑洞
  9. require(“module”) 没有强制要求添加.js 扩展名
  10. 无用的 index.js 设计

Deno特性

Deno是一个简单、现代、安全的JavaScript和TypeScript运行时,采用了Chrome V8引擎(与Node.js相同),基于Rust和Tokio构建。由于Rust原生支持WebAssembly,所以Deno也能直接运行WebAssembly。
Deno开发REST API实战_第2张图片

  • 基于最新的 JavaScript 语言,现代化的Javascript ES 模块,支持async/await语法。
  • 原生支持TypeScript,无需额外配置,无需任何手动编译。
  • 去中心化的模块系统,Deno不使用npm,不使用package.json,不需要一个集中的存储库。
  • 默认情况下是安全的。除非显式启用,否则无法访问文件、网络或环境变量。
  • 单一可执行文件(deno),其中包含功能完整的Runtime API,没有外部依赖,可以部署到各种环境中。
  • 内置实用工具,如依赖检查器(deno info)、代码格式化工具(deno fmt)。
  • 脚本可以打包到单个 JavaScript 文件中。
  • 拥有一套经过审核、功能广泛的标准库:deno.land/std。
  • 可以从网络上的任何位置导入模块,比如GitHub、CDN、个人服务器。Deno官方提供代码托管服务deno.land/x,现已托管千余个第三方模块。

依赖管理

Deno没有包管理器的概念,可以通过URL网址将外部模块直接导入本地模块,可以有选择地加载所需部件:

import {
      oakCors } from "https://deno.land/x/cors/mod.ts";

在URL中可以指定版本号,若未指定,则使用最近的版本:

import {
      oakCors } from "https://deno.land/x/[email protected]/mod.ts";

第一次访问外部模块时,Deno会自动下载并缓存到本地,再次使用时就不需要下载了。如要重新下载,只需在命令中添加–reload选项即可。而本地运行Node.js时,必须先从 NPM 安装所有依赖项,不同的项目需要重新下载。

deps.ts
在具有许多依赖项的大项目中,如果每个模块都单独导入,更新这些模块将变得非常繁琐和耗时。为解决这个问题,可以创建名为deps.ts的文件来集中管理外部模块:

export {
     serve} from "https://deno.land/[email protected]/http/server.ts";
export * as bcrypt from "https://deno.land/x/[email protected]/mod.ts";
export {
     oakCors} from "https://deno.land/x/[email protected]/mod.ts";

本地模块引用deps.ts:

import {
     Application, createConnection, oakCors} from "./deps.ts";

import maps
import maps是管理模块的另一种方案,目前尚不稳定。

首先创建import_map.json文件:

{
     
   "imports": {
     
      "fmt/": "https://deno.land/[email protected]/fmt/"
   }
}

使用import_map.json中映射的名称导入模块:

import {
      red } from "fmt/colors.ts";

console.log(red("hello world"));

在运行时指定–importmap选项:

$ deno run --importmap=import_map.json --unstable color.ts

安装与配置

安装Deno

Deno可以运行在macOS、Linux和Windows系统,仅一个可执行文件,没有外部依赖。

安装
Using Shell (macOS and Linux):

curl -fsSL https://deno.land/x/install/install.sh | sh

Using PowerShell (Windows):

iwr https://deno.land/x/install/install.ps1 -useb | iex

测试安装
查看deno版本:

$ deno --version

运行第一个程序:

$ deno run https://deno.land/std/examples/welcome.ts
...
Welcome to Deno 

升级
升级到最新版:

deno upgrade

安装指定版本:

deno upgrade --version 1.2.1

配置环境变量

  • DENO_DIR 设置cache目录,默认值为$HOME/.cache/deno
  • DENO_INSTALL_ROOT 设置Deno的安装目录,默认值为$HOME/.deno/bin
  • NO_COLOR 关闭彩色输出

运行deno info命令可以查看cache信息,Windows系统如下:

deno info
DENO_DIR location: "C:\\Users\\jason\\AppData\\Local\\deno"
Remote modules cache: "C:\\Users\\jason\\AppData\\Local\\deno\\deps"
TypeScript compiler cache: "C:\\Users\\jason\\AppData\\Local\\deno\\gen"

IDE插件

以IntelliJ IDEA为例,访问Deno插件页面,点击Install to IDE,按提示操作即可完成安装。重启IDE后,进入Preferences > Languages & Frameworks > Deno,启用Enable Deno support for this project。

Denon

Denon 类似于Node.js的nodemon自动重启工具,用来在开发环境中监视文件的变更,发现更改后会自动重新启动。它显著提升了开发体验,开发人员无需再手动停止和重启服务。

Denon还支持配置脚本功能,其灵感源于velociraptor(迅猛龙)。Deno没有包管理器,缺少一些npm功能,尤其是不方便运行脚本。Deno CLI命令可能会非常长,如果没有地方存储,不容易共享脚本,也不方便在外部工具中调用。

安装Denon

deno install --allow-read --allow-run --allow-write --allow-net -f -q --unstable https://deno.land/x/[email protected]/denon.ts

用法
可以直接使用denon命令运行程序,可以传递deno命令支持的所有参数:

$ denon run --allow-env app.ts --arg-for-my-app

更常用来运行script:

$ denon [script name]

配置
支持json和yaml两种格式的配置文件。

运行如下命令创建denon.json文件:

$ denon --init

生成的文件内容如下:

{
     
  // optional but highly recommended
  "$schema": "https://deno.land/x/denon/schema.json",
  "scripts": {
     
    "start": {
     
      "cmd": "deno run app.ts",
      "desc": "run my app.ts file"
    }
  }
}

也可以使用denon.yml文件:

scripts:
  start:
    cmd: "deno run app.ts"
    desc: "run my app.ts file"

Script选项
简单的scripts:

{
     
  "scripts": {
     
    // they all resolve to `deno run app.ts` when you run `denon start`
    "start": "app.ts",
    // OR
    "start": "run app.ts",
    // OR
    "start": "deno run app.ts"
  }
}

也可以定义复杂的script对象。denon script支持环境变量、权限、tsconfig、imap、log等选项,可以配置是否监视文件,详细信息请查阅官方文档,一些选项的含义可以查看下节Deno CLI。

{
     
  // globally applied to all scripts
  // now denon will essentialy be a script runner
  "watch": false

  // globally applied to all scripts
  "env": {
     
    "TOKEN": "SUPER SECRET TOKEN",
  },

  // globally applied to all scripts
  // as object ...
  "allow": {
     
    "read": "/etc,/tmp", // --allow-read=/etc,/tmp
    "env": true     // --allow-env
  },
  // ... or as array
  "allow": [
    "run", // --allow-run
    "net" // --allow-net
  ]

  "scripts": {
     
    "start": {
     
      "cmd": "deno run app.ts",
      "desc": "Run the main server.",
      "importmap": "importmap.json",
      "tsconfig": "tsconfig.json",
      "log": "debug", // or "info"
      "unstable": true,
      // you can still enable watch on a script-by-script basis
      "watch": true,

      "inspect": "127.0.0.1:9229",
      // OR
      "inspectBrk": "127.0.0.1:9229"

      "env": {
     
        "PORT": 3000
      },

      // specific for a single script
      // as object ...
      "allow": {
     
        "read": "/etc,/tmp", // --allow-read=/etc,/tmp
        "env": true     // --allow-env
      },
      // ... or as array
      "allow": [
        "run", // --allow-run
        "net" // --allow-net
      ]
    }
  }
}

Nest.land

Nest.land是Deno去中心化的模块registry和CDN。您可以访问nest.land/gallery查找所需模块:
Deno开发REST API实战_第3张图片
比如,我们也可以从Nest.land安装denon:

$ deno install --allow-read --allow-run --allow-write --allow-net -f -q --unstable https://x.nest.land/[email protected]/denon.ts

相对于将模块托管在Github、deno.land/x或你自己的Web服务器,使用Nest.land有以下优点:

  • 永不可变,Nest.land将Deno模块存储在Arweave Permaweb,永远不会被删除或修改。
  • 安全、开源
  • 无需Git,可以使用任何版本管理工具,只需将模块相关代码上传

Eggs CLI
Eggs CLI是Nest.land管理和发布模块的工具。
安装Eggs CLI

$ deno install -A -f --unstable -n eggs https://x.nest.land/[email protected]/mod.ts
or
$ deno run -A --unstable https://x.nest.land/[email protected]/mod.ts install -A -f --unstable -n eggs https://x.nest.land/[email protected]/mod.ts

Eggs CLI支持的命令

help         - Show this help or the help of a sub-command.
completions  - Generate shell completions for zsh and bash.
link         - Links your nest.land API key to the CLI
init         - Initiates a new module for the nest.land registry.
publish      - Publishes the current directory to the nest.land registry.
update       - Update your dependencies
install      - A simple wrapper around the deno install command to handle global script updates.
upgrade      - Upgrade the current nest.land CLI.

有关发布模块的流程请查看官方文档。对于普通用户,update是非常有用的命令,会自动更新deps.ts的依赖版本。官方文档声明当前支持以下的Registry:

  • x.nest.land
  • deno.land/x
  • deno.land/std
  • raw.githubusercontent.com
  • denopkg.com

本人测试x.nest.land能更新成功,deno.land/x、deno.land/std更新失败。

Deno CLI

帮助

查看帮助

# Using the subcommand.
deno help

# Using the short flag
deno -h

# Using the long flag
deno --help

查看子命令的帮助

deno help bundle
deno bundle -h
deno bundle --help

支持的子命令

bundle         Bundle module and dependencies into single file
cache          Cache the dependencies
completions    Generate shell completions
doc            Show documentation for a module
eval           Eval script
fmt            Format source files
help           Prints this message or the help of the given subcommand(s)
info           Show info about cache or info related to source file
install        Install script as an executable
lint           Lint source files
repl           Read Eval Print Loop
run            Run a program given a filename or url to the module. Use '-' as a filename to read from stdin.
test           Run tests
types          Print runtime TypeScript declarations
upgrade        Upgrade deno executable to given version

deno run

deno run可以从本地文件、url、stdin获取脚本:

deno run main.ts
deno run https://mydomain.com/main.ts
cat main.ts | deno run -

如果在无服务器服务上部署了 Deno,指定一个网址就能运行这个服务,而不用上传代码到无服务器服务平台。

运行流程
在安装deno时曾运行过welcome示例:

$ deno run https://deno.land/std/examples/welcome.ts
Download https://deno.land/std/examples/welcome.ts
Warning Implicitly using latest version (0.70.0) for https://deno.land/std/examples/welcome.ts
Download https://deno.land/[email protected]/examples/welcome.ts
Check https://deno.land/[email protected]/examples/welcome.ts
Welcome to Deno 

首次运行时先下载welcome.ts文件,然后编译为JavaScript,最后通过 V8 引擎运行编译后的js文件。再次运行时则直接执行缓存中已生成的文件:

deno run https://deno.land/std/examples/welcome.ts
Welcome to Deno 

刷新缓存
要重新下载和编译文件,使用–reload选项:

deno run --reload https://deno.land/std/examples/welcome.ts
Download https://deno.land/std/examples/welcome.ts
Warning Implicitly using latest version (0.70.0) for https://deno.land/std/examples/welcome.ts
Download https://deno.land/[email protected]/examples/welcome.ts
Check https://deno.land/[email protected]/examples/welcome.ts
Welcome to Deno 

TypeScript类型检查
默认启用所有的strict检查,任何不合格的TypeScript代码都会得到提示。下面是当前的默认配置:

{
     
  "compilerOptions": {
     
    "allowJs": false,
    "allowUmdGlobalAccess": false,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "alwaysStrict": true,
    "assumeChangesOnlyAffectDirectDependencies": false,
    "checkJs": false,
    "disableSizeLimit": false,
    "generateCpuProfile": "profile.cpuprofile",
    "jsx": "react",
    "jsxFactory": "React.createElement",
    "lib": [],
    "noFallthroughCasesInSwitch": false,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitUseStrict": false,
    "noStrictGenericChecks": false,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "preserveConstEnums": false,
    "removeComments": false,
    "resolveJsonModule": true,
    "strict": true,
    "strictBindCallApply": true,
    "strictFunctionTypes": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": true,
    "suppressExcessPropertyErrors": false,
    "suppressImplicitAnyIndexErrors": false,
    "useDefineForClassFields": false
  }
}

可以自定义tsconfig.json文件覆盖默认配置:

deno run -c tsconfig.json [your-script.ts]

为减少程序启动时间,在执行过TypeScript类型检查后,可以使用–no-check选项将之关闭。
安全权限
启用环境变量、网络、文件访问权限:

deno run --allow-env --allow-net --allow-read --allow-write <SCRIPT_ARG>

其中–allow-net、 --allow-read、 --allow-write可以进一步指定具体参数:

deno run --allow-net=github.com,deno.land fetch.ts
deno run --allow-read=/etc https://deno.land/[email protected]/examples/cat.ts /etc/passwd

启用调试

--inspect=
    activate inspector on host:port (default: 127.0.0.1:9229)

--inspect-brk=
    activate inspector on host:port and break at start of user script

为激活调试功能,使用–inspect 或 --inspect-brk选项运行deno。–inspect允许在任何时间点连接调试器,–inspect-brk将等待调试器连接,第一行代码即暂停执行。在IntelliJ IDEA点击调试按钮会自动启用–inspect-brk。

$ deno run --inspect-brk --allow-read --allow-net https://deno.land/[email protected]/http/file_server.ts
Debugger listening on ws://127.0.0.1:9229/ws/e43c9cae-c270-47a9-b0ac-99718cd9d6b7
Download https://deno.land/[email protected]/http/file_server.ts
Download https://deno.land/[email protected]/flags/mod.ts
Check https://deno.land/[email protected]/http/file_server.ts

启用调试后,我们使用 Chrome DevTools来调试程序,打开chrome://inspect,然后点击inspect:
Deno开发REST API实战_第4张图片
说明:若未显示target,点击Port forwarding和Configure配置主机和端口。

稍等一会儿,待所有模块加载完毕即可进入Chrome Devtools调试程序了:
Deno开发REST API实战_第5张图片

deno cache

下载、编译模块及其所有静态依赖项,并保存到本地缓存中。如:

deno cache https://deno.land/std/http/file_server.ts

重新加载全部/部分模块:

deno cache --reload my_module.ts
deno cache --reload=https://deno.land/[email protected]/fs/copy.ts,https://deno.land/[email protected]/fmt/colors.ts my_module.ts

deno install

deno install用来安装和分发可执行代码,简单来说就是生成一个shell或cmd可执行文件,默认会安装在deno的bin目录下。前面安装denon时曾使用过此命令:

deno install --allow-read --allow-run --allow-write --allow-net -f -q --unstable https://deno.land/x/[email protected]/denon.ts

也可以指定安装目录:

deno install --allow-net --allow-read -n file_server --root /usr/local https://deno.land/[email protected]/http/file_server.ts

注意,必须指定运行脚本时要使用的权限。

在windows下生成的cmd文件内容如下:
file_server.cmd

% generated by deno install %
@deno.exe "run" "--allow-read" "--allow-net" "https://deno.land/[email protected]/http/file_server.ts" %*

deno bundle

deno bundle将所有的依赖打包为一个JavaScript文件,例如:

deno bundle https://deno.land/[email protected]/examples/colors.ts colors.bundle.js
Bundle https://deno.land/[email protected]/examples/colors.ts
Download https://deno.land/[email protected]/examples/colors.ts
Emit "colors.bundle.js" (9.83 KB)

运行打包后的文件:

deno run colors.bundle.js

deno info

前文我们使用deno info查看cache信息,还可以使用deno info查看模块和其所有依赖:

deno info https://deno.land/[email protected]/http/file_server.ts
local: C:\Users\jason\AppData\Local\deno\deps\https\deno.land\93365353fdab1ce0009ac74c0900e9ad4ea0676c3cbb14fcc1c3d45b40d511d7
type: TypeScript
compiled: C:\Users\jason\AppData\Local\deno\gen\https\deno.land\93365353fdab1ce0009ac74c0900e9ad4ea0676c3cbb14fcc1c3d45b40d511d7.js
deps:
https://deno.land/[email protected]/http/file_server.ts
  ├─┬ https://deno.land/[email protected]/path/mod.ts
  │ ├── https://deno.land/[email protected]/path/_constants.ts
  │ ├─┬ https://deno.land/[email protected]/path/win32.ts
  │ │ ├── https://deno.land/[email protected]/path/_constants.ts
  │ │ ├─┬ https://deno.land/[email protected]/path/_util.ts
  │ │ │ └── https://deno.land/[email protected]/path/_constants.ts
...

deno fmt

格式化TypeScript和JavaScript代码。

# format all JS/TS files in the current directory and subdirectories
deno fmt
# format specific files
deno fmt myfile1.ts myfile2.ts
# check if all the JS/TS files in the current directory and subdirectories are formatted
deno fmt --check
# format stdin and write to stdout
cat file.ts | deno fmt -

Rest API实战

本部分将使用Deno重构本人博客Angular 9集成Spring Boot 2详解中的Spring Boot项目,演示Deno开发Rest API的过程与Deno框架(或模块)的用法。

技术框架

应用的技术框架或模块:

  • Web框架:oak
  • ORM框架:Deno fork of TypeORM
  • JWT模块:djwt
  • bcrypt加密:bcrypt
  • CORS模块:cors
  • 校验模块:Validasaur
  • 环境变量:dotenv
  • Security中间件:Snelm - Improved Security Middleware for Deno Web Frameworks
  • oak测试框架:SuperOak

dep.ts

export {
     
  green,
  cyan,
  bold,
  yellow,
} from "https://deno.land/[email protected]/fmt/colors.ts";
export * as bcrypt from "https://deno.land/x/[email protected]/mod.ts";
export {
      oakCors } from "https://deno.land/x/[email protected]/mod.ts";
export {
     
  makeJwt,
  setExpiration,
  Jose,
  Payload,
} from "https://deno.land/x/[email protected]/create.ts";
export {
      validateJwt } from "https://deno.land/x/[email protected]/validate.ts";
export {
      config } from "https://deno.land/x/[email protected]/mod.ts";
export {
     
  Application,
  Context,
  helpers as oakHelpers,
  HttpError,
  httpErrors,
  HTTPMethods,
  isHttpError,
  Request,
  Response,
  Router,
  RouterContext,
  RouteParams,
  Status,
} from "https://deno.land/x/[email protected]/mod.ts";
export {
     
  Column,
  ConnectionOptions,
  ConnectionOptionsReader,
  createConnection,
  CreateDateColumn,
  DeleteResult,
  Entity,
  EntityRepository,
  getConnection,
  getCustomRepository,
  getRepository,
  JoinTable,
  ManyToMany,
  MigrationInterface,
  PrimaryGeneratedColumn,
  Repository,
  QueryRunner,
  UpdateDateColumn,
} from "https://raw.githubusercontent.com/denolib/typeorm/master/mod.ts";
export {
      Snelm } from "https://deno.land/x/[email protected]/mod.ts";
export {
     
  firstMessages,
  InputData,
  isEmail,
  lengthBetween,
  match,
  required,
  validate,
  ValidationErrors,
  ValidationRules,
} from "https://deno.land/x/[email protected]/mod.ts";

export {
     
  assert,
  assertEquals,
  assertThrows,
  assertThrowsAsync,
} from "https://deno.land/[email protected]/testing/asserts.ts";
export {
      superoak } from "https://deno.land/x/[email protected]/mod.ts";

说明:

  1. 下载Deno模块时若出现不能访问https://raw.githubusercontent.com的情况,请到https://www.ipaddress.com/查询其IP,然后添加到hosts文件中。
  2. 目前typeorm master支持最新版本的deno,但尚未release,因此直接使用了github raw。

创建TypeORM项目

Deno fork of TypeORM
TypeORM是Node.js中常用的ORM框架,我们使用其Deno fork版。TypeORM支持mysql、mariadb、postgres、cockroachdb、sqlite、mssql、oracle、mongodb、cordova、react-native、expo、nativescript等数据库。

TypeORM CLI
安装TypeORM CLI:

deno install --allow-read --allow-write --allow-net --allow-env -f -n typeorm https://raw.githubusercontent.com/denolib/typeorm/master/cli.ts

支持的命令:

schema:sync [...args]         Synchronizes your entities with database schema. It runs schema update queries on all connections you have. 
schema:log [...args]          Shows sql to be executed by schema:sync command. It shows sql log only for your default connection. 
schema:drop [...args]         Drops all tables in the database on your default connection. 
query [...args]               Executes given SQL query on a default connection. Specify connection name to run query on a specific connection.
entity:create [...args]       Generates a new entity.
subscriber:create [...args]   Generates a new subscriber.
migration:create [...args]    Creates a new migration file.
migration:generate [...args]  Generates a new migration file with sql needs to be executed to update schema.
migration:run [...args]       Runs all pending migrations.
migration:show [...args]      Show all migrations and whether they have been run or not
migration:revert [...args]    Reverts last executed migration.
version [...args]             Prints TypeORM version this project uses.
cache:clear [...args]         Clears all data stored in query runner cache.
init [...args]                Generates initial TypeORM project structure. 

创建项目
TypeORM提供CLI命令来快速创建项目,我们使用sqlite数据库来创建heroes-deno项目:

typeorm init --name heroes-deno --database sqlite

创建的项目包含User Entity、mod.ts、ormconfig.json、tsconfig.json等文件,习惯上mod.ts一般用于导出模块的公用组件,我们将之重命名为app.ts。

目前,创建的项目存在问题是不能成功运行的,需做以下修改:

  1. 修改User Entity导入Column注解
  2. 修改tsconfig.json,如下:
{
     
   "compilerOptions": {
     
      "experimentalDecorators": true,
      "importsNotUsedAsValues": "remove",
      "isolatedModules": false,
      "strictPropertyInitialization": false
   }
}

修改后,执行下面命令测试运行app.ts:

deno run -c tsconfig.json app.ts

还是会报错:

NotImplementedError: `PlatformTools.load("app-root-path")` is not supported
    at Function.load (PlatformTools.ts:75:23)
    at ConnectionOptionsReader.get baseDirectory (ConnectionOptionsReader.ts:212:29)
    at ConnectionOptionsReader.get baseFilePath (ConnectionOptionsReader.ts:203:21)
    at ConnectionOptionsReader.load (ConnectionOptionsReader.ts:87:40)
    at ConnectionOptionsReader.all (ConnectionOptionsReader.ts:40:36)
    at ConnectionOptionsReader.get (ConnectionOptionsReader.ts:52:39)
    at getConnectionOptions (index.ts:194:42)
    at createConnection (index.ts:229:77)
    at app.ts:5:1

我们来看一下app.ts:

import {
     createConnection} from "https://denolib.com/denolib/[email protected]/mod.ts";
import {
     User} from "./entity/User.ts";

createConnection().then(async connection => {
     

    console.log("Inserting a new user into the database...");
    const user = new User();
    user.firstName = "Timber";
    user.lastName = "Saw";
    user.age = 25;
    await connection.manager.save(user);
    console.log("Saved a new user with id: " + user.id);

    console.log("Loading users from the database...");
    const users = await connection.manager.find(User);
    console.log("Loaded users: ", users);

    console.log("Here you can setup and run express/koa/any other framework.");

}).catch(error => console.log(error));

代码中调用了createConnection()函数来创建数据库连接,这会通过ConnectionOptionsReader从项目根目录的ormconfig或env文件(.env或ormconfig.env)读取连接选项,查找根目录时调用了Node.js的方法,因此报错。我们稍做修改,添加getConnectionOptions()函数,调用ConnectionOptionsReader时传入root参数:

import {
     
    ConnectionOptions,
    ConnectionOptionsReader,
    createConnection
} from "https://denolib.com/denolib/[email protected]/mod.ts";
import {
     User} from "./entity/User.ts";

/**
 * Reads connection options stored in ormconfig configuration file.
 */
async function getConnectionOptions(connectionName: string = "default"): Promise<ConnectionOptions> {
     
    return new ConnectionOptionsReader({
     root: "."}).get(connectionName);
}

const connectionOptions: ConnectionOptions = await getConnectionOptions();

createConnection(connectionOptions).then(async connection => {
     
   ...
}).catch(error => console.log(error));

再次运行程序会成功输出如下结果:

deno run -c tsconfig.json --allow-env --allow-net --allow-read --allow-write  app.ts
Inserting a new user into the database...
Saved a new user with id: 1
Loading users from the database...
Loaded users:  [ User { id: 1, firstName: "Timber", lastName: "Saw", age: 25 } ]
Here you can setup and run express/koa/any other framework.

环境变量

dotenv能自动从项目根目录下的.env 文件读取环境变量。TypeORM也使用了dotenv,可使用环境变量配置连接选项,如:

TYPEORM_CONNECTION = sqlite
TYPEORM_DATABASE = heroes.sqlite
TYPEORM_SYNCHRONIZE = true
TYPEORM_LOGGING = true
TYPEORM_MIGRATIONS_RUN = true
TYPEORM_ENTITIES = entity/**/*.ts
TYPEORM_MIGRATIONS = migration/**/*.ts
TYPEORM_ENTITIES_DIR = entity
TYPEORM_MIGRATIONS_DIR = migration

在项目根目录下创建.env和.env.example两个文件,分别添加如下内容供后面程序使用:
.env

# .env
APP_HOST = localhost
APP_PORT = 8080

IGNORE_PATHS = /api/auth

JWT_SECRET = mySecret
JWT_ACCESS_TOKEN_EXP = 1800
JWT_REFRESH_TOKEN_EXP = 7200
JWT_ISSUER = iTRunner

CORS_ORIGIN = *
CORS_METHODS = GET,POST,DELETE,PUT,OPTIONS
CORS_ALLOWED_HEADERS = Accept,Accept-Encoding,Accept-Language,Authorization,Connection,Content-Type,Host,Origin,Referer,User-Agent,X-Requested-With

.env.example

# .env.example
APP_HOST =
APP_PORT =

IGNORE_PATHS =

JWT_SECRET =
JWT_ACCESS_TOKEN_EXP =
JWT_REFRESH_TOKEN_EXP =
JWT_ISSUER =

CORS_ORIGIN =
CORS_METHODS =
CORS_ALLOWED_HEADERS =

.env.example文件的作用是确保.env文件配置了所有环境变量,防止生产程序中出现运行时错误。当启用安全模式加载时,若.env中有未配置的项,在启动程序时就会报错:

error: Uncaught MissingEnvVarsError: The following variables were defined in the example file but are not present in the environment:
  APP_HOST

Make sure to add them to your env file.

启用安全模式加载环境变量:
config/config.ts

import {
      config as loadConfig } from "../deps.ts";

export const config = loadConfig({
      safe: true });

Entity

TypeORM与Hibernate非常相信似,Entity注解有关知识请查看官方文档,这里直接给出源代码:
entity/hero.ts

import {
     Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn} from "../deps.ts";

@Entity({
      orderBy: {
      id: "ASC" } })
export class Hero {
     
    @PrimaryGeneratedColumn()
    id!: number;

    @Column("varchar", {
     name: "hero_name", length: 50})
    name: string;

    @CreateDateColumn({
     name: "created_date"})
    createdDate: Date;

    @UpdateDateColumn({
     name: "last_modified_date"})
    lastModifiedDate: Date;
}

entity/user.ts

import {
     
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
} from "../deps.ts";
import {
      Authority } from "./authority.ts";

@Entity("users")
export class User {
     
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({
      type: String, length: 50 })
  username: string;

  @Column({
      type: String, length: 100 })
  password: string;

  @Column({
      type: String, length: 50 })
  email: string;

  @Column({
      type: Boolean })
  enabled: boolean = true;

  @ManyToMany((type) => Authority, {
      eager: true })
  @JoinTable({
     
    name: "user_authority",
    joinColumn: {
     
      name: "user_id",
      referencedColumnName: "id",
    },
    inverseJoinColumn: {
     
      name: "authority_id",
      referencedColumnName: "id",
    },
  })
  authorities: Authority[];
}

entity/authority.ts

import {
     Column, Entity, ManyToMany, PrimaryGeneratedColumn} from "../deps.ts";
import {
     User} from "./user.ts";

export enum AuthorityName {
     
    ADMIN = "ROLE_ADMIN",
    USER = "ROLE_USER",
}

@Entity()
export class Authority {
     
    @PrimaryGeneratedColumn()
    id!: number;

    @Column({
     type: String, name: "authority_name", length: 10})
    name: AuthorityName = AuthorityName.USER;

    @ManyToMany((type) => User, (user) => user.authorities)
    users: User[];
}

Repository

标准Repository

每个Entity都有自己的Repository,能够处理CRUD操作。相对于EntityManager,使用Repository更便利。下面使用Repository改造一下app.ts:

createConnection(connectionOptions).then(async connection => {
     
    console.log("Inserting a new hero into the database...");
    const hero = new Hero();
    hero.name = "Jason";

    const heroRepository = connection.getRepository(Hero);
    await heroRepository.save(hero);
    console.log("Saved a new hero with id: " + hero.id);

    console.log("Loading heroes from the database...");
    const heroes = await heroRepository.find();
    console.log("Loaded heroes: ", heroes);
}).catch(error => console.log(error));

Spring Data至少还需要声明一个Repository接口,而TypeORM这都不必了。

自定义Repository

有三种方法自定义Repository:

  1. 扩展Repository
/**
 * First type of custom repository - extends standard repository.
 */
@EntityRepository(User)
export class UserRepository extends Repository<User> {
     

    findByName(firstName: string, lastName: string) {
     
        return this.findOne({
      firstName, lastName });
    }

}
  1. 扩展AbstractRepository
/**
 * Second type of custom repository - extends abstract repository.
 */
@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
     

    createAndSave(firstName: string, lastName: string) {
     
        const user = new User();
        user.firstName = firstName;
        user.lastName = lastName;
        return this.manager.save(user);
    }

    findByName(firstName: string, lastName: string) {
     
        return this.repository.findOne({
      firstName, lastName });
    }

}

与Repository的不同之处,AbstractRepository没有public方法,仅有protected方法,可以访问manager和repository。如果不想暴露Repository的所有方法,就扩展AbstractRepository。

  1. 调用EntityManager
/**
 * Third type of custom repository - extends nothing and accepts entity manager as a first constructor parameter.
 */
@EntityRepository()
export class UserRepository {
     

    constructor(private manager: EntityManager) {
     
    }

    createAndSave(firstName: string, lastName: string) {
     
        const user = new User();
        user.firstName = firstName;
        user.lastName = lastName;
        return this.manager.save(user);
    }

    findByName(firstName: string, lastName: string) {
     
        return this.manager.findOne(User, {
      firstName, lastName });
    }

}

调用自定义Repository同调用标准Repository一样简单,只需使用getCustomRepository:

const userRepository = getCustomRepository(UserRepository);
...
const timber = await userRepository.findByName("Timber", "Saw");

我们使用第一种方式自定义Repository:
repository/UserRepository.ts

import {
      EntityRepository, Repository } from "../deps.ts";
import {
      User } from "../entity/user.ts";

@EntityRepository(User)
export class UserRepository extends Repository<User> {
     
  findByUsername(username: string) {
     
    return this.findOne({
      username, enabled: true });
  }
}

repository/HeroRepository.ts

import {
      Hero } from "../entity/hero.ts";
import {
      EntityRepository, Repository } from "../deps.ts";

@EntityRepository(Hero)
export class HeroRepository extends Repository<Hero> {
     
  async findByName(name: string): Promise<Hero[]> {
     
    return this.createQueryBuilder("hero")
      .where("hero.name like :name", {
      name: `%${
       name}%` })
      .getMany();
  }
}

分页查询

TypeORM的SelectQueryBuilder提供了skip()、take()和getManyAndCount()方法来支持分页查询。为了与原Spring查询接口保持一致,自定义了如下分页查询方法:
repository/HeroRepository.ts

import {
      Hero } from "../entity/hero.ts";
import {
      EntityRepository, Repository } from "../deps.ts";
import {
      Page, Pageable } from "../util/pages.ts";

@EntityRepository(Hero)
export class HeroRepository extends Repository<Hero> {
     
  async findAll(pageable: Pageable): Promise<Page<Hero>> {
     
    const queryBuilder = this.createQueryBuilder("hero");

    if (pageable.sort) {
     
      queryBuilder.orderBy(`hero.${
       pageable.sort.property}`, pageable.sort.order);
    }

    return queryBuilder.skip(pageable.page * pageable.size).take(pageable.size)
      .getManyAndCount().then((entitiesWithCount) => {
     
        return {
     
          content: entitiesWithCount[0],
          totalElements: entitiesWithCount[1],
        };
      });
  }

  ...
}

分页参数与辅助方法:
util/pages.ts

const DEFAULT_PAGE = 0;
const DEFAULT_SIZE = 10;

export type Order = "ASC" | "DESC";

export interface Pageable {
     
    page: number;
    size: number;
    sort?: {
      property: string; order: Order };
}

export interface Page<T> {
     
    content: T[];
    totalElements: number;
}

export function parsePageQuery(query: Record<string, string>): Pageable {
     
    const {
     page: pageString, size: sizeString, sort: sortString} = query;

    const page = parseInt(pageString);
    const size = parseInt(sizeString);

    const pageable: Pageable = {
     
        page: isNaN(page) ? DEFAULT_PAGE : page,
        size: isNaN(size) ? DEFAULT_SIZE : size
    };

    if (sortString) {
     
        const sort = sortString.split(",");
        const [property, order] = sort;
        pageable.sort = {
     property, order: order ? <Order>order : "ASC"};
    }

    return pageable;
}

数据初始化

TypeORM没有提供类似hibernate的数据初始化方法,我们可以利用它的Migration功能达到此目的。

Migration常用来更新生产环境的数据库,可以执行各种数据库脚本:

export class PostRefactoringTIMESTAMP implements MigrationInterface {
     

    async up(queryRunner: QueryRunner): Promise<void> {
     
        await queryRunner.query(`ALTER TABLE "post" RENAME COLUMN "title" TO "name"`);
    }

    async down(queryRunner: QueryRunner): Promise<void> {
     
        await queryRunner.query(`ALTER TABLE "post" RENAME COLUMN "name" TO "title"`); // reverts things made in "up" method
    }
}

Migration类名必须含有TIMESTAMP,必须实现MigrationInterface接口,必须实现up()和down()两个方法,up用于执行migration,down用于回滚。
运行如下命令将会在migration目录创建{TIMESTAMP}-InitialData.ts文件:

typeorm migration:create -n InitialData

修改文件内容如下:
migration/1598262813255-InitialData.ts

import {
     MigrationInterface, QueryRunner} from "../deps.ts";

export class InitialData1598262813255 implements MigrationInterface {
     
    async up(queryRunner: QueryRunner): Promise<any> {
     
        const DATA = Deno.readTextFileSync("resources/data.sql").split("\r\n");

        for (let sql of DATA) {
     
            if (sql.trim().length > 0) {
     
                await queryRunner.query(sql);
            }
        }
    }

    async down(queryRunner: QueryRunner): Promise<any> {
     
        await queryRunner.query("TRUNCATE TABLE hero");
    }
}

up方法读取数据初始化文件,然后调用queryRunner的方法写入数据。
resources/data.sql

INSERT INTO hero(hero_name) VALUES('Dr Nice');
INSERT INTO hero(hero_name) VALUES('Narco');
INSERT INTO hero(hero_name) VALUES('Bombasto');
INSERT INTO hero(hero_name) VALUES('Celeritas');
INSERT INTO hero(hero_name) VALUES('Magneta');
INSERT INTO hero(hero_name) VALUES('RubberMan');
INSERT INTO hero(hero_name) VALUES('Dynama');
INSERT INTO hero(hero_name) VALUES('Dr IQ');
INSERT INTO hero(hero_name) VALUES('Magma');
INSERT INTO hero(hero_name) VALUES('Tornado');

INSERT INTO users(username, password, email, enabled) VALUES ('admin', '$2a$10$igIlvYV9lOf2n9j3SA3BM.m.cmAxkCbR/TcF57TY9Dl3R3wHnN6Fu', '[email protected]', true);
INSERT INTO users(username, password, email, enabled) VALUES ('jason', '$2a$10$7hv9FPHJWMDHss90Mq3m7eG/FPdtHrVr.TpGdU2Z0zzDMfhA7zuF6', '[email protected]', true);
INSERT INTO users(username, password, email, enabled) VALUES ('coco', '$2a$10$M/7EUC.lbNKhU7COEIO2c.lYnrFGqaUDeKI0DNQkmboF0IqpLFt.i', '[email protected]', false);

INSERT INTO authority (authority_name) VALUES ('ROLE_ADMIN');
INSERT INTO authority (authority_name) VALUES ('ROLE_USER');

INSERT INTO user_authority (user_id, authority_id) VALUES (1, 1);
INSERT INTO user_authority (user_id, authority_id) VALUES (1, 2);
INSERT INTO user_authority (user_id, authority_id) VALUES (2, 1);
INSERT INTO user_authority (user_id, authority_id) VALUES (3, 1);

修改ormconfig.json,添加"dropSchema": true 和 “migrationsRun”: true,每次启动程序时则会重建数据库、自动运行migration:

{
     
  "name": "default",
  "type": "sqlite",
  "database": "heroes.sqlite",
  "dropSchema": true,
  "synchronize": true,
  "logging": true,
  "migrationsRun": true,
  "entities": [
    "entity/**/*.ts"
  ],
  "migrations": [
    "migration/**/*.ts"
  ],
  "subscribers": [
    "subscriber/**/*.ts"
  ],
  "cli": {
     
    "entitiesDir": "entity",
    "migrationsDir": "migration",
    "subscribersDir": "subscriber"
  }
}

接下来,我们再修改一下app.ts,测试一下分页方法:

createConnection(connectionOptions).then(async (connection) => {
     
    console.log("Inserting a new hero into the database...");
    const hero = new Hero();
    hero.name = "Jason";

    const heroRepository = connection.getCustomRepository(HeroRepository);
    await heroRepository.save(hero);
    console.log("Saved a new hero with id: " + hero.id);

    console.log("Loading top 5 heroes from the database...");
    const heroes = await heroRepository.findAll({
     page: 0, size: 5});
    console.log("Loaded heroes: ", heroes);
}).catch((error) => console.log(error));

会输出如下结果:

Loaded heroes:  {
  content: [
    Hero {
      id: 1,
      name: "Dr Nice",
      createdDate: 2020-08-24T10:36:28.000Z,
      lastModifiedDate: 2020-08-24T10:36:28.000Z
    },
    Hero {
      id: 2,
      name: "Narco",
      createdDate: 2020-08-24T10:36:28.000Z,
      lastModifiedDate: 2020-08-24T10:36:28.000Z
    },
    Hero {
      id: 3,
      name: "Bombasto",
      createdDate: 2020-08-24T10:36:28.000Z,
      lastModifiedDate: 2020-08-24T10:36:28.000Z
    },
    Hero {
      id: 4,
      name: "Celeritas",
      createdDate: 2020-08-24T10:36:28.000Z,
      lastModifiedDate: 2020-08-24T10:36:28.000Z
    },
    Hero {
      id: 5,
      name: "Magneta",
      createdDate: 2020-08-24T10:36:28.000Z,
      lastModifiedDate: 2020-08-24T10:36:28.000Z
    }
  ],
  totalElements: 11
}

JWT

我们使用djwt来生成和校验JSON Web Token,djwt目前支持HMAC SHA-256 (“HS256”)、HMAC SHA-512 (“HS512”) 和 none (Unsecured JWTs)算法。

djwt的两个主要函数makeJwt 和 validateJwt,用于生成和校验JWT。另一个辅助函数setExpiration,用于设置过期时间,支持Date和number(秒)两种参数:

// A specific date:
setExpiration(new Date("2025-07-01"));
// One hour from now:
setExpiration(60 * 60);

下面是我们定义的生成和校验JWT的辅助函数:
util/jwt.ts

import {
     Jose, makeJwt, Payload, setExpiration, validateJwt} from "../deps.ts";
import {
     config} from "../config/config.ts";

const {
     
    JWT_SECRET,
    JWT_ACCESS_TOKEN_EXP,
    JWT_REFRESH_TOKEN_EXP,
    JWT_ISSUER,
} = config;

const header: Jose = {
     
    alg: "HS256",
    typ: "JWT",
};

async function makeAccessToken(user: any): Promise<string> {
     
    const payload: Payload = {
     
        iss: JWT_ISSUER,
        sub: user.username,
        email: user.email,
        authorities: user.authorities.map((auth: {
      name: string }) => {
     
            return auth.name;
        }),
        exp: setExpiration(parseInt(JWT_ACCESS_TOKEN_EXP)),
    };

    return await makeJwt({
     key: JWT_SECRET, header, payload});
}

async function makeRefreshToken(user: any): Promise<string> {
     
    const payload: Payload = {
     
        iss: JWT_ISSUER,
        sub: user.username,
        exp: setExpiration(parseInt(JWT_REFRESH_TOKEN_EXP)),
    };

    return makeJwt({
     key: JWT_SECRET, header, payload});
}

async function getPayload(jwt: string): Promise<Payload | null> {
     
    const jwtValidation = await validateJwt(
        {
     jwt, key: JWT_SECRET, algorithm: "HS256"}
    );
    return jwtValidation.isValid ? jwtValidation.payload : null;
}

export {
     makeAccessToken, makeRefreshToken, getPayload};

Service

service/hero_service.ts
对HeroRepository进行简单的封装,其中getHeroById调用了findOneOrFail方法,当没有找到hero时会抛出异常。

import {
     DeleteResult, getCustomRepository} from "../deps.ts";
import {
     HeroRepository} from "../repository/HeroRepository.ts";
import {
     Page, Pageable} from "../util/pages.ts";
import {
     Hero} from "../entity/hero.ts";

async function getHeroById(id: number): Promise<Hero> {
     
    return await getCustomRepository(HeroRepository).findOneOrFail(id);
}

async function getHeroesByPage(pageable: Pageable): Promise<Page<Hero>> {
     
    return await getCustomRepository(HeroRepository).findAll(pageable);
}

async function findHeroesByName(name: string): Promise<Hero[]> {
     
    return await getCustomRepository(HeroRepository).findByName(name);
}

async function saveHero(hero: Hero): Promise<Hero> {
     
    return await getCustomRepository(HeroRepository).save(hero);
}

async function deleteHeroById(id: number): Promise<DeleteResult> {
     
    return await getCustomRepository(HeroRepository).delete(id);
}

export {
     getHeroById, getHeroesByPage, findHeroesByName, saveHero, deleteHeroById};

service/user_service.ts
校验用户名、密码,校验通过则返回token和refresh token,否则抛出401异常。

import {
     LoginCredential} from "../interfaces/LoginCredential.ts";
import {
     bcrypt, getCustomRepository, httpErrors} from "../deps.ts";
import {
     makeAccessToken, makeRefreshToken} from "../util/jwt.ts";
import {
     UserRepository} from "../repository/UserRepository.ts";

export async function authenticate(credential: LoginCredential) {
     
    const {
     username, password} = credential;
    const user = await getCustomRepository(UserRepository).findByUsername(username);

    if (user) {
     
        const passHash = user.password;
        const isValidPass = await bcrypt.compare(password, passHash);
        if (isValidPass) {
     
            return {
     
                "token": await makeAccessToken(user),
                "refresh_token": await makeRefreshToken(user)
            };
        }
    }

    throw new httpErrors.Unauthorized("Wrong credential");
}

Oak

oak灵感来源于Node.js Web 框架Koa,路由中间件的灵感来源于@koa/router。

下面是oak的一个例子:

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

const app = new Application();

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

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

// Hello World!
app.use((ctx) => {
     
  console.log(">> hello world");
  ctx.response.body = "Hello World!";
  console.log("<< hello world");
});

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

从上例可见启动一个oak服务非常简单,仅需使用Application类,调用use()方法添加中间件,然后调用listen()方法启动服务。

洋葱模型
谈到中间件,就不得不说一下Koa的 “洋葱模型”:
Deno开发REST API实战_第6张图片
Koa 通过洋葱模型封装 HTTP,中间件相当于洋葱层,每个请求都会一层层的经过中间件。调用中间件时,将传递context和堆栈中的next方法,每个中间件函数都可以控制响应流。

访问上面的的oak服务http://localhost:8000/,后台会输出如下的日志:

>> logger middleware
>> timing middleware
>> hello world
<< hello world
<< timing middleware
GET http://localhost:8000/ - 2ms
<< logger middleware
>> logger middleware
>> timing middleware
>> hello world
<< hello world
<< timing middleware
GET http://localhost:8000/favicon.ico - 1ms
<< logger middleware

Router
上面的示例中配置了logger和timing两个全局中间件,使用任何URI访问服务,页面都会显示"Hello World!"。当然你可以根据context.request.url.pathname显示不同内容,但往往还需要复杂的路径分析、获取请求参数等。幸好有Router来解决这些问题:

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

const books = new Map<string, any>();
books.set("1", {
     
  id: "1",
  title: "The Hound of the Baskervilles",
  author: "Conan Doyle, Arthur",
});

const router = new Router();
router
  .get("/", (context) => {
     
    context.response.body = "Hello world!";
  })
  .get("/book", (context) => {
     
    context.response.body = Array.from(books.values());
  })
  .get("/book/:id", (context) => {
     
    if (context.params && context.params.id && books.has(context.params.id)) {
     
      context.response.body = books.get(context.params.id);
    }
  });

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

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

实际应用中,一般创建一个单独的routes.ts文件,其中只保存router配置信息,在controller中定义处理请求和响应的代码。Router中每个路径还可以绑定其他功能的middleware,比如权限控制等。

routes.ts

import {
     Router} from "./deps.ts";
import {
     addHero, deleteHero, getHero, getHeroes, searchHeroes, updateHero} from "./controller/hero_controller.ts";
import {
     login} from "./controller/auth_controller.ts";
import {
     authorityGuard} from "./middleware/authority_guard.ts";

const router = new Router({
     prefix: "/api"});

router.post("/auth", login);

router.get("/heroes/", (context, next) => authorityGuard(context, next, ["USER"]), searchHeroes)
    .get("/heroes", (context, next) => authorityGuard(context, next, ["USER"]), getHeroes)
    .get("/heroes/:id", (context, next) => authorityGuard(context, next, ["USER"]), getHero)
    .post("/heroes", (context, next) => authorityGuard(context, next, ["ADMIN"]), addHero)
    .put("/heroes", (context, next) => authorityGuard(context, next, ["ADMIN"]), updateHero)
    .delete("/heroes/:id", (context, next) => authorityGuard(context, next, ["ADMIN"]), deleteHero);

export default router;

接下来,删除app.ts的测试代码,启用oak吧:
app.ts

import {
     
    Application,
    bold,
    ConnectionOptions,
    ConnectionOptionsReader,
    createConnection,
    oakCors,
    yellow,
} from "./deps.ts";
import {
     config} from "./config/config.ts";
import router from "./routes.ts";
import {
     authorizationGuard, errorHandler, logger, notFound, oakSnelm, timing} from "./middleware/mod.ts";

/**
 * Reads connection options stored in ormconfig configuration file.
 */
async function getConnectionOptions(connectionName: string = "default"): Promise<ConnectionOptions> {
     
    return new ConnectionOptionsReader({
     root: "."}).get(connectionName);
}

const {
     APP_HOST, APP_PORT, CORS_ORIGIN, CORS_METHODS, CORS_ALLOWED_HEADERS} = config;

const connectionOptions: ConnectionOptions = await getConnectionOptions();

await createConnection(connectionOptions);

const cors = oakCors({
     
    origin: CORS_ORIGIN,
    methods: CORS_METHODS,
    allowedHeaders: CORS_ALLOWED_HEADERS
});

const app = new Application();

app.use(oakSnelm);
app.use(cors);
app.use(logger);
app.use(timing);
app.use(errorHandler);
app.use(authorizationGuard);

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

app.addEventListener("listen", ({
     hostname, port}) => {
     
    console.log(bold("Start listening on ") + yellow(`${
       hostname}:${
       port}`));
});

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

下面两节依次讲解涉及的middleware和controller。

中间件

Snelm

Snelm 是 Deno Web 框架的安全中间件,可完全自定义,支持以下Web框架:

  • Oak framework
  • ABC framework
  • Alosaur framework
  • Pogo framework
  • Aqua framework
  • Attain framework

Snelm支持以下安全组件,可使用后面的关键字进行配置:

  • X-Permitted-Cross-Domain-Policies Middleware -> crossDomain
  • Content Security Policy Middleware -> csp
  • DNS Prefetch Control Middleware -> dnsPrefetchControl
  • Dont Sniff Mimetype Middleware -> dontSniffMimetype
  • Expect-CT Middleware -> expectCt
  • Feature Policy Middleware -> featurePolicy
  • Frameguard Middleware -> frameguard
  • Hide X-Powered-By Middleware -> hidePoweredBy
  • HTTP Strict Transport Security Middleware -> hsts
  • Internet Explorer Restrict Untrusted HTML Middleware -> ieNoOpen
  • Referrer Policy Middleware -> referrerPolicy
  • X-XSS-Protection Middleware -> xssProtection

Snelm为所有主要Web框架提供简单、统一的接口,仅需指定框架名称、初始化,然后给snelm函数传入request 和 response对象,Snelm将为response添加security header。
middleware/snelm.ts

import {
     Context, Snelm} from "../deps.ts";

// referrerPolicy config
const referrerPolicyConfig = {
     
    policy: 'same-origin'
};

// hidePoweredBy config
const hidePoweredByConfig = {
     
    setTo: 'iTRunner'
};

const snelm = new Snelm("oak", {
     
    referrerPolicy: referrerPolicyConfig,
    hidePoweredBy: hidePoweredByConfig
});

export async function oakSnelm(ctx: Context, next: () => Promise<void>) {
     
    ctx.response = snelm.snelm(ctx.request, ctx.response);
    await next();
}

若要禁用某一组件,仅需设置其值为null:

const snelm = new Snelm("oak", {
     
    hidePoweredBy: null,
});

CORS

cors支持ABC、Attain、Mith、Oak、Opine Web框架。支持以下配置选项:

  • origin 配置 Access-Control-Allow-Origin CORS header
  • methods 配置 Access-Control-Allow-Methods CORS header
  • allowedHeaders 配置 Access-Control-Allow-Headers CORS header
  • exposedHeaders 配置 Access-Control-Expose-Headers CORS header
  • credentials 配置 Access-Control-Allow-Credentials CORS header
  • maxAge 配置 Access-Control-Max-Age CORS header
  • preflightContinue Pass the CORS preflight response to the next handler
  • optionsSuccessStatus Provides a status code to use for successful OPTIONS requests

例如:

import {
      oakCors } from "https://deno.land/x/cors/mod.ts";

const cors = oakCors({
     
    origin: "*",
    methods: "GET,POST,DELETE,PUT,OPTIONS",
    allowedHeaders: "Accept,Accept-Encoding,Accept-Language,Authorization,Connection,Content-Type,Host,Origin,Referer,User-Agent,X-Requested-With"
});

错误处理

定义一个负责全局错误处理的中间件Error Handler,这样就可以集中配置各种错误的响应状态码和错误信息。
middleware/error_handler.ts

import {
     Context, isHttpError, Status} from "../deps.ts";

export async function errorHandler(context: Context, next: () => Promise<void>) {
     
    try {
     
        await next();
    } catch (err) {
     
        console.log(err);

        if (isHttpError(err)) {
     
            context.response.status = err.status;
        } else {
     
            switch (err.name) {
     
                case "EntityNotFound":
                    context.response.status = Status.NotFound;
                    break;
                default:
                    // handle other statuses
                    context.response.status = Status.BadRequest;
            }
        }

        context.response.body = {
     error: err.name, message: err.message, timestamp: Date.now()};
        context.response.type = "json";
    }
}

Authorization Guard

Authorization Guard检查Authorization header,验证Bearer Token是否有效,仅授权用户才能访问受保护的资源,并将用户信息保存在context.state中。
middleware/authorization_guard.ts

import {
     Context, httpErrors, Request} from "../deps.ts";
import {
     getPayload} from "../util/jwt.ts";
import {
     config} from "../config/config.ts";

const ignorePaths = config.IGNORE_PATHS.split(",");

/**
 * Authorization Guard Middleware
 */
async function authorizationGuard(context: Context, next: () => Promise<void>) {
     
    if (!isIgnore(context.request)) {
     
        await parseBearerToken(context);

        if (!context.state.user) {
     
            throw new httpErrors.Unauthorized();
        }
    }

    await next();
}

function isIgnore(request: Request) {
     
    return request.method === "OPTIONS" || ignorePaths.indexOf(request.url.pathname) >= 0;
}

async function parseBearerToken(context: Context): Promise<void> {
     
    const authHeader = context.request.headers.get("Authorization");
    if (authHeader) {
     
        const token = authHeader.replace(/^bearer/i, "").trim();
        const user = await getPayload(token);

        if (user) {
     
            context.state.user = user;
        }
    }
}

export {
     authorizationGuard};

Authority Guard

Authority Guard与router绑定,仅拥有相应角色的用户才能访问此路径。

router.get("/heroes/", (context, next) => authorityGuard(context, next, ["USER"]), searchHeroes)

middleware/authority_guard.ts

import {
     httpErrors, RouterContext, Status} from "../deps.ts";

/**
 * Authority Guard Middleware
 */
async function authorityGuard(context: RouterContext, next: () => Promise<void>, roles: string[]) {
     
    context.assert(roles, Status.BadRequest);
    context.assert(context.state.user, Status.Unauthorized);

    if (hasAnyRole(context.state.user.authorities, roles)) {
     
        await next();
    } else {
     
        throw new httpErrors.Forbidden();
    }
}

function hasAnyRole(authorities: string[], roles: string[]) {
     
    let hasRole = false;

    for (const role of roles) {
     
        if (authorities.indexOf(`ROLE_${
       role}`) >= 0) {
     
            hasRole = true;
            break;
        }
    }

    return hasRole;
}

export {
     authorityGuard};

Not Found

当访问不存在的router时,直接返回404。
middleware/not_found.ts

import {
     Context, Status} from "../deps.ts";

function notFound(context: Context) {
     
    context.response.status = Status.NotFound;
    context.response.body = {
     error: "NotFound", message: `Path ${
       context.request.url} not found`};
}

export {
     notFound};

Controller

Controller调用相应的service,调用成功则设置response body 和 status。默认status值为Status.OK(200),一般不需设置。
controller/hero_controller.ts

import {
     lengthBetween, match, oakHelpers, required, RouterContext, Status} from "../deps.ts";
import {
     Hero} from "../entity/hero.ts";
import {
     parsePageQuery} from "../util/pages.ts";
import {
     validateRequest} from "../util/validator.ts";
import {
     deleteHeroById, findHeroesByName, getHeroById, getHeroesByPage, saveHero} from "../service/hero_service.ts";

// get heroes
async function getHeroes(context: RouterContext) {
     
    const query = oakHelpers.getQuery(context);
    context.response.body = await getHeroesByPage(parsePageQuery(query));
}

// get hero by id
async function getHero(context: RouterContext) {
     
    const {
     id} = context.params;
    await validateId(id);

    if (id) {
     
        context.response.body = await getHeroById(parseInt(id));
    }
}

// search heroes by name
async function searchHeroes(context: RouterContext) {
     
    const {
     name} = oakHelpers.getQuery(context);
    await validateName(name);
    context.response.body = await findHeroesByName(name);
}

// add a new hero
async function addHero(context: RouterContext) {
     
    const hero: Hero = await context.request.body().value;
    await validateHero(hero);
    context.response.body = await saveHero(hero);
}

// update a hero information
async function updateHero(context: RouterContext) {
     
    const hero: Hero = await context.request.body().value;
    await validateHero(hero);
    context.response.body = await saveHero(hero);
}

// Delete a hero by id
async function deleteHero(context: RouterContext) {
     
    const {
     id} = context.params;
    await validateId(id);

    if (id) {
     
        await deleteHeroById(parseInt(id));
        context.response.status = Status.OK;
    }
}

async function validateHero(hero: Hero): Promise<void> {
     
    const heroSchema = {
     name: [required, lengthBetween(3, 30)]}
    await validateRequest(hero, heroSchema);
}

async function validateId(id: string | undefined): Promise<void> {
     
    await validateRequest(
        {
     id},
        {
     id: [match(/^\d*$/)]},
        {
     messages: {
     "id": "id must be a number"}}
    );
}

async function validateName(name: string | undefined): Promise<void> {
     
    await validateRequest(
        {
     name},
        {
     name: [required]},
        {
     messages: {
     "name.required": "Required parameter 'name' is not present"}}
    );
}

export {
     
    addHero,
    deleteHero,
    getHero,
    getHeroes,
    searchHeroes,
    updateHero,
};

controller/auth_controller.ts

import {
     Request, required, Response} from "../deps.ts";
import {
     LoginCredential} from "../interfaces/LoginCredential.ts";
import * as authService from "../service/user_service.ts";
import {
     validateRequest} from "../util/validator.ts";

async function login({
     request, response}: {
      request: Request; response: Response }) {
     
    const credential = (await request.body().value) as LoginCredential;
    await validateCredential(credential);
    response.body = await authService.authenticate(credential);
}

async function validateCredential(credential: LoginCredential): Promise<void> {
     
    const credentialSchema = {
     username: [required], password: [required]};
    await validateRequest(credential, credentialSchema);
}

export {
     login};

校验

在controller中对请求参数进行了校验,我们使用了Validasaur校验模块,它提供常用的校验规则,可以自定义错误信息,也可以自定义校验规则,简单易用。

例如:

import {
     
  validate,
  flattenMessages,
  firstMessages,
  required,
  isNumber
} from "https://deno.land/x/validasaur/mod.ts";

const inputs = {
     
  name: "",
  age: "20"
};

const [ passes, errors ] = await validate(inputs, {
     
  name: required,
  age: [required, isNumber]
});

const firstErrors = firstMessages(errors);
const flattenErrors = flattenMessages(errors);

// Show the difference
console.log({
     
  defaultErrors: errors,
  firstErrors,
  flattenErrors
});

Validasaur支持三种错误信息格式,上例的输出结果如下:

{
  "defaultErrors": {
    "name": {
      "required": "name is required"
    },
    "age": {
      "isNumber": "age must be a number"
    }
  },
  "firstErrors": {
    "name": "name is required",
    "age": "age must be a number"
  },
  "flattenErrors": {
    "name.required": "name is required",
    "age.isNumber": "age must be a number",
    "name": "name is required",
    "age": "age must be a number"
  }
}

我们定义一个辅助函数,当校验失败时抛出BadRequest异常,并将错误信息拼接为一个字符串:
util/validator.ts

import {
     
    firstMessages,
    httpErrors,
    InputData,
    validate,
    ValidationErrors,
    ValidationOptions,
    ValidationRules
} from "../deps.ts";

function getMessages(errors: ValidationErrors): string {
     
    const firstErrors = firstMessages(errors);

    let messages = "";
    for (let field in firstErrors) {
     
        messages += firstErrors[field] + "; ";
    }

    return messages;
}

async function validateRequest(input: InputData, rules: ValidationRules, options?: ValidationOptions) {
     
    // @ts-ignore
    const [isValid, errors] = await validate(input, rules, options);

    if (!isValid) {
     
        const message = getMessages(errors);
        throw new httpErrors.BadRequest(message);
    }
}

export {
     validateRequest};

自定义校验错误信息的方式如下:

async function validateId(id: string | undefined): Promise<void> {
     
    await validateRequest(
        {
     id},
        {
     id: [match(/^\d*$/)]},
        {
     messages: {
     "id": "id must be a number"}}
    );
}

async function validateName(name: string | undefined): Promise<void> {
     
    await validateRequest(
        {
     name},
        {
     name: [required]},
        {
     messages: {
     "name.required": "Required parameter 'name' is not present"}}
    );
}

运行App

程序开发完成,赶快启动服务来看一下效果吧:

deno run --allow-env --allow-net --allow-read --allow-write --unstable --config tsconfig.json app.ts
或
denon start

您可以使用Postman测试REST API:
Deno开发REST API实战_第7张图片
也可以运行前台Angular程序查看效果:
Deno开发REST API实战_第8张图片

测试

Deno Test

Deno内建test runner(Deno.test),testing标准库(https://deno.land/std/testing)提供了一些断言函数。

为调用Deno.test执行测试需要要定义测试的名称和函数,例如:
util/pages_test.ts

import {
     assertEquals} from "../deps.ts";
import {
     parsePageQuery} from "./pages.ts";

Deno.test("default pageable parameter", () => {
     
    assertEquals(parsePageQuery({
     }), {
     page: 0, size: 10});
    assertEquals(parsePageQuery({
     page: "page", size: "size"}), {
     page: 0, size: 10});
    assertEquals(parsePageQuery({
     sort: "field"}), {
     page: 0, size: 10, sort: {
     property: "field", order: "ASC"}});
});

Deno.test("normal pageable parameter", () => {
     
    assertEquals(parsePageQuery({
     page: "2", size: "10"}), {
     page: 2, size: 10});
    assertEquals(parsePageQuery({
     page: "2", size: "10", sort: "field,DESC"}),
        {
     page: 2, size: 10, sort: {
     property: "field", order: "DESC"}});
});

异步测试:
util/validator_test.ts

import {
     assertThrowsAsync, HttpError, isEmail, lengthBetween, required} from "../deps.ts";
import {
     validateRequest} from "./validator.ts";

const userSchema = {
     
    username: [required, lengthBetween(3, 30)],
    password: [required, lengthBetween(10, 20)],
    email: [required, isEmail]
}

Deno.test("Test Assert Throws Async", async () => {
     
    assertThrowsAsync(async () => {
     
            return await validateRequest({
     }, userSchema);
        },
        HttpError,
        "username is required; password is required; email is required;");

    assertThrowsAsync(async () => {
     
            return await validateRequest({
     username: "A", password: "123456", email: "test"}, userSchema);
        },
        HttpError,
        "username characters length must be between 3-30; " +
        "password characters length must be between 10-20; " +
        "email is not a valid email address;");
});

运行测试
运行deno test命令进行测试:

# Run just pages_test.ts
deno test util/pages_test.ts

# Run all tests in the util directory
deno test util/

# Run all tests in the current directory and all sub-directories
deno test 

可以指定具体的测试文件名称,也可以指定目录,未提供参数时则为当前目录,会自动运行目录和子目录下所有以"_test.ts"或".test.ts"结尾的文件。

SuperOak

SuperOak用于测试Oak Web框架,它扩展了SuperDeno,可以减少Oak 集成和功能测试所需的一些样板代码。

SuperOak可以测试正在运行的Oak服务(传递一个URL),也可以自动绑定一个临时端口,自主启停Oak Application。在我们的代码中已指定了主机和端口,需要进行如下修改:

if (import.meta.main) {
     
    await app.listen(`${
       APP_HOST}:${
       APP_PORT}`);
}

export {
     app};

通过检查import.meta.main,可以判断app.ts是否作为main input被执行。SuperOak启动Oak Application时则不会执行此段代码,而是使用一个随机未占用的端口,下面是superoak函数的部分代码:

const freePort = await getFreePort(random(1024, 49151));

app.listen(
  {
      hostname: "127.0.0.1", port: freePort, signal },
);

使用SuperOak测试非常简单,仅需调用superoak函数,然后调用http方法,可以调用一次或多次expect执行断言检查,如下:

Deno.test("should get token successfully", async () => {
     
    const request = await superoak(app);
    await request.post("/api/auth").send({
     username: "admin", password: "admin"})
        .expect('Content-Type', 'application/json; charset=utf-8')
        .expect(/{"token":.*,"refresh_token":.*}/i);
});

每个调用链结束SuperOak都会关闭服务,因此SuperOak实例不能重用,若要连续测试其他API需要创建一个新实例,如下:

Deno.test("crud should be executed successfully", async () => {
     
    // add hero
    let request = await superoak(app);
    await request.post("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     name: "Jack"})
        .expect(200).expect(/{"name":"Jack","id":11,.*}/);

    // update hero
    request = await superoak(app);
    await request.post("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     name: "Jacky", id: 11})
        .expect(200).expect(/{"name":"Jacky","id":11,.*}/);
    ...
});

完整的测试代码如下:
app_test.ts

import {
     superoak} from "./deps.ts";
import {
     app} from "./app.ts";
import {
     makeAccessToken} from "./util/jwt.ts";
import {
     User} from "./entity/user.ts";
import {
     Authority, AuthorityName} from "./entity/authority.ts";

async function generateAdminToken() {
     
    const user = new User();
    user.username = "admin";
    user.email = "[email protected]";

    const roleAdmin = new Authority();
    roleAdmin.name = AuthorityName.ADMIN;
    const roleUser = new Authority();

    user.authorities = [roleAdmin, roleUser];

    return await makeAccessToken(user);
}

async function generateUserToken() {
     
    const user = new User();
    user.username = "user";
    user.email = "[email protected]";

    const roleUser = new Authority();

    user.authorities = [roleUser];

    return await makeAccessToken(user);
}

const adminToken = await generateAdminToken();
const userToken = await generateUserToken();

Deno.test("Unauthorized Error", async () => {
     
    const request = await superoak(app);
    await request.get("/api/heroes").expect(401);
});

Deno.test("Forbidden Error", async () => {
     
    const request = await superoak(app);
    await request.post("/api/heroes").auth(userToken, {
     type: "bearer"}).send({
     name: "Jack"})
        .expect(403);
});

Deno.test("should get token successfully", async () => {
     
    const request = await superoak(app);
    await request.post("/api/auth").send({
     username: "admin", password: "admin"})
        .expect('Content-Type', 'application/json; charset=utf-8')
        .expect(/{"token":.*,"refresh_token":.*}/i);
});

Deno.test("should provide correct credential", async () => {
     
    const request = await superoak(app);
    await request.post("/api/auth").send({
     username: "admin", password: "111111"})
        .expect(401);
});

Deno.test("crud should be executed successfully", async () => {
     
    // add hero
    let request = await superoak(app);
    await request.post("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     name: "Jack"})
        .expect(200).expect(/{"name":"Jack","id":11,.*}/);

    // update hero
    request = await superoak(app);
    await request.post("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     name: "Jacky", id: 11})
        .expect(200).expect(/{"name":"Jacky","id":11,.*}/);

    // find heroes by name
    request = await superoak(app);
    await request.get("/api/heroes/?name=m").auth(adminToken, {
     type: "bearer"})
        .expect(200);

    // find heroes by page
    request = await superoak(app);
    await request.get("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     page: 0, size: 10})
        .expect(200).expect(/{.*"totalElements":11.*}/);

    // get hero by id
    request = await superoak(app);
    await request.get("/api/heroes/11").auth(adminToken, {
     type: "bearer"})
        .expect(200).expect(/{"id":11,"name":"Jacky",.*}/);

    // delete hero
    request = await superoak(app);
    await request.delete("/api/heroes/11").auth(adminToken, {
     type: "bearer"}).expect(200);
});

Deno.test("validation failed", async () => {
     
    // add hero
    const request = await superoak(app);
    await request.post("/api/heroes").auth(adminToken, {
     type: "bearer"}).send({
     name: "J"})
        .expect(400).expect(/.*,"message":"name characters length must be between 3-30; ",.*/i);
});

Deno.test("Path not found", async () => {
     
    const request = await superoak(app);
    await request.get("/api/test").auth(adminToken, {
     type: "bearer"}).expect(404);
});

Deno.test("Entity not found", async () => {
     
    const request = await superoak(app);
    await request.get("/api/test/9999").auth(adminToken, {
     type: "bearer"}).expect(404);
});

运行测试

deno test --allow-env --allow-net --allow-read --allow-write --unstable --config tsconfig.json
或
denon test

常见问题

  1. TS1205 [ERROR]: Re-exporting a type when the ‘–isolatedModules’ flag is provided requires using ‘export type’. TS1371 [ERROR]: This import is never used as a value and must use ‘import type’ because the ‘importsNotUsedAsValues’ is set to ‘error’.

Deno升级到1.4后,编译时会输出上面的错误,解决办法是在tsconfig.json中添加"importsNotUsedAsValues"和"isolatedModules"选项,如下:

{
     
   "compilerOptions": {
     
      "experimentalDecorators": true,
      "importsNotUsedAsValues": "remove",
      "isolatedModules": false,
      "strictPropertyInitialization": false
   }
}

相关资料请查看Type-Only Imports and Export。

参考资料

Deno
Deno中文
Nest.land - A module registry for Deno
Koa - next generation web framework for node.js
oak - A middleware framework for Deno’s net server ️
TypeORM - ORM for TypeScript and JavaScript
Deno fork of TypeORM
Build an in-memory REST API with Deno and Oak
Creating your first REST API with Deno and Postgres
Deno Docker

你可能感兴趣的:(Deno)