マイクロサービス・フレームワーク Armeria の始め方

Armeria とは

マイクロサービス用のネットワーク・ライブラリです。

Netty をベースにして、主に以下の機能が提供されます。

  • メトリクス
  • サーキットブレーカー
  • ヘルスチェックと負荷分散、サービスディスカバリへの応答
  • 分散呼トレーシング(Zipkin)
  • HTTP/1, HTTP/2
  • gRPC / Thrift との統合


Armeria の始め方

簡単な例でAmeria を動かして雰囲気を掴みましょう。

Gradle でプロジェクトを作成します。

$ mkdir armeria-example
$ cd armeria-example
$ gradle init --type java-application --dsl kotlin --test-framework junit-jupiter --project-name armeria-example --package armeria.example

build.gradle.ktscom.linecorp.armeria:armeria の依存を追加します。

plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(platform("com.linecorp.armeria:armeria-bom:1.25.2"))  
    implementation("com.linecorp.armeria:armeria")
    implementation("org.slf4j:slf4j-simple:2.0.9")
}

tasks.withType<JavaCompile> {
    options.compilerArgs.add("-parameters")
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

application {
    mainClass.set("armeria.example.App")
}

options.compilerArgs.add("-parameters") は、後述の @Param でパラメータ名を省略する場合に必要になるため、ここであらかじめ設定しておきます。

App.java を以下のように編集します。

package armeria.example;

import com.linecorp.armeria.server.Server;  
import com.linecorp.armeria.server.ServerBuilder;  
import com.linecorp.armeria.common.HttpResponse;
import java.util.concurrent.CompletableFuture;
  
public class App {  
  
    public static void main(String[] args) {  
  
        ServerBuilder sb = Server.builder().http(8080);  
  
        sb.service("/greet/{name}", (ctx, req) ->  
                HttpResponse.of("Hello, %s!", ctx.pathParam("name")));  
  
        Server server = sb.build();
        server.closeOnJvmShutdown();
        CompletableFuture<Void> future = server.start();
        future.join();
    }  
}

./gradlew run して http://localhost:8080/greet/armeria にアクセスすれば以下のような結果が得られます。

ServerBuilder でサーバの設定を行います。 service()(または annotatedService(), serviceUnder()など)で、リクエストパスに該当する処理を登録します。

サービスには、デコレータにより、メトリクスや、分散トレースなどの処理を追加できます。


サービス

service() にてリクエストパスと紐づけてサービスを登録します。

以下の例では、GET リクエストに応答するサービスを登録しています。

sb.service("/greet/{name}", new AbstractHttpService() {
    @Override
    protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) {
        String name = ctx.pathParam("name");
        return HttpResponse.of("Hello, %s!", name);
    }
});

service() では、指定したパスの完全一致にマップされます。

パスの前方一致でマップする場合には serviceUnder を使い、以下のようにできます。

sb.serviceUnder("/greet", (ctx, req) -> {
    String path = ctx.mappedPath();  // Get the path without the prefix ('/greet3')
    // ...
});

JAX-RS のようにアノテーションでリクエストとメソッドの紐づけを行うには annotatedService() を使います。

sb.annotatedService(new Object() {
    @Get("/greet")
    public HttpResponse greetGet(@Param("name") String name) {
        return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8, "{\"name\":\"%s\"}", name);
    }

    @Post("/greet")
    @Consumes(MediaTypeNames.FORM_DATA)
    public HttpResponse greetPost(@Param("name") String name) {
        return HttpResponse.of(HttpStatus.OK);
    }
});

@Get@Post などのHTTPメソッドに応じたアノテーションで、該当パスに応答するメソッドを定義します。

コンパイラオプションで -parameters を付与し、コンパイル後の引数名が有効になっていれば以下のようにパラメータ名を省略することができます。

@Get("/greet")
public HttpResponse greetGet(@Param String name) {
}


Route

ルーティングの定義は ServiceBindingBuilder で以下のようにラムダで定義することもできます。

ServerBuilder sb = Server.builder();
sb.withRoute(builder -> builder.path("/baz")
                               .methods(HttpMethod.GET, HttpMethod.POST)
                               .build((ctx, req) -> HttpResponse.of(OK)));

ServiceBindingBuilderroute() で直接取得して、以下のように定義することもできます。

