精华 【翻译】How to build a basic API with TypeScript, Koa, and TypeORM
发布于 3 年前 作者 gocpplua 6113 次浏览 来自 分享

原文:How to build a basic API with TypeScript, Koa, and TypeORM

本教程探讨如何使用 TypeScript、Koa 和 TypeORM 构建基本 API。 你需要一个支持 await/async 的 Node.js 版本。

TypeScript 是一种开源编程语言,在软件开发社区中越来越流行。

由 Microsoft 开发和维护,它减少了像我这样的软件工程师需要编写的测试数量,并通过在您键入时报告错误来加快开发速度,这要归功于快速和智能的 VSCode 集成。

本教程将通过演示实现和使用 TypeScript 的简单程度,向您展示 TypeScript 的一些优势。 我们还将探索附加功能(例如类装饰器)如何进一步加速您的开发。

为此,我们将创建一个简单的 API,用于存储电影名称、发行年份和数字评级。 然后,数据将使用 TypeORM(一种 TypeScript 友好的数据映射器)存储在 PostgreSQL 中。

这篇文章基于 Node.js 10,但 8 就可以了。 您还需要一个可用的 PostgreSQL 安装。 我们将记录如何通过 Docker 启动和运行它,但如果您没有可用的 Docker,您可以尝试在线服务,例如 ElephantSQL

设置项目

Node 基础

首先,我们将创建一个基本的 Node.js 项目。 使用以下命令开始:

mkdir -p typescript-koa && cd typescript-koa

然后我们要创建 Node.js 项目。 我们可以使用速记,因为我们不会创建一个实时项目:

npm init -y

最后,我们将要获取正常的 Node 依赖项:

现在我们已准备好设置 TypeScript。

TypeScript 设置

我们已经安装了基本的 Node 依赖项,理论上我们现在就可以开始了。 但是我们想要 TypeScript,所以让我们配置它。 我们首先需要一些额外的依赖。 不过,这些只是这次的开发依赖项:

npm i -D typescript ts-node tslint tslint-config-airbnb nodemon

这将为我们提供启动和运行所需的大部分环境。 通常,我会建议不要使用 ts-node 并提倡使用 Docker,但这只是一个简短的概述,我们现在将使用它。

最后,我们将要添加我们的类型定义。 以前,我们需要为此使用 Typings,但现在我们可以从 @types 组织安装。 如果您想了解更多信息,您绝对应该查看绝对类型的 GitHub 存储库和来自 Microsoft 的 TypeSearch

npm i -D [@types](/user/types)/{node,koa,koa-router,http-status-codes,koa-bodyparser}

安装@types/node 将安装最新版本的类型定义。 如果您运行的是 NodeJS 8,那么您需要在安装时指定您的特定版本。

我们现在已经拥有了开始所需的一切。 接下来我们需要一个配置文件,让 TypeScript 知道如何处理我们的项目。 在您的存储库根目录中创建一个名为 tsconfig.json 的文件并粘贴以下内容:

{
  "compilerOptions": {
    "target": "ES2017",
    "module": "commonjs",
    "lib": ["es2017"],
    "outDir": "dist",
    "rootDir": "src",
    "noImplicitAny": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  }
}

我们不会深入了解每个设置的作用,但是如果您想了解更多信息,可以运行 node_modules/.bin/tsc --init。 这将创建一个新的配置文件,其中列出了所有可用选项,并附有描述其功能的注释。

最后,我们将设置 TSLint。 这不是必需的,但这是一个很好的实践。 创建一个名为 tslint.json 的文件并粘贴以下内容:

{
  "extends": "tslint-config-airbnb",
  "rules": {
    "import-name": false
  }
}

这包括 Airbnb 规则TSLint 版本。我们唯一要删除的是导入名称规则,因为它可能非常严格。

我们快到了…!

设置 Nodemon 以实现无缝服务器重启

幸运的是,当我们使用 ts-node 时,这并不复杂,因为我们实际上不需要在每次重启之间编译 TypeScript。

为了在发生更改时重新启动我们的服务器,我们将使用 Nodemon 监视更改。 另一种选择是 PM2,这在 Docker 容器中开发时特别有用,但这是另一个时间的更大主题。

我的偏好是将其配置保存在一个单独的文件中; 创建一个 nodemon.json 文件并添加以下内容:

{
  "watch": ["src"],
  "exec": "npm run serve",
  "ext": "ts"
}

这将监视 src 目录中 .ts 文件的任何更改,然后运行 npm run start。 这意味着我们需要在 package.json 中设置一个启动脚本。 打开你的 package.json 并添加以下脚本:

"scripts": {
  "lint": "tslint --project tsconfig.json --format stylish",
  "build": "tsc",
  "serve": "ts-node src/server.ts",
  "start": "nodemon"
}

