微服务框架Armeria简介

在本文中,我们简要介绍了 Armeria:一个用于高效构建微服务的灵活框架。我们将了解它是什么、我们可以用它做什么以及如何使用它。

简单来说,Armeria 为我们提供了一种构建微服务客户端和服务器的简单方法,这些客户端和服务器可以使用多种协议进行通信 - 包括 REST、gRPC、Thrift和GraphQL。然而,Armeria 还提供与许多其他不同类型的技术的集成。

例如,我们支持使用Consul、Eureka或Zookeeper进行服务发现,支持使用Zipkin进行分布式跟踪,或支持与 Spring Boot、Dropwizard或RESTEasy等框架集成

依赖项
在我们可以使用 Armeria 之前,我们需要在我们的构建中包含最新版本,在撰写本文时是1.29.2 。

JetCache 带有我们需要的几个依赖项,具体取决于我们的确切需求。该功能的核心依赖项位于com.linecorp.armeria:armeria中。

如果我们使用 Maven,我们可以将其包含在pom.xml中:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria</artifactId>
    <version>1.29.2</version>
</dependency>

我们还有许多其他依赖项可用于与其他技术集成,具体取决于我们正在做的事情。

BOM 使用
由于 Armeria 提供的依赖项数量众多,我们还可以选择使用Maven BOM来管理所有版本。我们通过在项目中添加适当的依赖项管理部分来利用此功能:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.linecorp.armeria</groupId>
            <artifactId>armeria-bom</artifactId>
            <version>1.29.2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

完成此操作后,我们可以包含所需的任何 Armeria 依赖项,而不必担心为它们定义版本:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria</artifactId>
</dependency>

当我们仅使用一个依赖项时,这似乎不是很有用,但随着数量的增长,它很快变得有用。

运行服务器
一旦我们获得了适当的依赖项,我们就可以开始使用 Armeria。我们首先要看的是运行 HTTP 服务器。

Armeria 为我们提供了ServerBuilder机制来配置我们的服务器。我们可以对其进行配置,然后构建一个要启动的服务器。为此,我们所需的最低要求是:

ServerBuilder sb = Server.builder();
sb.service("/handler", (ctx, req) -> HttpResponse.of("Hello, world!"));
Server server = sb.build();
CompletableFuture<Void> future = server.start();
future.join();

这为我们提供了一个工作服务器,它在一个随机端口上运行,并带有一个硬编码的处理程序。我们很快会看到有关如何配置所有这些的更多信息。

当我们开始运行程序时,输出告诉我们 HTTP 服务器正在运行:

07:36:46.508 [main] INFO com.linecorp.armeria.common.Flags -- verboseExceptions: rate-limit=10 (default)
07:36:46.957 [main] INFO com.linecorp.armeria.common.Flags -- useEpoll: false (default)
07:36:46.971 [main] INFO com.linecorp.armeria.common.Flags -- annotatedServiceExceptionVerbosity: unhandled (default)
07:36:47.262 [main] INFO com.linecorp.armeria.common.Flags -- Using Tls engine: OpenSSL BoringSSL, 0x1010107f
07:36:47.321 [main] INFO com.linecorp.armeria.common.util.SystemInfo -- hostname: k5mdq05n (from 'hostname' command)
07:36:47.399 [armeria-boss-http-*:49167] INFO com.linecorp.armeria.server.Server -- Serving HTTP at /[0:0:0:0:0:0:0:0%0]:49167 - http://127.0.0.1:49167/

除此之外,我们现在不仅可以清楚地看到服务器正在运行,还可以看到它正在监听的地址和端口。

配置服务器
在启动服务器之前,我们可以通过多种方式来配置服务器。

其中最有用的是指定我们的服务器应侦听的端口。如果没有这个,服务器将在启动时随机选择一个可用的端口。

使用ServerBuilder.http()方法指定 HTTP 端口:

ServerBuilder sb = Server.builder();
sb.http(8080);

或者,我们可以使用ServerBuilder.https()指定我们想要的 HTTPS 端口。但是,在执行此操作之前,我们还需要配置我们的 TLS 证书。Armeria 提供了所有常见的标准支持,但也提供了自动生成和使用自签名证书的帮助程序:

ServerBuilder sb = Server.builder();
sb.tlsSelfSigned();
sb.https(8443);

添加访问日志
默认情况下,我们的服务器不会对传入请求进行任何形式的记录。这通常没有问题。例如,如果我们在负载平衡器或其他形式的代理后面运行我们的服务,这些代理本身可能会进行访问记录。

