跳转至

1. 入门

在本教程结束时,您将创建一个Aqueduct应用程序,从PostgreSQL数据库中为虚构的英雄提供服务。你将学会以下内容:

  • 运行一个Aqueduct应用程序
  • 将HTTP请求路由到你的代码中的相应处理程序。
  • 存储和检索数据库数据
  • 为每个端点编写自动测试
  • 要求对HTTP请求进行授权

获得帮助

如果你在任何时候遇到困难,请跳到Aqueduct Slack channel。你也可以在这里看到这个应用程序的完成版。

安装

首先,请确保您已安装以下软件:

  1. Dart (安装说明)
  2. IntelliJ IDEA或任何其他Jetbrains IDE,包括免费的社区版。 (安装说明)
  3. The IntelliJ IDEA Dart 插件(安装说明)

在shell中运行下面的命令来安装 aqueduct命令行工具:

pub global activate aqueduct

如果你收到关于你的PATH的警告文字,请确保在继续之前阅读它。

创建项目

在shell中输入以下内容,创建一个名为 "heroes "的新项目:

aqueduct create heroes

这样就创建了一个 "heroes "项目目录。通过将项目文件夹拖到IntellIJ IDEA的图标上,用IntelliJ IDEA打开这个目录。

在IntelliJ的项目视图中,找到 "lib "目录;这是你的项目代码的所在。这个项目有两个源文件heroes.dartchannel.dart。打开文件heroes.dart。点击编辑器右上角的Enable Dart Support

处理HTTP请求

在浏览器中,导航到http://aqueduct-tutorial.stablekernel.io。这个浏览器应用程序是一个Hero Manager -它允许用户查看、创建、删除和更新英雄。(它是AngularDart英雄教程的略微修改版)。它将向http://localhost:8888发出HTTP请求,以获取和操作英雄数据。你将在本教程中构建的应用程序响应这些请求。

在本地运行浏览器应用程序

浏览器应用程序通过HTTP提供服务,因此当您的Aqueduct应用程序在您的机器上本地运行时,它可以访问您的应用程序。您的浏览器可能会警告您导航到一个不安全的网页,因为事实上它是不安全的。您可以通过从这里获取源代码在本地运行这个应用程序。

在第一章中,你将编写代码来处理两个请求:一个是获取英雄列表,另一个是通过其标识符获取单个英雄。这两个请求是:

  1. GET /heroes 英雄列表
  2. GET /heroes/:id 获得单个英雄

HTTP Operation Shorthand

术语 GET /heroes称为操作。它是HTTP方法和请求路径的组合。每个操作对一个应用程序来说都是独一无二的,所以你的代码被分割成每个操作的单元。带冒号的部分,如':id'段,是可变的:它们可以是1,2,3,等等。

控制器对象处理请求

请求由控制器对象处理。一个控制器对象可以响应请求。它也可以采取其他行动,让另一个控制器作出响应。例如,它可能会检查请求是否被授权,或者向其他服务发送分析数据。 控制器被组合在一起,组合中的每个控制器按顺序执行其逻辑。这使得一些控制器可以重复使用,并且可以更好地组织代码。控制器的组成被称为channel,因为请求在一个方向上流经控制器。

我们的应用将连接两个控制器:

  • 确保请求路径为/heroes/heroes/:idRouter
  • 一个用英雄对象响应的HeroesControllers

您的应用程序从一个名为application channel的通道对象开始。 您将应用程序中的控制器链接到此通道。

ApplicationChannel entryPoint

每个应用程序都有一个ApplicationChannel的子类,您可以重写其中的方法来设置控制器。这个类型已经在lib/channel.dart中声明了,打开这个文件并找到ApplicationChannel.entryPoint

  @override
  Controller get entryPoint {
    final router = Router();

    router
      .route('/example')
      .linkFunction((request) async {
        return Response.ok({'key': 'value'});
      });

    return router;
  }

当您的应用程序收到一个请求时,entryPoint控制器是第一个处理它的。在我们的例子中,这是一个Router,一个Controller的子类。

控制器的子类

您使用的每个控制器都是 Controller的子类。 在Aqueduct中已经有一些控制器子类用于常见行为。