在这里,我们向 lint 添加了一个脚本,以防您选择的编辑器没有自动执行(就个人而言,我使用带有 TSLint 扩展的 VSCode)。

我们还添加了一个通用的 serve 命令来运行服务器。 此命令不会监视更改或重新启动。

最后,我们使启动脚本直接指向 nodemon。 然后,Nodemon 将在任何更改时依次运行(并重新运行)npm serve。

如前所述,由于我们使用的是 ts-node,因此我们根本不需要编译。 也就是说,我们已经添加了一个构建命令。 这意味着您的项目可以通过 npm run SCRIPT(即 npm run lint)直接从 npm 运行。 我们也可以跳过启动脚本的运行部分,通过运行 npm start 来启动我们的项目。

###在 Docker 中设置 PostgreSQL(可选) 因为我们将使用 PostgreSQL 作为我们 API 的数据库,所以我们需要一个可用的安装。 为此,我们将使用 Docker。 如果你已经在本地安装了 PostgreSQL,或者你已经配置了一个外部服务,那么你可以跳过这一步。

创建一个 Docker Compose 文件。 在您的项目根目录中创建一个文件名 docker-compose.yml,并添加以下内容:

version: '3'

services:

  database:
    image: postgres:11-alpine
    restart: always
    expose:
      - "5432"
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: typescript-koa

  adminer:
    image: adminer:latest
    restart: always
    ports:
      - "8080:8080"
    environment:
      ADMINER_DEFAULT_SERVER: database
      ADMINER_DESIGN: lucas-sandery

这将使 PostgreSQL 在端口 5432 上本地可用,并且 Adminer 管理 GUI 在 http://127.0.0.1:8080 上可用,一旦容器启动,我们将在稍后进行。

现在我们准备好写一些代码了!

构建我们的 API

准备好 Koa

我们要做的第一件事是运行一个基本的 Koa 应用程序。 我们将要创建的应用程序将允许我们存储电影名称、发行日期和数字评级。

运行以下命令以创建应用程序文件:

mkdir -p src/app && touch src/app/app.ts

这是我们将在其中创建基本应用程序的文件。 在此文件中,添加以下代码:

import * as Koa from 'koa';
import * as HttpStatus from 'http-status-codes';

const app:Koa = new Koa();

// Generic error handling middleware.
app.use(async (ctx: Koa.Context, next: () => Promise<any>) => {
  try {
    await next();
  } catch (error) {
    ctx.status = error.statusCode || error.status || HttpStatus.INTERNAL_SERVER_ERROR;
    error.status = ctx.status;
    ctx.body = { error };
    ctx.app.emit('error', error, ctx);
  }
});

// Initial route
app.use(async (ctx:Koa.Context) => {
  ctx.body = 'Hello world';
});

// Application error logging.
app.on('error', console.error);

export default app;

Koa 完全基于中间件。 上面的代码创建了一个 Koa 实例,并添加了一小块自定义中间件来稍微改进我们的错误日志记录。 对于实际应用程序,您可能需要更健壮的东西,但这对我们来说非常有用。

您会注意到我们也在导出应用程序。 这有两个目的:

  1. 它使我们的应用程序保持模块化,并且不会将我们的应用程序定义与服务器的运行联系起来。
  2. 它使我们能够更轻松地测试应用程序。

要启动我们的服务器运行,请在 src 目录中创建一个名为 server.ts 的文件:touch src/server.ts。

在这个文件中,我们将导入我们的应用程序并启动服务器。 为此,我们需要以下代码:

import app from './app/app';

// Process.env will always be comprised of strings, so we typecast the port to a
// number.
const PORT:number = Number(process.env.PORT) || 3000;

app.listen(PORT);

如果你现在运行 npm start,Nodemon 应该启动我们的服务器监听 3000 端口。如果你访问 127.0.0.1:3000 你应该会看到一个很好的“Hello world”。

这一切都很好,但是我们将如何创建具有单一端点的movies?

Adding our routes

要添加我们的路由,我们需要使用 koa-router

Koa 不附带开箱即用的路由器,而是让您以比其他框架更加模块化的方式组合应用程序。 – Koajs.com

为了便于使用,我们将在单独的文件中创建我们的路由:

mkdir src/movie && touch src/movie/movie.controller.ts

打开这个文件,粘贴以下代码:

import * as Koa from 'koa';
import * as Router from 'koa-router';

const routerOpts: Router.IRouterOptions = {
  prefix: '/movies',
};

const router: Router = new Router(routerOpts);

router.get('/', async (ctx:Koa.Context) => {
  ctx.body = 'GET ALL';
});

router.get('/:movie_id', async (ctx:Koa.Context) => {
  ctx.body = 'GET SINGLE';
});

router.post('/', async (ctx:Koa.Context) => {
  ctx.body = 'POST';
});

