四、构建 Web 应用

我们到了! 我们走了很长一段路才到这里。 这就是所有乐趣的开始。 我们已经经历了三个阶段:了解什么是 Deno,探索它提供的工具链,以及通过其运行时了解细节和功能。

在这一章中,几乎所有前几章的内容都会被证明是有用的。 希望介绍的章节能让你有足够的信心开始应用我们在一起学到的东西。 我们将使用这些章节,再加上你现有的 TypeScript 和 JavaScript 知识,来构建一个完整的 web 应用。

我们将编写一个包含业务逻辑、处理身份验证、授权和日志等等的 API。 我们将涵盖足够的基本部分,为您,在最后,感到舒适地选择 Deno 构建您的下一个伟大的应用。

在本章中,除了讨论 Deno,我们还将讨论一些关于软件工程和应用架构基础的想法。 我们认为,在从头开始构建应用时,记住一些事情是至关重要的。 我们将着眼于基础,这将被证明是有用的,并帮助我们构建代码,使其易于更改,从而使其在未来得以发展。

稍后,我们将开始引用一些第三方模块,查看它们的方法,并决定从这里开始使用什么来帮助我们处理路由和 HTTP 相关的挑战。 我们还将确保以一种第三方代码被隔离的方式构建我们的应用,并将其作为我们想要构建的功能的启动器,而不是功能本身。

在本章中,我们将讨论以下主题:

  • 构建一个 web 应用
  • 探索 Deno HTTP 框架
  • 让我们开始吧!

技术要求

本章使用的代码文件可在以下链接获得:https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter04/museums-api

构建 web 应用

在启动一个应用时,花一些时间考虑它的结构和架构是很重要的。 这部分将从这里开始:通过查看应用架构的主干。 我们将看看它带来了什么优势,并将我们自己与一组原则保持一致,这些原则将帮助我们随着应用的发展扩展它。

然后,我们将开发应用的第一个端点。 但是,首先,我们将从业务逻辑开始。 接着是持久化层,最后我们将查看作为应用入口点的 HTTP 端点。

Deno 是一个未脱壳的工具

当我们使用低层次的工具并将许多决策委托给开发人员时,如 Node.js 和 Deno,构建应用是出现的最大挑战之一。

这与固执己见的 web 框架(如 PHP Symfony、Java SpringBoot 或 Ruby on Rails)非常不同,在这些框架中,许多决策都是由我们自己做出的。

大多数决策都与结构有关; 也就是代码和文件夹结构。 这些框架通常为我们提供了处理依赖关系、导入的方法,甚至提供了一些关于不同应用层的指导。 由于我们使用的是原始的语言和一些包,我们将在本书中自己处理结构。

上述框架不能直接与 Deno 相比,因为它们是构建在 PHP、Java 和 Ruby 等语言之上的框架。 但是当我们看看 JS 的世界,也就是 Node.js,我们可以看到最流行的用来创建 HTTP 服务器的工具是 Express.js 和 Kao。 这些往往是更轻比上述框架,尽管也有一些固体完全的替代品如 Nest.js 或 hapi.js, node . js 社区往往更喜欢图书馆方法超过一个框架。

*即使这些非常流行的库提供了大量的功能,许多决策仍然委托给开发人员。 这不是图书馆的错,而是社区的偏好。

一方面,直接访问这些原语让我们能够构建非常适合我们用例的应用。 另一方面,灵活性是一种权衡。 有了很大的灵活性,就有了做无数决定的责任。 当需要做很多决定时,就有很多机会做出糟糕的决定。 困难的部分是,这些决定通常会极大地影响代码库的扩展方式,而这正是它们如此重要的原因。

在当前的状态下,Deno 及其社区在这个框架与库的主题上采用了与 Node.js 非常相似的方法。 社区主要是把赌注押在由开发人员创建的轻且小的软件上,以满足他们的特定需求。 我们将在本章的后面对其中一些进行评估。

从这里开始,贯穿全书的,我们将使用一个应用结构,我们相信它为手边的用例提供了巨大的好处。 然而,不要期望结构和体系结构是一个银弹,因为我们非常确定在软件世界中不存在这样的东西; 每一种架构都必须随着它的发展而不断发展。

我们想要熟悉的是一种思维方式,一种理论基础,而不是简单地给出一个配方并遵循它。 这将使我们能够在今后的道路上做出正确的决定,并牢记一个目标:编写易于更改的代码。

通过编写易于更改的代码,我们总能不费多大力气就把应用变得更好。

应用中最重要的部分

应用是为了满足某种目的而创建的。 不管目的是为了支持一项业务还是一个简单的宠物项目。 在一天结束的时候,我们希望它做点什么。 使应用有用的是东西

这似乎是显而易见的,但有时候作为开发者,我们很容易对一项技术过于热情,以至于忘记它只是达到目的的一种手段。