你使用router的route方法将一个控制器附加到一个route上。路由是一个匹配请求路径的字符串语法。在我们目前的实现中,路由将匹配每一个路径为/example的请求。收到该请求后,linkFunction链接的函数将运行并返回带有示例JSON对象主体的200 OK响应。

我们需要将路径/heroes路由到我们自己的控制器,这样我们就可以控制发生的事情。让我们创建一个HeroesController。在lib/controller/heroes_controller.dart中创建一个新文件,并添加以下代码(你需要创建子目录lib/controller/):

import 'package:aqueduct/aqueduct.dart';
import 'package:heroes/heroes.dart';

class HeroesController extends Controller {
  final _heroes = [
    {'id': 11, 'name': 'Mr. Nice'},
    {'id': 12, 'name': 'Narco'},
    {'id': 13, 'name': 'Bombasto'},
    {'id': 14, 'name': 'Celeritas'},
    {'id': 15, 'name': 'Magneta'},    
  ];

  @override
  Future<RequestOrResponse> handle(Request request) async {
    return Response.ok(_heroes);
  }
}

请注意,HeroesControllerController的一个子类;这就是它成为控制器对象的原因。它通过返回一个Response对象来覆盖它的handle方法。这个响应对象的状态码是200 OK,它的主体包含一个JSON编码的英雄对象列表。当控制器从它的handle方法中返回一个Response对象时,它会被发送到客户端。

现在,我们的 HeroesController还没有连接到application channel。我们需要把它链接到路由器上。首先,在channel.dart的顶部导入我们的新文件。

import 'controller/heroes_controller.dart';

然后将这个HeroesController链接到/heroes路径的Router:

@override
Controller get entryPoint {
  final router = Router();

  router
    .route('/heroes')
    .link(() => HeroesController());

  router
    .route('/example')
    .linkFunction((request) async {
      return Response.ok({'key': 'value'});
    });

  return router;
}

现在,我们有一个应用程序,它将返回英雄列表。 在项目目录中,从命令行运行以下命令:

aqueduct serve

这将启动你的应用程序在本地运行。重新加载浏览器页面http://aqueduct-tutorial.stablekernel.io。它将向http://localhost:8888/heroes发出请求,你的应用程序将为其提供服务。你将在你的网页浏览器中看到你的英雄:

英雄应用的截图

Aqueduct Heroes First Run

你也可以通过在shell中输入以下内容来查看请求的实际响应:

curl -X GET http://localhost:8888/heroes

你会得到这样的JSON输出:

[
  {"id":11,"name":"Mr. Nice"},
  {"id":12,"name":"Narco"},
  {"id":13,"name":"Bombasto"},
  {"id":14,"name":"Celeritas"},
  {"id":15,"name":"Magneta"}
]

你还将在启动aqueduct serve所在的shell中看到该请求。

链接控制器

当一个控制器处理一个请求时,它可以发送一个响应,也可以让其链接的控制器之一处理请求。默认情况下,Router 将为任何请求发送404 Not Found响应。在Router中添加路由会创建一个新通道的入口点,控制器可以链接到这个通道。在我们的应用中,HeroesController被链接到路由/heroes

控制器有两种不同的风格:端点和中间件。端点控制器,像HeroesController一样,总是发送响应。它们实现了请求所寻求的行为。中间件控制器,如Router,在请求到达端点控制器之前处理它们。例如,路由器通过将请求引导到正确的控制器来处理请求。像Authorizer这样的控制器可以验证请求的授权。你可以创建各种控制器来提供你喜欢的任何行为。

一个通道可以有零个或多个中间件控制器,但必须以一个端点控制器结束。大多数控制器只能有一个链接的控制器,但一个Router允许有许多控制器。例如,一个较大的应用程序可能看起来像这样:

@override
Controller get entryPoint {
  final router = Router();

  router
    .route('/users')
    .link(() => APIKeyValidator())
    .link(() => Authorizer.bearer())
    .link(() => UsersController());

  router
    .route('/posts')
    .link(() => APIKeyValidator())
    .link(() => PostsController());

  return router;
}

这些对象中的每一个都是 Controller的子类,使它们能够链接在一起处理请求。一个请求会按照它们被链接的顺序通过控制器。对路径/users的请求将经过APIKeyValidatorAuthorizer和最后的UsersController。这些控制器中的每一个都有机会做出响应,防止下一个控制器收到请求。

