Vert.x-Web

Vert.x-Web 是一组用于使用 Vert.x 构建 Web 应用程序的构建块。可以将其视为构建现代化、可伸缩 Web 应用程序的瑞士军刀。

Vert.x Core 提供了一套相当低级别的 HTTP 处理功能,对于某些应用程序来说,这已足够。

Vert.x-Web 在 Vert.x Core 的基础上构建,为更轻松地构建真实 Web 应用程序提供了更丰富的功能集。

它是 Vert.x 2.x 中 Yoke 的继任者,并从 Node.js 世界的 Express 和 Ruby 世界的 Sinatra 等项目中汲取灵感。

Vert.x-Web 设计为功能强大、不强制特定观点且完全可嵌入。您只需使用您想要的部分,别无他求。Vert.x-Web 不是容器。

您可以使用 Vert.x-Web 创建经典的服务器端 Web 应用程序、RESTful Web 应用程序、“实时”(服务器推送)Web 应用程序,或任何您能想到的其他类型的 Web 应用程序。Vert.x-Web 不关心这些。选择您喜欢的应用程序类型取决于您,而不是 Vert.x-Web。

Vert.x-Web 非常适合编写 RESTful HTTP 微服务,但我们不强制您编写此类应用程序。

Vert.x-Web 的一些主要功能包括:

  • 路由(基于方法、路径等)

  • 路径的正则表达式模式匹配

  • 从路径中提取参数

  • 内容协商

  • 请求体处理

  • 请求体大小限制

  • 多部分表单

  • 多部分文件上传

  • 子路由器

  • 会话支持 - 包括本地会话(用于粘性会话)和集群会话(用于非粘性会话)

  • CORS(跨域资源共享)支持

  • 错误页面处理器

  • HTTP 基本/摘要认证

  • 基于重定向的认证

  • 授权处理器

  • 基于 JWT/OAuth2 的授权

  • 用户/角色/权限授权

  • Favicon 处理

  • 服务器端渲染的模板支持,包括开箱即用的以下模板引擎支持:

    • Handlebars

    • Pug,

    • MVEL

    • Thymeleaf

    • Apache FreeMarker

    • Pebble

    • Rocker

  • 响应时间处理器

  • 静态文件服务,包括缓存逻辑和目录列表。

  • 请求超时支持

  • SockJS 支持

  • 事件总线桥接

  • CSRF 跨站请求伪造

  • 虚拟主机

Vert.x-Web 中的大多数功能都实现为处理器,因此您总是可以编写自己的处理器。我们预计将来会有更多处理器被编写。

我们将在本手册中讨论所有这些功能。

使用 Vert.x Web

要使用 vert.x web,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web:5.0.1'
}

开发模式

Vert.x Web 默认以生产模式运行。您可以通过将 dev 值分配给以下任一变量来切换到开发模式:

  • VERTXWEB_ENVIRONMENT 环境变量,或

  • vertxweb.environment 系统属性

在开发模式下

  • 模板引擎缓存被禁用

  • ErrorHandler 不显示异常详情

  • StaticHandler 不处理缓存头

  • GraphiQL 开发工具被禁用

Vert.x Core HTTP 服务器回顾

Vert.x-Web 使用并暴露了 Vert.x Core 的 API,因此如果您还不熟悉,非常值得熟悉使用 Vert.x Core 编写 HTTP 服务器的基本概念。

Vert.x Core HTTP 文档对此进行了大量详细说明。

以下是使用 Vert.x Core 编写的“Hello World”Web 服务器。此时不涉及 Vert.x-Web:

HttpServer server = vertx.createHttpServer();

server.requestHandler(request -> {

  // This handler gets called for each request that arrives on the server
  HttpServerResponse response = request.response();
  response.putHeader("content-type", "text/plain");

  // Write to the response and end it
  response.end("Hello World!");
});

server.listen(8080);

我们创建了一个 HTTP 服务器实例,并为其设置了一个请求处理器。每当请求到达服务器时,都会调用该请求处理器。

届时,我们只需将内容类型设置为 text/plain,写入 Hello World! 并结束响应。

然后我们告诉服务器监听端口 8080(默认主机是 localhost)。

您可以运行它,并将浏览器指向 https://:8080 以验证其是否按预期工作。

Vert.x Web 基本概念

这是高层视角:

Router 是 Vert.x-Web 的核心概念之一。它是一个维护零个或多个 Routes 的对象。

路由器将暂停传入的 HttpServerRequest,以确保请求体或任何协议升级不会丢失。其次,它会为该请求找到第一个匹配的路由,并将请求传递给该路由。

路由可以有一个与之关联的*处理器*,然后由该处理器接收请求。您可以*对请求进行操作*,然后,要么结束它,要么将其传递给下一个匹配的处理器。

这是一个简单的路由器示例:

HttpServer server = vertx.createHttpServer();

Router router = Router.router(vertx);

router.route().handler(ctx -> {

  // This handler will be called for every request
  HttpServerResponse response = ctx.response();
  response.putHeader("content-type", "text/plain");

  // Write to the response and end it
  response.end("Hello World from Vert.x-Web!");
});

server.requestHandler(router).listen(8080);

它基本上与上一节的 Vert.x Core HTTP 服务器“Hello World”示例做相同的事情,但这次使用的是 Vert.x-Web。

我们像以前一样创建一个 HTTP 服务器,然后创建一个路由器。完成此操作后,我们创建一个简单的路由,不带任何匹配条件,因此它将匹配到达服务器的*所有*请求。

然后我们为该路由指定一个处理器。该处理器将为到达服务器的所有请求而调用。

传递给处理器的对象是 RoutingContext——它包含标准的 Vert.x HttpServerRequestHttpServerResponse,以及其他各种有用的内容,这些内容使 Vert.x-Web 的工作变得更简单。

对于每个路由的请求,都会有一个唯一的路由上下文实例,并且相同的实例会传递给该请求的所有处理器。

设置好处理器后,我们将 HTTP 服务器的请求处理器设置为将所有传入请求传递给 handle

所以,这就是基础。现在我们来更详细地看看:

处理请求并调用下一个处理器

当 Vert.x-Web 决定将请求路由到匹配的路由时,它会调用路由的处理器,并传入一个 RoutingContext 实例。一个路由可以有不同的处理器,您可以使用 handler 追加它们。

如果您的处理器没有结束响应,您应该调用 next,以便另一个匹配的路由可以处理请求(如果有)。

您不必在处理器执行完毕之前调用 next。如果需要,您可以在稍后进行此操作:

Route route = router.route("/some/path/");
route.handler(ctx -> {

  HttpServerResponse response = ctx.response();
  // enable chunked responses because we will be adding data as
  // we execute over other handlers. This is only required once and
  // only if several handlers do output.
  response.setChunked(true);

  response.write("route1\n");

  // Call the next matching route after a 5 second delay
  ctx.vertx().setTimer(5000, tid -> ctx.next());
});

route.handler(ctx -> {

  HttpServerResponse response = ctx.response();
  response.write("route2\n");

  // Call the next matching route after a 5 second delay
  ctx.vertx().setTimer(5000, tid -> ctx.next());
});

route.handler(ctx -> {

  HttpServerResponse response = ctx.response();
  response.write("route3");

  // Now end the response
  ctx.response().end();
});

在上面的示例中,route1 写入响应,然后 5 秒后 route2 写入响应,再 5 秒后 route3 写入响应,然后响应结束。

请注意,所有这些都发生在没有任何线程阻塞的情况下。

简单响应

处理器功能强大,可以帮助您构建相当复杂的应用程序。对于简单响应,例如直接从 vert.x API 返回异步响应,路由器包含一个处理器的快捷方式,该处理器确保:

  1. 响应以 JSON 格式返回。

  2. 如果处理处理器时发生错误,则返回适当的错误。

  3. 如果将响应序列化为 JSON 时发生错误,则返回适当的错误。

router
  .get("/some/path")
  // this handler will ensure that the response is serialized to json
  // the content type is set to "application/json"
  .respond(
    ctx -> Future.succeededFuture(new JsonObject().put("hello", "world")));

router
  .get("/some/path")
  // this handler will ensure that the Pojo is serialized to json
  // the content type is set to "application/json"
  .respond(
    ctx -> Future.succeededFuture(new Pojo()));

但是,如果提供的函数调用 writeend,您也可以将其用于非 JSON 响应:

router
  .get("/some/path")
  .respond(
    ctx -> ctx
      .response()
      .putHeader("Content-Type", "text/plain")
      .end("hello world!"));

router
  .get("/some/path")
  // in this case, the handler ensures that the connection is ended
  .respond(
    ctx -> ctx
      .response()
      .setChunked(true)
      .write("Write some text..."));

使用阻塞处理器

有时,您可能需要在处理器中执行一些可能阻塞事件循环一段时间的操作,例如调用旧的阻塞 API 或进行一些密集的计算。

您无法在普通处理器中执行此操作,因此我们提供了在路由上设置阻塞处理器的功能。

阻塞处理器看起来就像一个普通处理器,但它是由 Vert.x 使用工作线程池中的线程而不是事件循环调用的。

您可以使用 blockingHandler 在路由上设置阻塞处理器。这是一个示例:

router.route().blockingHandler(ctx -> {

  // Do something that might take some time synchronously
  service.doSomethingThatBlocks();

  // Now call the next handler
  ctx.next();

});

默认情况下,在相同上下文(例如,相同的 Verticle 实例)上执行的任何阻塞处理器都是*有序的*——这意味着在前面的处理器完成之前,下一个处理器不会执行。如果您不关心顺序并且不介意您的阻塞处理器并行执行,您可以使用 blockingHandler 将阻塞处理器设置为 ordered 为 false。

请注意,如果您需要从阻塞处理器处理多部分表单数据,您*必须*首先使用非阻塞处理器来调用 setExpectMultipart(true)。这是一个示例:

router.post("/some/endpoint").handler(ctx -> {
  ctx.request().setExpectMultipart(true);
  ctx.next();
}).blockingHandler(ctx -> {
  // ... Do some blocking operation
});

按精确路径路由

可以设置路由以匹配请求 URI 中的路径。在这种情况下,它将匹配所有路径与指定路径相同的请求。

在以下示例中,处理器将针对请求 /some/path/ 被调用。我们还忽略尾部斜杠,因此它也将针对路径 /some/path/some/path// 被调用:

Route route = router.route().path("/some/path/");

route.handler(ctx -> {
  // This handler will be called for the following request paths:

  // `/some/path/`
  // `/some/path//`
  //
  // but not:
  // `/some/path` the end slash in the path makes it strict
  // `/some/path/subdir`
});

// paths that do not end with slash are not strict
// this means that the trailing slash is optional
// and they match regardless
Route route2 = router.route().path("/some/path");

route2.handler(ctx -> {
  // This handler will be called for the following request paths:

  // `/some/path`
  // `/some/path/`
  // `/some/path//`
  //
  // but not:
  // `/some/path/subdir`
});

按以特定内容开头的路径路由

通常您希望路由所有以特定路径开头的请求。您可以使用正则表达式来完成此操作,但一个简单的方法是在声明路由路径时在路径末尾使用星号 *

在以下示例中,处理器将针对任何 URI 路径以 /some/path/ 开头的请求被调用。

例如,/some/path/foo.html/some/path/otherdir/blah.css 都将匹配。

Route route = router.route().path("/some/path/*");

route.handler(ctx -> {
  // This handler will be called for any path that starts with
  // `/some/path/`, e.g.

  // `/some/path/`
  // `/some/path/subdir`
  // `/some/path/subdir/blah.html`
  //
  // but **NOT**:
  // `/some/path` the final slash is never optional with a wildcard to
  //              handle sub routing and composition without risking wrong
  //              configuration like bellow:
  // `/some/patha`
  // `/some/patha/`
  // etc...
});

在创建路由时也可以指定任意路径:

Route route = router.route("/some/path/*");

route.handler(ctx -> {
  // This handler will be called same as previous example
});

捕获路径参数

可以使用参数的占位符来匹配路径,这些参数随后在上下文 pathParam 中可用。

这是一个示例:

router
  .route(HttpMethod.POST, "/catalogue/products/:productType/:productID/")
  .handler(ctx -> {

    String productType = ctx.pathParam("productType");
    String productID = ctx.pathParam("productID");

    // Do something with them...
  });

占位符由 : 后跟参数名称组成。参数名称由任何字母、数字或下划线组成。在某些情况下,这有点受限,因此用户可以切换到包含 2 个额外字符 -$ 的扩展名称规则。扩展参数规则作为系统属性启用。

-Dio.vertx.web.route.param.extended-pattern=true

在上述示例中,如果向路径 /catalogue/products/tools/drill123/ 发出 POST 请求,则路由将匹配,并且 productType 将接收值 toolsproductID 将接收值 drill123

参数不要求是路径段。例如,以下路径参数也有效:

router
  .route(HttpMethod.GET, "/flights/:from-:to")
  .handler(ctx -> {
    // when handling requests to /flights/AMS-SFO will set:
    String from = ctx.pathParam("from"); // AMS
    String to = ctx.pathParam("to"); // SFO
    // remember that this will not work as expected when the parameter
    // naming pattern in use is not the "extended" one. That is because in
    // that case "-" is considered to be part of the variable name and
    // not a separator.
  });

注意:您也可以将 作为路径参数 进行捕获。

使用正则表达式路由

正则表达式也可以用于匹配路由中的 URI 路径。

Route route = router.route().pathRegex(".*foo");

route.handler(ctx -> {

  // This handler will be called for:

  // /some/path/foo
  // /foo
  // /foo/bar/wibble/foo
  // /bar/foo

  // But not:
  // /bar/wibble
});

或者,可以在创建路由时指定正则表达式:

Route route = router.routeWithRegex(".*foo");

route.handler(ctx -> {

  // This handler will be called same as previous example

});

使用正则表达式捕获路径参数

在使用正则表达式时,您也可以捕获路径参数,这是一个示例:

Route route = router.routeWithRegex(".*foo");

// This regular expression matches paths that start with something like:
// "/foo/bar" - where the "foo" is captured into param0 and the "bar" is
// captured into param1
route.pathRegex("\\/([^\\/]+)\\/([^\\/]+)").handler(ctx -> {

  String productType = ctx.pathParam("param0");
  String productID = ctx.pathParam("param1");

  // Do something with them...
});

在上面的示例中,如果向路径 /tools/drill123/ 发出请求,则路由将匹配,并且 productType 将接收值 toolsproductID 将接收值 drill123

捕获在正则表达式中用捕获组表示(即,用圆括号将捕获内容括起来)。

使用命名捕获组

在某些情况下,使用整数索引参数名可能很麻烦。可以在正则表达式路径中使用命名捕获组。

router
  .routeWithRegex("\\/(?<productType>[^\\/]+)\\/(?<productID>[^\\/]+)")
  .handler(ctx -> {

    String productType = ctx.pathParam("productType");
    String productID = ctx.pathParam("productID");

    // Do something with them...
  });

在上面的示例中,命名捕获组被映射到与组同名的路径参数。

此外,您仍然可以像使用普通组一样访问组参数(即 params0, params1…)。

按 HTTP 方法路由

默认情况下,路由将匹配所有 HTTP 方法。

如果您希望路由仅匹配特定的 HTTP 方法,您可以使用 method

Route route = router.route().method(HttpMethod.POST);

route.handler(ctx -> {

  // This handler will be called for any POST request

});

或者您可以在创建路由时通过路径指定:

Route route = router.route(HttpMethod.POST, "/some/path/");

route.handler(ctx -> {
  // This handler will be called for any POST request
  // to a URI path starting with /some/path/
});

如果您想针对特定的 HTTP 方法进行路由,您还可以使用以 HTTP 方法名称命名的方法,例如 getpostput。例如:

router.get().handler(ctx -> {

  // Will be called for any GET request

});

router.get("/some/path/").handler(ctx -> {

  // Will be called for any GET request to a path
  // starting with /some/path

});

router.getWithRegex(".*foo").handler(ctx -> {

  // Will be called for any GET request to a path
  // ending with `foo`

});

如果您想指定一个路由匹配多种 HTTP 方法,您可以多次调用 method

Route route = router.route().method(HttpMethod.POST).method(HttpMethod.PUT);

route.handler(ctx -> {

  // This handler will be called for any POST or PUT request

});

如果您正在创建需要自定义 HTTP 动词的应用程序,例如 WebDav 服务器,那么您可以指定自定义动词,例如:

Route route = router.route()
  .method(HttpMethod.valueOf("MKCOL"))
  .handler(ctx -> {
    // This handler will be called for any MKCOL request
  });
重要的是要注意,重路由等功能将不接受自定义 http 方法,并且检查路由动词将生成枚举值 OTHER 而不是自定义名称。

路由顺序

默认情况下,路由按照它们添加到路由器的顺序进行匹配。

当请求到达时,路由器将遍历每个路由并检查它是否匹配,如果匹配,则会调用该路由的处理器。

如果处理器随后调用 next,则会调用下一个匹配路由(如果有)的处理器。依此类推。

这是一个示例,用以说明这一点:

router
  .route("/some/path/")
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    // enable chunked responses because we will be adding data as
    // we execute over other handlers. This is only required once and
    // only if several handlers do output.
    response.setChunked(true);

    response.write("route1\n");

    // Now call the next matching route
    ctx.next();
  });

router
  .route("/some/path/")
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    response.write("route2\n");

    // Now call the next matching route
    ctx.next();
  });

router
  .route("/some/path/")
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    response.write("route3");

    // Now end the response
    ctx.response().end();
  });

在上述示例中,响应将包含:

route1
route2
route3

因为对于任何以 /some/path 开头的请求,路由都按该顺序被调用。

如果您想覆盖路由的默认排序,您可以使用 order 来指定一个整数值。

路由在创建时被分配一个顺序,对应于它们添加到路由器的顺序,第一个路由编号为 0,第二个路由编号为 1,依此类推。

通过为路由指定顺序,您可以覆盖默认顺序。顺序也可以是负数,例如,如果您想确保在路由 0 之前评估某个路由。

让我们更改 route2 的顺序,使其在 route1 之前运行:

router
  .route("/some/path/")
  .order(1)
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    response.write("route1\n");

    // Now call the next matching route
    ctx.next();
  });

router
  .route("/some/path/")
  .order(0)
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    // enable chunked responses because we will be adding data as
    // we execute over other handlers. This is only required once and
    // only if several handlers do output.
    response.setChunked(true);

    response.write("route2\n");

    // Now call the next matching route
    ctx.next();
  });

router
  .route("/some/path/")
  .order(2)
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    response.write("route3");

    // Now end the response
    ctx.response().end();
  });

那么响应现在将包含:

route2
route1
route3

如果两个匹配的路由具有相同的顺序值,则它们将按照添加的顺序被调用。

您也可以使用 last 指定路由最后处理。

注意:路由顺序只能在配置处理器之前指定!

基于请求 MIME 类型路由

您可以使用 consumes 指定路由将匹配匹配的请求 MIME 类型。

在这种情况下,请求将包含一个 content-type 头,指定请求体的 MIME 类型。这将与 consumes 中指定的值进行匹配。

基本上,consumes 描述了处理器可以*消费*哪些 MIME 类型。

匹配可以基于精确的 MIME 类型匹配进行:

router.route()
  .consumes("text/html")
  .handler(ctx -> {

    // This handler will be called for any request with
    // content-type header set to `text/html`

  });

也可以指定多个精确匹配:

router.route()
  .consumes("text/html")
  .consumes("text/plain")
  .handler(ctx -> {

    // This handler will be called for any request with
    // content-type header set to `text/html` or `text/plain`.

  });

支持对子类型使用通配符匹配:

router.route()
  .consumes("text/*")
  .handler(ctx -> {

    // This handler will be called for any request
    // with top level type `text` e.g. content-type
    // header set to `text/html` or `text/plain`
    // will both match

  });

您也可以匹配顶级类型:

router.route()
  .consumes("*/json")
  .handler(ctx -> {

    // This handler will be called for any request with sub-type json
    // e.g. content-type header set to `text/json` or
    // `application/json` will both match

  });

如果您没有在 consumers 中指定 /,它将假定您指的是子类型。

基于客户端可接受 MIME 类型路由

HTTP accept 头用于表明客户端可接受的响应 MIME 类型。

一个 accept 头可以有多个 MIME 类型,用 ',' 分隔。

MIME 类型还可以附加一个 q 值,该值表示如果有多个响应 MIME 类型与 accept 头匹配时要应用的权重。q 值是 0 到 1.0 之间的数字。如果省略,则默认为 1.0。

例如,以下 accept 头表示客户端将只接受 text/plain 类型的 MIME。

Accept: text/plain

以下内容表示客户端将接受 text/plaintext/html,没有偏好。

Accept: text/plain, text/html

以下内容表示客户端将接受 text/plaintext/html,但偏好 text/html,因为它具有更高的 q 值(默认值为 q=1.0):

Accept: text/plain; q=0.9, text/html

如果服务器可以同时提供 text/plain 和 text/html,在这种情况下它应该提供 text/html。

通过使用 produces,您可以定义路由生成哪些 MIME 类型,例如,以下处理器生成一个 MIME 类型为 application/json 的响应。

router.route()
  .produces("application/json")
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();
    response.putHeader("content-type", "application/json");
    response.end(someJSON);

  });

在这种情况下,路由将匹配任何带有 accept 头且匹配 application/json 的请求。

以下是一些将匹配的 accept 头示例:

Accept: application/json
Accept: application/*
Accept: application/json, text/html
Accept: application/json;q=0.7, text/html;q=0.8, text/plain

您还可以将路由标记为生成多种 MIME 类型。在这种情况下,您可以使用 getAcceptableContentType 来查找实际接受的 MIME 类型。

router.route()
  .produces("application/json")
  .produces("text/html")
  .handler(ctx -> {

    HttpServerResponse response = ctx.response();

    // Get the actual MIME type acceptable
    String acceptableContentType = ctx.getAcceptableContentType();

    response.putHeader("content-type", acceptableContentType);
    response.end(whatever);
  });

在上面的示例中,如果您发送一个带有以下 accept 头的请求:

Accept: application/json; q=0.7, text/html

那么路由将匹配,并且 acceptableContentType 将包含 text/html,因为两者都是可接受的,但 text/html 具有更高的 q 值。

基于虚拟主机路由

您可以配置 Route 以匹配请求主机名。

请求会与 Host 头进行匹配检查,模式允许使用 通配符,例如 .vertx.io 或完整的域名如 www.vertx.io

router.route().virtualHost("*.vertx.io").handler(ctx -> {
  // do something if the request is for *.vertx.io
});

组合路由标准

您可以以多种不同方式组合上述所有路由标准,例如:

router.route(HttpMethod.PUT, "myapi/orders")
  .consumes("application/json")
  .produces("application/json")
  .handler(ctx -> {

    // This would be match for any PUT method to paths starting
    // with "myapi/orders" with a content-type of "application/json"
    // and an accept header matching "application/json"

  });

启用和禁用路由

您可以使用 disable 禁用路由。禁用的路由在匹配时将被忽略。

您可以使用 enable 重新启用禁用的路由。

转发支持

您的应用程序可能位于代理服务器(例如 HAProxy)之后。在这种设置下工作时,访问客户端连接详细信息将无法正确返回预期结果。例如,客户端主机 IP 地址将是代理服务器的 IP 地址,而不是客户端的。

为了获取正确的连接信息,一个特殊的头 Forward 已经标准化以包含正确的信息。然而,这个标准并不久远,所以许多代理都使用其他通常以 X-Forward 为前缀的头。Vert.x Web 允许使用和解析这些头,但默认情况下不启用。

默认禁用这些头的原因是为了防止恶意应用程序伪造其来源并隐藏其真实来源。

如前所述,转发默认是禁用的,要启用它,您应该使用:

router.allowForward(AllowForwardHeaders.FORWARD);

// we can now allow forward header parsing
// and in this case only the "X-Forward" headers will be considered
router.allowForward(AllowForwardHeaders.X_FORWARD);

// we can now allow forward header parsing
// and in this case both the "Forward" header and "X-Forward" headers
// will be considered, yet the values from "Forward" take precedence
// this means if case of a conflict (2 headers for the same value)
// the "Forward" value will be taken and the "X-Forward" ignored.
router.allowForward(AllowForwardHeaders.ALL);

同样的规则适用于明确禁用头的解析:

router.allowForward(AllowForwardHeaders.NONE);

要了解更多关于头格式的信息,请查阅:

在幕后,此功能会更改您的连接(HTTP 或 WebSocket)的以下值:

  • 协议

  • 主机名

  • 主机端口

上下文数据

您可以使用 RoutingContext 中的上下文数据来维护您希望在请求生命周期内共享的任何数据。

这是一个示例,其中一个处理器在上下文数据中设置了一些数据,而后续处理器检索了它:

您可以使用 put 放置任何对象,并使用 get 从上下文数据中检索任何对象。

发送到路径 /some/path/other 的请求将匹配两个路由。

router.get("/some/path").handler(ctx -> {

  ctx.put("foo", "bar");
  ctx.next();

});

router.get("/some/path/other").handler(ctx -> {

  String bar = ctx.get("foo");
  // Do something with bar
  ctx.response().end();

});

或者,您可以使用 data 访问整个上下文数据映射。

元数据

虽然上下文允许您在请求-响应生命周期中存储数据,但有时拥有运行时元数据可用很重要。例如,用于构建 API 文档,或为给定路由保存特定配置。

元数据功能与上下文数据类似。您可以访问持有 Map,也可以使用在 RouterRoute 接口上定义的专用 getter 和 setter。

router
  .route("/metadata/route")
  .putMetadata("metadata-key", "123")
  .handler(ctx -> {
    Route route = ctx.currentRoute();
    String value = route.getMetadata("metadata-key"); // 123
    // will end the request with the value 123
    ctx.end(value);
  });

辅助函数

虽然路由上下文允许您访问底层请求和响应对象,但有时如果存在一些快捷方式来帮助完成常见任务,会更高效。上下文中存在一些辅助函数,以方便完成此任务。

提供“附件”,附件是一种响应,它会触发浏览器在配置用于处理特定 MIME 类型的操作系统应用程序中打开响应。想象您正在生成 PDF:

ctx
  .attachment("weekly-report.pdf")
  .end(pdfBuffer);

执行重定向到不同的页面或主机。一个示例是重定向到应用程序的 HTTPS 版本:

ctx.redirect("https://securesite.com/");

// there is a special handling for the target "back".
// In this case the redirect would send the user to the
// referrer url or "/" if there's no referrer.

ctx.redirect("back");

向客户端发送 JSON 响应:

ctx.json(new JsonObject().put("hello", "vert.x"));
// also applies to arrays
ctx.json(new JsonArray().add("vertx").add("web"));
// or any object that will be converted according
// to the json encoder available at runtime.
ctx.json(someObject);

简单内容类型检查

ctx.is("html"); // => true
ctx.is("text/html"); // => true

// When Content-Type is application/json
ctx.is("application/json"); // => true
ctx.is("html"); // => false

根据缓存头和当前最后修改/etag 值,验证请求是否“新鲜”。

ctx.lastModified("Wed, 13 Jul 2011 18:30:00 GMT");
// this will now be used to verify the freshness of the request
if (ctx.isFresh()) {
  // client cache value is fresh perhaps we
  // can stop and return 304?
}

以及其他一些不言自明的快捷方式:

ctx.etag("W/123456789");

// set the last modified value
ctx.lastModified("Wed, 13 Jul 2011 18:30:00 GMT");

// quickly end
ctx.end();
ctx.end("body");
ctx.end(buffer);

重新路由

到目前为止,所有路由机制都允许您以顺序方式处理请求,但是有时您可能希望返回。由于上下文不暴露任何关于前一个或下一个处理器的信息,主要是因为这些信息是动态的,所以有一种方法可以从当前路由器的开头重新启动整个路由。

router.get("/some/path").handler(ctx -> {

  ctx.put("foo", "bar");
  ctx.next();

});

router
  .get("/some/path/B")
  .handler(ctx -> ctx.response().end());

router
  .get("/some/path")
  .handler(ctx -> ctx.reroute("/some/path/B"));

从代码中可以看出,如果请求到达 /some/path,它首先向上下文添加一个值,然后移动到下一个处理器,该处理器将请求重新路由到 /some/path/B,从而终止请求。

您可以基于新路径或基于新路径和方法进行重路由。但请注意,基于方法的重路由可能会引入安全问题,因为例如,通常安全的 GET 请求可能会变为 DELETE。

在失败处理器上也可以进行重路由,但是由于重路由器的性质,当被调用时,当前状态码和失败原因会被重置。如果需要,重路由的处理器应生成正确的状态码,例如:

router.get("/my-pretty-notfound-handler").handler(ctx -> ctx.response()
  .setStatusCode(404)
  .end("NOT FOUND fancy html here!!!"));

router.get().failureHandler(ctx -> {
  if (ctx.statusCode() == 404) {
    ctx.reroute("/my-pretty-notfound-handler");
  } else {
    ctx.next();
  }
});

应该清楚的是,重新路由适用于 paths,因此如果您需要在重新路由之间保留或添加状态,则应该使用 RoutingContext 对象。例如,您想使用额外的参数重新路由到新路径:

router.get("/final-target").handler(ctx -> {
  // continue from here...
});

// (Will reroute to /final-target including the query string)
router.get().handler(ctx -> ctx.reroute("/final-target?variable=value"));

// A safer way would be to add the variable to the context
router.get().handler(ctx -> ctx
  .put("variable", "value")
  .reroute("/final-target"));

重新路由也将重新解析查询参数。请注意,以前的查询参数将被丢弃。该方法还将静默丢弃并忽略路径中的任何 HTML 片段。这是为了使重新路由的语义在常规请求和重新路由之间保持一致。

如果需要向新请求传递更多信息,应使用在整个 HTTP 事务生命周期中保留的上下文。

子路由器

有时,如果您有很多处理器,将它们分成多个路由器可能是有意义的。如果您想在另一个应用程序中重用一组处理器,而该应用程序根位于不同的路径根,这也很有用。

为此,您可以将路由器挂载到另一个路由器中的*挂载点*。被挂载的路由器称为*子路由器*。子路由器可以挂载其他子路由器,因此您可以有多个级别的子路由器(如果需要)。

让我们看一个子路由器挂载到另一个路由器的简单示例。

这个子路由器将维护一组与简单虚构 REST API 对应的处理器。我们将把它挂载到另一个路由器上。REST API 的完整实现未显示。

这是子路由器:

Router restAPI = Router.router(vertx);

restAPI.get("/products/:productID").handler(ctx -> {

  // TODO Handle the lookup of the product....
  ctx.response().write(productJSON);

});

restAPI.put("/products/:productID").handler(ctx -> {

  // TODO Add a new product...
  ctx.response().end();

});

restAPI.delete("/products/:productID").handler(ctx -> {

  // TODO delete the product...
  ctx.response().end();

});

如果此路由器用作顶级路由器,则对 /products/product1234 等 URL 的 GET/PUT/DELETE 请求将调用 API。

然而,假设我们已经有一个由另一个路由器描述的网站:

Router mainRouter = Router.router(vertx);

// Handle static resources
mainRouter.route("/static/*").handler(myStaticHandler);

mainRouter.route(".*\\.templ").handler(myTemplateHandler);

现在我们可以将子路由器挂载到主路由器上,挂载点为 /productsAPI

mainRouter.route("/productsAPI/*")
  .subRouter(restAPI);

这意味着 REST API 现在可以通过以下路径访问:/productsAPI/products/product1234

在可以使用子路由器之前,必须满足以下几个规则:

  • 路由路径必须以通配符结尾

  • 允许参数但不允许完整的正则表达式模式

  • 在此调用之前或之后只能注册 1 个处理器(但可以在同一路径的新路由对象上注册)

  • 每个路径对象只能有 1 个路由器

验证发生在路由器添加到 HTTP 服务器时。这意味着由于子路由器的动态性质,您在构建时不会遇到任何验证错误。它们的验证取决于上下文。

本地化

Vert.x Web 解析 Accept-Language 头并提供一些辅助方法,以识别客户端的首选语言环境或按质量排序的首选语言环境列表。

Route route = router.get("/localized").handler(ctx -> {
  // although it might seem strange by running a loop with a switch we
  // make sure that the locale order of preference is preserved when
  // replying in the users language.
  for (LanguageHeader language : ctx.acceptableLanguages()) {
    switch (language.tag()) {
      case "en":
        ctx.response().end("Hello!");
        return;
      case "fr":
        ctx.response().end("Bonjour!");
        return;
      case "pt":
        ctx.response().end("Olá!");
        return;
      case "es":
        ctx.response().end("Hola!");
        return;
    }
  }
  // we do not know the user language so lets just inform that back:
  ctx.response().end("Sorry we don't speak: " + ctx.preferredLanguage());
});

主方法 acceptableLanguages 将返回用户理解的语言环境的有序列表,如果您只对用户首选的语言环境感兴趣,则辅助方法:preferredLanguage 将返回列表的第一个元素,如果用户未提供任何语言环境,则返回 null

路由匹配失败

如果没有任何路由匹配任何特定请求,Vert.x-Web 将根据匹配失败发出错误信号:

  • 404 如果没有路由匹配路径

  • 405 如果路由匹配路径但HTTP方法不匹配

  • 406 如果路由匹配路径和方法,但无法提供与 Accept 头匹配的内容类型的响应

  • 415 如果路由匹配路径和方法,但无法接受 Content-type

  • 400 如果路由匹配路径和方法,但无法接受空请求体

您可以使用 errorHandler 手动管理这些失败。

错误处理

除了设置处理器来处理请求外,您还可以设置处理器来处理路由中的失败。

失败处理器使用与普通处理器完全相同的路由匹配条件。

例如,您可以提供一个失败处理器,它只处理在路由到某些路径或某些 HTTP 方法时发生的失败。

这允许您为应用程序的不同部分设置不同的失败处理器。

这是一个失败处理器示例,它只会在路由到以 /somepath/ 开头的 GET 请求时发生的失败才会被调用:

Route route = router.get("/somepath/*");

route.failureHandler(ctx -> {

  // This will be called for failures that occur
  // when routing requests to paths starting with
  // '/somepath/'

});

如果处理器抛出异常,或者处理器调用 fail 并指定 HTTP 状态码以故意发出失败信号,则会发生失败路由。

如果从处理器中捕获到异常,这将导致发出状态码为 500 的失败信号。

处理失败时,失败处理器将获得路由上下文,该上下文也允许检索失败或失败代码,以便失败处理器可以使用它来生成失败响应。

Route route1 = router.get("/somepath/path1/");

route1.handler(ctx -> {

  // Let's say this throws a RuntimeException
  throw new RuntimeException("something happened!");

});

Route route2 = router.get("/somepath/path2");

route2.handler(ctx -> {

  // This one deliberately fails the request passing in the status code
  // E.g. 403 - Forbidden
  ctx.fail(403);

});

// Define a failure handler
// This will get called for any failures in the above handlers
Route route3 = router.get("/somepath/*");

route3.failureHandler(failureRoutingContext -> {

  int statusCode = failureRoutingContext.statusCode();

  // Status code will be 500 for the RuntimeException
  // or 403 for the other failure
  HttpServerResponse response = failureRoutingContext.response();
  response.setStatusCode(statusCode).end("Sorry! Not today");

});

如果错误处理器运行时,状态消息头中使用了不允许的字符导致发生错误,那么原始状态消息将更改为错误代码的默认消息。这是为了保持 HTTP 协议语义的正常工作,而不是突然崩溃并关闭套接字而未能正确完成协议。

请求体处理

BodyHandler 允许您检索请求体、限制请求体大小和处理文件上传。

您应该确保在任何需要此功能的请求的匹配路由上都有一个请求体处理器。

此处理器的使用要求它尽快安装。该处理器将恢复 HttpServerRequest 处理,因为它安装了处理器来消费 HTTP 请求体。

router.route().handler(BodyHandler.create());
上传可能成为 DDoS 攻击的来源,为了减少攻击面,建议在 setBodyLimit 上设置合理的限制(例如:通用上传为 10MB,JSON 为 100KB)。

获取请求体

如果您知道请求体是 JSON,则可以使用 body 并使用正确的 getter(例如:.asJsonObject())。如果您知道它是字符串,则可以使用 .asString(),或者要将其作为缓冲区检索,请使用 .buffer()

限制请求体大小

要限制请求体的大小,创建请求体处理器,然后使用 setBodyLimit 指定最大请求体大小(以字节为单位)。这对于避免因请求体过大而耗尽内存非常有用。

如果尝试发送大于最大大小的请求体,将发送 HTTP 状态码 413 - 请求实体过大

默认情况下,请求体大小限制为 10 兆字节。

合并表单属性

默认情况下,请求体处理器会将所有表单属性合并到请求参数中。如果您不希望此行为,可以使用 setMergeFormAttributes 禁用它。

处理文件上传

请求体处理器也用于处理多部分文件上传。

如果请求的匹配路由上有一个请求体处理器,则任何文件上传都将自动流式传输到上传目录,默认情况下为 file-uploads

每个文件都将获得一个自动生成的文件名,并且文件上传将在路由上下文中通过 fileUploads 访问。

这是一个示例:

router.route().handler(BodyHandler.create());

router.post("/some/path/uploads").handler(ctx -> {

  List<FileUpload> uploads = ctx.fileUploads();
  // Do something with uploads....

});

每个文件上传都由一个 FileUpload 实例描述,该实例允许访问各种属性,例如名称、文件名和大小。

管理上传文件目录

可以配置 BodyHandler 以清理上传文件目录:

router.route().handler(BodyHandler.create().setDeleteUploadedFilesOnEnd(true));

这将在发送相应响应时删除作为请求一部分上传的任何文件。

处理 Cookie

Vert.x-Web 开箱即用地支持 Cookie。

操作 Cookie

您可以使用 cookies 按名称检索 Cookie,或使用 cookies 检索整个集合。

要删除 Cookie,请使用 removeCookie

要添加 Cookie,请使用 addCookie

当响应头写入时,Cookie 集将自动写回响应中,以便浏览器可以存储它们。

Cookie 由 Cookie 实例描述。这允许您检索名称、值、域、路径和其他常规 Cookie 属性。

以下是查询和添加 Cookie 的示例:

Cookie someCookie = ctx.request().getCookie("mycookie");
String cookieValue = someCookie.getValue();

// Do something with cookie...

// Add a cookie - this will get written back in the response automatically
ctx.response().addCookie(Cookie.cookie("othercookie", "somevalue"));

处理会话

Vert.x-Web 开箱即用地支持会话。

会话在浏览器会话期间的 HTTP 请求之间持续存在,为您提供一个可以添加会话范围信息(如购物车)的地方。

Vert.x-Web 使用会话 Cookie 来标识会话。会话 Cookie 是临时的,当浏览器关闭时将被删除。

我们不会将会话的实际数据放入会话 Cookie 中——Cookie 只是使用标识符在服务器上查找实际会话。该标识符是使用安全随机数生成的随机 UUID,因此它应该实际上无法猜测。

Cookie 在 HTTP 请求和响应中通过网络传递,因此在使用会话时始终明智地确保您使用 HTTPS。如果您尝试通过普通 HTTP 使用会话,Vert.x 将发出警告。

要在应用程序中启用会话,您必须在应用程序逻辑之前,在匹配路由上有一个 SessionHandler

会话处理器处理会话 Cookie 的创建和会话的查找,因此您不必自己动手。

会话数据在响应头发送给客户端后会自动保存到会话存储中。但请注意,通过此机制,无法保证数据在客户端接收响应之前完全持久化。然而,有时需要这种保证。在这种情况下,您可以强制刷新。这将禁用自动保存过程,除非刷新操作失败。这允许在完成响应之前控制状态,例如:

router.route().handler(ctx -> sessionHandler.flush(ctx)
  .onSuccess(v -> ctx.end("Success!"))
  .onFailure(err -> {
    // session wasn't saved...
    // go for plan B
  }));

Vert.x 会话处理器状态默认使用 Cookie 存储会话 ID。会话 ID 是一个唯一的字符串,用于在访问之间识别单个访问者。但是,如果客户端的 Web 浏览器不支持 Cookie 或者访问者在 Web 浏览器设置中禁用了 Cookie,我们就无法在客户端机器上存储会话 ID。在这种情况下,每个请求都会创建一个新会话。这种行为是无用的,因为我们无法在两个请求之间记住特定访问者的信息。我们可以说,默认情况下,如果浏览器不支持 Cookie,会话就无法工作。

Vert.x Web 支持无 Cookie 会话,也称为“无 Cookie”会话。作为替代方案,Vert.x Web 可以在页面 URL 中嵌入会话 ID。通过这种方式,所有页面链接都将包含会话 ID 字符串。当访问者点击其中一些链接时,它将从页面 URL 读取会话 ID,因此我们不需要 Cookie 支持即可拥有功能性会话。

要启用无 Cookie 会话:

router.route()
  .handler(SessionHandler.create(store).setCookieless(true));

重要的是要知道,在此模式下,会话 ID 应由应用程序传递给最终用户,通常通过在 HTML 页面或脚本中渲染它。有一些重要规则。会话 ID 由路径上的以下模式识别:/optional/path/prefix/'('sessionId')'/path/suffix

例如,给定路径:https://:2677/WebSite1/(S(3abhbgwjg33aqrt3uat2kh4d))/api/,会话 ID 将是:3abhbgwjg33aqrt3uat2kh4d

使用会话时主要的安全性问题是恶意用户可能会发现其他人的会话 ID。如果两个用户共享相同的会话 ID,他们也共享相同的会话变量,并且网站会将他们视为一个访问者。如果会话用于任何私人或敏感数据,或允许访问网站的受限制区域,这可能是一个安全风险。当使用 Cookie 时,会话 ID 可以通过 SSL 保护,并标记 Cookie 为安全。但是,在无 Cookie 会话的情况下,会话 ID 是 URL 的一部分,并且更易受攻击。

会话存储

要创建会话处理器,您需要一个会话存储实例。会话存储是用于保存应用程序实际会话的对象。

会话存储负责持有一个安全伪随机数生成器,以保证安全的会话 ID。此 PRNG 独立于存储,这意味着给定存储 A 的会话 ID,不能推导出存储 B 的会话 ID,因为它们具有不同的种子和状态。

默认情况下,此 PRNG 使用混合模式,阻塞用于播种,非阻塞用于生成。PRNG 还将每 5 分钟用 64 位新熵重新播种。然而,所有这些都可以通过系统属性进行配置:

  • io.vertx.ext.auth.prng.algorithm 例如:SHA1PRNG

  • io.vertx.ext.auth.prng.seed.interval 例如:1000(每秒)

  • io.vertx.ext.auth.prng.seed.bits 例如:128

大多数用户不需要配置这些值,除非您注意到 PRNG 算法正在影响应用程序的性能。

Vert.x-Web 开箱即用地提供了两种会话存储实现,如果您愿意,也可以编写自己的实现。

这些实现预计将遵循 ServiceLoader 约定,并且所有在运行时从类路径可用的存储都将暴露出来。当有多个实现可用时,第一个能够成功实例化和配置的实现将成为默认值。如果没有可用的,则默认值取决于 Vert.x 创建的模式。如果集群模式可用,则集群会话存储是默认值,否则本地存储是默认值。

本地会话存储

使用此存储,会话本地存储在内存中,并且仅在此实例中可用。

如果您只有一个 Vert.x 实例,或者您在应用程序中使用粘性会话并已将负载均衡器配置为始终将 HTTP 请求路由到相同的 Vert.x 实例,则此存储是合适的。

如果您无法确保您的请求都将终止于同一服务器,则不要使用此存储,因为您的请求可能最终会到达一个不了解您会话的服务器。

本地会话存储通过使用共享本地映射实现,并具有一个清理过期会话的收割器。

收割器间隔可以通过一个 JSON 消息配置,键为:reaperInterval

以下是一些创建本地 SessionStore 的示例:

SessionStore store1 = LocalSessionStore.create(vertx);

// Create a local session store specifying the local shared map name to use
// This might be useful if you have more than one application in the same
// Vert.x instance and want to use different maps for different applications
SessionStore store2 = LocalSessionStore.create(
  vertx,
  "myapp3.sessionmap");

// Create a local session store specifying the local shared map name to use and
// setting the reaper interval for expired sessions to 10 seconds
SessionStore store3 = LocalSessionStore.create(
  vertx,
  "myapp3.sessionmap",
  10000);

集群会话存储

使用此存储,会话存储在分布式映射中,可在 Vert.x 集群中访问。

如果您*不*使用粘性会话,即您的负载均衡器将来自同一浏览器的不同请求分发到不同的服务器,则此存储是合适的。

使用此存储,您的会话可从集群中的任何节点访问。

要使用集群会话存储,您应该确保您的 Vert.x 实例是集群化的。

以下是一些创建集群 SessionStore 的示例:

Vertx.clusteredVertx(new VertxOptions())
  .onSuccess(vertx -> {
  // Create a clustered session store using defaults
  SessionStore store1 = ClusteredSessionStore.create(vertx);

  // Create a clustered session store specifying the distributed map name to use
  // This might be useful if you have more than one application in the cluster
  // and want to use different maps for different applications
  SessionStore store2 = ClusteredSessionStore.create(
    vertx,
    "myclusteredapp3.sessionmap");
});

其他存储

其他存储也可用,通过将正确的 jar 导入项目即可使用这些存储。其中一个示例是 Cookie 存储。此存储的优点是它不需要后端或服务器端状态,这在某些情况下可能很有用,**但是**所有会话数据都将以**加密**的 Cookie 形式发送回客户端,因此如果您需要存储私有信息,则不应使用此存储。

如果您正在使用粘性会话,即您的负载均衡器将来自同一浏览器的不同请求分发到不同的服务器,则此存储是合适的。

由于会话存储在 Cookie 中,这意味着会话在服务器崩溃后也能幸存下来。

第二个已知实现是 Redis 会话存储。此存储的工作方式与普通集群存储类似,但正如其名称所示,它使用 Redis 后端来集中管理会话数据。

此外,还有 Infinispan 会话存储(详情如下)。

这些存储可通过以下坐标获得:

  • groupId: io.vertx

  • artifactId: vertx-web-sstore-{cookie|redis|infinispan|caffeine}

Infinispan Web 会话存储

SessionStore 的一个实现,它依赖于 Infinispan Java 客户端。

快速入门

要使用此模块,请将以下内容添加到您的 Maven POM 文件的 依赖项 部分

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-sstore-infinispan</artifactId>
  <version>5.0.1</version>
</dependency>

或者,如果您使用 Gradle

compile 'io.vertx:vertx-web-sstore-infinispan:5.0.1'
使用方法

如果此会话存储是您依赖项中唯一的存储,则可以以通用方式对其进行初始化:

JsonObject config = new JsonObject()
  .put("servers", new JsonArray()
    .add(new JsonObject()
      .put("host", "server1.datagrid.mycorp.int")
      .put("username", "foo")
      .put("password", "bar"))
    .add(new JsonObject()
      .put("host", "server2.datagrid.mycorp.int")
      .put("username", "foo")
      .put("password", "bar"))
  );
SessionStore store = SessionStore.create(vertx, config);
SessionHandler sessionHandler = SessionHandler.create(store);
router.route().handler(sessionHandler);

否则,明确使用 InfinispanSessionStore 类型:

JsonObject config = new JsonObject()
  .put("servers", new JsonArray()
    .add(new JsonObject()
      .put("host", "server1.datagrid.mycorp.int")
      .put("username", "foo")
      .put("password", "bar"))
    .add(new JsonObject()
      .put("host", "server2.datagrid.mycorp.int")
      .put("username", "foo")
      .put("password", "bar"))
  );
InfinispanSessionStore store = InfinispanSessionStore.create(vertx, config);
SessionHandler sessionHandler = SessionHandler.create(store);
router.route().handler(sessionHandler);
配置
配置项

根配置项为:

  • servers: 必填,服务器定义 JSON 数组(见下文)

  • cacheName: 可选,用于存储会话数据的缓存名称(默认为 vertx-web.sessions

  • retryTimeout: 可选,会话处理器从存储中检索值时使用的重试超时值(毫秒)(默认为 5000

服务器定义的配置项为:

  • uri : 可选,一个 Hot Rod URI

  • host: 可选(默认为 localhost

  • port: 可选(默认为 11222

  • clientIntelligence: 可选(可选值:BASIC, TOPOLOGY_AWARE, HASH_DISTRIBUTION_AWARE

  • username: 必填

  • password: 必填

  • realm: 可选(默认为 default

  • saslMechanism: 可选(默认为 DIGEST-MD5

  • saslQop: 可选(可选值:AUTH, AUTH_INT, AUTH_CONF

如果设置了 uri 项,则其他项将被忽略。
自定义 Infinispan 客户端

对于高级配置要求,您可以提供自定义的 RemoteCacheManager

InfinispanSessionStore sessionStore = InfinispanSessionStore.create(vertx, config, remoteCacheManager);
Caffeine 会话存储

Caffeine 会话存储是一个本地会话存储,它使用 Caffeine 缓存库将会话存储在内存中。它由一个高性能的内存缓存库支持,该库提供了一种有效的方式来存储、检索和管理会话数据。此存储适用于需要快速访问会话数据且不需要在多个 Vert.x 实例之间共享会话数据的应用程序。由于其优化的驱逐策略,它也推荐用于需要处理大量会话的应用程序。

要使用此模块,请将以下内容添加到 Maven POM 文件的 dependencies 部分:

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-sstore-caffeine</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-sstore-caffeine:5.0.1'
}

创建会话处理器

创建会话存储后,您可以创建会话处理器,并将其添加到路由。您应该确保您的会话处理器在您的应用程序处理器之前被路由。

这是一个示例:

Router router = Router.router(vertx);

// Create a clustered session store using defaults
SessionStore store = ClusteredSessionStore.create(vertx);

SessionHandler sessionHandler = SessionHandler.create(store);

// the session handler controls the cookie used for the session
// this includes configuring, for example, the same site policy
// like this, for strict same site policy.
sessionHandler.setCookieSameSite(CookieSameSite.STRICT);

// Make sure all requests are routed through the session handler too
router.route().handler(sessionHandler);

// Now your application handlers
router.route("/somepath/blah/").handler(ctx -> {

  Session session = ctx.session();
  session.put("foo", "bar");
  // etc

});

会话处理器将确保您的会话从会话存储中自动查找(如果不存在会话则创建)并设置在路由上下文中,然后才到达您的应用程序处理器。

默认情况下,会话处理器总是将一个会话 Cookie 添加到 HTTP 响应中,即使您的应用程序尚未访问会话。要仅在会话已被使用时才创建会话 Cookie,请使用 sessionHandler.setLazySession(true)

使用会话

在您的处理器中,您可以使用 session 访问会话实例。

您可以使用 put 将数据放入会话,使用 get 从会话中获取数据,并使用 remove 从会话中移除数据。

会话中项目的键始终是字符串。本地会话存储的值可以是任何类型,而集群会话存储的值可以是任何基本类型,或 BufferJsonObjectJsonArray 或可序列化对象,因为这些值必须在集群中序列化。

以下是操作会话数据的示例:

router.route().handler(sessionHandler);

// Now your application handlers
router.route("/somepath/blah").handler(ctx -> {

  Session session = ctx.session();

  // Put some data from the session
  session.put("foo", "bar");

  // Retrieve some data from a session
  int age = session.get("age");

  // Remove some data from a session
  JsonObject obj = session.remove("myobj");

});

会话在响应完成后自动写回存储。

您可以使用 destroy 手动销毁会话。这将从上下文和会话存储中移除会话。请注意,如果不存在会话,则浏览器通过会话处理器路由的下一个请求将自动创建一个新会话。

会话超时

如果会话在超过超时期的指定时间未被访问,它们将自动超时。当会话超时时,它将从存储中移除。

当请求到达并查找会话时,以及当响应完成并将会话存储回存储时,会话会自动标记为已访问。

您也可以使用 setAccessed 手动将会话标记为已访问。

会话超时可以在创建会话处理器时配置。默认超时时间为 30 分钟。

认证 / 授权

Vert.x 提供了开箱即用的处理器,用于处理认证和授权。在 vert.x web 中,这两个词的含义是:

  • 认证 - 告诉用户是谁

  • 授权 - 告诉用户被允许做什么

虽然认证与一个众所周知的协议紧密绑定,例如:

  • HTTP 基本认证

  • HTTP 摘要认证

  • OAuth2 认证

  • …​

Vert.x 中的授权非常通用,可以不考虑之前的认证而使用。然而,在两种情况下都使用相同的提供者模块也是可能且有效的用例。

创建认证处理器

要创建认证处理器,您需要一个 AuthenticationProvider 实例。认证提供者用于用户认证。Vert.x 在 vertx-auth 项目中开箱即用地提供了多个认证提供者实例。有关认证提供者以及如何使用和配置它们的完整信息,请查阅认证文档。

这是一个根据认证提供者创建基本认证处理器的简单示例:

router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));

AuthenticationHandler basicAuthHandler = BasicAuthHandler.create(authProvider);

在应用程序中处理认证

假设您希望所有以 /private/ 开头的路径的请求都进行认证。为此,您需要确保您的认证处理器位于这些路径的应用程序处理器之前:

router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));

AuthenticationHandler basicAuthHandler = BasicAuthHandler.create(authProvider);

// All requests to paths starting with '/private/' will be protected
router.route("/private/*").handler(basicAuthHandler);

router.route("/someotherpath").handler(ctx -> {

  // This will be public access - no login required

});

router.route("/private/somepath").handler(ctx -> {

  // This will require a login

  // This will have the value true
  boolean isAuthenticated = ctx.userContext().authenticated();

});

如果认证处理器成功认证了用户,它将一个 User 对象注入到 UserContext 中,以便在您的处理器中可以通过路由上下文 userContext 访问它。

如果您希望将用户对象存储在会话中,以便在请求之间可用,从而不必在每个请求上进行认证,那么您应该确保在认证处理器之前有一个会话处理器。

一旦您拥有用户对象,您还可以以编程方式使用其方法来授权用户。

如果您想让用户退出登录,您可以调用路由上下文 user getter 上的 logout。退出登录将从会话中移除用户(如果存在),并默认重定向到可选 URI 或 /

HTTP 基本认证

HTTP 基本认证是一种简单的认证方式,适用于简单应用程序。

使用基本认证,凭据以未加密的方式通过 HTTP 头发送,因此您的应用程序必须使用 HTTPS 而不是 HTTP 提供服务。

使用基本认证,如果用户请求需要认证的资源,基本认证处理器将发送回一个带有 WWW-Authenticate 头的 401 响应。这会提示浏览器显示登录对话框,并提示用户输入用户名和密码。

请求再次发送到资源,这次设置了 Authorization 头,其中包含 Base64 编码的用户名和密码。

当基本认证处理器收到此信息时,它会使用用户名和密码调用已配置的 AuthenticationProvider 来认证用户。如果认证成功,则允许请求的路由继续到应用程序处理器,否则返回 403 响应表示拒绝访问。

重定向认证处理器

使用重定向认证处理时,如果用户试图访问受保护资源但未登录,则会被重定向到登录页面。

然后用户填写登录表单并提交。服务器处理此请求,认证用户,如果认证成功,则将用户重定向回原始资源。

要使用重定向认证,您需要配置一个 RedirectAuthHandler 实例,而不是基本认证处理器。

您还需要设置处理器来提供实际的登录页面,以及一个处理器来处理实际的登录本身。为了处理登录,我们为此目的提供了一个预构建的处理器 FormLoginHandler

这是一个简单应用程序的示例,在默认重定向 URL /loginpage 上使用重定向认证处理器。

router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));

// All requests to paths starting with '/private/' will be protected
router
  .route("/private/*")
  .handler(RedirectAuthHandler.create(authProvider));

// Handle the actual login
// One of your pages must POST form login data
router.post("/login").handler(FormLoginHandler.create(authProvider));

// Set a static server to serve static resources, e.g. the login page
router.route().handler(StaticHandler.create());

router
  .route("/someotherpath")
  .handler(ctx -> {
    // This will be public access - no login required
  });

router
  .route("/private/somepath")
  .handler(ctx -> {

    // This will require a login

    // This will have the value true
    boolean isAuthenticated = ctx.userContext().authenticated();

  });

JWT 认证

使用 JWT 认证,可以通过权限保护资源,权限不足的用户将被拒绝访问。您需要添加 io.vertx:vertx-auth-jwt:5.0.1 依赖项才能使用 JWTAuthProvider

要使用此处理器,需要两个步骤:

  • 设置一个处理器来颁发令牌(或依赖第三方)

  • 设置处理器来过滤请求

请注意,这两个处理器应仅在 HTTPS 上可用,否则允许嗅探传输中的令牌,这会导致会话劫持攻击。

以下是颁发令牌的示例:

Router router = Router.router(vertx);

JWTAuthOptions authConfig = new JWTAuthOptions()
  .setKeyStore(new KeyStoreOptions()
    .setType("jceks")
    .setPath("keystore.jceks")
    .setPassword("secret"));

JWTAuth jwt = JWTAuth.create(vertx, authConfig);

router.route("/login").handler(ctx -> {
  // this is an example, authentication should be done with another provider...
  if (
    "paulo".equals(ctx.request().getParam("username")) &&
      "secret".equals(ctx.request().getParam("password"))) {
    ctx.response()
      .end(jwt.generateToken(new JsonObject().put("sub", "paulo")));
  } else {
    ctx.fail(401);
  }
});

现在您的客户端已经有了令牌,所有后续请求只需要在 HTTP 头 Authorization 中填充:Bearer ,例如:

Router router = Router.router(vertx);

JWTAuthOptions authConfig = new JWTAuthOptions()
  .setKeyStore(new KeyStoreOptions()
    .setType("jceks")
    .setPath("keystore.jceks")
    .setPassword("secret"));

JWTAuth authProvider = JWTAuth.create(vertx, authConfig);

router.route("/protected/*").handler(JWTAuthHandler.create(authProvider));

router.route("/protected/somepage").handler(ctx -> {
  // some handle code...
});

JWT 允许您将任何信息添加到令牌本身。通过这样做,服务器中没有状态,这使得您可以扩展应用程序而无需集群会话数据。为了向令牌添加数据,在创建令牌时只需将数据添加到 JsonObject 参数中:

JWTAuthOptions authConfig = new JWTAuthOptions()
  .setKeyStore(new KeyStoreOptions()
    .setType("jceks")
    .setPath("keystore.jceks")
    .setPassword("secret"));

JWTAuth authProvider = JWTAuth.create(vertx, authConfig);

authProvider
  .generateToken(
    new JsonObject()
      .put("sub", "paulo")
      .put("someKey", "some value"),
    new JWTOptions());

消费时也一样:

Handler<RoutingContext> handler = ctx -> {
  String theSubject = ctx.user().principal().getString("sub");
  String someKey = ctx.user().principal().getString("someKey");
};

配置授权

到目前为止,所有示例都涵盖了认证。授权是处理用户时的下一个逻辑步骤。虽然认证非常具体于协议,但授权是独立的,所有信息都从 User 对象中提取。

在此之前,需要将授权加载到此对象中。为此,应使用 AuthorizationHandler。授权处理器将从给定的 AuthorizationProvider 加载所有已知的授权。

router.route().handler(
  // create the handler that will perform the attestation
  AuthorizationHandler.create(
      // what to attest
      PermissionBasedAuthorization.create("can-do-work"))
    // where to lookup the authorizations for the user
    .addAuthorizationProvider(authProvider));

查找可以在多个源上执行,只需不断向处理器添加 addAuthorizationProvider(provider)

以下是一个配置应用程序的示例,以便应用程序的不同部分需要不同的权限。请注意,权限的含义由您使用的底层认证提供者决定。例如,某些提供者可能支持基于角色/权限的模型,而另一些则可能使用其他模型。

router.route("/listproducts/*").handler(
  // create the handler that will perform the attestation
  AuthorizationHandler.create(
      // what to attest
      PermissionBasedAuthorization.create("list_products"))
    // where to lookup the authorizations for the user
    .addAuthorizationProvider(authProvider));

// Only "admin" has access to /private/settings
router.route("/private/settings/*").handler(
  // create the handler that will perform the attestation
  AuthorizationHandler.create(
      // what to attest
      RoleBasedAuthorization.create("admin"))
    .addAuthorizationProvider(authProvider));

链式多个认证处理器

有时您可能希望在单个应用程序中支持多种认证机制。为此,您可以使用 ChainAuthHandler。链式认证处理器将尝试在一系列处理器上执行认证。

重要的是要知道有些处理器需要特定的提供者,例如:

因此,不期望提供者在所有处理器之间共享。在某些情况下,可以在处理器之间共享提供者,例如:

假设您想创建一个同时接受 HTTP 基本认证表单重定向 的应用程序。您将开始配置您的链,如下所示:

ChainAuthHandler chain = ChainAuthHandler.any();

// add http basic auth handler to the chain
chain.add(BasicAuthHandler.create(provider));
// add form redirect auth handler to the chain
chain.add(RedirectAuthHandler.create(provider));

// secure your route
router.route("/secure/resource").handler(chain);
// your app
router.route("/secure/resource").handler(ctx -> {
  // do something...
});

因此,当用户发出没有 Authorization 头的请求时,这意味着链将无法通过基本认证处理器进行认证,并将尝试使用重定向处理器进行认证。由于重定向处理器总是重定向,您将被发送到您在该处理器中配置的登录表单。

与 Vert.x-Web 中的常规路由一样,认证链是一个序列,因此如果您希望回退到浏览器请求用户凭据(使用 HTTP 基本认证)而不是重定向,您所需要做的就是反转添加到链的顺序。

现在假设您发出一个请求,其中提供 Authorization 头,值为 Basic [token]。在这种情况下,基本认证处理器将尝试认证,如果成功,链将停止,Vert.x-Web 将继续处理您的处理器。如果令牌无效,例如用户名/密码错误,则链将继续到下一个条目。在这种特定情况下,是重定向认证处理器。

复杂的链式结构也是可能的,例如,构建逻辑序列,如:HandlerA OR (HandlerB AND HandlerC)。

ChainAuthHandler chain =
  ChainAuthHandler.any()
    .add(authNHandlerA)
    .add(ChainAuthHandler.all()
      .add(authNHandlerB)
      .add(authNHandlerC));

// secure your route
router.route("/secure/resource").handler(chain);
// your app
router.route("/secure/resource").handler(ctx -> {
  // do something...
});

模拟

在使用认证处理器时,用户的身份可能会随时间而改变。例如,用户可能需要在一个特定时间段内成为 admin。这可以通过调用以下方法实现:

router
  .route("/high/security/route/check")
  .handler(ctx -> {
    // if the user isn't admin, we ask the user to login again as admin
    ctx
      .userContext()
      .loginHint("admin")
      .impersonate();
  });

通过调用以下方法可以逆转该操作:

router
  .route("/high/security/route/back/to/me")
  .handler(ctx -> {
    ctx
      .userContext()
      .restore();
  });

模拟行为不需要调用其他路由端点,以避免从外部被利用。

刷新身份

有时,关键操作可能需要证明用户的存在。虽然这种证明很复杂,但我们可以通过刷新用户身份来实现。这可以通过调用实现

router
  .route("/high/security/route/refresh/me")
  .handler(ctx -> {
    ctx
      .userContext()
      .refresh();
  });

这将要求用户再次认证以确认身份。

安全审计

Vert.x-Web 提供了一个开箱即用的处理程序,用于审计安全事件。此处理程序可用于将日志记录到 EDR/XDR 等外部系统。EDR 代表端点检测与响应(Endpoint Detection and Response),XDR 代表扩展检测与响应(Extended Detection and Response)。这些是用于检测和响应端点威胁的安全产品。该处理程序名为 SecurityAuditLoggerHandler,并作为顶级处理程序使用,如下所示

router.route()
  .handler(SecurityAuditLoggerHandler.create());

事件类型

该处理程序将记录以下事件

请求

当收到请求时(无论是成功还是失败),您将获得包含以下信息的日志事件

[REQUEST epoch="1679389193762" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /protected/foo" status=200] OK

当发生故障时,您将获得包含以下信息的日志事件

[REQUEST epoch="1679389193924" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /somedir" status=500] FAIL

您将获得的信息是

  • epoch - 自 epoch 以来的毫秒时间

  • source - 源 IP 地址

  • destination - 目标 IP 地址

  • resource - HTTP 资源

  • status - HTTP 状态码

认证

当请求经过认证时,您将获得包含以下信息的日志事件

[AUTHENTICATION epoch="1679389193762" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /protected/foo" token="********************************..."] OK

该处理程序会屏蔽密码、令牌等众所周知的个人身份信息(PII)字段。此外,对于失败,也将记录一个事件

[AUTHENTICATION epoch="1679389193568" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /protected/foo" token="********************************..."] FAIL

授权

当请求获得授权时,您将获得包含以下信息的日志事件

[AUTHORIZATION epoch="1679389193678" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /protected/page1" subject="paulo" authorization="ROLE[role3]"] OK

当请求未获得授权时,您将获得包含以下信息的日志事件

[AUTHORIZATION epoch="1679389193678" source="127.0.0.1" destination="127.0.0.1" resource="HTTP/1.1 GET /protected/page1" subject="paulo" authorization="ROLE[role3]"] OK

该事件将包含几个额外字段

  • subject - 已认证的主体

  • authorization - 授权该请求所需要的授权

记录器将不包含用户特定的授权信息,以避免泄露个人身份信息(PII)/受保护健康信息(PHI)。

配置

该处理程序通常会使用 rfc5424 格式(通常称为 syslog)记录日志,但您可以将其配置为使用其他格式。此配置是全局的,因为它会影响日志格式,因此要更改它,您需要在启动时定义一个系统属性来完成

io.vertx.ext.auth.audit.format=rfc5424

// OR

io.vertx.ext.auth.audit.format=json

使用 JSON 时,相同的数据会记录在一个单行 JSON 文档中。

服务静态资源

Vert.x-Web 提供了一个开箱即用的处理程序,用于服务静态 Web 资源,因此您可以非常轻松地编写静态 Web 服务器。

要服务 .html.css.js 或任何其他静态资源,您需要使用 StaticHandler 的实例。

对由静态处理程序处理的路径的任何请求都将导致文件从文件系统上的目录或类路径中提供。默认的静态文件目录是 webroot,但可以配置。

在以下示例中,所有以 /static/ 开头的路径请求都将从 webroot 目录提供。

router.route("/static/*").handler(StaticHandler.create());

例如,如果有一个路径为 /static/css/mystyles.css 的请求,静态服务将在 webroot/css/mystyle.css 目录中查找文件。

它还会查找类路径中名为 webroot/css/mystyle.css 的文件。这意味着您可以将所有静态资源打包到 JAR 文件(或 fatjar)中并进行分发。

当 Vert.x 第一次在类路径中找到资源时,它会将其提取并缓存到磁盘上的一个临时目录中,这样就无需每次都执行此操作。

该处理程序将处理范围感知请求。当客户端向静态资源发出请求时,处理程序将通过在 Accept-Ranges 头中声明单位来通知其可以处理范围感知请求。包含正确单位和起始结束索引的 Range 头的后续请求将收到带有正确 Content-Range 头的局部响应。

配置缓存

默认情况下,静态处理程序将设置缓存头,以使浏览器能够有效地缓存文件。

Vert.x-Web 设置 cache-controllast-modifieddate 头。

cache-control 默认设置为 max-age=86400。这相当于一天。如果需要,可以使用 setMaxAgeSeconds 进行配置。

如果浏览器发送带有 if-modified-since 头的 GET 或 HEAD 请求,并且资源自该日期以来未被修改,则返回 304 状态,指示浏览器使用其本地缓存的资源。

如果不需要处理缓存头,可以使用 setCachingEnabled 禁用。

启用缓存处理后,Vert.x-Web 会在内存中缓存资源的最后修改日期,这避免了每次都访问磁盘检查实际最后修改日期的开销。

缓存中的条目有过期时间,过期后,将再次检查磁盘上的文件并更新缓存条目。

如果您知道文件在磁盘上永远不会更改,那么缓存条目实际上永远不会过期。这是默认设置。

如果您知道文件在服务器运行时可能会在磁盘上更改,那么可以使用 setFilesReadOnly 将文件设置为非只读。

要启用内存中同时可以缓存的最大条目数,可以使用 setMaxCacheSize

要配置缓存条目的过期时间,可以使用 setCacheEntryTimeout

配置索引页

对根路径 / 的任何请求都将导致服务索引页。默认情况下,索引页是 index.html。这可以使用 setIndexPage 进行配置。

更改 Web 根目录

默认情况下,静态资源将从 webroot 目录提供。要配置此项,请使用 StaticHandler.create

服务隐藏文件

默认情况下,服务将提供隐藏文件(以 . 开头的文件)。

如果您不希望服务隐藏文件,可以使用 setIncludeHidden 进行配置。

目录列表

服务器还可以执行目录列表。默认情况下,目录列表是禁用的。要启用它,请使用 setDirectoryListing

启用目录列表后,返回的内容取决于 accept 头中的内容类型。

对于 text/html 目录列表,用于渲染目录列表页的模板可以使用 setDirectoryTemplate 进行配置。

禁用磁盘文件缓存

默认情况下,Vert.x 会将从类路径提供的文件缓存到当前工作目录中名为 .vertx 的子目录中的磁盘文件中。这主要在生产环境中将服务部署为 fatjar 时非常有用,因为每次从类路径提供文件可能会很慢。

在开发中,这可能会导致问题,因为如果您在服务器运行时更新静态内容,将提供缓存文件而不是更新后的文件。

要禁用文件缓存,您可以将 Vert.x 选项的 fileResolverCachingEnabled 属性设置为 false。为了向后兼容,它还会将该值默认为系统属性 vertx.disableFileCaching。例如,您可以在 IDE 中设置运行配置,在运行主类时设置此项。

CORS 处理

跨域资源共享(CORS)是一种安全机制,允许从一个域请求资源并从另一个域提供资源。

Vert.x-Web 包含一个处理程序 CorsHandler,它为您处理 CORS 协议。

这是一个示例:

router.route()
  .handler(
    CorsHandler.create()
      .addOriginWithRegex("vertx\\.io")
      .allowedMethod(HttpMethod.GET));

router.route().handler(ctx -> {

  // Your app handlers

});

多租户

有些情况下,您的应用程序需要处理不止一个租户。在这种情况下,提供了一个辅助处理程序,可简化应用程序的设置。

如果租户通过 HTTP 头标识,例如 X-Tenant,那么创建处理程序就非常简单,如下所示

router.route().handler(MultiTenantHandler.create("X-Tenant"));

现在您应该注册针对给定租户应执行的处理程序

MultiTenantHandler.create("X-Tenant")
  .addTenantHandler("tenant-A", ctx -> {
    // do something for tenant A...
  })
  .addTenantHandler("tenant-B", ctx -> {
    // do something for tenant B...
  })
  // optionally
  .addDefaultHandler(ctx -> {
    // do something when no tenant matches...
  });

这对于安全情况很有用

OAuth2Auth gitHubAuthProvider = GithubAuth
  .create(vertx, "CLIENT_ID", "CLIENT_SECRET");

// create a oauth2 handler on our running server
// the second argument is the full url to the callback
// as you entered in your provider management console.
OAuth2AuthHandler githubOAuth2 = OAuth2AuthHandler.create(
  vertx,
  gitHubAuthProvider,
  "https://myserver.com/github-callback");

// setup the callback handler for receiving the GitHub callback
githubOAuth2.setupCallback(router.route("/github-callback"));

// create an OAuth2 provider, clientID and clientSecret
// should be requested to Google
OAuth2Auth googleAuthProvider = OAuth2Auth.create(vertx, new OAuth2Options()
  .setClientId("CLIENT_ID")
  .setClientSecret("CLIENT_SECRET")
  .setSite("https://#")
  .setTokenPath("https://www.googleapis.com/oauth2/v3/token")
  .setAuthorizationPath("/o/oauth2/auth"));

// create a oauth2 handler on our domain: "https://:8080"
OAuth2AuthHandler googleOAuth2 = OAuth2AuthHandler.create(
  vertx,
  googleAuthProvider,
  "https://myserver.com/google-callback");

// setup the callback handler for receiving the Google callback
googleOAuth2.setupCallback(router.route("/google-callback"));

// At this point the 2 callbacks endpoints are registered:

// /github-callback -> handle github Oauth2 callbacks
// /google-callback -> handle google Oauth2 callbacks

// As the callbacks are made by the IdPs there's no header
// to identify the source, hence the need of custom URLs

// However for out Application we can control it so later
// we can add the right handler for the right tenant

router.route().handler(
  MultiTenantHandler.create("X-Tenant")
    // tenants using github should go this way:
    .addTenantHandler("github", githubOAuth2)
    // tenants using google should go this way:
    .addTenantHandler("google", googleOAuth2)
    // all other should be forbidden
    .addDefaultHandler(ctx -> ctx.fail(401)));

租户 ID 可以随时从上下文中读取,例如,用于决定加载哪个资源或连接哪个数据库。

router.route().handler(ctx -> {
  // the default key is "tenant" as defined in
  // MultiTenantHandler.TENANT but this value can be
  // modified at creation time in the factory method
  String tenant = ctx.get(MultiTenantHandler.TENANT);

  switch (tenant) {
    case "google":
      // do something for google users
      break;
    case "github":
      // so something for github users
      break;
  }
});

多租户是一个强大的处理程序,它允许应用程序并存,但它不提供执行沙箱。它不应被用作隔离,因为编写不当的应用程序可能会在租户之间泄露状态。

模板

Vert.x-Web 包含动态页面生成功能,开箱即用地支持多种流行的模板引擎。您也可以轻松添加自己的模板引擎。

模板引擎由 TemplateEngine 描述。为了渲染模板,使用 render 方法。

使用模板最简单的方法不是直接调用模板引擎,而是使用 TemplateHandler。此处理程序根据 HTTP 请求中的路径为您调用模板引擎。

默认情况下,模板处理程序将在名为 templates 的目录中查找模板。这可以配置。

该处理程序默认将以 text/html 的内容类型返回渲染结果。这也可以配置。

创建模板处理程序时,您会传入所需模板引擎的实例。模板引擎不嵌入在 vertx-web 中,因此您必须配置您的项目才能访问它们。每个模板引擎都提供了配置。

以下是 Handlebars 模板引擎的一个示例

TemplateEngine engine = HandlebarsTemplateEngine.create();
TemplateHandler handler = TemplateHandler.create(engine);

// This will route all GET requests starting with /dynamic/ to the template handler
// E.g. /dynamic/graph.hbs will look for a template in /templates/graph.hbs
router.get("/dynamic/*").handler(handler);

// Route all GET requests for resource ending in .hbs to the template handler
router.getWithRegex(".+\\.hbs").handler(handler);

MVEL 模板引擎

要使用 MVEL 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-mvel</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-mvel:5.0.1'
}

使用 io.vertx.ext.web.templ.mvel.MVELTemplateEngine#create(io.vertx.core.Vertx) 创建 MVEL 模板引擎实例

使用 MVEL 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .templ 扩展名的模板。

路由上下文 data 在 MVEL 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

The value of 'bar' from routing context data is @{bar}.

只要模板位于文件系统上,您就可以使用 @include{} 将模板文件包含到 MVEL 模板中。

如果模板是使用 Vert.x 文件解析器从类路径加载的,则模板包含不起作用。

这是 MVEL 引擎的一个限制:其他一些引擎允许 Vert.x 插入自定义模板文件解析器,但 MVEL 不允许。

请查阅 MVEL 模板文档,了解如何编写 MVEL 模板。

Pug 模板引擎

要使用 Pug 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-pug</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-pug:5.0.1'
}

使用 io.vertx.ext.web.templ.pug.PugTemplateEngine#create(io.vertx.core.Vertx) 创建 Pug 模板引擎实例。

使用 Pug 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .pug 扩展名的模板。

路由上下文 data 在 Pug 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

!!! 5
html
  head
    title The value of 'bar' from routing context data is #{bar}.
  body

请查阅 Pug4j 文档,了解如何编写 Pug 模板。

Handlebars 模板引擎

要使用 Handlebars 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-handlebars</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-handlebars:5.0.1'
}

使用 io.vertx.ext.web.templ.handlebars.HandlebarsTemplateEngine#create(io.vertx.core.Vertx) 创建 Handlebars 模板引擎实例。

使用 Handlebars 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .hbs 扩展名的模板。

路由上下文 data 在 Handlebars 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。

Handlebars 模板无法调用对象中的任意方法。

如果您想访问请求路径、请求参数或会话数据等其他数据,您应该在模板处理程序之前的处理程序中将其添加到上下文数据中。例如

TemplateHandler handler = TemplateHandler.create(engine);

router.get("/dynamic").handler(ctx -> {

  ctx.put("request_path", ctx.request().path());
  ctx.put("session_data", ctx.session().data());

  ctx.next();
});

router.get("/dynamic/").handler(handler);

请查阅 Handlebars Java 移植文档,了解如何编写 Handlebars 模板。

Thymeleaf 模板引擎

要使用 Thymeleaf 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-thymeleaf</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-thymeleaf:5.0.1'
}

使用 io.vertx.ext.web.templ.thymeleaf.ThymeleafTemplateEngine#create(io.vertx.core.Vertx) 创建 Thymeleaf 模板引擎实例。

使用 Thymeleaf 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .html 扩展名的模板。

路由上下文 data 在 Thymeleaf 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

<div th:fragment="example"><p th:text="${foo}"></p>
<p th:text="${bar}"></p></div>

请查阅 Thymeleaf 文档,了解如何编写 Thymeleaf 模板。

Apache FreeMarker 模板引擎

要使用 FreeMarker 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-freemarker</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-freemarker:5.0.1'
}

使用 io.vertx.ext.web.templ.Engine#create() 创建 Apache FreeMarker 模板引擎实例。

使用 Apache FreeMarker 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .ftl 扩展名的模板。

路由上下文 data 在 FreeMarker 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

Hello ${foo} and ${bar}

请查阅 Apache FreeMarker 文档,了解如何编写 Apache FreeMarker 模板。

Pebble 模板引擎

要使用 Pebble 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-pebble</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-pebble:5.0.1'
}

使用 io.vertx.ext.web.templ.pebble.PebbleTemplateEngine#create(vertx) 创建 Pebble 模板引擎实例。

使用 Pebble 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .peb 扩展名的模板。

路由上下文 data 在 Pebble 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

Hello {{foo}} and {{bar}}

请查阅 Pebble 文档,了解如何编写 Pebble 模板。

Rocker 模板引擎

要使用 Rocker 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-rocker</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-rocker:5.0.1'
}

使用 io.vertx.ext.web.templ.rocker.RockerTemplateEngine#create() 创建 Rocker 模板引擎实例。

路由上下文 data 在 Rocker 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

@import io.vertx.core.json.JsonObject
@args (String foo, String bar)
Hello @foo and @bar

HTTL 模板引擎

要使用 HTTL 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-httl</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-httl:5.0.1'
}

使用 io.vertx.ext.web.templ.httl.HTTLTemplateEngine#create(io.vertx.core.Vertx) 创建 HTTL 模板引擎实例。

使用 HTTL 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .httl 扩展名的模板。

路由上下文 data 在 HTTL 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

<!-- #set(String foo, String bar) -->
Hello ${foo} and ${bar}

请查阅 HTTL 文档,了解如何编写 HTTL 模板。

Rythm 模板引擎

要使用 Rythm 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-rythm</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-rythm:5.0.1'
}

使用 io.vertx.ext.web.templ.rythm.RythmTemplateEngine#create(io.vertx.core.Vertx) 创建 Rythm 模板引擎实例。

使用 Rythm 模板引擎时,如果文件名中未指定扩展名,它将默认查找带有 .html 扩展名的模板。

路由上下文 data 在 Rythm 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <p>@foo</p>
  <p>@bar</p>
</body>
</html>

请查阅 RythmEngine 文档,了解如何编写模板。

JTE 模板引擎

要使用 JTE 模板引擎,请将以下依赖项添加到您的构建描述符的 dependencies 部分

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web-templ-jte</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

dependencies {
  compile 'io.vertx:vertx-web-templ-jte:5.0.1'
}

使用 io.vertx.ext.web.templ.jte.JteTemplateEngine#create(io.vertx.core.Vertx, java.lang.String) 创建 JTE 模板引擎实例。

路由上下文 data 在 JTE 模板中作为变量源可用,这意味着您可以根据上下文数据中的任何内容渲染模板。如果您需要来自请求、响应、会话的数据,则需要将其显式添加到数据中。

这是一个示例

@import io.vertx.core.json.JsonObject
@param String foo
@param String bar

Hello ${foo} and ${bar}

缓存

许多引擎都支持编译模板的缓存。缓存存储在 Vert.x 共享数据本地映射中,这使得引擎能够以高效安全的方式在多个 Verticle 之间共享相同的缓存。

禁用缓存

在开发过程中,您可能希望禁用模板缓存,以便在每个请求上重新评估模板。为此,您需要将系统属性 vertxweb.environment 或环境变量 VERTXWEB_ENVIRONMENT 设置为 devdevelopment。默认情况下,缓存始终启用。

错误处理程序

您可以使用模板处理程序或其他方式渲染自己的错误,但 Vert.x-Web 还包含一个开箱即用的“美观”错误处理程序,可以为您渲染错误页面。

该处理程序是 ErrorHandler。要使用错误处理程序,只需将其设置为您希望覆盖的任何路径的故障处理程序即可。

请求日志记录器

Vert.x-Web 包含一个处理程序 LoggerHandler,您可以使用它来记录 HTTP 请求。您应该在任何可能导致 RoutingContext 失败的处理程序之前安装此处理程序。

默认情况下,请求会记录到 Vert.x 记录器,该记录器可以配置为使用 JUL 日志、log4j 或 SLF4J。

参见 LoggerFormat

提供网站图标

Vert.x-Web 包含处理程序 FaviconHandler,专门用于提供网站图标。

可以使用文件系统路径指定网站图标,或者默认情况下 Vert.x-Web 将在类路径中查找名为 favicon.ico 的文件。这意味着您可以将网站图标捆绑到应用程序的 JAR 文件中。

超时处理程序

Vert.x-Web 包含一个超时处理程序,如果请求处理时间过长,您可以使用它来使请求超时。

这是使用 TimeoutHandler 的实例进行配置的。

如果请求在响应写入之前超时,则会向客户端返回 503 响应。

以下是使用超时处理程序的一个示例,它将在 5 秒后使所有以 /foo 开头的路径请求超时

router.route("/foo/").handler(TimeoutHandler.create(5000));

响应时间处理器

此处理程序设置响应头 x-response-time,其中包含从请求接收到响应头写入的时间(以毫秒为单位),例如

x-response-time: 1456ms

内容类型处理程序

ResponseContentTypeHandler 可以自动设置 Content-Type 头。假设我们正在构建一个 RESTful Web 应用程序。我们需要在所有处理程序中设置内容类型

router
  .get("/api/books")
  .produces("application/json")
  .handler(ctx -> findBooks()
    .onSuccess(books -> ctx.response()
      .putHeader("Content-Type", "application/json")
      .end(toJson(books))).onFailure(ctx::fail));

如果 API 表面变得相当大,设置内容类型可能会变得繁琐。为了避免这种情况,请将 ResponseContentTypeHandler 添加到相应的路由中。

router.route("/api/*").handler(ResponseContentTypeHandler.create());
router
  .get("/api/books")
  .produces("application/json")
  .handler(ctx -> findBooks()
    .onSuccess(books -> ctx.response()
      .end(toJson(books))).onFailure(ctx::fail));

该处理程序从 getAcceptableContentType 获取适当的内容类型。因此,您可以轻松共享同一个处理程序来生成不同类型的数据。

router.route("/api/*").handler(ResponseContentTypeHandler.create());

router
  .get("/api/books")
  .produces("text/xml")
  .produces("application/json")
  .handler(ctx -> findBooks()
    .onSuccess(books -> {
      if (ctx.getAcceptableContentType().equals("text/xml")) {
        ctx.response().end(toXML(books));
      } else {
        ctx.response().end(toJson(books));
      }
    })
    .onFailure(ctx::fail));

SockJS

SockJS 是一个客户端 JavaScript 库和协议,它提供了一个简单的类似 WebSocket 的接口,允许您连接到 SockJS 服务器,无论实际浏览器或网络是否允许真实的 WebSocket。

它通过支持浏览器和服务器之间的各种不同传输方式,并根据浏览器和网络能力在运行时选择其中一种来实现这一点。

所有这些对您来说都是透明的——您只需获得一个“开箱即用”的类似 WebSocket 的接口。

有关 SockJS 的更多信息,请参阅 SockJS 网站

SockJS 处理程序

Vert.x 提供了一个开箱即用的处理程序,名为 SockJSHandler,用于在 Vert.x-Web 应用程序中使用 SockJS。

您应该为每个 SockJS 应用程序使用 SockJSHandler.create 创建一个处理程序。您还可以在创建实例时指定配置选项。配置选项由 SockJSHandlerOptions 的实例描述。

Router router = Router.router(vertx);

SockJSHandlerOptions options = new SockJSHandlerOptions()
  .setHeartbeatInterval(2000);

SockJSHandler sockJSHandler = SockJSHandler.create(vertx, options);

处理 SockJS 套接字

在服务器端,您在 SockJS 处理程序上设置一个处理程序,并且每当客户端建立 SockJS 连接时,此处理程序都将被调用。

传递给处理程序的对象是一个 SockJSSocket。它具有熟悉的类似套接字的接口,您可以像 NetSocketWebSocket 一样对其进行读写。它还实现了 ReadStreamWriteStream,因此您可以将其泵入和泵出其他读写流。RoutingContext 可用于手动会话管理,而 SockJS 连接则使用 routingContext 加载。通过它,您可以管理通过 webSessionwebUser 访问的用户和会话。

以下是一个简单的 SockJS 处理程序示例,它只是将读取到的任何数据回显回去

Router router = Router.router(vertx);

SockJSHandlerOptions options = new SockJSHandlerOptions()
  .setHeartbeatInterval(2000);

SockJSHandler sockJSHandler = SockJSHandler.create(vertx, options);

router.route("/myapp/*")
  .subRouter(sockJSHandler.socketHandler(sockJSSocket -> {

    // Just echo the data back
    sockJSSocket.handler(sockJSSocket::write);

  }));

客户端

在客户端 JavaScript 中,您使用 SockJS 客户端库建立连接。为方便起见,该包可在 https://npmjs.net.cn/package/sockjs-client 上找到。

这意味着您可以从打包器或构建工具中引用它。但是,如果您想获取一个 CDN 版本直接在您的 HTML 文档中使用,首先您需要引用 sockjs 依赖项

<html>
<head>
  <script src="https://unpkg.io/[email protected]/dist/sockjs.min.js"></script>
</head>
<body>
  ...
</body>
</html>

有关使用 SockJS JavaScript 客户端的完整详细信息,请访问 SockJS 网站,但总而言之,您可以这样使用它

var sock = new SockJS('http://mydomain.com/myapp');

sock.onopen = function() {
  console.log('open');
};

sock.onmessage = function(e) {
  console.log('message', e.data);
};

sock.onevent = function(event, message) {
  console.log('event: %o, message:%o', event, message);
  return true; // in order to signal that the message has been processed
};

sock.onunhandled = function(json) {
  console.log('this message has no address:', json);
};

sock.onclose = function() {
  console.log('close');
};

sock.send('test');

sock.close();

配置 SockJS 处理程序

该处理程序可以使用 SockJSHandlerOptions 进行各种选项配置。

默认情况下,配置不包含默认的 Origin 属性。为了防止来自 Web 浏览器的跨站 WebSocket 劫持攻击,建议将此属性设置为应用程序面向互联网的源。这将强制检查 Web 套接字源是否来自此应用程序。此检查很重要,因为 WebSocket 不受同源策略的限制,攻击者可以轻易地从恶意网页发起针对 SockJS 桥的 ws://wss:// 端点 URL 的 WebSocket 请求。

通过事件总线写入 SockJS 套接字

创建 SockJSSocket 时,它可以通过事件总线注册事件处理程序。该处理程序的地址由 writeHandlerID 提供。

默认情况下,事件处理程序未注册。必须在 SockJSHandlerOptions 中启用它。

Router router = Router.router(vertx);

SockJSHandlerOptions options = new SockJSHandlerOptions()
  .setRegisterWriteHandler(true);

SockJSHandler sockJSHandler = SockJSHandler.create(vertx, options);

router.route("/myapp/*")
  .subRouter(sockJSHandler.socketHandler(sockJSSocket -> {

    // Retrieve the writeHandlerID and store it (e.g. in a local map)
    String writeHandlerID = sockJSSocket.writeHandlerID();

  }));
默认情况下,处理程序仅在本地注册。可以使用 setLocalWriteHandler 使其在整个集群中可用。

然后,您可以通过事件总线将 `Buffer` 写入 SockJS 套接字。

eventBus.send(writeHandlerID, Buffer.buffer("foo"));

SockJS 事件总线桥

Vert.x-Web 带有一个内置的 SockJS 套接字处理程序,称为事件总线桥,它有效地将服务器端 Vert.x 事件总线扩展到客户端 JavaScript。

这创建了一个分布式事件总线,它不仅跨越服务器端的多个 Vert.x 实例,还包括在浏览器中运行的客户端 JavaScript。

因此,我们可以创建一个涵盖众多浏览器和服务器的庞大分布式总线。只要服务器相互连接,浏览器就不必连接到同一台服务器。

这是通过提供一个名为 vertx-eventbus.js 的简单客户端 JavaScript 库来完成的,该库提供了一个与服务器端 Vert.x 事件总线 API 非常相似的 API,允许您向事件总线发送和发布消息,并注册处理程序以接收消息。

该 JavaScript 库使用 JavaScript SockJS 客户端,通过 SockJS 连接将事件总线流量隧道传输到服务器端的 SockJSHandler

然后在 SockJSHandler 上安装一个特殊的 SockJS 套接字处理程序,该处理程序处理 SockJS 数据并将其桥接到服务器端事件总线。

要激活桥接,只需在 SockJS 处理程序上调用 bridge 即可。

Router router = Router.router(vertx);

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);
SockJSBridgeOptions options = new SockJSBridgeOptions();
// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler.bridge(options));

在客户端 JavaScript 中,您使用 @vertx/eventbus-bridge-client.js 库来创建与事件总线的连接并发送和接收消息。该库可在 NPM 上获取,因此可以轻松与打包器或构建工具一起使用,但也可以轻松地从 CDN 使用(像之前的 SockJS 示例一样)。

<script src="https://unpkg.io/[email protected]/dist/sockjs.min.js"></script>
<script src='https://unpkg.io/@vertx/[email protected]/vertx-eventbus.js'></script>

<script>

var eb = new EventBus('https://:8080/eventbus');

eb.onopen = () => {

  // set a handler to receive a message
  eb.registerHandler('some-address', (error, message) => {
    console.log('received a message: ' + JSON.stringify(message));
  });

  // send a message
  eb.send('some-address', {name: 'tim', age: 587});

}

</script>

示例做的第一件事是创建一个事件总线实例

var eb = new EventBus('https://:8080/eventbus');

构造函数的参数是连接到事件总线的 URI。由于我们使用前缀 eventbus 创建了桥接,我们将连接到那里。

在连接打开之前,您实际上无法对连接执行任何操作。当连接打开时,将调用 onopen 处理程序。

该桥支持自动重新连接,并具有可配置的延迟和退避选项。

var eb = new EventBus('https://:8080/eventbus');
eb.enableReconnect(true);
eb.onopen = function() {}; // Set up handlers here, will be called on initial connection and all reconnections
eb.onreconnect = function() {}; // Optional, will only be called on reconnections

// Alternatively, pass in an options object
var options = {
    vertxbus_reconnect_attempts_max: Infinity, // Max reconnect attempts
    vertxbus_reconnect_delay_min: 1000, // Initial delay (in ms) before first reconnect attempt
    vertxbus_reconnect_delay_max: 5000, // Max delay (in ms) between reconnect attempts
    vertxbus_reconnect_exponent: 2, // Exponential backoff factor
    vertxbus_randomization_factor: 0.5 // Randomization factor between 0 and 1
};

var eb2 = new EventBus('https://:8080/eventbus', options);
eb2.enableReconnect(true);
// Set up handlers...

保护桥接

如果您像上面的示例一样启动了一个未受保护的桥接,并尝试通过它发送消息,您会发现消息神秘地消失了。它们发生了什么?

对于大多数应用程序来说,您可能不希望客户端 JavaScript 能够向服务器端或所有其他浏览器上的任何处理程序发送任意消息。

例如,您可能在事件总线上有一个允许访问或删除数据的服务。我们不希望行为不端或恶意的客户端能够删除数据库中的所有数据!

此外,我们也不一定希望任何客户端都能够监听任何事件总线地址。

为了解决这个问题,SockJS 桥默认会拒绝通过任何消息。您需要告诉桥接哪些消息可以通行。(回复消息除外,它们总是被允许通过)。

换句话说,该桥接就像一种防火墙,具有默认的“拒绝所有”策略。

配置桥接以告知它应该通过哪些消息很简单。

您可以在调用 bridge 时传入 SockJSBridgeOptions,以指定您希望允许哪些 匹配 的入站和出站流量。

每个匹配都是一个 PermittedOptions 对象

setAddress

这表示消息发送到的确切地址。如果要根据确切地址允许消息,请使用此字段。

setAddressRegex

这是一个将与地址匹配的正则表达式。如果要根据正则表达式允许消息,请使用此字段。如果指定了 address 字段,则此字段将被忽略。

setMatch

这允许您根据消息的结构来允许消息。匹配中的任何字段都必须以相同的值存在于消息中才能被允许。这目前仅适用于 JSON 消息。

如果消息是入站的(即从客户端 JavaScript 发送到服务器),当 Vert.x-Web 收到它时,它将检查所有入站允许的匹配项。如果匹配成功,则允许通过。

如果消息是出站的(即从服务器发送到客户端 JavaScript),在将其发送到客户端之前,Vert.x-Web 将检查所有出站允许的匹配项。如果匹配成功,则允许通过。

实际匹配的工作方式如下

如果已指定 address 字段,则 address 必须与消息的地址完全匹配才能被视为匹配。

如果未指定 address 字段但指定了 addressRegex 字段,则 address_re 中的正则表达式必须与消息的地址匹配才能被视为匹配。

如果指定了 match 字段,则消息的结构也必须匹配。结构匹配通过查看匹配对象中的所有字段和值,并检查它们是否都存在于实际消息正文中来实现。

这是一个示例:

Router router = Router.router(vertx);

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);


// Let through any messages sent to 'demo.orderMgr' from the client
PermittedOptions inboundPermitted1 = new PermittedOptions()
  .setAddress("demo.orderMgr");

// Allow calls to the address 'demo.persistor' from the client as
// long as the messages have an action field with value 'find'
// and a collection field with value 'albums'
PermittedOptions inboundPermitted2 = new PermittedOptions()
  .setAddress("demo.persistor")
  .setMatch(new JsonObject().put("action", "find")
    .put("collection", "albums"));

// Allow through any message with a field `wibble` with value `foo`.
PermittedOptions inboundPermitted3 = new PermittedOptions()
  .setMatch(new JsonObject().put("wibble", "foo"));

// First let's define what we're going to allow from server -> client

// Let through any messages coming from address 'ticker.mystock'
PermittedOptions outboundPermitted1 = new PermittedOptions()
  .setAddress("ticker.mystock");

// Let through any messages from addresses starting with "news."
// (e.g. news.europe, news.usa, etc)
PermittedOptions outboundPermitted2 = new PermittedOptions()
  .setAddressRegex("news\\..+");

// Let's define what we're going to allow from client -> server
SockJSBridgeOptions options = new SockJSBridgeOptions().
  addInboundPermitted(inboundPermitted1).
  addInboundPermitted(inboundPermitted1).
  addInboundPermitted(inboundPermitted3).
  addOutboundPermitted(outboundPermitted1).
  addOutboundPermitted(outboundPermitted2);

// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler.bridge(options));

要求消息授权

事件总线桥也可以配置为使用 Vert.x-Web 授权功能来要求对桥接上的消息进行授权,无论是入站还是出站。

为此,您可以在上一节中描述的匹配中添加额外的字段,以确定匹配所需的权限。

要声明需要登录用户的特定权限才能访问和允许消息,请使用 setRequiredAuthority 字段。

这是一个示例:

PermittedOptions inboundPermitted = new PermittedOptions()
  .setAddress("demo.orderService");

// But only if the user is logged in and has the authority "place_orders"
inboundPermitted.setRequiredAuthority("place_orders");

SockJSBridgeOptions options = new SockJSBridgeOptions()
  .addInboundPermitted(inboundPermitted);

用户要获得授权,首先必须登录,其次必须具有所需的权限。

要处理登录并实际进行身份验证,您可以配置普通的 Vert.x 身份验证处理程序。例如

Router router = Router.router(vertx);

// Let through any messages sent to 'demo.orderService' from the client
PermittedOptions inboundPermitted = new PermittedOptions()
  .setAddress("demo.orderService");

// But only if the user is logged in and has the authority "place_orders"
inboundPermitted.setRequiredAuthority("place_orders");

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);

// Now set up some basic auth handling:

router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));

AuthenticationHandler basicAuthHandler = BasicAuthHandler.create(authProvider);

router.route("/eventbus/*").handler(basicAuthHandler);

// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler.bridge(new SockJSBridgeOptions()
    .addInboundPermitted(inboundPermitted)));

处理事件总线桥事件

如果您希望在桥接上发生事件时收到通知,您可以在调用 bridge 时提供一个处理程序。

每当桥接上发生事件时,它都会被传递给处理程序。事件由 BridgeEvent 的实例描述。

事件可以是以下类型之一

SOCKET_CREATED (套接字已创建)

当创建新的 SockJS 套接字时,将发生此事件。

SOCKET_IDLE (套接字空闲)

当 SockJS 套接字空闲时间超过初始配置时,将发生此事件。

SOCKET_PING (套接字心跳)

当 SockJS 套接字的最后一次心跳时间戳更新时,将发生此事件。

SOCKET_CLOSED (套接字已关闭)

当 SockJS 套接字关闭时,将发生此事件。

SOCKET_ERROR (套接字错误)

当底层传输发生错误时,将发生此事件。

SEND (发送)

当尝试将消息从客户端发送到服务器时,将发生此事件。

PUBLISH (发布)

当尝试将消息从客户端发布到服务器时,将发生此事件。

RECEIVE (接收)

当尝试将消息从服务器传递到客户端时,将发生此事件。

REGISTER (注册)

当客户端尝试注册处理程序时,将发生此事件。

UNREGISTER (注销)

当客户端尝试注销处理程序时,将发生此事件。

您可以使用 type 检索事件类型,并使用 getRawMessage 检查事件的原始消息。

原始消息是一个具有以下结构的 JSON 对象

{
  "type": "send"|"publish"|"receive"|"register"|"unregister",
  "address": the event bus address being sent/published/registered/unregistered
  "body": the body of the message
}
SOCKET_ERROR 事件可能包含消息。在这种情况下,检查类型属性可能会引入一种新的消息。一个 err 消息。这是当发生套接字异常时生成的合成消息。该消息将遵循桥接线协议,并如下所示
{
  "type": "err",
  "failureType": "socketException",
  "message": "optionally a message from the exception being raised"
}

该事件也是 Promise 的实例。处理完事件后,您可以将 Promise 以 true 完成,以启用进一步的处理。

如果您不希望处理事件,可以将 Promise 以 false 完成。这是一个有用的功能,使您能够对通过桥接的消息进行自己的过滤,或者应用一些细粒度的授权或指标。

以下是一个示例,其中如果消息包含“Armadillos”一词,我们将拒绝所有流经桥接的消息。

Router router = Router.router(vertx);

// Let through any messages sent to 'demo.orderMgr' from the client
PermittedOptions inboundPermitted = new PermittedOptions()
  .setAddress("demo.someService");

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);
SockJSBridgeOptions options = new SockJSBridgeOptions()
  .addInboundPermitted(inboundPermitted);

// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler
    .bridge(options, be -> {
      if (be.type() == BridgeEventType.PUBLISH ||
        be.type() == BridgeEventType.RECEIVE) {

          if (be.getRawMessage().getString("body").equals("armadillos")) {
            // Reject it
            be.complete(false);
            return;
          }
        }
        be.complete(true);
      }));

以下是配置和处理 SOCKET_IDLE 桥接事件类型的示例。请注意 setPingTimeout(5000),它表示如果 ping 消息在 5 秒内未从客户端到达,则会触发 SOCKET_IDLE 桥接事件。

Router router = Router.router(vertx);

// Initialize SockJSHandler handler
SockJSHandler sockJSHandler = SockJSHandler.create(vertx);
SockJSBridgeOptions options = new SockJSBridgeOptions()
  .addInboundPermitted(inboundPermitted)
  .setPingTimeout(5000);

// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler.bridge(options, be -> {
    if (be.type() == BridgeEventType.SOCKET_IDLE) {
      // Do some custom handling...
    }

      be.complete(true);
    }));

在客户端 JavaScript 中,您使用 'vertx-eventbus.js` 库来创建与事件总线的连接并发送和接收消息。

<script src="https://unpkg.io/[email protected]/dist/sockjs.min.js"></script>
<script src='https://unpkg.io/@vertx/[email protected]/vertx-eventbus.js'></script>

<script>

var eb = new EventBus('https://:8080/eventbus', {"vertxbus_ping_interval": 300000}); // sends ping every 5 minutes.

eb.onopen = function() {

 // set a handler to receive a message
 eb.registerHandler('some-address', function(error, message) {
   console.log('received a message: ' + JSON.stringify(message));
 });

 // send a message
 eb.send('some-address', {name: 'tim', age: 587});
}

</script>

示例做的第一件事是创建一个事件总线实例

var eb = new EventBus('https://:8080/eventbus', {"vertxbus_ping_interval": 300000});

构造函数的第二个参数告诉 SockJS 库每 5 分钟发送一次 ping 消息。由于服务器配置为每 5 秒期望一次 ping → 服务器将触发 SOCKET_IDLE

您还可以修改原始消息,例如更改正文。对于从客户端流入的消息,您还可以向消息添加标头,示例如下

Router router = Router.router(vertx);

// Let through any messages sent to 'demo.orderService' from the client
PermittedOptions inboundPermitted = new PermittedOptions()
  .setAddress("demo.orderService");

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);
SockJSBridgeOptions options = new SockJSBridgeOptions()
  .addInboundPermitted(inboundPermitted);

// mount the bridge on the router
router
  .route("/eventbus/*")
  .subRouter(sockJSHandler.bridge(options, be -> {
    if (
      be.type() == BridgeEventType.PUBLISH ||
        be.type() == BridgeEventType.SEND) {

        // Add some headers
        JsonObject headers = new JsonObject()
          .put("header1", "val")
          .put("header2", "val2");

        JsonObject rawMessage = be.getRawMessage();
        rawMessage.put("headers", headers);
        be.setRawMessage(rawMessage);
      }
      be.complete(true);
    }));

CSRF 跨站请求伪造

CSRF,有时也称为 XSRF,是一种未经授权的网站可以获取用户私人数据的技术。Vert.x-Web 包含一个处理程序 CSRFHandler,您可以使用它来防止跨站请求伪造。

在此处理程序下的每个 GET 请求中,响应中都会添加一个带有唯一令牌的 cookie。然后客户端需要将此令牌返回到请求头中。由于发送了 cookie,因此要求路由器上也存在 cookie 处理程序。

在开发依赖 User-Agent 执行 POST 操作的非单页应用程序时,HTML 表单无法指定请求头。为了解决此问题,仅当表单属性中不存在与请求头同名的请求头时,才会检查请求头值,例如

<form action="/submit" method="POST">
<input type="hidden" name="X-XSRF-TOKEN" value="abracadabra">
</form>

用户有责任为表单字段填写正确的值。喜欢仅使用 HTML 解决方案的用户可以通过从路由上下文(键为 X-XSRF-TOKEN)或他们在实例化 CSRFHandler 对象时选择的请求头名称中获取令牌值来填写此值。

router.route().handler(CSRFHandler.create(vertx, "abracadabra"));
router.route().handler(ctx -> {

});

请注意,此处理程序是会话感知的。如果存在可用会话,则在 POST 操作期间可能会省略表单参数或请求头,因为它将从会话中读取。这还意味着令牌只会在会话升级时重新生成。

请注意,为了额外安全,建议用户轮换用于签名令牌的密钥。这可以通过在线替换处理程序或使用新配置重新启动应用程序来完成。点击劫持仍然可能影响应用程序。如果这是一个关键应用程序,请考虑设置请求头:X-Frame-Options,如:https://mdn.org.cn/en-US/docs/Web/HTTP/Headers/X-Frame-Options 中所述。

使用 AJAX

通过 AJAX 访问受保护路由时,CSRF 令牌都需要在请求中传递。通常,这是通过使用请求头完成的,因为添加请求头通常可以在中央位置轻松完成,而无需修改负载。

CSRF 令牌从服务器端上下文的键 X-XSRF-TOKEN 下获取(除非您指定了不同的名称)。此令牌需要暴露给客户端,通常通过将其包含在初始页面内容中来实现。一种可能性是将其存储在 HTML <meta> 标签中,然后可以在请求时通过 JavaScript 检索其值。

以下内容可以包含在您的视图中(Handlebars 示例在下方)

<meta name="csrf-token" content="${X-XSRF-TOKEN}">

以下是使用 Fetch API 将带有页面上 <meta> 标签中的 CSRF 令牌的请求 POST 到 /process 路由的示例

// Read the CSRF token from the <meta> tag
var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')

// Make a request using the Fetch API
fetch('/process', {
  credentials: 'same-origin', // <-- includes cookies in the request
  headers: {
    'X-XSRF-TOKEN': token // <-- is the csrf token as a header
  },
  method: 'POST',
  body: {
    key: 'value'
  }
})

HSTS 处理程序

HTTP 严格传输安全 (HSTS) 是一种网络安全策略机制,有助于保护网站免受中间人攻击,例如协议降级攻击和 Cookie 劫持。它允许 Web 服务器声明 Web 浏览器(或其他兼容的用户代理)应仅使用 HTTPS 连接与其自动交互,HTTPS 提供传输层安全 (TLS/SSL),而不同于单独使用的不安全 HTTP。HSTS 是一种 IETF 标准跟踪协议,在 RFC 6797 中指定。

此处理程序将一步配置应用程序的正确请求头

router.route().handler(HSTSHandler.create());

CSP 处理程序

内容安全策略(CSP)是一个额外的安全层,有助于检测和缓解某些类型的攻击,包括跨站脚本(XSS)和数据注入攻击。这些攻击被用于从数据盗窃到网站篡改再到恶意软件分发的所有目的。

CSP 被设计为完全向后兼容。不支持它的浏览器仍然可以与实现它的服务器一起工作,反之亦然:不支持 CSP 的浏览器会直接忽略它,像往常一样运行,默认为 Web 内容的标准同源策略。如果网站不提供 CSP 请求头,浏览器同样使用标准同源策略。

router.route().handler(
  CSPHandler.create()
    .addDirective("default-src", "*.trusted.com"));

XFrame 处理程序

X-Frame-Options HTTP 响应头可用于指示是否允许浏览器在 frameiframeembedobject 中渲染页面。网站可以使用它来避免点击劫持攻击,确保其内容不被嵌入到其他网站中。

只有当访问文档的用户使用的浏览器支持 X-Frame-Options 时,才能提供额外的安全性。

如果您指定 DENY,则不仅从其他站点加载页面到 frame 中的尝试会失败,从同一站点加载的尝试也会失败。另一方面,如果您指定 SAMEORIGIN,只要包含页面的站点与提供页面的站点相同,您仍然可以在 frame 中使用该页面。

此处理程序将一步配置应用程序的正确请求头

router.route().handler(XFrameHandler.create(XFrameHandler.DENY));

OAuth2AuthHandler 处理程序

OAuth2AuthHandler 允许使用 OAuth2 协议快速设置安全路由。此处理程序简化了 authCode 流程。使用它保护某些资源并使用 GitHub 进行身份验证的示例如下实现

OAuth2Auth authProvider = GithubAuth
  .create(vertx, "CLIENT_ID", "CLIENT_SECRET");

// create a oauth2 handler on our running server
// the second argument is the full url to the
// callback as you entered in your provider management console.
OAuth2AuthHandler oauth2 = OAuth2AuthHandler
  .create(vertx, authProvider, "https://myserver.com/callback");

// setup the callback handler for receiving the GitHub callback
oauth2.setupCallback(router.route("/callback"));

// protect everything under /protected
router.route("/protected/*").handler(oauth2);
// mount some handler under the protected zone
router
  .route("/protected/somepage")
  .handler(ctx -> ctx.response().end("Welcome to the protected resource!"));

// welcome page
router
  .get("/")
  .handler(ctx -> ctx.response()
    .putHeader("content-type", "text/html")
    .end("Hello<br><a href=\"/protected/somepage\">Protected by Github</a>"));

OAuth2AuthHandler 将设置一个适当的回调 OAuth2 处理程序,因此用户无需处理权限服务器响应的验证。了解权限服务器响应仅一次有效非常重要,这意味着如果客户端重新加载回调 URL,它将被断言为无效请求,因为验证将失败。

一条经验法则是,一旦执行了有效的回调,就向受保护的资源发出客户端重定向。此重定向还应创建会话 cookie(或其他会话机制),以便用户无需为每个请求进行身份验证。

由于 OAuth2 规范的性质,要使用其他 OAuth2 提供程序需要进行细微更改,但 vertx-auth 为您提供了许多开箱即用的实现。

但是,如果您使用未列出的提供程序,您仍然可以使用基本 API 像这样进行操作

OAuth2Auth authProvider = OAuth2Auth.create(vertx, new OAuth2Options()
  .setClientId("CLIENT_ID")
  .setClientSecret("CLIENT_SECRET")
  .setSite("https://#")
  .setTokenPath("https://www.googleapis.com/oauth2/v3/token")
  .setAuthorizationPath("/o/oauth2/auth"));

// create a oauth2 handler on our domain: "https://:8080"
OAuth2AuthHandler oauth2 = OAuth2AuthHandler
  .create(vertx, authProvider, "https://:8080");

// these are the scopes
oauth2.withScope("profile");

// setup the callback handler for receiving the Google callback
oauth2.setupCallback(router.get("/callback"));

// protect everything under /protected
router.route("/protected/*").handler(oauth2);
// mount some handler under the protected zone
router
  .route("/protected/somepage")
  .handler(ctx -> ctx.response().end("Welcome to the protected resource!"));

// welcome page
router
  .get("/")
  .handler(ctx -> ctx.response()
    .putHeader("content-type", "text/html")
    .end("Hello<br><a href=\"/protected/somepage\">Protected by Google</a>"));

您需要手动提供提供程序的所有详细信息,但最终结果是相同的。

该处理程序会将您的应用程序固定到配置的回调 URL。用法很简单,只需为处理程序提供一个路由实例,所有设置都将为您完成。在典型用例中,您的提供程序会询问您的应用程序的回调 URL 是什么,然后您输入一个类似 https://myserver.com/callback 的 URL。这是处理程序的第二个参数,现在您只需设置它。为了方便最终用户,您只需调用 setupCallback 方法。

这就是您将处理程序固定到服务器 https://myserver.com:8447/callback 的方式。请注意,端口号对于默认值(http 为 80,https 为 443)不是强制性的。

OAuth2AuthHandler oauth2 = OAuth2AuthHandler
  .create(vertx, provider, "https://myserver.com:8447/callback");

// now allow the handler to setup the callback url for you
oauth2.setupCallback(router.route("/callback"));

在示例中,路由对象由 Router.route() 内联创建,但是如果您希望完全控制处理程序的调用顺序(例如,您希望它在链中尽可能早地被调用),您总是可以先创建路由对象,然后将其作为引用传递给此方法。

一个真实世界的示例

到目前为止,您已经学习了如何使用 OAuth2 处理程序,但是您会注意到每个请求都需要进行身份验证。这是因为处理程序没有状态,并且示例中没有应用状态管理。

尽管对于面向 API 的端点(例如使用 JWT,我们稍后会介绍)建议无状态,但对于面向用户的端点,我们可以将身份验证结果存储在会话中。为此,我们需要一个类似以下代码片段的应用程序。

OAuth2Auth authProvider =
  GithubAuth
    .create(vertx, "CLIENTID", "CLIENT SECRET");
// We need a user session handler too to make sure
// the user is stored in the session between requests
router.route()
  .handler(SessionHandler.create(LocalSessionStore.create(vertx)));
// we now protect the resource under the path "/protected"
router.route("/protected").handler(
  OAuth2AuthHandler.create(
      vertx,
      authProvider,
      "https://:8080/callback")
    // we now configure the oauth2 handler, it will
    // setup the callback handler
    // as expected by your oauth2 provider.
    .setupCallback(router.route("/callback"))
    // for this resource we require that users have
    // the authority to retrieve the user emails
    .withScope("user:email")
);
// Entry point to the application, this will render
// a custom template.
router.get("/").handler(ctx -> ctx.response()
  .putHeader("Content-Type", "text/html")
  .end(
    "<html>\n" +
      "  <body>\n" +
      "    <p>\n" +
      "      Well, hello there!\n" +
      "    </p>\n" +
      "    <p>\n" +
      "      We're going to the protected resource, if there is no\n" +
      "      user in the session we will talk to the GitHub API. Ready?\n" +
      "      <a href=\"/protected\">Click here</a> to begin!</a>\n" +
      "    </p>\n" +
      "    <p>\n" +
      "      <b>If that link doesn't work</b>, remember to provide your\n" +
      "      own <a href=\"https://github.com/settings/applications/new\">\n" +
      "      Client ID</a>!\n" +
      "    </p>\n" +
      "  </body>\n" +
      "</html>"));
// The protected resource
router.get("/protected").handler(ctx -> {
  // at this moment your user object should contain the info
  // from the Oauth2 response, since this is a protected resource
  // as specified above in the handler config the user object is never null
  User user = ctx.user();
  // just dump it to the client for demo purposes
  ctx.response().end(user.toString());
});

混合使用 OAuth2 和 JWT

一些提供商使用 JWT 令牌作为访问令牌,这是 RFC6750 的一个特性,在需要混合客户端基于身份验证和 API 授权时非常有用。例如,假设您有一个应用程序提供一些受保护的 HTML 文档,但您也希望它可供 API 消费。在这种情况下,API 无法轻易执行 OAuth2 所需的重定向握手,但可以使用预先提供的令牌。

只要提供程序配置为支持 JWT,此操作就会由处理程序自动处理。

在实际应用中,这意味着您的 API 可以使用带有值 Bearer BASE64_ACCESS_TOKENAuthorization 请求头访问您的受保护资源。

WebAuthn4J

这是使用 WebAuthn4J 的 WebAuthn 替代实现,API 略有不同

我们的在线生活依赖于过时且脆弱的密码概念。密码是恶意用户与您的银行账户或社交媒体账户之间的障碍。密码难以维护;它们难以存储在服务器上(密码可能被盗)。它们难以记忆,或难以不告诉他人(网络钓鱼攻击)。

但有更好的办法!一个无密码的世界,它是 W3C 和 FIDO 联盟在您的浏览器上运行的标准。

WebAuthn 是一个 API,允许服务器使用公钥加密而非密码来注册和认证用户,该 API 借助认证设备(例如 yubikey 令牌或您的手机)以用户可访问的方式使用加密技术。

协议要求至少将第一个回调挂载到路由器上

  1. /webauthn4j/response 用于执行所有验证的回调

  2. /webauthn4j/login 允许用户启动登录流程的端点(可选,但没有它将无法登录)

  3. /webauthn4j/register 允许用户注册新标识符的端点(可选,如果数据已存储则不需要此端点)

受保护应用程序的示例如下

router
  .route("/high/security/route/check")
  .handler(ctx -> {
    // if the user isn't admin, we ask the user to login again as admin
    ctx
      .userContext()
      .loginHint("admin")
      .impersonate();
  });

应用程序在后端不安全,但需要在客户端执行一些代码。需要一些样板代码,请看这两个函数

/**
 * Converts PublicKeyCredential into serialised JSON
 * @param  {Object} pubKeyCred
 * @return {Object}            - JSON encoded publicKeyCredential
 */
