六、将所有这些放在一起——示例应用

这是最后一章,到目前为止,我们不仅讨论了语言和运行时,还讨论了自发布之日起(老实说,在此之前)社区所做的令人惊叹的工作,构建工具和模块来帮助推动技术向前发展。

在这一章中,我将展示几个我用 Deno 构建的非常不同的项目,以便向您展示到目前为止所涉及的所有内容是如何组合在一起的。它们都是示例项目,当然还没有完全投入生产,但是它们应该涵盖所有感兴趣的领域,如果您将 GitHub 项目作为一个起点(所有这些项目都可以在 GitHub 帐户上使用),您应该能够对其进行定制,并很快使其成为您自己的项目。

所以事不宜迟,让我们开始检查项目。

Deno runner

我们要解决的第一个项目是一个简单但非常有用的项目。从我们到目前为止所介绍的内容来看,每次执行 Deno 脚本时,都需要指定权限标志,以便为脚本提供这些权限。这是事实,也是这个运行时背后的团队的设计决策。

然而,可以有另一种方式;如果您创建一个工具,从预设文件中读取这些权限,然后作为子进程执行预期的脚本,那么您可以为用户提供更好的体验。这就是这个项目的目的:简化执行 Deno 脚本的用户体验,而不必担心非常长的命令行,虽然很明确,但对于新手用户来说也可能很复杂和可怕。

目标是从这样的命令行移动:

$ deno run --allow-net --allow-read=/etc --allow-write=/output-folder --allow-env your-script.ts

取而代之的是,有一个专门设计的文件来存放你的安全标志,类似于清单 6-1 的东西,跑步者可以为你所用,而不必担心它。

--allow-net
--allow-read=/etc
--allow-write=/output-folder
--allow-env

Listing 6-1Content of the flags file

现在,一个更简单的命令行读取该文件,然后执行该脚本,如下所示:

$ the-runner your-script.ts

非常非常简单,如果您考虑一下,如果您尝试执行的脚本附带了 flags 文件,您会发现使用这个工具会更加友好,尤其是对新手而言。

计划

该工具很简单,并且使其工作所需的步骤也很简单:

  1. 构建一个入口点,它接收要作为参数执行的脚本的名称。

  2. 确保您可以找到标志文件(包含脚本安全标志的文件)。

  3. 使用标志文件中的标志和脚本的名称,创建执行脚本所需的命令。

  4. 然后,使用 Deno 的run方法执行它。

为了使这个工作,我们将只使用标准库;在某种程度上,这也证明了 Deno 的创造者对其标准库的承诺。

这个项目的结构也很简单;为了让一切井然有序,我们只需要几个文件:

  • 主脚本,即所谓的入口点,是将由用户执行的脚本,也是解析 CLI 参数的脚本。

  • 所有外部依赖项都将从deps.ts文件中导入,遵循已经覆盖的模式,以便于我们将来可能需要的任何更新或包含。

  • 我们将要编写的三个函数将存在于一个utils.ts文件中,只是为了将入口点的代码与这些支持函数分开。

  • 最后,将代码捆绑到单个文件并使其最终可执行所需的脚本将是一个简单的 bash 脚本。这是因为我们需要运行一些终端命令,使用 bash 比使用 JS 要容易得多。

代码

这个小项目的完整源代码位于这里 1 ,以防你需要检查任何其他细节或者甚至克隆存储库。

也就是说,入口点的代码公开了整个脚本背后的主要逻辑,您可以在清单 6-2 中看到这一点。

import { parse, bold } from './deps.ts'
import { parseValidFlags, runScript } from './utils.ts'

// The only argument we care about: the script's name
const ARGS = parse(Deno.args)
const scriptName:string = <string>ARGS["_"][0]

const FLAGFILE = "./.flags" //this is the location and the name of the flags file

// Required to turn the binary array from Deno.readFile into a simple string
const decoder = new TextDecoder('UTF-8')
let secFlags = ""
try { //Make sure we capture any error reading the file...
  const flags = await Deno.readFile(FLAGFILE)
  secFlags = decoder.decode(flags)
} catch (e) {//... and in that case, just ignore privileges
  console.log(bold("No flags file detected, running script without privileges"))
}