高级路由

现在,我们的应用程序处理GET /heroes请求。浏览器应用程序使用这个列表来填充其英雄面板。如果我们点击一个单独的英雄,浏览器应用程序将显示一个单独的英雄。当浏览到这个页面时,浏览器应用程序会向我们的服务器发出一个请求,以获取一个单独的英雄。这个请求包含路径中所选英雄的唯一id,例如/heroes/11/heroes/13

我们的服务器还没有处理这个请求--它只处理路径正好是/heroes的请求。由于对单个英雄的请求会根据英雄的不同而改变路径,我们需要在路由中包含一个路径变量

路径变量是指与传入请求路径中相同段的值相匹配的路由段。路径变量是以冒号(:)为前缀的段。例如,路径/heroes/:id包含一个名为id的路径变量。如果请求路径是/heroes/1/heroes/2,以此类推,请求将被发送到我们的HeroesControllerHeroesController将可以访问路径变量的值来决定返回哪个英雄。

有一个小插曲。路径/heroes/:id不再匹配路径/heroes。如果/heroes/heroes/:id都进入我们的HeroesController,那么我们的代码组织就会容易很多;它做的是英雄的事情。出于这个原因,我们可以通过用方括号包装来声明我们路由中的:id部分是可选的。在channel.dart中,修改/heroes路由:

router
  .route('/heroes/[:id]')
  .link(() => HeroesController());

由于路径的第二段是可选的,路径/heroes仍然与路径匹配。如果路径中包含第二段,那么这一段的值就会被绑定到名为id的路径变量中。我们可以通过Request对象来访问路径变量。在heroes_controller.dart中,修改handle:

// 稍后,我们将用更好的代码替换此代码,
// 但重要的是要首先了解这些信息的来源!
@override
Future<RequestOrResponse> handle(Request request) async {
  if (request.path.variables.containsKey('id')) {
    final id = int.parse(request.path.variables['id']);
    final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);
    if (hero == null) {
      return Response.notFound();
    }

    return Response.ok(hero);
  }

  return Response.ok(_heroes);
}

在当前运行该应用程序的shell中,按Ctrl-C停止应用程序。然后,再次运行aqueduct serve。在浏览器应用程序中,点击一个英雄,你会被带到该英雄的详细页面。

Screenshot of Hero Detail Page

你可以通过执行curl -X GET http://localhost:8888/heroes/11查看单个英雄对象来验证你的服务器是否正确响应。你也可以通过获取一个不存在的英雄来触发404 Not Found响应。

ResourceController和操作方法

我们的HeroesController现在还好,但很快就会遇到一个问题:当我们想创建一个新的英雄时怎么办?或者更新一个现有英雄的名字? 我们的handle方法将很快变得难以管理。

这就是ResourceController的用武之地。ResourceController允许你为我们可以对英雄执行的每个操作创建一个不同的方法。一个方法将处理获取英雄列表,另一个方法将处理获取单个英雄,以此类推。每个方法都有一个注解,用于标识请求必须具备的HTTP方法和路径变量以触发它。

heroes_controller.dart中,将HeroesController替换为以下内容:

class HeroesController extends ResourceController {
  final _heroes = [
    {'id': 11, 'name': 'Mr. Nice'},
    {'id': 12, 'name': 'Narco'},
    {'id': 13, 'name': 'Bombasto'},
    {'id': 14, 'name': 'Celeritas'},
    {'id': 15, 'name': 'Magneta'},
  ];

  @Operation.get()
  Future<Response> getAllHeroes() async {
    return Response.ok(_heroes);
  }

  @Operation.get('id')
  Future<Response> getHeroByID() async {
    final id = int.parse(request.path.variables['id']);
    final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);
    if (hero == null) {
      return Response.notFound();
    }

    return Response.ok(hero);
  }
}

请注意,我们没有必要在ResourceController中覆盖handleResourceController已实现了这个方法来调用我们的一个操作方法。一个操作方法,像getAllHeroesgetHeroByID,必须有一个Operation注解。命名的构造函数Operation.get意味着当请求的方法是GET时,这些方法会被调用。操作方法还必须返回一个Future<Response>