鲍勃叔叔说他在架构——失去了年(https://www.youtube.com/watch?v=hALFGQNeEnU),这是很常见的人们忘记应用的目的,更多地关注技术本身。 在应用开发的所有阶段都要记住这一点,这一点非常重要,但在设置初始结构时,这一点就更加重要了。 接下来,我们将发现我们将在本书其余部分构建的应用的需求。

我们的应用是关于什么的?

尽管我们确实相信业务逻辑是任何应用中最重要的东西,但在本书中,情况略有不同。 我们将创建一个示例应用,但它只是实现主要目标的一种方法:学习 Deno。 然而,由于我们希望这个过程尽可能真实,我们想要在脑海中有一个明确的目标。

我们将构建一个应用,让人们创建一个博物馆列表并与之互动。 我们可以把它的特点作为用户故事列出,如下所示:

  • 用户能够注册和登录。
  • 用户可以创建一个带有标题、描述和位置的博物馆。
  • 用户可以查看博物馆列表。

在整个过程中,我们将开发 api 和逻辑来支持这些特性。

既然我们已经熟悉了最终目标,我们可以开始考虑如何构造应用了。

了解文件夹结构和应用架构

关于文件夹结构,我们需要注意的第一件事是,特别是当我们在没有框架的情况下从头开始一个项目时,它会随着项目的发展而发展。 对于有两个端点的项目来说,文件夹结构很好,但对于有数百个端点的项目来说就不那么好了。 这取决于很多因素,从团队规模到定义的标准,最终到偏好。

在定义文件夹结构时,重要的是我们要找到一个地方,以便于我们将来决定在哪里定位一段代码。 文件夹结构应该为如何做出良好的架构决策提供清晰的提示。

同时,我们当然不想创建一个过度设计的应用。 我们将创建足够多的抽象,这样模块就会非常包容,不会有超出其领域的知识,但不会超过这些。 记住这一点也会迫使我们构建灵活的代码和清晰的界面。

最终,最重要的是架构使的代码基础如下:

  • 可测试的
  • 容易扩展
  • 从特定的技术或库解耦
  • 易于导航和推理

我们必须记住,在创建文件夹、文件和模块时,我们不希望前面列出的任何主题被破坏。

这些原则是非常符合固体软件设计的原则,由 Bob 大叔,罗伯特·c·马丁(https://en.wikipedia.org/wiki/SOLID),另一个值得关注的谈话(https://youtu.be/zHiWqnTWsn4)。

如果您有 Node.js 的背景,那么本书中我们将要使用的文件夹结构可能听起来很熟悉。

和 Node.js 一样,没有什么能阻止我们在单个文件中创建一个完整的 API。 然而,我们不会这样做,因为我们相信一些最初的关注点分离将极大地提高我们以后的灵活性,而不会牺牲开发人员的生产力。

在下一节中,我们将讨论不同层的职责,以及在为应用开发特性时如何将它们组合在一起。

通过遵循这一思路,我们努力保证模块之间一定程度的解耦。 例如,我们希望确保在 web 框架中进行更改并不意味着我们必须接触业务逻辑对象。

所有这些建议,以及我们将在本书中提出的建议,将有助于确保我们的应用的中心部分是我们的业务逻辑,而其他一切只是插件。 JSON API 只是将数据发送给用户的一种方式,而数据库只是一种持久化数据的方式; 这些都不应该是应用的核心部分。

一种确保我们正在做的方法是,当我们写代码的时候,做以下的心理练习:

“当你编写业务逻辑时,想象这些对象将在不同的上下文中使用。 例如,使用不同的交付机制(例如 CLI)或不同的持久性引擎(内存数据库而不是 NOSQL 数据库)的业务逻辑。”

在接下来的几页中,我们将向您介绍如何创建不同的层,并解释所有的设计决策及其作用。

让我们开始实践并创建我们项目的骨干。

定义文件夹结构

我们要做的第一件事是在项目的文件夹中创建一个src文件夹。

可以预见,我们的代码将存在于此。 我们不希望任何代码位于项目的根级,因为可能会添加配置文件、readme、文档文件夹等。 这将使代码难以区分。

在接下来的章节中,我们将花费大部分时间在src文件夹中。 由于我们的应用是关于博物馆的,我们将在src文件夹中创建一个名为museums的文件夹。 这是本章中大部分逻辑的所在。 稍后,我们将为类型、控制器和存储库创建文件。 然后,我们将创建src/web文件夹。

控制器文件是业务逻辑所在的位置。 存储库将负责与数据访问相关的逻辑,而 web 层将处理所有与web 相关的

你可以通过查看本书的 GitHub 存储库:https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter04/museums-api来看到最终的结构。

本章的初始要求是有一个路由,我们可以执行 GET 请求并接收 JSON 格式的博物馆列表。

我们将从控制器文件(src/museums/controller.ts)开始,并编写所需的业务逻辑。

这是的文件夹的结构应该是这样的:

└── src
    ├── museums
    │   ├── controller.ts
    │   ├── repository.ts
    │   └── types.ts
    └── web

这是我们的起点。 所有与博物馆相关的内容都位于museums文件夹中,我们将其称为模块。 controller文件将承载业务逻辑,repository文件将承载数据抓取功能,而types文件将位于类型所在的位置。

现在,让我们开始编码吧!

开发业务逻辑

我们之前说过业务逻辑是应用中最重要的部分。 尽管我们的程序现在超级简单,但这是我们首先要开发的。

因为我们将在应用中使用 TypeScript,所以让我们创建一个接口来定义我们的Museum对象。 遵循以下步骤:

  1. Go into src/museums/types.ts and create a type that defines a Museum:

    export type Museum = {   id: string,   name: string,   description: string,   location: {     lat: string,     lng: string   } }

    确保它被导出,因为我们将在其他文件中使用它。

    现在我们知道了类型,我们必须创建一些业务逻辑来获得博物馆列表。

  2. src/museums/types.ts内部,创建一个定义MuseumController的接口。 它应该包含一个列出所有博物馆的方法:

  3. Inside src/museums/controller.ts, create a class that will act as the controller. It should contain a function named getAll. In the future, this is where the business logic will live, but for now, we can just return an empty array:

    import type { MuseumController } from "./types.ts"; export class Controller implements MuseumController {   async getAll() {     return [];   } }

    我们可以用这个直接访问数据库并获取某些记录。 然而,由于我们希望能够将业务逻辑隔离开来,而不与应用的其他部分耦合,所以我们不会这样做。

    除此之外,我们还希望业务逻辑能够独立测试,而不依赖于与数据库或服务器的连接。 为了实现这一点,我们不能直接从控制器访问数据源。 稍后,我们将创建一个抽象,该抽象将负责从数据库中获取这些记录。

    现在,我们知道我们需要调用一个外部模块,它将为我们获取所有的博物馆,它将它们给我们的控制器-这并不重要从哪里。

    记住以下软件设计的最佳实践:

    简单地说,这个引号意味着我们应该定义模块的签名,然后才开始考虑它的实现。 这对于设计清晰的界面非常有帮助。

    回到我们的控制器,我们知道控制器的getAll方法,在某些时候,必须调用一个模块来从数据源获取数据。

  4. src/museums/types.ts内部,定义MuseumRepository模块,该模块将负责从数据源获取博物馆:

    export interface MuseumRepository {   getAll: () => Promise<Museum[]> }

  5. Inside src/museums/controller.ts, add an injected class called museumRepository to the constructor:

    import type { MuseumRepository, MuseumController }   from "./types.ts"; interface ControllerDependencies {   museumRepository: MuseumRepository } export class Controller implements MuseumController {   museumRepository: MuseumRepository   constructor({ museumRepository }:     ControllerDependencies) {       this.museumRepository = museumRepository     }   async getAll() {     return this.museumRepository.getAll();   } }

    通过这个,我们声明无论谁实例化控制器,都必须提供一个实现MuseumRepository接口的museumRepository。 通过创建这个和移除依赖项,我们不再需要从控制器返回一个空数组。

    在编写更多逻辑之前,让我们确保代码运行并检查它是否工作。 我们只是少了一件事。

  6. 创建一个名为src/index.ts的文件,导入MuseumController,并实例化它,调用getAll方法,记录其输出。 现在,您可以注入一个只返回空数组的虚拟存储库:

  7. Run it to check whether it's working:

    $ deno run src/index.ts []

    就是这样! 我们刚刚收到了来自虚拟存储库函数的空数组!

有了我们创建的抽象,控制器现在就与数据源解耦了。 它的依赖是通过构造函数注入的,允许我们在不改变控制器的情况下更改存储库。

我们刚才所做的叫做依赖倒置——SOLID 原则中的D——它包括提升部分依赖到函数调用者。 这使得独立测试内部函数非常容易,正如我们将在第 8 章测试-单元和集成中看到的,在那里我们将涵盖测试。

为了把我们刚刚写的变成一个功能完整的应用,我们需要有一个数据库或类似的东西。 我们需要一个能存储和检索博物馆列表的东西。 让我们现在创建它。

开发数据访问逻辑

在开发控制器时,我们注意到我们需要一些能够获取数据的东西; 也就是存储库。 该模块将抽象对数据源的所有调用,在本例中是存储博物馆的数据源。 它将拥有一组定义良好的方法,任何想要访问数据的人都应该通过这个模块访问数据。

我们已经在src/museums/types.ts内部定义了它的部分接口,所以让我们编写一个实现它的类。 现在,我们不会将它连接到真正的数据库。 我们将使用 ES6 Map 作为内存数据库。

让我们进入存储库文件,按照以下步骤开始编写数据访问逻辑:

  1. Open the src/museums/repository.ts file and create a Repository class.

    它应该有一个名为storage的属性,这将是一个 JavaScriptMap。 键应该是字符串,值应该是Museum类型的对象:

    import type { Museum, MuseumRepository } from   "./types.ts"; export class Repository implements MuseumRepository {   storage = new Map<string, Museum>(); }

    我们使用 TypeScript 泛型来设置Map的类型。 注意,我们已经从博物馆控制器导入了Museum接口,还有MuseumRepository,它是由我们的类实现的。

    既然数据库已经“就绪”,我们必须公开某些方法,以便人们可以与它交互。 上一节的要求是我们可以从数据库中获得所有记录。 接下来让我们实现它。

  2. Inside the repository class, create a method named getAll. It should be responsible for returning all the records in our storage Map:

    export class Repository implements MuseumRepository {   storage = new Map<string, Museum>();   async getAll() {     return [...this.storage.values()];   } }

    我们让它成为一个异步函数,因为我们希望它返回一个承诺。 这可以帮助我们防止将来在用例中需要执行异步逻辑。

    在我们继续之前,我们将定义一个模式,它将在稍后证明自己是有用的。

    规则是,每个直接在src内的文件夹只能通过单个文件从外部访问。 这意味着无论谁想要从src/museums中导入内容,都只能从单个src/museums/index.ts文件中进行。

  3. Create a src/museums/index.ts file that exports the museum's controller and repository:

    export { Controller } from "./controller.ts"; export { Repository } from "./repository.ts"; export type { Museum, MuseumController,   MuseumRepository } from "./types.ts";

    为了保持一致,我们需要转到以前所有从非src/museums/index.ts文件导入的导入,并更改它们,使它们只从这个文件导入内容。

  4. Update the controller.ts and repository.ts imports to import from the index file:

    import type { MuseumController, MuseumRepository }   from "./index.ts";

    你可能已经猜到我们接下来要做什么了……还记得上一节的结尾吗?我们在博物馆的控制器中注入了一个虚拟函数,它将返回一个空数组。 让我们回到这个,用刚才写的逻辑。

  5. Go back to src/index.ts, import the Repository class we've just developed and inject it into the MuseumController constructor:

    import {   Controller as MuseumController,   Repository as MuseumRepository, } from "./museums/index.ts"; const museumRepository = new MuseumRepository(); const museumController = new MuseumController({   museumRepository }) console.log(await museumController.getAll())

    现在,让我们添加一个 fixture 到我们的“数据库”,以便我们可以检查它是否真的在打印什么东西。

  6. Access the storage property in museumRepository and add a fixture to it.

    这目前是一个反模式,因为我们直接访问模块的数据库,但我们将创建一个方法,以便我们可以在后面正确地添加 fixture:

    const museumRepository = new MuseumRepository(); … museumRepository.storage.set   ("1fbdd2a9-1b97-46e0-b450-62819e5772ff", {   id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",   name: "The Louvre", description: "The world's largest art museum     and a historic monument in Paris, France.",   location: {     lat: "48.860294",     lng: "2.33862",   }, }); console.log(await museumController.getAll())

  7. Now, let's run our code again:

    $ deno run src/index.ts [   {     id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",     name: "The Louvre",     description: "The world's largest art       museum and a historic monument in Paris,         France.",     location: { lat: "48.860294", lng: "2.33862" }   } ]

    这样,与数据库的连接就可以工作了,正如我们可以从打印的 fixture 中看到的那样。

我们在前一节中创建的抽象使我们能够在不更改控制器的情况下更改数据源。 这是我们正在使用的架构的优点之一。

现在,如果我们回想一下最初的要求,我们可以确定已经完成一半了。 我们已经创建了满足用例的业务逻辑——只是缺少了 HTTP 部分。

创建 web 服务器

现在我们已经有了我们的功能,我们需要通过一个 web 服务器来暴露它。 让我们按照以下步骤,使用我们从标准库中学到的知识来创建它:

  1. Create a file named index.ts inside the src/web folder and add the logic there to create a server. We can copy and paste it from the previous chapter's exercise:

    import { serve } from   "https://deno.land/std@0.83.0/http/server.ts"; const PORT = 8080; const server = serve({ port: PORT }); console.log(`Server running at   https://localhost:${PORT}`); for await (let req of server) {   req.respond({ body: 'museums api', status: 200 }) }

    因为我们希望我们的应用易于配置,所以我们不希望在这里硬编码port,而是从外部进行配置。 我们需要将这个服务器创建逻辑作为函数导出。

  2. Wrap the server logic creation inside a function that receives the configuration and port as an argument:

    import { serve } from   "https://deno.land/std@0.83.0/http/server.ts"; export async function createServer({   configuration: {     port   } }) {   const server = serve({ port });   console.log(`Server running at     http://localhost:${port}`);   for await (let req of server) {     req.respond({ body: "museums api", status: 200 })   } }

    你可能已经注意到 TypeScript 编译器警告我们不要使用port来定义它的类型。

  3. Make this function's parameters an interface. This will help us in terms of documentation and will also add type safety and static checks:

    interface CreateServerDependencies {   configuration: {     port: number   } } export async function createServer({   configuration: {     port   } }: CreateServerDependencies) { …

    现在我们已经配置了 web 服务器,我们可以考虑使用它作为我们的用例。

  4. 返回src/index.ts,导入createServer,使用它创建一个运行在8080:

    import { createServer } from "./web/index.ts"; … createServer({   configuration: {     port: 8080   } }) …

    端口上的服务器。 5. 运行它,看看它是否工作:

在这里,我们可以看到,我们有一个日志,说明服务器正在运行,还有一个日志记录了上一节的结果。

现在,我们可以用curl测试 web 服务器,以保证它是工作的:

$ curl http://localhost:8080
museums api

正如我们所看到的,它是有效的——我们有一些相当基本的逻辑,虽然仍然不能满足我们的要求,但却可以使 web 服务器运转起来。 我们接下来要做的是用我们之前写的逻辑连接这个 web 服务器。

将 web 服务器连接到业务逻辑

我们已经非常接近完成本章开始时我们计划做的。 我们目前有一个 web 服务器和一些业务逻辑; 这是缺失的联系。

连接两者的一个快速方法是直接在src/web/index.ts上导入控制器并在那里使用它。 在这里,应用将具有所需的行为,目前,这不会带来任何问题。

然而,由于我们正在考虑一种可以在没有很多问题的情况下发展的应用架构,我们不会这样做。 这是因为它将使我们很难单独测试我们的 web 逻辑,从而损害了我们的原则之一。

如果我们直接从 web 服务器导入控制器,每次我们在测试环境中调用createServer函数时,它会自动从MuseumController导入并调用方法,这不是我们想要的。

我们将再次使用依赖倒置将控制器的方法发送到 web 服务器。 如果这看起来仍然太抽象,不要担心——我们将在一分钟后看到代码。

为了确保我们没有忘记我们最初的目标,我们想要的是,当用户向我们的 web 服务器发送GET请求/api/museums时,返回一个博物馆列表。

因为我们是作为练习来做的,所以我们暂时不使用路由库。

我们只是想添加一个基本的检查,以确保请求的 URL 和方法是我们想回答的。 如果是,我们想归还博物馆名单。

让我们回到createServer函数并添加我们的路由处理程序:

export async function createServer({
  configuration: {
    port
  }
}: CreateServerDependencies) {
  const server = serve({ port });
  console.log(`Server running at
    http://localhost:${port}`);
  for await (let req of server) {
    if (req.url === "/api/museums" && req.method === "GET")     
     {
req.respond({ 
body: JSON.stringify({ 
museums: [] 
}), 
status: 200 
      })
      continue
    }
    req.respond({ body: "museums api", status: 200 })
  }
}

我们已经添加了一个基本的检查请求 URL 和方法,并在它们符合初始需求时添加了一个不同的响应。 让我们运行代码看看它的行为:

$ deno run --allow-net src/index.ts 
Server running at http://localhost:8080

再次,用curl测试它:

$ curl http://localhost:8080/api/museums
{"museums":[]}

它工作了-酷!

现在到了定义需要什么来满足这个接口请求的部分。

我们最终需要一个函数来返回注入服务器的博物馆列表。 让我们按照以下步骤将其添加到CreateServerDependencies界面中:

  1. Back inside src/web/index.ts, add MuseumController as a dependency to the createServer function:

    import type { MuseumController } from   "../museums/index.ts"; interface CreateServerDependencies {   configuration: {     port: number   },   museum: MuseumController }

    注意,我们导入了在博物馆模块中定义的MuseumController类型。 我们还在configuration对象旁边添加了一个museum对象。

  2. Call the getAll function from the museum's controller to get a list of all the museums and respond to the request:

    export async function createServer({   configuration: {     port   },   museum }: CreateServerDependencies) {   const server = serve(`:${port}`);   console.log(`Server running at     http://localhost:${port}`);   for await (let req of server) {     if (req.url === "/api/museums" &&       req.method === "GET") {       req.respond({         body: JSON.stringify({           museums: await museum.getAll()         }),         status: 200       })       continue     }     req.respond({ body: 'museums api', status: 200 })   } }

    现在,如果我们尝试运行这段代码,我们将得到以下错误:

    $ deno run --allow-net src/index.ts error: TS2345 [ERROR]: Argument of type '{ configuration: { port: number; }; }' is not assignable to parameter of type 'CreateServerDependencies'.   Property 'museum' is missing in type '{ configuration: { port: number; }; }' but required in type 'CreateServerDependencies'. createServer({…

    TypeScript 编译器是正确的:我们添加了博物馆的控制器作为createServer函数的必选参数,但当调用createServer时没有发送它。 让我们解决这个问题。

  3. 返回src/index.ts,这是我们调用createServer函数的地方,从MuseumController发送getAll函数。 您也可以从上一节中删除直接调用控制器方法的代码,因为它目前是无用的:

    import { createServer } from "./web/index.ts"; import {   Controller as MuseumController,   Repository as MuseumRepository, } from "./museums/index.ts"; const museumRepository = new MuseumRepository(); const museumController = new MuseumController({   museumRepository }) museumRepository.storage.set ("1fbdd2a9-1b97-46e0-b450-62819e5772ff", {   id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",   name: "The Louvre",   description: "The world's largest art museum     and a historic monument in Paris, France.",   location: {     lat: "48.860294",     lng: "2.33862",   }, }); createServer({   configuration: { port: 8080 },   museum: museumController })

  4. 再次运行应用:

    $ deno run --allow-net src/index.ts Server running at http://localhost:8080

  5. 发送请求到 http://localhost:8080/api/museums; 你会得到一份博物馆列表:

就是这样,我们拿到了博物馆的名单!

我们刚刚完成了本节的目标; 也就是说,将我们的业务逻辑连接到 web 服务器。

注意我们是如何让控制器方法被注入的,而不是让 web 服务器直接导入它。 这是因为我们使用了依赖倒置。 在本书中,每当我们想要解耦并增加模块和函数的可测试性时,我们都会一直这样做。

在进行测试代码耦合的脑力练习时,当我们想要使用不同的交付机制(如 CLI)的当前业务逻辑时,没有什么会妨碍我们。 我们仍然可以重用相同的控制器和存储库。 这意味着我们正在很好地使用抽象来将业务逻辑与应用逻辑解耦。

现在我们已经有了应用架构和文件夹结构的基础,并且我们也了解了其背后的原因,我们可以开始查看可能帮助我们构建它的实用程序。

在下一节中,我们将了解目前存在于 Deno 社区中的 HTTP 框架。 我们不会花太多时间做这件事,但我们想要了解每一个的利弊,并最终选择一个来帮助我们完成余下的旅程。

探索 Deno HTTP 框架

当您正在构建一个比简单的教程更复杂的应用时,如果您不想采用一种纯粹的方法,那么您最有可能使用第三方软件。

显然,这不是 Deno 特有的。 尽管有一些社区比其他社区更热衷于使用第三方模块,但所有的社区都使用第三方软件。

我们可以回顾一下人们做这件事或不做这件事的原因,但更普遍的原因总是与可靠性和时间管理有关。 这可能是因为您希望使用经过实战测试的软件,而不是自己构建。 有时,这仅仅是一个时间管理的问题,即不想重写已经创建的内容。

我们必须要说的一件重要的事情是,我们必须非常谨慎地考虑我们正在构建的应用中有多少是与第三方软件相结合的。 我们的意思并不是说你应该尝试着去实现一切完全脱钩的乌托邦,特别是因为这会带来其他问题和许多间接的问题。 我们要说的是,我们应该非常清楚将依赖项引入代码的成本以及它带来的权衡。

在本章的第一部分,我们为一个 web 应用建立了基础,我们将在本书的其余部分中为它添加特性。 在当前状态下,它仍然非常小,所以除了标准库之外,它没有任何依赖项。

在这个应用中,我们做了一些我们认为不能很好扩展的事情,比如使用简单的if语句匹配 url 和 HTTP 方法来定义路由。

随着应用的发展,我们很可能会有更高级的需求。 这些需求可以从处理不同格式的 HTTP 请求体,到拥有更复杂的路由系统、处理头和 cookie,或者连接到数据库。

因为我们不相信在开发应用时重复发明轮子,我们将分析一些目前存在于 Deno 社区的库和框架,并专注于创建 web 应用。

我们将大致了解现有的解决方案,并探索它们的特性和方法。

最后,我们将选择一个我们认为对我们的用例来说是最好的权衡。

有什么替代方案?

在撰写的时候,有一些第三方包提供了大量的功能来创建 web 应用和 api。 其中一些受到了非常流行的 Node.js 包的极大启发,比如 Express.JS、Koa 或 happy .js,而另一些则受到了 JavaScript 之外的其他框架的启发,比如 Laravel、Flask 等。

我们将探讨其中四种在写作时相当流行且维护良好的方法。 请记住,由于 Deno 和提到的包正在快速发展,这可能会随着时间而改变。

重要提示

Craig Morten 写了一篇很棒的文章,对现有的图书馆做了非常彻底的分析和探索。 如果你想了解更多,我强烈推荐这篇文章(https://dev.to/craigmorten/what-is-the-best-deno-web-framework-2k69)。

当涉及到我们将要探索的包时,我们将尝试多样化。 有一些提供了比其他更多的抽象和结构,还有一些只提供实用函数和可组合功能。

我们将探索的包如下:

  • Drash
  • 服务
  • 橡木
  • Alosaur

让我们分开来看每一个。

Drash

Drash(https://github.com/drashland/deno-drash)旨在不同于现有的 Deno 和 Node.js 框架。 其维护者 Edward Bebbington 在一篇博文中明确提到了这种动机,他将 Drash 与其他替代品进行了比较,并解释了其创建背后的动机(https://dev.to/drash_land/what-makes-drash-different-idd)。

这些动机非常强大,而 Laravel、Flask 和 Tonic 等非常流行的软件工具的启发也证明了这些决定的合理性。 当你看到 Drash 的代码时,你会发现其中的一些相似之处。

与 Express.js 或 Koa 等库相比,它确实提供了一种不同的方法,正如文档所述:

Deno 与 Node.js 的不同之处是,Drash 的目标是与 Express 或 Koa 的不同之处,利用资源和完全基于类的系统。”

主要的区别在于,Drash 不希望提供一个应用对象,让开发者可以像一些流行的 Node.js 框架那样注册他们的端点。 它将端点视为定义在类中的资源,类似如下:

import { Drash } from
  "https://deno.land/x/drash@v1.2.2/mod.ts";
class HomeResource extends Drash.Http.Resource {
  static paths = ["/"];
  public GET() {
    this.response.body = "Hello World!";
    return this.response;
  }
}

这些资源随后被插入到 Drash 的应用中:

const server = new Drash.Http.Server({
  response_output: "text/html",
  resources: [HomeResource]
});
server.run({
  hostname: "localhost",
  port: 1447
});

在这里,我们可以直接声明它实际上与我们提到的其他框架不同。 这些差异是经过深思熟虑和计划的,目的是为了取悦那些喜欢这种方法的开发人员,以及它为其他框架解决的问题。 这些用例在 Drash 的文档中有很好的解释。

Drash 基于资源的方法绝对值得关注。 它的灵感来自于非常成熟的软件,如 Flask 和 Tonic,这无疑带来了一些东西,并提出了一个解决方案,帮助解决一些未关联工具所存在的常见问题。 该文档完整且易于理解,这使得在选择用于构建应用的工具时非常有用。

服务

服务(https://servestjs.org/)调用本身“为 Deno 的渐进式 HTTP 服务器。” 【5】

创建的原因之一是,它的作者想让来自标准库的 HTTP 模块的一些 api 更容易使用和试验新特性。 对于一个需要稳定性的标准库来说,后者是很难做到的。

Servest 直接关注与标准库的 HTTP 模块的比较。 它的主要目标之一(直接显示在项目的主页上)是使从标准库的 HTTP 模块迁移到 Servest 变得容易。 这很好地总结了 Servest 的愿景。

在 api 方面,Servest 非常类似于我们过去使用的 Express.js 和 Koa。 它提供了一个可以注册路由的应用对象。 您还可以识别出标准库模块所提供的明显影响,正如我们在以下代码片段中所看到的:

import { createApp } from
  "https://servestjs.org/@v1.1.4/mod.ts";
const app = createApp();
app.handle("/", async (req) => {
  await req.respond({
    status: 200,
    headers: new Headers({
      "content-type": "text/plain",
    }),
    body: "Hello, Servest!",
  });
});
app.listen({ port: 8899 });

我们可以从知名的 Node.js 库中识别应用对象,从标准库中识别请求对象。

在此功能之上,Servest 还提供了一些通用特性,比如支持直接呈现 JSX 页面、提供静态文件和身份验证。 文档也很清晰,并且有很多示例。

Servest 试图利用 Node.js 用户的知识和熟悉程度,同时利用 Deno 提供的好处。 它的进步性质带来了非常好的特性,承诺让开发人员比使用标准库 HTTP 包时更高效。

橡木

Oak(https://oakserver.github.io/oak/)是目前最流行的 Deno 库,当涉及到创建 web 应用。 它的名字来源于 Koa 的文字游戏,Koa 是一个非常流行的 Node.js 中间件框架,也是 Oak 的主要灵感来源。

由于其丰富的灵感,它的 api 使用异步函数和上下文对象类似于 Koa 也就不足为奇了。 Oak 还包括一个路由,也受到了@koa/router的启发。

如果您知道 Koa,下面的代码可能对您非常熟悉:

import { Application } from
  "https://deno.land/x/oak/mod.ts";
const app = new Application();
app.use((ctx) => {
  ctx.response.body = "Hello world!";
});
await app.listen("127.0.0.1:8000");

对于那些不熟悉 Koa 的人,我们将简单地解释一下,因为理解它将帮助你们理解 Oak。

通过使用现代 JavaScript 特性,Koa 提供了一种最小且无关联的方法。 创建 Koa 的最初原因之一(与创建 Express.js 的团队相同)是,它的创建者希望创建一个能够利用现代 JavaScript 特性的框架,而不是 Express,后者是在 Node.js 的生命周期之初创建的。

该团队希望使用 promise 和 async/await 等新特性,然后解决开发人员在 Express.JS 中面临的挑战。 这些挑战大多与错误处理、回调处理和某些 api 缺乏清晰度有关。

Oak 的受欢迎程度并非凭空而来,它目前在 GitHub 上与其他品牌的距离也反映了这一点。 GitHub 星级本身并没有什么意义,但是结合开放和封闭的问题、发布的数量等等,我们可以看出人们为什么信任它。 当然,这种熟悉程度对这个包的受欢迎程度起着很大的作用。

在当前的状态下,Oak 是构建 web 应用的一种可靠方式(就 Deno 的社区标准而言),因为它提供了一套非常清晰和直接的功能。

Alosaur

Alosaur(https://github.com/alosaur/alosaur)是一个基于 decorator 和 classes 的 Denoweb 应用框架。 它在某种程度上与 Drash 相似,尽管最终的方法完全不同。

在它的主要特性中,Alosaur 提供了诸如模板呈现、依赖注入和 OpenAPI 支持等功能。 这些特性是在我们这里提出的所有替代方案(如中间件支持和路由)的标准之上添加的。

这个框架的方法是通过使用类来定义控制器,并使用装饰器来定义它们的行为,如下面的代码所示:

import { Controller, Get, Area, App } from
  'https://deno.land/x/alosaur@v0.21.1/mod.ts';
@Controller() // or specific path @Controller("/home")
export class HomeController {
    @Get() // or specific path @Get("/hello")
    text() {
        return 'Hello world';
    }
}
// Declare module
@Area({
    controllers: [HomeController],
})
export class HomeArea {}
// Create alosaur application
const app = new App({
    areas: [HomeArea],
});
app.listen();

在这里,我们可以看到应用的实例化与 Drash 有相似之处。 它还使用 TypeScript 装饰器来声明框架的行为。

Alosaur 采取了与前面提到的大多数图书馆不同的方法,主要是因为它不尝试最小化。 相反,它提供了一组在构建特定类型的应用时被证明是有用的特性。

我们决定看一看它,不仅因为它做了它应该做的,而且因为它有一些在 Node.js 和 Deno 世界中不太常见的特性。 这包括依赖注入和 OpenAPI 支持,这些都是其他解决方案所没有提供的。 同时,它保留了模板渲染等特性,这可能是你在 Express.JS 中熟悉的东西,但在更现代的框架中就不那么熟悉了。

最终的解决方案在其提供的功能方面非常有希望和完整。 这是绝对需要关注的东西,这样你就可以看到它的发展。

The verdict

在看了所有提出的解决方案并认识到它们都有各自的优点后,我们决定在这本书的其余部分使用 Oak。

这并不意味着这本书将聚焦于奥克。 它不会,因为它只处理 HTTP 和路由。 Oak 的最小方法将非常适合我们接下来要做的事情,帮助我们增量地创建功能,而不受它的阻碍。 事实上,它也是 Deno 社区中最稳定、最维护和最受欢迎的选项之一,这对我们的决定有明显的影响。

请注意,这个决定并不意味着我们将在接下来几章学到的东西不能在任何替代方案中实现。 事实上,由于我们组织代码和架构的方式,我们相信使用一个不同的框架会很容易跟上我们将要做的大部分事情。

在本书的其余部分,我们将使用其他第三方模块来帮助我们构建我们提出的功能。 我们之所以决定深入研究处理 HTTP 的库,是因为这是我们将要开发的应用的基本交付机制。

小结

在本章中,我们最终开始构建一个利用我们对 Deno 的了解的应用。 我们首先考虑构建应用并定义其架构时的主要目标。 这些目标将为我们在本书中关于架构和结构的大部分对话定下基调,我们将继续回顾这些目标,确保我们与它们保持一致。

我们从创建文件夹结构开始,并尝试实现第一个应用目标:拥有一个列出博物馆的 HTTP 端点。 我们首先构建了简单的业务逻辑,然后在出现关注点分离、隔离和责任等需求时继续前进。 这些需求派生了我们的体系结构,证明了为什么我们创建的层和抽象是有用的,并演示了它们添加了什么。

通过定义职责和模块接口,我们知道可以通过使用内存中的数据库来临时构建应用,我们也确实这样做了。 构建应用来满足本章的要求是可能的,层分离允许我们稍后再回来,并在没有任何问题的情况下将其更改为适当的持久化层。 在定义了业务和数据访问逻辑之后,我们创建了一个 web 服务器,该服务器具有作为交付机制的标准库。 在创建了一个非常基本的路由系统之后,我们插入了之前构建的业务逻辑,满足了本章的主要需求:拥有一个返回博物馆列表的应用。

我们在没有创建业务逻辑、数据获取和交付层之间直接耦合的情况下完成了所有这些工作。 当我们开始添加复杂性、扩展应用并添加测试时,我们相信这是非常有用的。

本章最后将介绍目前在 Deno 社区中存在的 HTTP 框架和库,并理解它们的差异和方法。 其中一些使用 Node.js 用户熟悉的方法,而另一些则深入使用 TypeScript 及其特性来创建更结构化的 web 应用。 通过查看目前可用的四种解决方案,我们了解了社区正在开发什么,以及他们可能的发展方向。

我们最终选择了 Oak,这是一个非常简单但相当成熟的解决方案,可以帮助我们解决在本书的其余部分将会遇到的路由和 HTTP 挑战。

在下一章中,我们将开始向代码库中添加 Oak,以及一些有用的特性,如身份验证和授权,使用中间件等概念,并扩展我们的应用,以实现我们设定的目标。

我们走吧!*