let validFlags:string[] = parseValidFlags(secFlags)
runScript(validFlags, scriptName)

Listing 6-2Code for the entry point script

脚本正在捕获位于Deno.args的命令行参数,由于parse方法(你将在deps.ts文件中看到)来自属于标准库的flags模块。然后,我们读取标志文件,如果脚本找不到它,就捕获它。有了这些内容,我们解析它,把它变成一个字符串列表,然后简单地请求运行它。

现在,关于代码的其余部分,我还想介绍两个细节。对标志的解析本质上需要读取一个带有标志列表的文件,每行一个标志,这有一个潜在的问题:如何将这些行转换成一个数组?请记住,换行字符并不总是相同的;这实际上取决于操作系统。幸运的是,Deno 为我们提供了一种方法来检测我们正在使用的行尾字符,因此脚本可以适应它运行的操作系统。您可以在清单 6-3 中看到我是如何做到的。

export function parseValidFlags(flags:string):string[] {
  const fileEOL:EOL|string = <string>detect(flags)
  if(flags.trim().length == 0) return []

  return <string[]>flags.split(fileEOL).map( flag => {
    flag = flag.trim()
    let flagData = findFlag(flag)
    if(flagData) {
      return flagData
    } else {
      console.log(":: Invalid Flag (ignored): ", bold(flag))
    }
  }).filter( f => typeof f != "undefined")
}

Listing 6-3Parsing the flags

注意所使用的detect函数,以便理解使用的是哪一个行尾字符。然后我们对split方法也这样做。剩下的就是确保从文件中读取的标志是有效的,如果不是,我们就忽略它。

最后,转换这些读取标志并运行脚本所需的代码如清单 6-4 所示。你可以看到这段代码有多简单;我们只需要用正确的参数调用Deno.run方法。

export function runScript(flags:string[], scriptFile:string) {
   flags.forEach( f => {
     console.log("Using flag", bold(f))
   })
   let cmd = ["deno", "run", ...flags, scriptFile]
   const sp = Deno.run({
     cmd
   })
   sp.status()
}

Listing 6-4Running the script

在这个函数中,我们对标志列表进行了额外的迭代,只是为了通知用户哪些权限被授予了正在执行的脚本。但是这段代码真正的核心是我们如何使用数组析构将数组合并到另一个数组中。

我想介绍的最后一点并不是真正的 Deno 代码。相反,它是几行 bash 代码。请参见清单 6-5 ,我稍后会解释。

#!/bin/bash

DENO="$(which deno)"
SHEBANG="#!${DENO} run -A"
CODE="$(deno bundle index.ts)"

BOLD=$(tput bold)
NORMAL=$(tput sgr0)

echo "${SHEBANG}
${CODE}" > bundle/denorun.js

chmod +x bundle/denorun.js

echo "----------------------------------------------------------------------------------"
echo "Thanks for installing DenoRunner, copy the file in ${BOLD}bundle/denorun.js${NORMAL} to a folder
you have in your PATH or add the following path to your PATH variable:

${BOLD}$(pwd)/bundle/${NORMAL}"
echo "----------------------------------------------------------------------------------"

Listing 6-5Build script written in bash

这个脚本的第一行被称为 shebang ,如果您从未见过它,它会告诉解释器将执行这个脚本的实际二进制文件的位置。它允许您执行脚本,而不必从命令行显式调用解释器;相反,当前的 bash 将为您做这件事。理解这一点很重要,因为它可以用任何脚本语言来完成,不仅仅是 bash,正如你马上要看到的,我们正试图对我们的脚本做同样的事情。

然后,我们捕获 deno 二进制文件在系统中的安装位置,以便创建一个包含新 shebang 行的字符串。根据您的系统,它可能看起来像这样:

/home/your-username/.deno/bin/deno run -A

然后我们将继续使用deno bundle命令,它将获取我们所有的外部和内部依赖项并创建一个文件。这对于分发我们的应用来说是完美的,因为它允许您简化这个任务。现在你不必要求你的用户下载一个潜在的非常大的项目,你只需要要求他们下载一个文件并使用它。