var publicKeyCredentialToJSON = (pubKeyCred) => {
  if (pubKeyCred instanceof Array) {
    let arr = [];
    for (let i of pubKeyCred) { arr.push(publicKeyCredentialToJSON(i)) }

    return arr
  }

  if (pubKeyCred instanceof ArrayBuffer) {
    return base64url.encode(pubKeyCred)
  }

  if (pubKeyCred instanceof Object) {
    let obj = {};

    for (let key in pubKeyCred) {
      obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
    }

    return obj
  }

  return pubKeyCred
};

/**
 * Generate secure random buffer
 * @param  {Number} len - Length of the buffer (default 32 bytes)
 * @return {Uint8Array} - random string
 */
var generateRandomBuffer = (len) => {
  len = len || 32;

  let randomBuffer = new Uint8Array(len);
  window.crypto.getRandomValues(randomBuffer);

  return randomBuffer
};

/**
 * Decodes arrayBuffer required fields.
 */
var preformatMakeCredReq = (makeCredReq) => {
  makeCredReq.challenge = base64url.decode(makeCredReq.challenge);
  makeCredReq.user.id = base64url.decode(makeCredReq.user.id);

  return makeCredReq
};

/**
 * Decodes arrayBuffer required fields.
 */
var preformatGetAssertReq = (getAssert) => {
  getAssert.challenge = base64url.decode(getAssert.challenge);

  for (let allowCred of getAssert.allowCredentials) {
    allowCred.id = base64url.decode(allowCred.id)
  }

  return getAssert
};