sb.route()                    // Configure the service.
  .post("/foo/bar")           // Matched when the path is "/foo/bar" and the method is POST.
  .consumes(MediaType.JSON)   // Matched when the "content-type" header is "application/json".
  .produces(MediaType.JSON)   // Matched when the "accept" headers is "application/json".
  .matchesHeaders("baz=qux")  // Matched when the "baz" header is "qux".
  .matchesParams("quux=quuz") // Matched when the "quux" parameter is "quuz".
  .requestTimeoutMillis(5000)
  .maxRequestLength(8192)
  .verboseResponses(true)
  .build((ctx, req) -> HttpResponse.of(OK));


デコレータ

リクエスト/レスポンスをインターセプトして様々な処理を追加するにはデコレータを使います。

デコレータを適用するには以下の 3 つの方法があります。

  • DecoratingHttpServiceFunction または DecoratingRpcServiceFunction を実装する
  • SimpleDecoratingHttpService または SimpleDecoratingRpcService を継承する
  • DecoratingService を継承する

DecoratingHttpServiceFunction

service.decorate()DecoratingHttpServiceFunction をラムダで渡すことで、サービスをデコレートできます。

HttpService service = (ctx, req) -> HttpResponse.of("Hello, world!");

HttpService decorated = service.decorate((delegate, ctx, req) -> {
    System.out.println(req.path());
    return delegate.serve(ctx, req);
});

sb.service("/greet", decorated);

SimpleDecoratingHttpService

SimpleDecoratingHttpService を継承してデリケートサービスを作成できます。

public class SoutService extends SimpleDecoratingHttpService {
    public SoutService(HttpService delegate) {
        super(delegate);
    }

    @Override
    public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
        System.out.println(req.path());
        HttpService delegate = unwrap();
        return delegate.serve(ctx, req);
    }
}

service.decorate() で作成したデリケートサービスを適用します。

sb.service("/greet", service.decorate(delegate -> new SoutService(delegate)));

DecoratingService

より低レベルな処理が必要な場合は、DecoratingService を継承してサービスを作成します。

以下は、HTTPのやり取りを、独自プロトコルの RPC に変換して処理する例になります。

import com.linecorp.armeria.server.RpcService;

// Transforms an RpcService into an HttpService.
public class MyRpcService
    extends DecoratingService<RpcRequest, RpcResponse, HttpRequest, HttpResponse> {

    public MyRpcService(Service<? super RpcRequest, ? extends RpcResponse> delegate) {
        super(delegate);
    }

    @Override
    public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
        // This method has been greatly simplified for easier understanding.
        // In reality, we will have to do this asynchronously.
        RpcRequest rpcReq = convertToRpcRequest(req);
        RpcResponse rpcRes = unwrap().serve(ctx, rpcReq);
        return convertToHttpResponse(rpcRes);
    }

    private RpcRequest convertToRpcRequest(HttpRequest req) { ... }
    private HttpResponse convertToHttpResponse(RpcResponse res) { ... }
}


クライアント

リクエストを発行するクライアントは、WebClient を使います。

WebClient webClient = WebClient.of("http://example.com/");
CompletableFuture<AggregatedHttpResponse> res =
        webClient.get("/foo/bar.txt").aggregate()
res.join();

aggregate() により得る CompletableFuture で、レスポンス(ヘッダフィールド、ボディ、トレーラフィールド)を完全に受信した時点でコールバックされます。

HTTP POST の場合は以下のようにできます。

AggregatedHttpResponse res = webClient.post("/upload", "{ \"foo\": \"bar\" }")
        .aggregate().join();

ベースURLを指定せず、個別にURLを指定することもできます。

WebClient webClient = WebClient.of();
AggregatedHttpResponse res = webClient.get("http://example.com/foo/bar.txt")
        .aggregate().join();

リクエストヘッダを指定するには RequestHeadersBuilder を使い、以下のように指定することができます。

HttpRequest req = HttpRequest.of(RequestHeaders.builder()
        .scheme(SessionProtocol.HTTP)
        .authority("example.com")
        .method(HttpMethod.GET)
        .path("/foo/bar.txt")
        .build());
AggregatedHttpResponse res = webClient.execute(req).aggregate().join();

リダイレクトは以下のように処理することができます。

AggregatedHttpResponse res = webClient.get("http://example.com/redirect")
        .aggregate().join();
if (res.status() == HttpStatus.TEMPORARY_REDIRECT) {
    res = webClient.get(redirected.headers().get(HttpHeaderNames.LOCATION))
            .aggregate().join();
}

サービスディスカバリでエンドポイントを特定する場合は、以下のように WebClient を構成することができます。

DnsServiceEndpointGroup group =
    DnsServiceEndpointGroup.of("k8s.default.svc.cluster.local.");