但是,我们的问题是,我们需要让我们的最终包是一个自动可执行文件,所以我们需要了解您的 deno 安装在哪里,以便创建正确的 shebang 行。将我们的包代码放在我们的CODE变量中,将 shebang 行放在SHEBANG变量中,然后我们将两个字符串输出到bundle文件夹中的一个文件(我们的最终包)中。然后,我们为我们的文件提供执行权限,这样您就可以从命令行直接调用它,shebang 就会生效。

将这一行作为脚本的第一行,您的 bash 将知道调用 Deno,告诉它执行我们新构建的文件,并为它提供所有可用的特权。这是为了确保我们不会遇到任何问题;您可以像过去一样更改-A以获得更详细的权限列表,但是一旦准备好,并且您已经将文件复制到您的PATH中的某个地方(即,当键入命令时您的终端将查找的某个地方)或者将文件夹添加到其中(参见清单 6-6 中如何做的示例),您就可以简单地键入

$ denorun.js your-script.ts

它会正确执行您的脚本,如果您创建了正确的.flags文件,它会读取并列出所有权限,在执行您的文件之前,它会列出这些权限以确保用户知道它们。

# To test it inside your current terminal window (will only work for the currently opened session)
export PATH="/path/to/deno-runner/bundle:$PATH"

# In order to make it work on every terminal
export PATH="/path/to/deno-runner/bundle:$PATH" >> ~/.bash_profile

Listing 6-6Adding a folder to your PATH variable

Note

清单 6-6 中的例子只适用于 Linux 和 Mac 系统;如果你有一个 Windows 盒子,你必须搜索如何更新你的路径。可以做到;这并不难,但是只需点击几下鼠标,而不是命令行。此外,该示例假设您正在使用默认的 bash 命令行;如果你正在使用别的东西,比如 Zsh, 2 ,你必须相应地更新代码片段。

测试应用

对于下一个使用 Deno 可以实现什么的例子,我想介绍标准库中另一个强大的模块:测试。 3

正如我已经提到的,Deno 已经为您提供了一个测试套件。当然,如果您打算做更复杂的事情,比如创建存根或模拟,您可能需要额外的资源,但是对于基本的设置,Deno 的测试模块已经足够了。

为此,我们将回到第一个示例,我们将添加一些示例测试,以便您可以看到它实际上有多简单。

添加一个测试就像创建一个以_test.ts.test.ts结尾的文件一样简单(如果您直接编写 JavaScript,则更改扩展名);这样,当您使用如下的测试命令执行它时,Deno 应该能够获得它并运行测试:deno test

清单 6-7 显示了设置测试套件所需的代码。

Deno.test("name of your test", () => {
  ///.... your test code here
})

Listing 6-7Basic test code

正如您所看到的,启动和运行您的测试只需要很少的东西;事实上,你可以在清单 6-8 中看到一个如何测试让 deno-runner 工作的一些函数的例子。

import { assertEquals } from "../deps.ts"
import { findFlag, parseValidFlags } from '../utils.ts'

Deno.test("findFlag #1: Find a valid flag by full name", () => {
  const fname = "--allow-net"
  const flag = findFlag(fname)
  assertEquals(flag, fname)
})

Deno.test("findFlag #2: It should not find a valid flag by partial name", () => {
  const fname = "allow-net"
  const flag = findFlag(fname)
  assertEquals(flag, false)
})

Deno.test("findFlag #3: Return false if flag can't be found", () => {
  const fname = "invalid"
  const flag = findFlag(fname)
  assertEquals(flag, false)
})

Deno.test("parseValidFlag #1: Should return an empty array if there are no matches", () => {
  let flags = parseValidFlags("")
  assertEquals(flags, [])
})

Listing 6-8Testing the deno-runner code

例如,如果您想做一些更复杂的事情并监视函数调用,您将需要一个外部模块,比如 mock。有了这个模块,你可以使用清单 6-9 中看到的间谍和模仿。

import { assertEquals } from "https://deno.land/std@0.50.0/testing/asserts.ts";
import { spy, Spy } from "https://raw.githubusercontent.com/udibo/mock/v0.3.0/spy.ts";