router.delete('/:movie_id', async (ctx:Koa.Context) => {
  ctx.body = 'DELETE';
});

router.patch('/:movie_id', async (ctx:Koa.Context) => {
  ctx.body = 'PATCH';
});

export default router;

这定义了我们的路线。 你会注意到我们设置了 /movies 的前缀。 这样当我们将路由挂载到应用程序中时,我们根本不需要在应用程序级别添加任何配置。 如果我们想把这个功能拆分成它自己的包,或者移动它,我们可以移动它。

如果您使用过 Express,您可能能够理解正在发生的事情。 路由器对象上有一些方法代表将用于我们的 API 的 HTTP 动词,然后是回调。 新的可能是回调是异步函数。

通过利用异步函数,Koa 允许您放弃回调并大大增加错误处理。-- Koajs.com

接下来,我们需要让这个控制器对应用程序可用。 在 app.js 的顶部,导入电影控制器:

import movieController from '../movie/movie.controller';

然后删除我们默认的“Hello world”端点,并将其替换为以下内容:

// Route middleware.
app.use(movieController.routes());
app.use(movieController.allowedMethods());

.routes() 部分将路由中间件添加到应用程序,而 .allowedMethods() 函数将添加另一块中间件,以确保对不允许或未实现的方法给出正确的响应。

如果您现在使用上述任何 HTTP 动词向我们的 API 发出任何请求,您应该会收到一个响应,其中提到了您发出的请求的类型。

好东西! 现在我们需要添加我们的数据库后端。

实现我们的持久层

添加数据库连接

此部分需要 PostgreSQL 数据库。 如果您已经在上面设置了 dockerfile,那么现在是在新的终端窗口中运行 docker-compose up 的好时机。 如果您在其他地方创建了数据库,请确保您手头有凭据。

我们需要做的第一件事是建立我们的数据库连接。 运行以下命令以创建我们将在其中存储数据库凭据的文件:

mkdir src/database && touch src/database/database.connection.ts

打开此文件并粘贴以下内容:

import 'reflect-metadata';
import { createConnection, Connection, ConnectionOptions } from 'typeorm';
import { join } from 'path';
const parentDir = join(__dirname, '..');

const connectionOpts: ConnectionOptions = {
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: Number(process.env.DB_PORT) || 5432,
  username: process.env.DB_USERNAME || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres',
  database: process.env.DB_NAME || 'typescript-koa',
  entities: [
    `${parentDir}/**/*.entity.ts`,
  ],
  synchronize: true,
};

const connection:Promise<Connection> = createConnection(connectionOpts);

export default connection;

这个文件使用 TypeORM 创建我们的连接。 您可以在此处找到所有配置选项的文档。

我们也导出了这个,所以我们可以把它拉到我们的引导 server.ts 文件中。 如果您正在运行自己的 PostgreSQL 实例,则可以更改上面的默认连接详细信息。

我们设置它的方式允许环境变量成为默认的真实来源,并且我们有非敏感的本地凭据作为备份。