这些函数将帮助您与服务器交互。仅此而已。让我们从用户登录开始

// using the functions defined before...
getGetAssertionChallenge({name: 'your-user-name'})
.then((response) => {
  // base64 must be decoded to a JavaScript Buffer
  let publicKey = preformatGetAssertReq(response);
  // the response is then passed to the browser
  // to generate an assertion by interacting with your token/phone/etc...
  return navigator.credentials.get({publicKey})
})
.then((response) => {
  // convert response buffers to base64 and json
  let getAssertionResponse = publicKeyCredentialToJSON(response);
  // send information to server
  return sendWebAuthnResponse(getAssertionResponse)
})
.then((response) => {
  // success!
  alert('Login success')
})
.catch((error) => alert(error));

// utility functions

let sendWebAuthnResponse = (body) => {
  return fetch('/webauthn4j/response', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  })
    .then(response => {
      if (!response.ok) {
        throw new Error(`Server responded with error: ${response.statusText}`);
      }
      return response;
    })
};

let getGetAssertionChallenge = (formBody) => {
  return fetch('/webauthn4j/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(formBody)
  })
    .then(response => {
      if (!response.ok) {
        throw new Error(`Server responded with error: ${response.statusText}`);
      }
      return response;
    })
    .then((response) => response.json())
};