但是,如果我们愿意,我们可以直接为我们的服务添加日志支持。这是使用 ServerBuilder.accessLogWriter ()方法完成的。这需要一个AccessLogWriter实例,如果我们想自己实现它,它是一个 SAM 接口。

Armeria 为我们提供了一些我们也可以使用的标准实现,以及一些标准日志格式 – 具体来说,是 Apache 通用日志和Apache 组合日志格式:

// Apache Common Log format
sb.accessLogWriter(AccessLogWriter.common(), true);
// Apache Combined Log format
sb.accessLogWriter(AccessLogWriter.combined(), true);

Armeria 将使用 SLF4J 写出这些内容,利用我们已经为应用程序配置的任何日志后端:

07:25:16.481 [armeria-common-worker-kqueue-3-2] INFO com.linecorp.armeria.logging.access -- 0:0:0:0:0:0:0:1%0 - - 17/Jul/2024:07:25:16 +0100 "GET /#EmptyServer$$Lambda/0x0000007001193b60 h1c" 200 13
07:28:37.332 [armeria-common-worker-kqueue-3-3] INFO com.linecorp.armeria.logging.access -- 0:0:0:0:0:0:0:1%0 - - 17/Jul/2024:07:28:37 +0100
"GET /unknown#FallbackService h1c" 404 35

添加服务处理程序
一旦我们有了服务器,我们就需要向其中添加处理程序,以便它能够发挥作用。Armeria开箱即用,支持以各种形式添加标准 HTTP 请求处理程序。我们还可以添加 gRPC、Thrift 或 GraphQL 请求的处理程序,但我们需要额外的依赖项来支持这些请求。

简单处理程序
注册处理程序的最简单方法是使用ServerBuilder.service()方法。该方法接受 URL 模式和任何实现HttpService接口的内容,并在收到与提供的 URL 模式匹配的请求时提供服务:

sb.service("/handler", handler);

HttpService接口是一个 SAM 接口,这意味着我们可以使用真实类或直接使用 lambda 来实现它:

sb.service("/handler", (ctx, req) -> HttpResponse.of("Hello, world!"));

我们的处理程序必须实现HttpResponse HttpService.serve(ServiceRequestContext, HttpRequest)方法 - 要么在子类中显式实现,要么以 la​​mbda 形式隐式实现。ServiceRequestContext和HttpRequest参数都用于访问传入 HTTP 请求的不同方面,而HttpResponse返回类型表示发送回客户端的响应。

URL 模式
Armeria 允许我们使用各种不同的 URL 模式来挂载我们的服务,让我们可以灵活地根据需要访问我们的处理程序。

最直接的方法是使用一个简单的字符串 -例如/handler - 它代表这个精确的 URL 路径。

但是,我们也可以使用花括号或冒号前缀表示法来使用路径参数:

sb.service("/curly/{name}", (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("name")));
sb.service(
"/colon/:name", (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("name")));

在这里,我们可以使用ServiceRequestContext.pathParam()来获取命名路径参数的传入请求中实际存在的值。

我们还可以使用 glob 匹配来匹配任意结构化的 URL,但不包含显式路径参数。当我们这样做时,我们必须使用“ glob: ”前缀来表明我们在做什么,然后我们可以使用“*”来表示单个 URL 段,使用“”来表示任意数量的 URL 段 - 包括零个:

ssb.service("glob:/base/*/glob/"
  (ctx, req) -> HttpResponse.of(
"Hello, " + ctx.pathParam("0") + ", " + ctx.pathParam("1")));

这将匹配“ /base/a/glob ”、“ /base/a/glob/b ”甚至“ /base/a/glob/b/c/d/e ”的 URL,但不匹配“ /base/a/b/glob/c ”。我们还可以将 glob 模式作为路径参数访问,并以其位置命名。ctx.pathParam (“0”)匹配此 URL 的“*”部分,而ctx.pathParam(“1”)匹配 URL 的“**”部分。

最后,我们可以使用正则表达式来更精确地控制匹配的内容。这是使用“ regex: ”前缀完成的,之后整个 URL 模式就是一个正则表达式,用于匹配传入的请求:

sb.service("regex:^/regex/[A-Za-z]+/[0-9]+$",
  (ctx, req) -> HttpResponse.of(
"Hello, " + ctx.path()));

使用正则表达式时,我们还可以为捕获组提供名称,以使它们可用作路径参数:

sb.service("regex:^/named-regex/(?<name>[A-Z][a-z]+)$",
  (ctx, req) -> HttpResponse.of(
"Hello, " + ctx.pathParam("name")));