附带说明一下,如果您直接通过 NodeJS 运行应用程序,并将 .ts 文件转换为 JavaScript,则需要将 ${parentDir}/*/.entity.js 添加到实体列表中。

接下来,重新访问 server.ts 并将内容更改为以下内容:

import app from './app/app';
import databaseConnection from './database/database.connection';

const PORT:number = Number(process.env.PORT) || 3000;

databaseConnection
  .then(() => app.listen(PORT))
  .catch(console.error);

我们创建的连接返回一个承诺。 由于我们的应用程序依赖于一个数据库,我们可以安全地在数据库连接的成功回调中启动服务器。

定义数据模型

TypeORM 将其数据模型称为实体。 实体是用 TypeScript 装饰器包装以添加底层功能的类。 它与 [Doctrine](https://www.doctrine-project.org/ 在 PHP 中的工作方式非常相似。 我们的演示只需要一个实体,所以我们接下来要创建一个实体类:

touch src/movie/movie.entity.ts

在此文件中,粘贴以下代码:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export default class Movie {

  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  name: string;

  @Column({ type: 'int', nullable: true, width: 4 })
  releaseYear: number;

  @Column({ type: 'int', nullable: true })
  rating: number;

}

信不信由你,这是我们能够访问整个持久层所需的唯一数据。 查看官方文档以了解有关创建实体的所有不同选项的更多信息。

保存完所有内容并重新启动服务器后,您应该注意到我们的数据库中现在有一个电影表! 这意味着是时候更新我们的路线了。

持续更新路由

我们需要做的最后一件事是使用持久化、编辑和删除电影实体所需的功能更新我们的路由。

访问我们的原始 app.ts 文件,并在顶部粘贴以下内容以导入正文解析器:

import * as bodyParser from 'koa-bodyparser';

这是 Koa 能够读取请求正文所必需的。 在路由use 调用之前,我们还需要添加以下内容:

// Middleware
app.use(bodyParser());

再次打开 movie.controller.ts 并将所有内容替换为以下代码(这是一个相当大的片段,因此已添加注释以帮助说明发生了什么):

import * as Koa from 'koa';
import * as Router from 'koa-router';
import { getRepository, Repository } from 'typeorm';
import movieEntity from './movie.entity';
import * as HttpStatus from 'http-status-codes';

const routerOpts: Router.IRouterOptions = {
  prefix: '/movies',
};

const router: Router = new Router(routerOpts);

router.get('/', async (ctx:Koa.Context) => {
  // Get the movie repository from TypeORM.
  const movieRepo:Repository<movieEntity> = getRepository(movieEntity);

  // Find the requested movie.
  const movies = await movieRepo.find();

  // Respond with our movie data.
  ctx.body = {
    data: { movies },
  };
});

router.get('/:movie_id', async (ctx:Koa.Context) => {
  // Get the movie repository from TypeORM.
  const movieRepo:Repository<movieEntity> = getRepository(movieEntity);

  // Find the requested movie.
  const movie = await movieRepo.findOne(ctx.params.movie_id);

  // If the movie doesn't exist, then throw a 404.
  // This will be handled upstream by our custom error middleware.
  if (!movie) {
    ctx.throw(HttpStatus.NOT_FOUND);
  }

  // Respond with our movie data.
  ctx.body = {
    data: { movie },
  };
});

router.post('/', async (ctx:Koa.Context) => {
  // Get the movie repository from TypeORM.
  const movieRepo:Repository<movieEntity> = getRepository(movieEntity);

  // Create our new movie.
  const movie: movieEntity = movieRepo.create(ctx.request.body);

  // Persist it to the database.
  await movieRepo.save(movie);

  // Set the status to 201.

  // Respond with our movie data.ctx.status = HttpStatus.CREATED;
  ctx.body = {
    data: { movie },
  };
});

router.delete('/:movie_id', async (ctx:Koa.Context) => {
  // Get the movie repository from TypeORM.
  const movieRepo:Repository<movieEntity> = getRepository(movieEntity);

  // Find the requested movie.
  const movie = await movieRepo.findOne(ctx.params.movie_id);

  // If the movie doesn't exist, then throw a 404.
  // This will be handled upstream by our custom error middleware.
  if (!movie) {
    ctx.throw(HttpStatus.NOT_FOUND);
  }

  // Delete our movie.
  await movieRepo.delete(movie);

  // Respond with no data, but make sure we have a 204 response code.
  ctx.status = HttpStatus.NO_CONTENT;
});

router.patch('/:movie_id', async (ctx:Koa.Context) => {
  // Get the movie repository from TypeORM.
  const movieRepo:Repository<movieEntity> = getRepository(movieEntity);

  // Find the requested movie.
  const movie:movieEntity = await movieRepo.findOne(ctx.params.movie_id);

  // If the movie doesn't exist, then throw a 404.
  // This will be handled upstream by our custom error middleware.
  if (!movie) {
    ctx.throw(HttpStatus.NOT_FOUND);
  }

  // Merge the existing movie with the new data.
  // This allows for really simple partial (PATCH).
  const updatedMovie = await movieRepo.merge(movie, ctx.request.body);

  // Save the new data.
  movieRepo.save(updatedMovie);


  // Respond with our movie data.// Response with the updated content.
  ctx.body = {
    data: { movie: updatedMovie },
  };
});

export default router;

以上使 GET、POST、PATCH 和 DELETE 端点能够与我们的电影实体进行交互。

这应该就是我们需要的一切!

您可以通过发出 POST 请求来测试我们的新 API。

使用 Insomnia 或 Postman(或类似工具),使用以下数据构建请求并将其发送到 http://127.0.0.1:3000/movies:

{
  "name": "Main in Manhattan",
  "releaseYear": 2002,
  "rating": 10
}

您应该返回电影实例。 记下 UUID,我们可以使用它来构建对 http://127.0.0.1:3000/movies/{UUID} 的 GET 请求。

总结

TypeScript 可以大大提高大中型应用程序的生产力。 从我的个人经验的角度来看,它减少了我必须编写的测试数量大约三分之一,并捕获类型错误——实际上是在我输入时,而不是在测试端点时。

我也是最近才尝试 TypeORM,从 Mongoose 转移过来。 虽然 Mongoose 很有趣并且使用起来相对简单,但是使用 TypeScript 启动和运行(以及一般情况下)需要很多样板。

TypeORM 是一股清新的空气,它的使用速度快得令人难以置信。 希望本教程有助于证明这一点。

您可以在此处找到上述文章的代码。

回到顶部