上述示例已经覆盖了 API 的 66%,涵盖了 3 个端点中的 2 个。最后一个端点是用户注册。用户注册是将新密钥注册到服务器凭证存储并映射到用户的过程,当然在客户端也创建了一个私钥并与服务器关联,但此密钥从未离开硬件令牌或您的手机安全芯片。

要注册用户并重用上面已定义的大部分函数

/* Handle for register form submission */
getMakeCredentialsChallenge({name: 'myalias', displayName: 'Paulo Lopes'})
.then((response) => {
  // convert challenge & id to buffer and perform register
  let publicKey = preformatMakeCredReq(response);
  // create a new secure key pair
  return navigator.credentials.create({publicKey})
})
.then((response) => {
  // convert response from buffer to json
  let makeCredResponse = window.publicKeyCredentialToJSON(response);
  // send to server to confirm the user
  return sendWebAuthnResponse(makeCredResponse)
})
.then((response) => {
  alert('Registration completed')
})
.catch((error) => alert(error));

// utility functions

let getMakeCredentialsChallenge = (formBody) => {
  return fetch('/webauthn4j/register', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(formBody)
  })
    .then(response => {
      if (!response.ok) {
        throw new Error(`Server responded with error: ${response.statusText}`);
      }
      return response;
    })
    .then((response) => response.json())
};
由于此 API 的安全性质,浏览器不允许您在纯文本 HTTP 上使用此 API。所有请求都必须通过 HTTPS。
WebAuthN 需要带有有效 TLS 证书的 HTTPS,在开发过程中您也可以使用自签名证书。

