阅读(243) (0)

Micronaut HTTP路由

2023-02-23 11:19:16 更新

上一节中使用的 @Controller 注释是允许您控制 HTTP 路由构造的几个注释之一。

URI 路径

@Controller 注释的值是一个 RFC-6570 URI 模板,因此您可以使用 URI 模板规范定义的语法在路径中嵌入 URI 变量。

许多其他框架,包括 Spring,都实现了 URI 模板规范

实际实现由扩展 UriTemplate 的 UriMatchTemplate 类处理。

您可以在应用程序中使用此类来构建 URI,例如:

使用 UriTemplate

 Java Groovy  Kotlin 
UriMatchTemplate template = UriMatchTemplate.of("/hello/{name}");

assertTrue(template.match("/hello/John").isPresent()); // (1)
assertEquals("/hello/John", template.expand( // (2)
        Collections.singletonMap("name", "John")
));
given:
UriMatchTemplate template = UriMatchTemplate.of("/hello/{name}")

expect:
template.match("/hello/John").isPresent() // (1)
template.expand(["name": "John"]) == "/hello/John" // (2)
val template = UriMatchTemplate.of("/hello/{name}")

assertTrue(template.match("/hello/John").isPresent) // (1)
assertEquals("/hello/John", template.expand(mapOf("name" to "John")))  // (2)
  1. 使用 match 方法匹配路径

  2. 使用 expand 方法将模板扩展为 URI

您可以使用 UriTemplate 构建路径以包含在您的响应中。

URI 路径变量

URI 变量可以通过方法参数引用。例如:

URI 变量示例

 Java Groovy  Kotlin 
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;

@Controller("/issues") // (1)
public class IssuesController {

    @Get("/{number}") // (2)
    public String issue(@PathVariable Integer number) { // (3)
        return "Issue # " + number + "!"; // (4)
    }
}
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable

@Controller("/issues") // (1)
class IssuesController {

    @Get("/{number}") // (2)
    String issue(@PathVariable Integer number) { // (3)
        "Issue # " + number + "!" // (4)
    }
}
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable

@Controller("/issues") // (1)
class IssuesController {

    @Get("/{number}") // (2)
    fun issue(@PathVariable number: Int): String { // (3)
        return "Issue # $number!" // (4)
    }
}
  1. @Controller 注释使用 /issues 的基本 URI 指定

  2. Get 注释将方法映射到 HTTP GET,URI 变量嵌入在名为 number 的 URI 中

  3. 方法参数可以选择用 PathVariable 注释

  4. URI变量的值在实现中被引用

Micronaut 为上述控制器映射 URI /issues/{number}。我们可以通过编写单元测试来断言这种情况:

测试 URI 变量

 Java Groovy  Kotlin 
import io.micronaut.context.ApplicationContext;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.runtime.server.EmbeddedServer;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class IssuesControllerTest {

    private static EmbeddedServer server;
    private static HttpClient client;

    @BeforeClass // (1)
    public static void setupServer() {
        server = ApplicationContext.run(EmbeddedServer.class);
        client = server
                    .getApplicationContext()
                    .createBean(HttpClient.class, server.getURL());
    }

    @AfterClass // (2)
    public static void stopServer() {
        if (server != null) {
            server.stop();
        }
        if (client != null) {
            client.stop();
        }
    }

    @Test
    public void testIssue() {
        String body = client.toBlocking().retrieve("/issues/12"); // (3)

        assertNotNull(body);
        assertEquals("Issue # 12!", body); // (4)
    }

    @Test
    public void testShowWithInvalidInteger() {
        HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () ->
                client.toBlocking().exchange("/issues/hello"));

        assertEquals(400, e.getStatus().getCode()); // (5)
    }

    @Test
    public void testIssueWithoutNumber() {
        HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () ->
                client.toBlocking().exchange("/issues/"));

        assertEquals(404, e.getStatus().getCode()); // (6)
    }
}
import io.micronaut.context.ApplicationContext
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.runtime.server.EmbeddedServer
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification

class IssuesControllerTest extends Specification {

    @Shared
    @AutoCleanup // (2)
    EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer) // (1)

    @Shared
    @AutoCleanup // (2)
    HttpClient client = HttpClient.create(embeddedServer.URL) // (1)

    void "test issue"() {
        when:
        String body = client.toBlocking().retrieve("/issues/12") // (3)

        then:
        body != null
        body == "Issue # 12!" // (4)
    }

    void "/issues/{number} with an invalid Integer number responds 400"() {
        when:
        client.toBlocking().exchange("/issues/hello")

        then:
        def e = thrown(HttpClientResponseException)
        e.status.code == 400 // (5)
    }

    void "/issues/{number} without number responds 404"() {
        when:
        client.toBlocking().exchange("/issues/")

        then:
        def e = thrown(HttpClientResponseException)
        e.status.code == 404 // (6)
    }
}
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.micronaut.context.ApplicationContext
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.runtime.server.EmbeddedServer