这将使我们的 URL 与提供的正则表达式匹配,并公开与我们的组相对应的“名称”路径参数- 一个大写字母后跟一个或多个小写字母。

配置处理程序映射
到目前为止,我们已经了解了如何进行简单的处理程序映射。我们的处理程序将对给定 URL 的任何调用做出反应,无论 HTTP 方法、标头或其他任何内容如何。

我们可以更加具体地说明如何使用流畅的 API 来匹配传入的请求。这样我们就可以只为非常特定的调用触发处理程序。我们使用ServerBuilder.route()方法来实现这一点:

sb.route()
  .methods(HttpMethod.GET)
  .path("/get")
  .produces(MediaType.PLAIN_TEXT)
  .matchesParams(
"name")
  .build((ctx, req) -> HttpResponse.of(
"Hello, " + ctx.path()));

这将仅匹配能够接受text/plain响应且具有name查询参数的 GET 请求。当传入请求不匹配时,我们还会自动获取正确的错误 - 如果请求不是 GET 请求,则为 HTTP 405 方法不允许;如果请求无法接受text/plain响应,则为 HTTP 406 不可接受。

带注释的处理程序
正如我们所见,除了直接添加处理程序外,Armeria 还允许我们提供具有适当注释方法的任意类,并自动将这些方法映射到处理程序。这可以使编写复杂的服务器变得更容易管理。

这些处理程序使用ServerBuilder.annotatedService()方法安装,提供我们的处理程序的一个实例:

sb.annotatedService(new AnnotatedHandler());

具体如何构建它取决于我们自己,这意味着我们可以为其提供其工作所需的任何依赖项。

在这个类中,我们必须使用@Get、  @Post、@Put、@Delete或任何其他适当的注释来注释方法。这些注释将要使用的 URL 映射作为参数 - 遵循与以前完全相同的规则 - 并指示注释的方法是我们的​​处理程序:

@Get("/handler")
public String handler() {
    return
"Hello, World!";
}

请注意,我们不必像以前一样遵循相同的方法签名。相反,我们可以要求将任意方法参数映射到传入的请求上,并且响应类型将映射到HttpResponse类型。

处理程序参数
我们方法的任何ServiceRequestContext、HttpRequest、RequestHeaders、QueryParams或Cookies类型的参数都将自动从请求中提供。这使我们能够以与普通处理程序相同的方式从请求中获取详细信息:

@Get("/handler")
public String handler(ServiceRequestContext ctx) {
    return
"Hello, " + ctx.path();
}

但是,我们可以让这变得更容易。Armeria 允许我们使用@Param注释任意参数,这些参数将根据请求自动填充:

@Get("/handler/{name}")
public String handler(@Param String name) {
    return
"Hello, " + name;
}

如果我们使用-parameters标志编译代码,则使用的名称将从参数名称中派生出来。如果没有,或者我们想要一个不同的名称,我们可以将其作为注释的值提供。

此注释将为我们的方法提供路径和查询参数。如果使用的名称与路径参数匹配,则这就是提供的值。如果不匹配,则使用查询参数。

默认情况下,所有参数都是必需的。如果请求中无法提供这些参数,则处理程序将不匹配。我们可以通过使用Optional<>作为参数来更改此设置,或者使用@Nullable或@Default对其进行注释。

请求主体
除了向我们的处理程序提供路径和查询参数外,我们还可以接收请求主体。Armeria 有几种方法来管理这一点,具体取决于我们的需求。

任何byte[]或HttpData类型的参数都将提供完整的、未修改的请求体,我们可以根据需要进行处理:

@Post("/byte-body")
public String byteBody(byte[] body) {
    return
"Length: " + body.length;
}

或者,任何未注释以其他方式使用的String或 CharSequence参数都将与完整的请求正文一起提供,但在这种情况下,它将根据适当的字符编码进行解码:

@Post("/string-body")
public String stringBody(String body) {
    return
"Hello: " + body;
}

最后,如果请求具有与 JSON 兼容的内容类型,则任何不是byte[]、HttpData、String、AsciiString、CharSequence或直接属于Object类型的参数,并且未注释为以其他方式使用的参数都将使用Jackson将请求主体反序列化为它。

@Post("/json-body")
public String jsonBody(JsonBody body) {
    return body.name +
" = " + body.score;
}
record JsonBody(String name, int score) {}

但是,我们可以更进一步。Armeria 为我们提供了编写自定义请求转换器的选项。这些是实现RequestConverterFunction接口的类:

public class UppercasingRequestConverter implements RequestConverterFunction {
    @Override
    public Object convertRequest(ServiceRequestContext ctx, AggregatedHttpRequest request,
        Class<?> expectedResultType, ParameterizedType expectedParameterizedResultType)
        throws Exception {
        if (expectedResultType.isAssignableFrom(String.class)) {
            return request.content(StandardCharsets.UTF_8).toUpperCase();
        }
        return RequestConverterFunction.fallthrough();
    }
}

然后,我们的转换器可以完全访问传入的请求,以生成所需的值。如果我们无法做到这一点(例如,因为请求与参数不匹配),那么我们返回RequestConverterFunction.fallthrough()以使 Armeria 继续进行默认处理。

然后我们需要确保使用了请求转换器。这是使用@RequestConverter注释完成的,该注释附加到处理程序类、处理程序方法或相关参数:

@Post("/uppercase-body")
@RequestConverter(UppercasingRequestConverter.class)
public String uppercaseBody(String body) {
    return
"Hello: " + body;
}

回应
与请求类似,我们也可以从处理函数返回任意值作为 HTTP 响应。

如果我们直接返回一个HttpResponse对象,那么这就是完整的响应。如果不是,Armeria 会将实际返回值转换为正确的类型。

按照标准,Armeria 能够进行多种标准转换:

  • null作为空响应主体,带有 HTTP 204 No Content 状态代码。
  • byte[]或HttpData作为具有application/octet-stream内容类型的原始字节。
  • 任何实现CharSequence 的内容(包括String )作为具有text/plain内容类型的 UTF-8 文本内容。
  • 任何将Jackson 的JsonNode实现为 JSON 且内容类型为application/json 的东西。

此外,如果处理程序方法用@ProducesJson或@Produces(“application/json”)注释,那么任何返回值都将使用 Jackson 转换为 JSON:

@Get("/json-response")
@ProducesJson
public JsonBody jsonResponse() {
    return new JsonBody(
"jdon", 42);
}

此外,我们还可以编写自己的自定义响应转换器,类似于编写自定义请求转换器的方式。它们实现了ResponseConverterFunction接口。它使用处理程序函数的返回值进行调用,并且必须返回一个HttpResponse对象:

public class UppercasingResponseConverter implements ResponseConverterFunction {
    @Override
    public HttpResponse convertResponse(ServiceRequestContext ctx, ResponseHeaders headers,
        @Nullable Object result, HttpHeaders trailers) {
        if (result instanceof String) {
            return HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8,
              ((String) result).toUpperCase(), trailers);
        }
        return ResponseConverterFunction.fallthrough();
    }
}

和以前一样,我们可以做任何需要的事情来产生所需的响应。如果我们无法做到这一点——例如因为返回值的类型错误——那么对ResponseConverterFucntion.fallthrough()的调用可以确保改用标准处理。

与请求转换器类似,我们需要用@ResponseConverter注释我们的函数来告诉它使用我们的新响应转换器:

@Post("/uppercase-response")
@ResponseConverter(UppercasingResponseConverter.class)
public String uppercaseResponse(String body) {
    return
"Hello: " + body;
}

我们可以将其应用于处理程序方法或整个类

异常
除了能够将任意响应转换为适当的 HTTP 响应之外,我们还可以随意处理异常。

默认情况下,Armeria 将处理一些众所周知的异常。IllegalArgumentException会产生 HTTP 400 Bad Request,HttpStatusException和HttpResponseException会转换为它们所代表的 HTTP 响应。其他任何情况都会产生 HTTP 500 Internal Server Error 响应。

但是,与处理函数的返回值一样,我们也可以编写异常转换器。它们实现了ExceptionHandlerFunction,它将抛出的异常作为输入并返回客户端的 HTTP 响应:

public class ConflictExceptionHandler implements ExceptionHandlerFunction {
    @Override
    public HttpResponse handleException(ServiceRequestContext ctx, HttpRequest req, Throwable cause) {
        if (cause instanceof IllegalStateException) {
            return HttpResponse.of(HttpStatus.CONFLICT);
        }
        return ExceptionHandlerFunction.fallthrough();
    }
}

与以前一样,它能够做任何需要的事情来产生正确的响应或者返回ExceptionHandlerFunction.fallthrough()来回退到标准处理。

和以前一样,我们在处理程序类或方法上使用@ExceptionHandler注释来连接它:

@Get("/exception")
@ExceptionHandler(ConflictExceptionHandler.class)
public String exception() {
    throw new IllegalStateException();
}