class Adder {
  public miniAdd(a: number, b:number): number {
   return a +b
  }
  public add( a: number, b: number, callback: (error: Error | void, value?: number) => void): void {
   const value: number = this.miniAdd(a,b)
   if (typeof value === "number" && !isNaN(value))    callback(undefined, value);
   else callback(new Error("invalid input"));
 }
}

Deno.test("calls fake callback", () => {
   const adder = new Adder()
   const callback: Spy<void> = spy();
   assertEquals(adder.add(2, 3, callback), undefined);
   assertEquals(adder.add(5, 4, callback), undefined);
   assertEquals(callback.calls, [
    { args: [undefined, 5] },
    { args: [undefined, 9] },
   ]);
});

Listing 6-9Using spies to test your code

该示例展示了如何覆盖回调函数并检查执行情况,从而允许您检查诸如执行次数、收到的参数等内容。事实上,清单 6-10 展示了如何为Adder类的一个方法创建存根以控制其行为的例子。

Deno.test("returns error if values can't be added", () => {
   const adder = new Adder()
   stub(adder, "miniAdd", () => NaN);
   const callback = (err: Error | void, value?: number) => {
      assertEquals((<Error>err).message, "invalid input");
   }
   adder.add(2, 3, callback)
});

Listing 6-10Creating a stub for one of the methods

只需一行简单的代码,您就可以用一个您可以控制的方法替换原来的方法。在清单 6-10 的例子中,您正在控制来自miniAdd方法的输出,从而帮助您测试与add方法相关联的其余逻辑(即,确保在这种情况下返回值是错误对象)。

聊天服务器

最后,构建聊天服务器通常需要处理套接字,因为它们允许您打开一个双向连接,该连接在关闭之前一直保持打开状态,这与普通的 HTTP 连接不同,普通的 HTTP 连接只存在很短的一段时间,并且实际上只允许在客户机和服务器之间发送单个请求及其相应的响应。

如果您来自 Node,您可能见过类似的基于套接字的聊天客户端和服务器的例子,本质上是在套接字库发出的事件之上工作。然而,Deno 的架构有点不同,因为它不依赖事件发射器,而是使用流来处理套接字。

在这个例子中,我将快速浏览 Deno 官方文档中显示的客户端和服务器的简化版本(对于 WebSocket 模块,是标准库 5 的一部分)。清单 6-11 展示了如何处理套接字流量(基本上,新消息被接收或者甚至是一个关闭套接字的请求)。

let sockets: WebSocket[] = []

async function handleWs(sock: WebSocket) {
 log.info("socket connected!");
 sockets.push(sock)
 try {
  for await (const ev of sock) {
    if (typeof ev === "string") {
      log.info("ws:Text", ev);
       for await(let s of sockets) {
        log.info("Sending the message: ", ev)
        await s.send(ev);
       }

      await sock.send(ev);
    } else if (isWebSocketCloseEvent(ev)) {
      // close
      const { code, reason } = ev;
      log.info("ws:Close", code, reason);
    }
  }
} catch (err) {
  log.error(`failed to receive frame: ${err}`);

  if (!sock.isClosed) {
    await sock.close(1000).catch(console.error);
  }
 }
}

Listing 6-11Handling new message on the socket connection

一旦建立了套接字连接,就要调用这个函数(稍后将详细介绍)。如您所见,它的要点是一个主for循环,遍历套接字的元素(实质上是新消息到达)。接收到的所有文本消息都将通过异步for循环中的socket.send方法发送回客户端和所有其他打开的套接字(注意代码中加粗的部分)。

为了启动服务器并开始监听新的套接字连接,您可以使用清单 6-12 中的代码。

const port = Deno.args[0] || "8080";
log.info(`websocket server is running on :${port}`);
for await (const req of serve(`:${port}`)) {
  const { conn, r: bufReader, w: bufWriter, headers } = req;
  acceptWebSocket({
    conn,
    bufReader,
    bufWriter,
    headers,
  })
   .then(handleWs)
   .catch(async (err:string) => {
      log.error(`failed to accept websocket: ${err}`);
      await req.respond({ status: 400 });
    });
  }

Listing 6-12Starting the server