一次性密码(多因素认证)

Vert.x 也支持多因素认证。有两种使用 MFA 的选项

  • HOTP - 基于哈希的一次性密码

  • TOTP - 基于时间的一次性密码

不同提供商的使用方式相同,因此提供了一个单一处理程序,允许您在构造函数级别选择所需模式。

此处理程序的行为可视为

如果当前请求中没有 User,则假定之前没有执行过身份验证。这意味着请求将立即以状态码 401 终止。

如果存在用户且对象缺少具有匹配类型(hotp/totp)的 mfa 属性,则请求将被重定向到验证 URL(如果提供),否则将被终止。此类 URL 应提供一种输入代码的方式,例如

<html>
<head>
  <meta charset="UTF-8">
  <title>OTP Authenticator Verification Example Page</title>
</head>
<body>
<form action="/otp/verify" method="post" enctype="multipart/form-data">
  <div>
    <label>Code:</label>
    <input type="text" name="code"/><br/>
  </div>
  <div>
    <input type="submit" value="Submit"/>
  </div>
</form>
</body>
</html>

用户输入有效代码后,请求将重定向到初始 URL,如果不知道原始 URL,则重定向到 /

当然,此流程假定已配置身份验证器应用程序或设备。为了配置新的应用程序/设备,一个示例 HTML 页面可以是