getHeroByID的注解也有一个参数,我们的路径变量id的名称。如果这个路径变量存在于请求的路径中,getHeroByID将被调用。如果它不存在,getAllHeroes将被调用。

操作方法命名

一个操作的纯英文短语,比如'get hero by id'是一个操作方法的真正好名字,当你从代码中生成OpenAPI文档时,一个好名字会很有用。

在运行aqueduct serve的终端中按Ctrl-C键重新加载应用程序,然后再次运行aqueduct serve。浏览器应用程序应该仍然表现相同。

浏览器客户端

除了curl之外,你还可以创建一个SwaggerUI浏览器应用程序,来执行针对你本地运行的应用程序的请求。在你的项目目录下,运行aqueduct document client,它会生成一个名为client.html的文件。在你的浏览器中打开这个文件,就可以看到一个构建和执行你的应用程序支持的请求的UI。

请求绑定

在我们的getHeroByID方法中,我们做了一个危险的假设,即路径变量'id'可以被解析成一个整数。如果'id'是其他的东西,比如一个字符串,int.parse就会抛出一个异常。当在操作方法中抛出异常时,控制器会捕捉到并发送一个500服务器错误响应。500是不好的,它们没有告诉客户端是什么问题。404 Not Found在这里是一个更好的响应,但编写代码来捕获该异常并创建这个响应是很麻烦的。

相反,我们可以依靠操作方法的一个特性,叫做请求绑定。一个操作方法可以声明参数,并将其绑定到请求的属性上。当我们的操作方法被调用时,它将从请求中传递值作为参数。请求绑定会自动将值解析成参数的类型(如果解析失败,会返回一个更好的错误响应)。把getHeroByID()方法改成:

@Operation.get('id')
Future<Response> getHeroByID(@Bind.path('id') int id) async {
  final hero = _heroes.firstWhere((hero) => hero['id'] == id, orElse: () => null);

  if (hero == null) {
    return Response.notFound();
  }

  return Response.ok(hero);
}

路径变量id的值将被解析为一个整数,并在id参数中可用于此方法。操作方法参数上的@Bind注解告诉Aqueduct我们想要绑定的请求中的值。使用命名的构造函数Bind.path绑定一个路径变量,该变量的名称在该构造函数的参数中表示。

你可以绑定路径变量、标头,查询参数和主体。在绑定路径变量时,我们要用@Bind.path(pathVariableName)的参数指定是哪个路径变量。

绑定参数名称

绑定参数的名称不一定要和路径变量的名称一致。我们可以将其声明为@Bind.path('id') int heroID。只有Bind的构造函数的参数必须与路径变量的实际名称相匹配。这对于其他类型的绑定是很有价值的,比如header,可能包含的字符不是有效的Dart变量名,比如X-API-Key

更多应该知道的:多线程和应用程序状态

在此简单练习中,我们使用了恒定的英雄列表作为数据来源。对于一个简单的演示程序,这很好。然而,在真实的应用中,你会将这些数据存储在数据库中。这样你就可以在其中添加数据,而不会在应用程序重启时有丢失的风险。

更一般来说,Web服务器永远不应该挂在可以改变的数据上。虽然以前只是一种最佳实践,但随着容器化和Kubernetes等工具的盛行,无状态Web服务器正在成为一种需求。Aqueduct通过其多线程策略使其更容易检测到违反这一规则的行为。

当你运行一个Aqueduct应用时,它会创建多个线程。这些线程中的每个线程在内存中都有自己的隔离堆;这意味着存在于一个线程上的数据无法从其他线程中访问。在Dart中,这些隔离的线程被称为isolates

为每个隔离区创建一个你的应用通道实例。每一个HTTP请求都只给其中一个隔离体处理。从某种意义上说,你的一个应用程序的行为与在负载均衡器后面的多个服务器上运行你的应用程序是一样的。(它也使你的应用程序的速度大大加快。)

如果你的应用中存储了任何数据,你会很快发现。为什么呢?一个改变数据的请求只会改变你的应用程序的一个隔离区中的数据。当你再次提出获取该数据的请求时,你不可能看到这些变化,另一个拥有不同数据的隔离体可能会处理该请求。

下一章: 从数据库中读取