使用serve函数启动服务器,这又创建了一个请求流,我们也在用异步for循环迭代这个请求流。在收到每个新的请求时(即打开一个新的套接字连接),我们调用acceptWebSocket函数。这个服务器和客户端的完整代码(我一会儿会讲到)可以在 GitHub、 6 上找到,所以一定要查看一下,以了解一切是如何组合在一起的。

简单的客户

没有合适的客户机,服务器什么也做不了,所以为了结束这个例子,我将向您展示如何使用标准库中的同一个模块来创建一个客户机应用,它将从前面连接到服务器并发送(和接收)消息。

清单 6-13 展示了客户端代码背后的基本架构;在使用了connectWebSocket函数之后,我们将创建两个不同的异步函数,一个用于从套接字读取消息,一个用于从标准输入读取文本。注意,除了标准库之外,我们没有使用任何外部库。

const sock = await connectWebSocket(endpoint);
console.log(green("ws connected! (type 'close' to quit)"));

// Read incoming messages
const messages = async (): Promise<void> => {
   for await (const msg of sock) {
      if (typeof msg === "string") {
         console.log(yellow(`< ${msg}`));
      } else if (isWebSocketCloseEvent(msg)) {
         console.log(red(`closed: code=${msg.code}, reason=${msg.reason}`));
      }
   }
};

// Read from standard input and send over socket
const cli = async (): Promise<void> => {
   const tpr = new TextProtoReader(new  BufReader(Deno.stdin));
   while (true) {
      await Deno.stdout.write(encode("> "));
      const line = await tpr.readLine();
      if (line === null || line === "close") {
         break;
      } else {
      await sock.send(username + ":: " + line);
   }
 }
};
await Promise.race([messages(), cli()]).catch(console.error);

Listing 6-13Core of the client code

注意我之前提到的两个异步函数(messagescli);它们都返回一个承诺,正因为如此,我们可以使用 Promise.race 让两个函数同时执行。使用这种方法,一旦任何一个承诺解决或失败,执行将结束。cli函数将从标准输入中读取输入,并使用socket.send方法通过套接字连接发送。

另一方面,messages函数就像在服务器端一样,迭代套接字的元素,本质上是对通过连接到达的消息做出反应。

通过将此客户端的实例连接到服务器,您可以在它们之间发送消息。服务器会负责将消息广播给每个人,客户端会用黄色显示从服务器收到的文本。如果你想测试这个项目,请参考完整代码 7

结论

这不仅是第六章的结尾,也是本书的结尾。希望到现在为止,您已经设法理解了创建 Deno 背后的动机,为什么提出 Node 并在后端开发行业留下印记的同一个人决定重新开始并更加努力。

Deno 远没有做到;事实上,当我开始编写这本书时,它的第一个版本刚刚发布,甚至不到两个月后,版本 1.2.0 就已经出来了,由于突破性的变化导致了一些问题。

但不要害怕;事实上,如果你对 Deno 背后的团队仍有疑虑,这就是你需要的证据。这不仅仅是一个人希望推翻后端的 JavaScript 国王,这是一个完整的团队,致力于满足不断增长的社区的需求,积极提供反馈和支持,以帮助生态系统每天都在增长。

如果你只是想从这本书里学到一样东西,我希望你带走玩一种全新技术的好奇心,希望你会爱上它。

感谢您阅读至此;下次再见!

Footnotes [1](#Fn1_source) [T2`https://github.com/deleteman/deno-runner`](https://github.com/deleteman/deno-runner)   [2](#Fn2_source) [T2`www.zsh.org/`](http://www.zsh.org/)   [3](#Fn3_source) [T2`https://deno.land/std/testing`](https://deno.land/std/testing)   [4](#Fn4_source) [T2`https://deno.land/x/mock`](https://deno.land/x/mock)   [5](#Fn5_source) [T2`https://deno.land/std/ws/`](https://deno.land/std/ws/)   [6](#Fn6_source) [T2`https://github.com/deleteman/deno-chat-example`](https://github.com/deleteman/deno-chat-example)   [7](#Fn7_source) [T2`https://github.com/deleteman/deno-chat-example`](https://github.com/deleteman/deno-chat-example)