class IssuesControllerTest: StringSpec() {

    val embeddedServer = autoClose( // (2)
        ApplicationContext.run(EmbeddedServer::class.java) // (1)
    )

    val client = autoClose( // (2)
        embeddedServer.applicationContext.createBean(
            HttpClient::class.java,
            embeddedServer.url) // (1)
    )

    init {
        "test issue" {
            val body = client.toBlocking().retrieve("/issues/12") // (3)

            body shouldNotBe null
            body shouldBe "Issue # 12!" // (4)
        }

        "test issue with invalid integer" {
            val e = shouldThrow<HttpClientResponseException> {
                client.toBlocking().exchange<Any>("/issues/hello")
            }

            e.status.code shouldBe 400 // (5)
        }

        "test issue without number" {
            val e = shouldThrow<HttpClientResponseException> {
                client.toBlocking().exchange<Any>("/issues/")
            }

            e.status.code shouldBe 404 // (6)
        }
    }
}
  1. 嵌入式服务器和 HTTP 客户端启动

  2. 测试完成后清理服务器和客户端

  3. 测试向 URI /issues/12 发送请求

  4. 然后断言响应是“Issue #12”

  5. 另一个测试断言当在 URL 中发送无效数字时返回 400 响应

  6. 另一个测试断言,当 URL 中未提供数字时,将返回 404 响应。要执行的路由需要存在的变量。

请注意,前面示例中的 URI 模板要求指定数字变量。您可以使用以下语法指定可选的 URI 模板:/issues{/number} 并使用 @Nullable 注释数字参数。

下表提供了 URI 模板示例及其匹配项:

表 1. URI 模板匹配
模板 描述 匹配 URI

/books/{id}

简单匹配

/books/1

/books/{id:2}

最多两个字符的变量

/books/10

/books{/id}

一个可选的 URI 变量

/books/10 or /books

/book{/id:[a-zA-Z]+}

带有正则表达式的可选 URI 变量

/books/foo

/books{?max,offset}

可选查询参数

/books?max=10&offset=10

/books{/path:.*}{.ext}

正则表达式路径与扩展匹配

/books/foo/bar.xml

URI 保留字符匹配

默认情况下,RFC-6570 URI 模板规范定义的 URI 变量不能包含保留字符,例如 /、? 等等。

如果您希望匹配或扩展整个路径,这可能会有问题。根据规范的第 3.2.3 节,您可以使用 + 运算符使用保留扩展或匹配。

例如,URI /books/{+path} 与 /books/foo 和 /books/foo/bar 都匹配,因为 + 指示变量路径应包含保留字符(在本例中为 /)。

路由注释

前面的示例使用 @Get 注释添加了一个接受 HTTP GET 请求的方法。下表总结了可用的注释以及它们如何映射到 HTTP 方法:

表 2. HTTP 路由注释
注解 HTTP 方法

@Delete

DELETE

@Get

GET

@Head

HEAD

@Options

OPTIONS

@Patch

PATCH

@Put

PUT

@Post

POST

@Trace

TRACE

所有方法注解默认为/。

多个 URI

每个路由注释都支持多个 URI 模板。对于每个模板,都会创建一条路线。此功能非常有用,例如更改 API 的路径并保留现有路径以实现向后兼容性。例如:

多个 URI

 Java Groovy  Kotlin 
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;

@Controller("/hello")
public class BackwardCompatibleController {

    @Get(uris = {"/{name}", "/person/{name}"}) // (1)
    public String hello(String name) { // (2)
        return "Hello, " + name;
    }
}
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get

@Controller("/hello")
class BackwardCompatibleController {

    @Get(uris = ["/{name}", "/person/{name}"]) // (1)
    String hello(String name) { // (2)
        "Hello, $name"
    }
}
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get

@Controller("/hello")
class BackwardCompatibleController {

    @Get(uris = ["/{name}", "/person/{name}"]) // (1)
    fun hello(name: String): String { // (2)
        return "Hello, $name"
    }
}
  1. 指定多个模板

  2. 像往常一样绑定到模板参数