HealthCheckedEndpointGroup healthCheckedGroup =
        HealthCheckedEndpointGroup.of(group, "/monitor/l7check");

WebClient.builder(SessionProtocol.HTTP, healthCheckedGroup)
         ...
         .build();

WebClientEndpointGroup を指定することで、動的に検索されたエンドポイントに対してリクエストが送られます。


クライアント デコレータ

WebClient ビルダにデコレータを指定することで、リクエストをカスタマイズできます。

WebClient.builder()
        .decorator(ConcurrencyLimitingClient.newDecorator(64))
        .decorator(ContentPreviewingClient.newDecorator(128))
        .decorator(RetryingClient.newDecorator(RetryRule.onUnprocessed()))
        .build();

以下のようなデコレータが準備されています。

  • BraveClient Braveによるアウトバウンドリクエストの追跡
  • CircuitBreakerClient サーキットブレーカー
  • CookieClient Cookie を保存
  • ConcurrencyLimitingClient 同時リクエスト数制限
  • ContentPreviewingClientリクエストとレスポンスの内容をプレビュー
  • DecodingClient 圧縮されたレスポンスを解凍
  • MetricCollectingClient Micrometerによるメトリクスを収集
  • LoggingClient リクエストとレスポンスをロギング
  • RetryingClient 失敗リクエストのリトライ


gRPC サービス

gRPC サービスを Armeria に統合することができます。

build.gradle.ktscom.linecorp.armeria:armeria-grpc の依存を追加し、Gradle com.google.protobuf プラグインを準備します。

import com.google.protobuf.gradle.*
plugins {
    application
    id("com.google.protobuf") version "0.9.4"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(platform("com.linecorp.armeria:armeria-bom:1.25.2"))
    implementation("com.linecorp.armeria:armeria")
    implementation("com.linecorp.armeria:armeria-grpc")
    implementation("javax.annotation:javax.annotation-api:1.3.1")
    implementation("org.slf4j:slf4j-simple:2.0.9")
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

tasks.withType<JavaCompile> {
    options.compilerArgs.add("-parameters")
}

application {
    mainClass.set("armeria.example.App")
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.6.1"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:1.15.1"
        }
    }
    generateProtoTasks {
        ofSourceSet("main").forEach {
            it.plugins {
                id("grpc") { }
            }
        }
    }
}

proto ファイルを作成し、

$ mkdir -p src/main/proto/
$ touch src/main/proto/hello.proto

以下のように編集します。

syntax = "proto3";

package grpc.hello;

option java_package = "com.example.grpc.hello";

service HelloService {
  rpc Hello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

HelloService というサービスは、文字列を含むリクエストを受け、文字列を含むレスポンスを返すものとして定義しています。

proto3 の文法については以下を参照してください。

https://blog1.mammb.com/entry/2019/10/03/212044

./gradlew build で  IDL から Grpc 向けのソースが自動生成されるので、これを利用してサービスを実装します。

生成された HelloServiceGrpc.HelloServiceImplBase を継承して HelloServiceImpl を作成します。

import armeria.example.grpc.hello.Hello.*;
import armeria.example.grpc.hello.HelloServiceGrpc;
import io.grpc.stub.StreamObserver;

public class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
    @Override
    public void hello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
        HelloReply reply = HelloReply.newBuilder()
                .setMessage("Hello, " + req.getName() + '!')
                .build();
        responseObserver.onNext(reply);
        responseObserver.onCompleted();
    }
}

作成した Grpc サービスは以下のように Armeria のサービスとして登録できます。

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

sb.service(GrpcService.builder()
        .addService(new HelloServiceImpl())
        .build());

Server server = sb.build();
server.closeOnJvmShutdown();
server.start().join();

Grpc のクライアントは GrpcClients から以下のように生成してサービスの呼び出しが可能です。

HelloServiceBlockingStub helloService = GrpcClients.newClient(
        "gproto+http://127.0.0.1:8080/", HelloServiceBlockingStub.class);

HelloRequest request = HelloRequest.newBuilder().setName("Armerian World").build();
HelloReply reply = helloService.hello(request);

System.out.println(reply.getMessage());

クライアントからサービスを呼び出せば、以下のような出力が得られます。

Hello, Armerian World!


まとめ

マイクロサービス・フレームワーク Armeria の使い方について簡単に紹介しました。

ここでは紹介しきれませんが、マイクロサービス向けの各種機能が準備されています。

ごく薄いライブラリで、他のテクノロジとの統合も容易になっているので、ドキュメントを一読してみてはいかがでしょうか。