GraphQL
到目前为止,我们已经研究了如何使用 Armeria 设置 RESTful 处理程序。但它能做的远不止这些,还包括 GraphQL、Thrift 和 gRPC。

为了使用这些附加协议,我们需要添加一些额外的依赖项。例如,添加 GraphQL 处理程序需要我们将 com.linecorp.armeria :armeria-graphql依赖项添加到我们的项目中:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria-graphql</artifactId>
</dependency>

完成此操作后,我们可以使用 GraphqlService 使用 Armeria 公开 GraphQL模式:

sb.service("/graphql",
  GraphqlService.builder().graphql(buildSchema()).build());

这将从GraphQL Java 库中获取一个GraphQL实例,我们可以按照自己的意愿构建它,并将其公开在指定的端点上。

运行客户端
除了编写服务器组件之外,Armeria 还允许我们编写可以与这些(或任何)服务器通信的客户端。

为了连接到 HTTP 服务,我们使用核心 Armeria 依赖项附带的WebClient类。我们可以直接使用它而无需任何配置,轻松进行传出 HTTP 调用:

WebClient webClient = WebClient.of();
AggregatedHttpResponse response = webClient.get("http://localhost:8080/handler")
  .aggregate()
  .join();

此处对WebClient.get()的调用将向提供的 URL 发出 HTTP GET 请求,然后返回流式 HTTP 响应。然后,一旦 HTTP 响应完成,我们调用HttpResponse.aggregate()以获取完全解析的 HTTP 响应的CompletableFuture 。

一旦我们获得了AggregatedHttpResponse,我们就可以使用它来访问 HTTP 响应的各个部分:

System.out.println(response.status());
System.out.println(response.headers());
System.out.println(response.content().toStringUtf8());

如果愿意,我们还可以为特定的基本 URL创建一个WebClient :

WebClient webClient = WebClient.of("http://localhost:8080");
AggregatedHttpResponse response = webClient.get(
"/handler")
  .aggregate()
  .join();

当我们需要从配置中提供基本 URL 时,这尤其有用,但我们的应用程序可以理解我们在下面调用的 API 的结构。

我们还可以使用此客户端发出其他请求。例如,我们可以使用WebClient.post()方法发出 HTTP POST 请求,并提供请求主体:

WebClient webClient = WebClient.of();
AggregatedHttpResponse response = webClient.post("http://localhost:8080/uppercase-body", "jdon")
  .aggregate()
  .join();

关于此请求的所有其他内容完全相同,包括我们如何处理响应。

复杂请求
我们已经了解了如何发出简单的请求,但更复杂的情况呢?到目前为止,我们看到的方法实际上只是对execute()方法的包装,这使我们能够提供更复杂的 HTTP 请求表示:

WebClient webClient = WebClient.of("http://localhost:8080");
HttpRequest request = HttpRequest.of(
  RequestHeaders.builder()
    .method(HttpMethod.POST)
    .path(
"/uppercase-body")
    .add(
"content-type", "text/plain")
    .build(),
  HttpData.ofUtf8(
"jdon"));
AggregatedHttpResponse response = webClient.execute(request)
  .aggregate()
  .join();

在这里我们可以看到如何根据需要详细地指定传出 HTTP 请求的所有不同部分。

我们还有一些辅助方法可以使此操作更加简单。例如,我们可以使用contentType()等方法,而不是使用add()来指定任意 HTTP 标头。这些方法更易于使用,而且类型更安全:

HttpRequest request = HttpRequest.of(
  RequestHeaders.builder()
    .method(HttpMethod.POST)
    .path("/uppercase-body")
    .contentType(MediaType.PLAIN_TEXT_UTF_8)
    .build(),
  HttpData.ofUtf8(
"jdon"));

我们可以在这里看到contentType()方法需要一个MediaType对象而不是纯字符串,所以我们知道我们传递了正确的值。

客户端配置
我们还可以使用许多配置参数来调整客户端本身。我们可以在构建WebClient时使用ClientFactory来配置这些参数。

ClientFactory clientFactory = ClientFactory.builder()
  .connectTimeout(Duration.ofSeconds(10))
  .idleTimeout(Duration.ofSeconds(60))
  .build();
WebClient webClient = WebClient.builder("http://localhost:8080")
  .factory(clientFactory)
  .build();

在这里,我们将底层 HTTP 客户端配置为在连接到 URL 时有 10 秒的超时时间,并在 60 秒不活动后关闭底层连接池中打开的连接。