Eisen's Blog

© 2024. All rights reserved.

在 Spring Boot 中使用 HATEOAS

2017 June-04

HATEOAS, Hypermedia as the Engine of Application State, 可以被简单的理解为为 REST API 中的 Resource 提供必要的链接,对,就像是 HTML 页面上的链接。我们在访问一个 web 站点的时候从来没有说要看一个说明文档并在其中找到我们所需要的资源的 URI,而是通过一个入口页面(当然,搜索引擎也提供了入口)所包含的链接,一步一步找到我们想要的内容。HATEOAS 是 REST 架构风格重要的组成部分,然而对于现在的诸多 REST 接口中却并没有它的身影。它被 Richardson Maturity Model 定义为 REST 的最终形态。

HATEOAS 的优势

然而,使用 HATEOAS 可以带来什么样子的好处呢。从我自身的感受有以下两个方面。

1. 协议解耦

既然是 RESTful API 那么其用户一般来说不是写这个 API 的人,比如前端,比如其他的服务提供者。为了尽量避免不必要的资源浪费,需要他们之间协调一致。这个时候 RESTful API 的接口设计就显得尤为重要了:如何快速达成一致并保证其接口的稳定是项目快速进展的重要前提。

通常的做法是通过文档定义接口的路径、动作、Payload 等内容。然后,这个文档就成了多个人或者是多个人之间的一个协议:想要做任何的修改都需要多方达成一致。并且,事无巨细的打成一直:路径、动作、Payload。不过 RESTful API 就是在规约方法和动作了:路径是资源的名称、资源的状态,动作是 GET PUT POST DELETE 的其中之一。而支持 HATEOAS 的 REST API 则更进一步,将路径转换为行为以进一步增加 REST API 自身的灵活性,尽量少让后端的接口定义与其他系统形成耦合。这样做之后,API 就可以有一个逐条定义的,非常琐碎的契约变成了一个可发现式的契约。

这里引用一个在 DDD & REST 的一个例子:

raw url protocol

semantic protocol

当然,探索式的 API 的问题在于原来一个步骤能完成的事情可能会变成多个步骤:从入口找到资源,再从资源中获取链接。

2. Passive View

Passive View 是以前后端渲染的 MVC 中 View 的一种实现方式。它强调

A screen and components with all application specific behavior extracted into a controller so that the widgets have their state controlled entirely by controller.

也就是说 View 仅仅负责数据的展示。任何业务逻辑都不应该在前端展示,因为一方面前端有可能会被跳过(比如我直接用 curl 去访问后端 API),另一方面,面对多个前端的时候会存在重复的代码,当业务逻辑需要修改也会是沉重的负担。这里先列举把后端逻辑暴露给前端的情况

  1. 数据验证逻辑,比如哪些字段必须是唯一的,那些数据是必选的
  2. 对后端发送请求的 payload 格式、路径、方法,比如创建一个订单时提交订单的数据格式
  3. 对不同用户访问相同资源时的权限差异,比如论坛系统中,只有管理员才能删除帖子,而对于普通用户就不应当出现删除的按钮
  4. 相同资源在不同状态下的不同的状态迁移方法,比如订单系统中,只有未付款的订单才需要支付的链接
  5. 对返回数据的格式的依赖

其中想要对 1 2 解耦需要后端提供一个表单验证的 schema 我觉得将来还是可以解决的,但是目前没见到谁在做。5 的格式本来就存在于 API 返回的结果之中,不辩自明,所以称不上耦合。3 4 涉及了权限以及资源本身的状态迁移,是很难做到解耦的:需要前端知道资源的权限和状态迁移的方向。而 HATEOAS 恰好解决了这个问题:通过提供或者不提供向某个状态迁移的链接来表示当前用户是否有权限这么做。

在 Spring Boot 实现 HATEOAS

HATEOAS 的实现,有几个比较典型的场景。我们结合代码介绍都要怎么做。不过首先先放上 gradle 项目的 build.gradle 文件:

buildscript {
	ext {
		springBootVersion = '1.5.3.RELEASE'
	}
	repositories {
		mavenCentral()
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
	}
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'

version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
	mavenCentral()
}


dependencies {
	compile('org.springframework.boot:spring-boot-starter-hateoas')
	compile('org.springframework.boot:spring-boot-starter-web')
	compile('org.atteo:evo-inflector:1.2.2')
	runtime('org.springframework.boot:spring-boot-devtools')
	compileOnly('org.projectlombok:lombok')
	testCompile('org.springframework.boot:spring-boot-starter-test')
	testCompile 'io.rest-assured:rest-assured:3.0.2'
	testCompile 'io.rest-assured:spring-mock-mvc:3.0.2'
}

可以看到,这里引入了 spring boot 的 hateoas 的依赖。

1. 最基本的,包装数据,为其提供链接

还有以 spring boot 中 Greeting 的对象为例,我们首先定义一个数据对象:

public class Greeting {
    private String content;

    public Greeting(String content) {
        this.content = content;
    }
}

然后有一个 API 可以访问到它:

@RequestMapping("greeting")
@RestController
public class GreetingApi {
    private static final String TEMPLATE = "Hello, %s";

    @GetMapping
    public GreetingResource getGreeting(
        @RequestParam(value = "name", defaultValue = "World") String name) {
        GreetingResource resource = new GreetingResource(
            new Greeting(String.format(TEMPLATE, name))); // 1
        resource.add(
            linkTo(methodOn(GreetingApi.class).getGreeting(name)).withSelfRel()); // 2
        return resource;
    }
}

可以看到

  1. GreetingResource 对原始的 Greeting 对象进行了包装。
  2. 通过 linkTo 方法,添加了一个 self link

该请求所获取的结果如下:

{
    "content": "Hello, World",
    "_links": {
        "self": {
            "href": "http://localhost/greeting?name=World"
        }
    }
}

其中 GreetingResource 如下:

class GreetingResource extends Resource<Greeting> {
    private Greeting greeting;

    public GreetingResource(Greeting content) {
        super(content);
    }
}

通过继承自 spring hateoas 所提供的 Resource<T> GreetingResource 默认将 Greeting 的 Get 方法都实现了。所以,上面的返回结果中会出现 content 字段。

2. 依据当前资源的状态,提供不同的链接

这里,我们有一个 Order 对象,对于其 Status 的不同,需要提供不同的 link

@Getter
@NoArgsConstructor
@EqualsAndHashCode(of = "id")
public class Order {
    private String id;
    private Status status;
    private List<LineItem> items;

    public Order(String id, List<LineItem> items) {
        this.id = id;
        this.items = items;
        status = Status.CREATED;
    }

    public void pay() {
        if (status != Status.CREATED) {
            throw new IllegalStateException("Only new order can be paid");
        }
        this.status = Status.PAID;
    }

    public boolean paid() {
        return status == Status.PAID;
    }

    @Value
    public static class LineItem {
        private String productId;
        private double price;
        private int amount;
    }

    public enum Status {
        CREATED, PAID, CANCELLED, FINISHED
    }
}

我们可以在 OrderResource 的构造函数中做手脚:

public class OrderResource extends Resource<Order> {
    public OrderResource(Order order) {
        super(order);

        this.add(
            linkTo(methodOn(OrderApi.class).orderResource(order.getId()))
                .withSelfRel());

        if (!order.paid()) {
            this.add(
                linkTo(methodOn(OrderApi.class).pay(order.getId()))
                    .withRel("payment"));
        }
    }
}

当然,在 controller 中将 link 传入构造函数也是可行的,那样的好处是将 Controller 的信息都留在了 Controller,但是不好的地方在于 Resource 这个对象实在是有点贫血,然后 controller 就变得庞大了一些。对其的测试就不在这里展示了。

3. 处理集合

除了 Resource<T> spring hateoas 还有一个 Resources<T> 用于处理集合:

@GetMapping
public ResponseEntity<?> all() {
    Resources<OrderResource> resources = new Resources<>(
        orderRepository
            .all()
            .stream()
            .map(OrderResource::new)
            .collect(Collectors.toList()));
    resources.add(linkTo(methodOn(OrderApi.class).all()).withSelfRel());
    return ResponseEntity.ok(resources);
}

其结果是这个样子的:

{
    "_embedded": {
        "orders": [ // 1
            {
                "id": "123",
                "status": "CREATED",
                "items": [
                    {
                        "productId": "product1",
                        "price": 1.22,
                        "amount": 1
                    }
                ],
                "_links": {
                    "self": {
                        "href": "http://localhost/orders/123"
                    },
                    "payment": {
                        "href": "http://localhost/orders/123/payment"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost/orders"
        }
    }
}
  1. 资源 order 被默默转变为了 orders 这是因为在 build.gradle 中添加了 compile('org.atteo:evo-inflector:1.2.2') 它可以帮助我们为集合提供英文复数的转换。如果你感兴趣可以尝试一下把这个依赖删除后会是神马样子。

完整的代码见 Github

相关资料


将 sonarQube 和 gradle spring boot 项目集成

2017 May-16

在联想的项目接触了一下 sonarQube 整体来说还是有很多可圈可点之处的,碰巧最近有一个相关产品的选择试用的调研,就尝试了一下下。

sonarQube 自己说自己用于做 continuous code quality, 从它所生成的默认的报告来看,主要包含了如下几个部分:

2021 07 27 13 34 04

不过打开一看会发现其实 Bugs & VulnerabilitiesCode Smells 基本上就是 lint 所做的事情,比如代码风格不符合 java 的规约呀,在使用 Optional 之前判断其是否 isPresent 呀等等,不过人家本来就是做静态检查的也无可厚非。

Code Smells 这个名字实在是太唬人了,在 重构 里定义设计的好坏在于有没有代码的坏味道,如果 sonarqube 有能力甄别所有的代码坏味道的话,那还怕神马低质量代码呢。不过起码 sonarqube 有能力甄别一部分低级的代码坏味道,比如过长的方法参数:

Constructor has 9 parameters, which is greater than 7 authorized.

再比如重复的代码呀

1 duplicated blocks of code must be removed.

甚至是提醒我们哪个方法所在的位置不合适

Move this method into "Builder"

这么看来其功能还是可圈可点的,而且我所使用的还仅仅是最基本的配置,没有做任何的自定义。

集成

下面我介绍一下如何把 sonarqube 和 spring boot gradle 的项目做集成的。

安装 sonarqube

最好的安装办法自然是 docker

docker run -d --name sonarqube -p 9000:9000 -p 9092:9092 sonarqube

修改 gradle

按照 sonarqube 的文档 添加插件。

plugins {
  id "org.sonarqube" version "2.4"
}

然后 ./gradlew clean test sonar 执行命令。之后就可以在 http://localhost:9000 看到结果了。

不过这个时候你会发现并没有测试覆盖率的数据。

这是因为 sonarqube 自己不做测试覆盖的处理,它依赖于其他的测试覆盖工具。比如这里我们使用 jacoco

build.gradle 中添加 jacoco:

apply plugin 'jacoco'

然后 ./gradlew clean test jacoco sonar 再去 http://localhost:9000 查看就有相应的数据了。

和 ci 集成

刚才我们只是将我们的项目和一个本地的 sonarqube server 集成了,但是在实际项目中我们通常都是在 ci 中执行 sonar 命令并有一个共有的 sonar server 展现目前主分支代码的质量。那么,我们就需要在 ci 中指定外部的 sonar server 地址以及一些其他自定义的内容。

由于 sonarqube 支持以命令行的方式传入参数,这样的工作非常的简单:

./gradlew sonar -Dsonar.host.url=http://sonar.mycompany.com -Dsonar.verbose=true

最后附上测试项目地址


在用 Spring MVC 构建 RESTful API 时进行验证和异常处理

2017 May-06

这一部分介绍一下我发现的在 Spring MVC 下进行输入处理以及验证信息反馈方面的一些思路。完整的示例代码见 GitHub

区别请求对象和实体对象

目前我所构建的 spring boot 的服务都是 REST 风格的 API 了,很多时候处理的都是 json 的数据。在获取的 HTTP 请求中,BODY 中所传的也都不再是表单而是一个 json 了。看了很多的例子发现在 demo 中喜欢直接把输入转化成一个实体对象。比如我要注册用户,那么我就直接把请求中的 json 映射成一个 User,多方便。但是很明显,它只能处理简单的情况,强行使用容易把真正的业务实体中加入很多诡异的功能,比如什么 password confirm,这都是以前很多代码中会出现的。实际上就算是处理表单型的数据,也早就有了 form object 的概念了,不能够说换成 json 就倒回去吧,说白了这依然是个表单而已。

区别表单验证和业务逻辑验证

有输入就要有验证,表单验证一直是一个非常蛋疼的问题,一方面它有很多内容很无聊,比如检查非空呀,控制输入的类型呀,判断长度呀,需要一个标准的方法避免这种重复的代码。另一方面,有的时候验证中又存在业务逻辑,那到底把这个验证放到哪里以及用神马方法验证都是一个很容易让人犹豫不决的事情。

要解决这个,最好的办法就是明确的区分那种和业务逻辑关系不大的格式的验证以及业务逻辑中的验证。对于长度、必选、枚举、是不是电子邮箱、是不是 URL 用 Bean Validation 解决。对于有关业务逻辑的,比如是不是合法的产品型号、是不是重复的注册名等都在 Controller 中进行处理。下面分别对两种验证方式进行说明。

1. Bean Validation 异常处理

Spring MVC 中对异常的处理基本都是在 Controller 中抛出一个具体的 Runtime 异常(比如 ProductNotFoundException,然后通过 ExceptionHandler 的方式去捕捉并转换为具体的报错请求。具体的示例见这里,我就不再重复了,我们在这里会使用 ControllerAdvice 的方式处理这种比较通用的情况,对于某些特殊处理的情况在 Controller 加 ExceptionHandler 即可。这里想强调的是如何把一个报错转化成一个格式良好的、便于 RESTful API 消费方处理的 JSON 的。

首先,有一个 UsersApi 用于创建用户的方法:

@RestController
@RequestMapping("/users")
public class UsersApi {
    private UserRepository userRepository;

    @Autowired
    public UsersApi(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @RequestMapping(method = POST)
    public ResponseEntity createUser(@Valid @RequestBody CreateUser createUser, 
                                     BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException("Error in create user", bindingResult);
        }
        User user = new User(UUID.randomUUID().toString(), createUser.getUsername());
        userRepository.save(user);
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

可以看到,上面的 createUser 方法中,有两个参数 CreateUserBindingResult。其中 CreateUser 是一个 Form Object 用于处理创建用户的输入,它通过 Bean Validation 的方式定义输入的一些要求,通过 @Valid 的注解可是让 java 自动帮我们进行表单验证,表单验证的结果就被放在 BindingResult 中了。在这里处理报错的好处在于可以附上在当前 Controller 中特有的 message (Error in create user)CreateUser 类如下所示。

@Getter // lombok 注解
public class CreateUser {
    @NotBlank // hibernate.validator 注解
    private String username;
}

接着,我们有一个测试用例覆盖错误输入的情况。可以看到 should_400_with_wrong_parameter 通过 rest assured 方法对我们想要获得的结果格式进行了测试,setUp 方法以及 rest assured 内容见 在 Spring Boot 1.5.3 中进行 Spring MVC 测试

@RunWith(SpringRunner.class)
public class UsersApiTest {

    private UserRepository userRepository;

    @Before
    public void setUp() throws Exception {
        userRepository = mock(UserRepository.class);
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UsersApi(userRepository))
                                         .setControllerAdvice(
                                             new CustomizeExceptionHandler()).build();
        RestAssuredMockMvc.mockMvc(mockMvc);
    }

    @Test
    public void should_400_with_wrong_parameter() throws Exception {

        Map<String, Object> wrongParameter = new HashMap<String, Object>() {{
            put("name", "aisensiy");
        }};

        given()
            .contentType("application/json")
            .body(wrongParameter)
            .when().post("/users")
            .then().statusCode(400)
            .body("fieldErrors[0].field", equalTo("username"))
            .body("fieldErrors.size()", equalTo(1));
    }
}

错误情况下 Api 的 Response 大概是这个样子:

{
    "code": "InvalidRequest",
    "message": "Error in create user",
    "fieldErrors": [
        {
            "resource": "createUser", 
            "field": "username", 
            "code": "NotBlank",
            "message": "may not be empty"
        }
    ]
}

这里我们重点看 InvalidRequestException 的处理。

@RestControllerAdvice
public class CustomizeExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({InvalidRequestException.class})
    public ResponseEntity<Object> handleInvalidRequest(RuntimeException e, 
                                                       WebRequest request) {
        InvalidRequestException ire = (InvalidRequestException) e;

        List<FieldErrorResource> errorResources = 
        	ire.getErrors().getFieldErrors().stream().map(fieldError ->
            new FieldErrorResource(fieldError.getObjectName(), 
                                   fieldError.getField(), 
                                   fieldError.getCode(),
                                   fieldError.getDefaultMessage())
                                  ).collect(Collectors.toList());

        ErrorResource error = new ErrorResource("InvalidRequest", 
                                                ire.getMessage(), 
                                                errorResources);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        return handleExceptionInternal(e, error, headers, BAD_REQUEST, request);
    }
}

handleInvalidRequest 方法把一个 InvalidRequestException 中的 FieldErrors 转化为 FieldErrorResource 然后通过一个 ErrorResource 方法包装后交给 handleExceptionInternal 方法并最终转换为一个 ResponseEntity

2. 业务逻辑错误处理

对于业务逻辑的报错,我们依然遵循上面的思路:将错误通过 BingResult 包装后抛出 InvalidRequestException。这里提供一个处理重复用户名的情况,需要在原来的 UsersApi 中做一些修改:

@RestController
@RequestMapping("/users")
public class UsersApi {
    private UserRepository userRepository;

    @Autowired
    public UsersApi(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @RequestMapping(method = GET)
    public List<UserData> getUsers() {
        return new ArrayList<>();
    }

    @RequestMapping(method = POST)
    public ResponseEntity createUser(@Valid @RequestBody CreateUser createUser, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException("Error in create user", bindingResult);
        }

        if (userRepository.findByUsername(createUser.getUsername()).isPresent()) {
            bindingResult.rejectValue("username", "Dupliated", "duplicated username");
            throw new InvalidRequestException("Error in create user", bindingResult);
        } // 处理重复用户名的问题
        User user = new User(UUID.randomUUID().toString(), createUser.getUsername());
        userRepository.save(user);
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

可以看到,通过使用 bindingResult.rejectValue 方法可以把我们自定义的报错添加进去.这里的报错使用了 UserRepository 如果想要在别的地方去处理类似的验证就需要注入它,远不如在这里来的简单清晰。对其的测试如下:

@Test
public void should_get_400_with_duplicated_username() throws Exception {
    User user = new User("123", "abc");
    when(userRepository.findByUsername(eq("abc"))).thenReturn(Optional.of(user));

    Map<String, Object> duplicatedUserName = new HashMap<String, Object>() {{
        put("username", "abc");
    }};

    given()
        .contentType("application/json")
        .body(duplicatedUserName)
        .when().post("/users")
        .then().statusCode(400)
        .body("message", equalTo("Error in create user"))
        .body("fieldErrors[0].field", equalTo("username"))
        .body("fieldErrors[0].message", equalTo("duplicated username"))
        .body("fieldErrors.size()", equalTo(1));
}