Vert.X Web 开发实战

1,293 阅读6分钟

我个人很喜欢 Vert.X ,它速度快、生态丰富、代码质量高、技术前沿,相比 Spring 它:

  • 没有同步,只有响应式编程
  • 没有注解(声明式编程)和反射、代理(IOC、AOP)魔法
  • 堆栈更少,打包体积更小,启动速度更快
  • 工程结构灵活
  • 有趣
  • 速度更快 (这里是压测数据证明

⭐目前 vert.x 发行版本是 vert.x 4,不过 vert.x 5 正在开发中(2023年底发行),其中一点是集成 Netty 5 和虚拟线程。可以在 Wiki Vert.x 5 中查看更多关于 vert.x 5 的事情。
Vert.x 5 base on Java11 ,Spring 6 base on Java 17。

对于生产环境下的业务开发,我们已经习惯了(Spring的)DI、命令式编程、项目结构和编程规范,无论是开发 web 服务还是 rpc 服务,我们都很熟悉,控制器、拦截器、服务层、数据层、配置、依赖管理、异常处理、如何调用其他对象/服务/数据库。。。

但是对于 Vert.X,它不是一套框架方案,它没有约定俗成的项目结构或编程规范,也没有像 Spring Boot 那样替你考虑并完成了默认的配置和服务,所以我写了这篇文章来总结下 Vert.X Web 开发中(自我感觉)不错的项目结构或编程规范。

💡如果你想寻找一个像 Spring 那样的框架来代替 Spring 可以考虑:

  • Helidon - Oracle 支持(没错开发Java的那群人做的),目前 Helidon 3 基于 Netty,下一代 Helidon 4 (2023年底发行)将抛弃 Netty 基于虚拟线程。
  • Quarkus - RedHat 支持,基于 Vert.X。

Spring-Web 提供的功能 Vert.x-Web 都可以做到,但是以 vert.x style 做到。

我寻找 Spring(或着说传统的) Web 开发中常见的主题/编程规范,探索它们发生在 Vert.X 中该怎么做。


如果你想,这里有一个简单基于 Vert.X 的 REST 应用案例 rest data faker,可以看看。

项目结构

类似传统的 Spring Boot 项目结构,各个功能模块可以按划分

  • MainVerticle - 作为应用启动准备工作,由 Launcher 加载执行,在它的 start 方法里可以加载配置、启动HTTP服务器、配置路由、部署其他 verticles 等
  • config - 一些配置
  • web - Web 相关的工作,比如数据模型、异常处理器、身份验证处理器等
  • exception - 一些自定义异常
  • *Handler - 自定义的路由处理器,类似 spring 中的控制器类。调用其他服务层方法可使用 EventBus 通信,可以定义一个 void mount(Router router) 方法来配置路由
  • Handler 配置顺序 - 可参照 What's new in Vert.x 4.3 Vertx Web 章节
  • 垂直拆分 - 可以按照应用进行垂直拆分,把相关的 Web 层、服务层、数据访问层、模型类等放到一个包里
  • util - 一些工具类
  • constant - 一些常量
  • annotation - 一些注解

关于启动,可以使用 vert.x 的 Launcher 类,就像 start.vertx.io/ 默认的那样;也可以自定义含有 main 方法的启动类。

统一响应数据模型

跟 Spring 的一样,可以自定义统一的数据模型,比如:

{
    "status": 200,
    "message": "OK",
    "data": {}
}

一般有两种请求响应模型方式:

  • 请求成功时直接返回数据,失败时返回带有错误信息的数据模型
  • 请求成功和失败时返回相同的数据模型

一般大部分场景用第二种。

在路由处理器的最后直接调用 context.json(data) 即可返回 200 json 数据。

异常处理

跟 Spring 一样,我们需要一些东西:

  • StatusResponseException - 包含了响应状态码和失败信息的运行时异常,可以被异常处理器处理返回上边定义的统一响应数据模型。它可以有很多子类,来具体定义一些常见的情况,比如资源不存在、请求参数错误、身份认证异常等

  • ExceptionHandler - 异常处理器,拿到 RoutingContext 中的异常,根据异常设置具体的响应状态码和数据,当然还可以配置响应头

  • 配置异常处理器 - 在配置 Router 添加两行代码:

        router.route("/api/*").failureHandler(ExceptionHandler.create());
        router.route().failureHandler(ErrorHandler.create(vertx));
    
  • 使用 - 跟 Spring 一样,在 Handler<RoutingContext> 中直接 throw StatusResponseException 异常既可以中断当前的路由处理,转而由 ExceptionHandler 处理异常返回响应。

简单的异常处理示例

这是一个简单的异常处理示例,相关的异常类定义如下:

public class StatusResponseException extends RuntimeException{
    
    private final int status;

    private final String reason;

    public StatusResponseException(int status) {
        this(status, null);
    }

    public StatusResponseException(int status, String reason){
        this(status, reason, null);
    }

    public StatusResponseException(int status, String reason, Throwable t){
        super(reason, t);
        this.status = status;
        this.reason = reason;
    }

    public int status(){
        return status;
    }

    public String reason(){
        return reason;
    }

    public static StatusResponseException create(int status) {
        return new StatusResponseException(status);
    }

    public static StatusResponseException create(int status, String reason, Object...args ) {
        return new StatusResponseException(status, String.format(reason, args));
    }
}

异常处理器

@Slf4j
public class ExceptionHandler implements Handler<RoutingContext> {

    public static final String PROBLEM_CONTENT_TYPE = "application/problem+json";

    public static ExceptionHandler create() {
        return new ExceptionHandler();
    }

    @Override
    public void handle(RoutingContext ctx) {
        if (ctx.response().ended() ) return;

        Throwable t = ctx.failure();
        if (t instanceof StatusResponseException e) {
            end(ctx, e.status(), e.reason(), null);
        } else if (t != null) {
            HttpServerRequest request = ctx.request();
            log.warn("Handle {} {} happened a exception", request.method(), request.path(), t);
            int statusCode = ctx.statusCode() == -1 ? 500 : ctx.statusCode();
            end(ctx, statusCode, "服务器内部错误", t.getMessage());
        } else {
            int statusCode = ctx.statusCode() == -1 ? 500 : ctx.statusCode();
            ctx.response().setStatusCode(statusCode).end();
        }
    }


    private static void end(RoutingContext ctx, int statusCode, String message, String detail) {
        if (ctx.response().ended()) return;
        JsonObject error = new JsonObject()
                .put("status", statusCode)
                .put("message", message)
                .put("detail", detail)
                .put("path", ctx.request().path())
                .put("timestamp", System.currentTimeMillis());

        ctx.response().setStatusCode(statusCode);
        ctx.response().putHeader("Content-Type", PROBLEM_CONTENT_TYPE);
        ctx.json(error);
    }
}

在处理异常时,

  1. 首先应该判断 RoutingContext 的响应写入是否已经关闭,否则在执行写入会报错
  2. RoutingContext 中拿出异常,如果为自定义的 StatusResponseException,则根据异常构造包含错误信息的数据并返回
  3. 如果为其他异常,比如一些数据库错误或别的运行时异常,则构造默认的500异常返回。一般情况下应尽可能减少这种情况,尽量把所有可能出现的异常情况都考虑到,并配置相关的异常处理或转化为StatusResponseException
  4. 如果没有异常,则查看 RoutingContext 是否配置了状态码(没有默认取500),直接返回响应

如下是一个返回 400 请求参数错误的响应:

image.png

Spring Boot 3 中 MVC 的错误响应支持 RFC7808,因此可以仿照它在 Vert.X 中实现支持 RFC7808 的异常处理器。

过滤器/拦截器

在 Vert.X Web 中没有 Spring 中以过滤器/拦截器命名的类,但是使用 Handler<RoutingContext> 请求处理器就可以达到相同的效果,我们以 LoggerHandler 的实现 LoggerHandlerImpl 为例,它用于记录每个请求的日志:

2023-07-07 19:22:56 [vert.x-eventloop-thread-1] WARN  i.v.e.w.h.i.LoggerHandlerImpl - GET /api/exception 400 109 - 127 ms

handle 方法源码如下:

  @Override
  public void handle(RoutingContext context) {
    // common logging data
    long timestamp = System.currentTimeMillis();
    String remoteClient = getClientAddress(context.request().remoteAddress());
    HttpMethod method = context.request().method();
    String uri = context.request().uri();
    HttpVersion version = context.request().version();

    if (immediate) {
      log(context, timestamp, remoteClient, version, method, uri);
    } else {
      context.addBodyEndHandler(v -> log(context, timestamp, remoteClient, version, method, uri));
    }

    context.next();
  }

handle 方法里做的事情相当于 Spring 拦截器 HandlerInterceptorpreHandle 做的事;之后通过 next 方法转到下一层请求处理器;配置 addBodyEndHandler(此外还有 addEndHandleraddHeadersEndHandler)相当于 Spring 拦截器里的 postHandle

需要注意的是,拦截器功能的请求处理器在 Router 中声明的 order 要高于自己的业务请求处理器

在 Vert.X Web 中有很多内置的处理器可以类比 Spring 中的过滤器/拦截器,可以在 Vert.X Web 文档 中了解详情。

下面的身份认证将会使用此方式来实现路由拦截功能。

身份认证

请求参数验证

服务层调用

RPC调用

数据库调用

配置和启动

未完待续