多个模板的路由验证更加复杂。如果一个通常需要的变量在所有模板中都不存在,则该变量被认为是可选的,因为它可能不会在每次执行该方法时都存在。

以编程方式构建路线

如果您不喜欢使用注解而是在代码中声明所有路由,那么不要担心,Micronaut 有一个灵活的 RouteBuilder API,可以轻松地以编程方式定义路由。

首先,继承 DefaultRouteBuilder 并将要路由到的控制器注入到该方法中,然后定义您的路由:

URI 变量示例

 Java Groovy  Kotlin 
import io.micronaut.context.ExecutionHandleLocator;
import io.micronaut.web.router.DefaultRouteBuilder;

import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
public class MyRoutes extends DefaultRouteBuilder { // (1)

    public MyRoutes(ExecutionHandleLocator executionHandleLocator,
                    UriNamingStrategy uriNamingStrategy) {
        super(executionHandleLocator, uriNamingStrategy);
    }

    @Inject
    void issuesRoutes(IssuesController issuesController) { // (2)
        GET("/issues/show/{number}", issuesController, "issue", Integer.class); // (3)
    }
}
import io.micronaut.context.ExecutionHandleLocator
import io.micronaut.core.convert.ConversionService
import io.micronaut.web.router.GroovyRouteBuilder

import jakarta.inject.Inject
import jakarta.inject.Singleton

@Singleton
class MyRoutes extends GroovyRouteBuilder { // (1)

    MyRoutes(ExecutionHandleLocator executionHandleLocator,
             UriNamingStrategy uriNamingStrategy,
             ConversionService conversionService) {
        super(executionHandleLocator, uriNamingStrategy, conversionService)
    }

    @Inject
    void issuesRoutes(IssuesController issuesController) { // (2)
        GET("/issues/show/{number}", issuesController.&issue) // (3)
    }
}
import io.micronaut.context.ExecutionHandleLocator
import io.micronaut.web.router.DefaultRouteBuilder
import io.micronaut.web.router.RouteBuilder
import jakarta.inject.Inject
import jakarta.inject.Singleton

@Singleton
class MyRoutes(executionHandleLocator: ExecutionHandleLocator,
               uriNamingStrategy: RouteBuilder.UriNamingStrategy) :
        DefaultRouteBuilder(executionHandleLocator, uriNamingStrategy) { // (1)

    @Inject
    fun issuesRoutes(issuesController: IssuesController) { // (2)
        GET("/issues/show/{number}", issuesController, "issue", Int::class.java) // (3)
    }
}
  1. 路由定义应该是 DefaultRouteBuilder 的子类

  2. 使用@Inject 注入一个方法与控制器路由到

  3. 使用 RouteBuilder::GET(String,Class,String,Class… ) 等方法路由到控制器方法。请注意,即使使用问题控制器,路由也不知道其@Controller 注释,因此必须指定完整路径。

不幸的是,由于类型擦除,Java 方法 lambda 引用不能与 API 一起使用。对于 Groovy,有一个可以子类化的 GroovyRouteBuilder 类,它允许传递 Groovy 方法引用。

路由编译时验证

Micronaut 支持在编译时使用验证库验证路由参数。首先,将验证依赖项添加到您的构建中:

build.gradle

annotationProcessor "io.micronaut:micronaut-validation" // Java only
kapt "io.micronaut:micronaut-validation" // Kotlin only
implementation "io.micronaut:micronaut-validation"

通过对类路径的正确依赖,路由参数将在编译时自动检查。如果满足以下任一条件,编译将失败:

  • URI 模板包含一个可选的变量,但方法参数未使用 @Nullable 进行注释或者是一个 java.util.Optional。

可选变量是允许路由与 URI 匹配的变量,即使该值不存在也是如此。例如 /foo{/bar} 匹配对 /foo 和 /foo/abc 的请求。非可选变体是 /foo/{bar}。

  • URI 模板包含方法参数中缺少的变量。

要禁用路由编译时验证,请设置系统属性 -Dmicronaut.route.validation=false。对于使用 Gradle 的 Java 和 Kotlin 用户,可以通过从 annotationProcessor/kapt 范围中移除验证依赖来实现相同的效果。

路由非标准 HTTP 方法

@CustomHttpMethod 注释支持客户端或服务器的非标准 HTTP 方法。 RFC-4918 Webdav 等规范需要额外的方法,例如 REPORT 或 LOCK。

路由示例

@CustomHttpMethod(method = "LOCK", value = "/{name}")
String lock(String name)

注释可以在任何可以使用标准方法注释的地方使用,包括控制器和声明性 HTTP 客户端。