<html>
<head>
  <title>OTP Authenticator Registration Example Page</title>
</head>
<body>
  <p>Scan this QR Code in Google Authenticator</p>
  <img id="qrcode">
  <p>- or enter this key manually -</p>
  <span id="url"></span>

  <script>
  const key = document.getElementById('url');
  const qrcode = document.getElementById('qrcode');

  fetch(
    '/otp/register',
    {
      method: 'POST',
      headers: {
        'Accept': 'application/json'
      }
    })
    .then(res => {
      if (res.status === 200) {
        return res;
      }
      throw new Error(res.statusText);
    })
    .catch(err => console.error(err))
    .then(res => res.json())
    .then(json => {
      key.innerText = json.url;
      qrcode.src =
        'https://chart.googleapis.com/chart?chs=166x166&chld=L|0&cht=qr&chl=' +
        encodeURIComponent(json.url);
    });
  </script>
</body>
</html>

此示例中的重要部分是脚本向配置的注册回调发出 POST 请求。同样,如果请求中没有已认证的用户,此回调将返回状态码 401。成功后,将返回一个 JSON 文档,其中包含一个 URL 和一些额外元数据。此 URL 应该用于配置身份验证器,可以通过在应用程序上手动输入,也可以通过渲染 QR 码。QR 码的渲染可以在后端或前端完成。为了简单起见,此示例利用 Google Charts API 在浏览器上渲染。

最后,这是您在 Vert.x 应用程序中使用处理程序的方式

router.post()
  .handler(BodyHandler.create());
// add a session handler (OTP requires state)
router.route()
  .handler(SessionHandler
    .create(LocalSessionStore.create(vertx))
    .setCookieSameSite(CookieSameSite.STRICT));

// add the first authentication mode, for example HTTP Basic Authentication
router.route()
  .handler(basicAuthHandler);

final OtpAuthHandler otp = OtpAuthHandler
  .create(TotpAuth.create()
    .authenticatorFetcher(authr -> {
      // fetch authenticators from a database
      // ...
      return Future.succeededFuture(new io.vertx.ext.auth.otp.Authenticator());
    })
    .authenticatorUpdater(authr -> {
      // update or insert authenticators from a database
      // ...
      return Future.succeededFuture();
    }));

otp
  // the issuer for the application
  .issuer("Vert.x Demo")
  // handle code verification responses
  .verifyUrl("/verify-otp.html")
  // handle registration of authenticators
  .setupRegisterCallback(router.post("/otp/register"))
  // handle verification of authenticators
  .setupCallback(router.post("/otp/verify"));

// secure the rest of the routes
router.route()
  .handler(otp);

// To view protected details, user must be authenticated and
// using 2nd factor authentication
router.get("/protected")
  .handler(ctx -> ctx.end("Super secret content"));

处理 HTTP 方法重写

许多公司和其他服务对其允许外部访问的 REST HTTP 方法施加限制。有些宽松,允许任何方法;大多数受限,只允许少量但不错的集合;有些只允许 GET 和 POST。这种限制的原因各不相同:浏览器或客户端限制,或非常严格的企业防火墙。只支持 GET 和 POST 的 Web 服务无法很好地表达 REST 思想。PUT、DELETE、OPTIONS 等方法对于指定对资源的操作非常有用。为了解决这个问题,创建了 X-HTTP-METHOD-OVERRIDE HTTP 头作为一种变通方法。

通过发送带有 GET/POST 方法的请求,并在 X-HTTP-METHOD-OVERRIDE HTTP 头中指定请求实际应处理的方法,服务器应识别该头并重定向到相应的方法。

Vert.x 允许这样做,只需通过

router.route().handler(MethodOverrideHandler.create());

router.route(HttpMethod.GET, "/").handler(ctx -> {
  // do GET stuff...
});

router.route(HttpMethod.POST, "/").handler(ctx -> {
  // do POST stuff...
});

由于它会重定向请求,因此明智的做法是避免不必要地触发请求处理程序,所以最好将 MethodOverrideHandler 作为第一个处理程序添加。

另外,请注意:这可能会成为恶意人员的攻击向量!

为了缓解此问题,MethodOverrideHandler 默认附带安全降级策略。此策略规定,X-HTTP-METHOD-OVERRIDE 中包含的方法可以覆盖原始方法,如果

  • 覆盖方法是幂等的;或

  • 覆盖方法是安全的,并且要被覆盖的方法不是幂等的;或

  • 要被覆盖的方法不安全。

尽管我们不推荐,Vert.x 不会强迫您做任何事。如果您希望允许任何覆盖,那么

router.route().handler(MethodOverrideHandler.create(false));

router.route(HttpMethod.GET, "/").handler(ctx -> {
  // do GET stuff...
});

router.route(HttpMethod.POST, "/").handler(ctx -> {
  // do POST stuff...
});

健康检查处理程序

Vert.x 健康检查模块提供了一种确定应用程序当前状态的方法。

将其包含到您的项目依赖项中

  • Maven(在您的 pom.xml 中)

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-health-check</artifactId>
  <version>5.0.1</version>
</dependency>
  • Gradle(在您的 build.gradle 文件中)

compile 'io.vertx:vertx-health-check:5.0.1'

然后声明 HealthCheckHandler 以通过 HTTP 公开计算的应用程序状态。

HealthCheckHandler healthCheckHandler = HealthCheckHandler.createWithHealthChecks(healthChecks);
router.get("/health*").handler(healthCheckHandler);
严格来说,使用星号并非必需。它仅在需要查询单个过程或单个组的状态时才有用。

无需事先创建 HealthChecks 实例。健康检查过程可以直接在处理程序上注册。

HealthCheckHandler healthCheckHandler = HealthCheckHandler.create(vertx);

// Register procedures
// It can be done after the route registration, or even at runtime
healthCheckHandler.register("my-procedure-name", promise -> {
  // Do the check ...
  // Upon success ...
  promise.complete(Status.OK());
  // Or, in case of failure ...
  promise.complete(Status.KO());
});

router.get("/health*").handler(healthCheckHandler);

HTTP 响应和 JSON 输出

使用 HealthCheckHandler 时,通过 HTTP 请求检索整体健康检查状态。

如果未注册任何过程,响应为 204 - NO CONTENT,表示系统状态为正常但未执行任何过程。响应不包含有效负载。

如果至少注册了一个过程,则会执行该过程并计算结果状态。响应将使用以下状态码

  • 200:一切正常

  • 503:至少一个过程报告了不健康状态

  • 500:一个过程抛出错误或未及时报告状态

内容是一个 JSON 文档,指示总体结果(outcome)。它要么是 UP,要么是 DOWN。还会提供一个 checks 数组,指示不同已执行过程的结果。如果过程报告了额外数据,也会提供这些数据。

{
 "checks" : [
 {
   "id" : "A",
   "status" : "UP"
 },
 {
   "id" : "B",
   "status" : "DOWN",
   "data" : {
     "some-data" : "some-value"
   }
 }
 ],
 "outcome" : "DOWN"
}

在分组/层级结构的情况下,checks 数组会描绘出这种结构

{
 "checks" : [
 {
   "id" : "my-group",
   "status" : "UP",
   "checks" : [
   {
     "id" : "check-2",
     "status" : "UP"
   },
   {
     "id" : "check-1",
     "status" : "UP"
   }]
 }],
 "outcome" : "UP"
}

如果过程抛出错误或报告失败(异常),JSON 文档会在 data 部分提供 cause。如果过程在超时前未返回报告,则指示的原因是 Timeout

认证

使用 HealthCheckHandler 时,您可以提供 AuthenticationProvider 来认证请求。

HealthCheckHandler healthCheckHandler = HealthCheckHandler.create(vertx, auth);
router.get("/health*").handler(healthCheckHandler);

请查阅 Vert.x Auth 文档,了解有关可用身份验证提供程序的更多详细信息。

该处理程序会创建一个包含以下内容的 JSON 对象

  • 请求头

  • 请求参数

  • 表单参数(如果有)

  • 内容(如果有)作为 JSON,并且如果请求将内容类型设置为 application/json

生成的对象会传递给身份验证提供程序以验证请求。如果身份验证失败,它将返回 403 - FORBIDDEN 响应。