Eisen's Blog

© 2024. All rights reserved.

Jackson 在 Spring Boot 中的使用小结 2

2017 September-19

上一篇介绍了三个用于 jackson 自定义序列化的场景。这一篇继续介绍其他一些实践。同样,所有的代码都可以在GitHub找到。

清理输入数据的外层包装(unwrap)

json api 不单单是数据的输出格式为 json 通常数据的输入(POST 或者 PUT 的 request body)也是 json 格式。很多情况下会需要默认将输入的 json 数据以一个父级对象包裹。例如在 realworld 项目的 api 规范中在创建一个 article 时,其输入的数据格式为:

{
  "article": {
    "title": "How to train your dragon",
    "description": "Ever wonder how?",
    "body": "You have to believe",
    "tagList": ["reactjs", "angularjs", "dragons"]
  }
}

其真正的数据被 article 这个属性包裹起来了。而在实际使用的时候,如果每次都要去自行解包这个层次实在是不够优雅。好在 jackson 可以通过配置自动帮我们 unwrap 这里的对象,只需要在 application.(yml|properties) 增加一个配置:

spring.jackson.deserialization.unwrap-root-value=true

比如我有一个这样的输入格式:

{
  "wrap": {
    "name": "name"
  }
}

为了对其自动解包,我们对要解析的对象提供相应的 @JsonRootName 即可:

@JsonRootName("wrap")
public class WrapJson {
    private String name;

    public WrapJson(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    private WrapJson() {
    }

    ...
}

但是,要注意这个配置是全局有效的,意味着一旦设置了之后所有的解析都会尝试将数据解包,即使没有提供 @JsonRootName 的注解,其依然会尝试使用类名称等方式去解包。因此,除了这个测试使用 spring.jackson.deserialization.unwrap-root-value 外默认关闭它

处理枚举类型

在 java 为了展示的方便,我们通常是需要将枚举按照字符串来处理的,jackson 默认也是这么做的。

OrderStatus:

public enum OrderStatus {
    UNPAID, PREPARING, COMPLETED, CANCELED
}

OrderWithStatus:

class OrderWithStatus {
    private OrderStatus status;
    private String id;

    public OrderWithStatus(OrderStatus status, String id) {
        this.status = status;
        this.id = id;
    }

    private OrderWithStatus() {

    }

    public OrderStatus getStatus() {
        return status;
    }

    public String getId() {
        return id;
    }

    ...
}

OrderWithStatus order = new OrderWithStatus(OrderStatus.UNPAID, "123");

对于 order 来说,其默认的序列化为:

{
  "id": "123",
  "status": "UNPAID"
}

当然对其进行反序列化也是会成功的,这是处理枚举最简单的情况了,不过 jackson 还支持自定义的序列化与反序列化,比如如果我们需要将原有的枚举变成小写:

{
  "id": "123",
  "status": "unpaid"
}

我们可以写自定义的 serializer 和 deserializer:

@JsonSerialize(using = OrderStatusSerializer.class)
@JsonDeserialize(using = OrderStatusDeserializer.class)
public enum OrderStatus {
    UNPAID, PREPARING, COMPLETED, CANCELED;
}

public class OrderStatusSerializer extends StdSerializer<OrderStatus> {
    public OrderStatusSerializer(Class<OrderStatus> t) {
        super(t);
    }

    public OrderStatusSerializer() {
        super(OrderStatus.class);
    }

    @Override
    public void serialize(OrderStatus value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        gen.writeString(value.toString().toLowerCase());
    }
}

public class OrderStatusDeserializer extends StdDeserializer<OrderStatus> {
    public OrderStatusDeserializer(Class<?> vc) {
        super(vc);
    }

    public OrderStatusDeserializer() {
        super(OrderStatus.class);
    }

    @Override
    public OrderStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        return OrderStatus.valueOf(p.getText().toUpperCase());
    }
}

处理使用自定义的反序列化外或者我们也可以提供一个包含 @JsonCreator 标注的构造函数进行自定义的反序列化,并用上一篇提到的 @JsonValue 进行序列化:

public enum OrderStatus {
    UNPAID, PREPARING, COMPLETED, CANCELED;

    @JsonCreator
    public static OrderStatus fromValue(@JsonProperty("status") String value) {
        return valueOf(value.toUpperCase());
    }

    @JsonValue
    public String ofValue() {
        return this.toString().toLowerCase();
    }
}

@JsonCreator 有点像是 MyBatis 做映射的时候那个 <constructor> 它可以让你直接使用一个构造函数或者是静态工厂方法来构建这个对象,可以在这里做一些额外的初始化或者是默认值选定的工作,有了它在反序列化的时候就不需要那个很讨厌的默认的无参数构造函数了。

当然枚举的处理还有一些更诡异的方式,这里有讲解,我就不再赘述了。

对多态的支持

在 DDD 中有领域事件(domain event)的概念,有时候我们需要将这些事件保存下来。由于每一个事件的结构是千差万别的,不论是存储在关系型数据库还是 nosql 数据库,在将其序列化保存的时候我们需要保留其原有的类型信息以便在反序列化的时候将其解析为之前的类型。jackson 对这种多态有很好的支持。

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = UserCreatedEvent.class, name = "user_created"),
    @JsonSubTypes.Type(value = ArticleCreatedEvent.class, name = "article_created")
})
public abstract class Event {
}

@JsonTypeName("user_created")
class UserCreatedEvent extends Event {
}

@JsonTypeName("article_created")
class ArticleCreatedEvent extends Event {
}

其中 @JsonTypeInfo 定义类型信息以什么方式保留在 json 数据中,这里就是采用了 type 的属性。@JsonSubTypes 定义了一系列子类与类型的映射关系。最后 @JsonTypeName 为每一个子类型定义了其名称,与 @JsonSubTypes 相对应。

那么对于 UserCreatedEventArticleCreatedEvent 类型,其解析的 json 如下:

{
  "type": "user_created"
}
{
  "type": "article_created"
}

使用 mixin

这是我非常喜欢的一个功能,第一次见到是在 spring restbucks 的例子里。它有点像是 ruby 里面的 mixin 的概念,就是在不修改已知类代码、甚至是不添加任何注释的前提下为其提供 jackson 序列化的一些设定。在两种场景下比较适用 mixin:

  1. 你需要对一个外部库的类进行自定义的序列化和反序列化
  2. 你希望自己的业务代码不包含一丝丝技术细节:写代码的时候很希望自己创建的业务类是 POJO,一个不需要继承自特定对象甚至是不需要特定技术注解的类,它强调的是一个业务信息而不是一个技术信息

这里就提供一个解析 joda time 的 mixin 的示例,它提供了一个 DateTimeSerializerjoda.DateTime 解析为 ISO 的格式。代码见这里

@Configuration
public class JacksonCustomizations {

    @Bean
    public Module realWorldModules() {
        return new RealWorldModules();
    }

    public static class RealWorldModules extends SimpleModule {
        public RealWorldModules() {
            addSerializer(DateTime.class, new DateTimeSerializer());
        }
    }

    public static class DateTimeSerializer extends StdSerializer<DateTime> {

        protected DateTimeSerializer() {
            super(DateTime.class);
        }

        @Override
        public void serialize(DateTime value, JsonGenerator gen, SerializerProvider provider) throws IOException {
            if (value == null) {
                gen.writeNull();
            } else {
                gen.writeString(ISODateTimeFormat.dateTime().withZoneUTC().print(value));
            }
        }
    }

}

其他

@JsonPropertyOrder

@JsonPropertyOrder({ "name", "id" })
public class MyBean {
    public int id;
    public String name;
}

按照其指定的顺序解析为:

{
    "name":"My bean",
    "id":1
}

展示空数据的策略

有这么一个对象:

User user = new User("123", "", "xu");

我们希望其任何为空的数据都不再显示,即其序列化结果为:

{
  "id": "123",
  "last_name": "xu"
}

而不是

{
  "id": "123",
  "first_name": "",
  "last_name": "xu"
}

当然,遇到 null 的时候也不希望出现这样的结果:

{
  "id": "123",
  "first_name": null,
  "last_name": "xu"
}

为了达到这个效果我们可以为 User.java 提供 @JsonInclude(JsonInclude.Include.NON_EMPTY) 注解:

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class User {
    private String id;
    @JsonProperty("first_name")
    private String firstName;
    @JsonProperty("last_name")
    private String lastName;

    public User(String id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }
}

除了 NON_EMPTY 还有很多其他的配置可以使用。

如果希望这个策略在我们的整个应用中都起效(而不是单个类)我们可以在 application.properties | application.yml 做配置:

spring.jackson.default-property-inclusion=non_empty

自定义标注

如果一个注解的组合频繁出现在我们的项目中,我们可以通过 @JacksonAnnotationsInside 将其打包使用:

@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonInclude(Include.NON_NULL)
@JsonPropertyOrder({ "name", "id", "dateCreated" })
public @interface CustomAnnotation {}

@CustomAnnotation
public class BeanWithCustomAnnotation {
    public int id;
    public String name;
    public Date dateCreated;
}

BeanWithCustomAnnotation bean 
      = new BeanWithCustomAnnotation(1, "My bean", null);

对于对象 bean 来说,其解析结果为

{
    "name":"My bean",
    "id":1
}

相关资料


Jackson 在 Spring Boot 中的使用小结 1

2017 September-18

Json 数据格式由于其和 js 的亲密性等原因,目前算是比较主流的数据传输格式。Spring Boot 也默认集成了 jackson 用于 application/json 格式的序列化与反序列化,在写这种 restful json api 的时候势必会需要使用其 api(尤其是它所支持的注解)对输入输出的数据进行各种各样的修改和加工。这里总结了一些常见到的场景、应对的方法并提供了一些额外的学习资料,写着写着发现内容还有点点多,先完成了序列化中的三个情况,后面的博客再补充。

所有的代码可以在GitHub找到。

样例的形式

和之前的博客类似,这次也是单独创建了一个用于演示的项目,然后通过测试的形式展示项目。在演示 jackson 时,为了方便测试,我们都将提供一个期望的 json 文件进行比对:

public class NormalJavaClass {
    private String name;
    private int number;

    public NormalJavaClass(String name, int number) {
        this.name = name;
        this.number = number;
    }

    public String getName() {
        return name;
    }

    public int getNumber() {
        return number;
    }
}

@RunWith(SpringRunner.class)
@JsonTest
public class NormalJavaClassTest {
    @Autowired
    private JacksonTester<NormalJavaClass> json;
    private NormalJavaClass obj;

    @Before
    public void setUp() throws Exception {
        obj = new NormalJavaClass("aisensiy", 18);
    }

    @Test
    public void should_serialize_success() throws Exception {
        assertThat(this.json.write(obj)).isEqualToJson("normaljavaclass.json");
    }
}

其中 normaljavaclass.json 为:

{
  "number": 18,
  "name": "aisensiy"
}

后面为了简洁,就不再展示这种不必要的测试了,仅仅展示最终的 json 文件。

驼峰格式变下划线格式

User.java:

public class User {
    private String id;
    @JsonProperty("first_name")
    private String firstName;
    @JsonProperty("last_name")
    private String lastName;

    public User(String id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }
}

为了将其属性 firstNamelastName 在展示时从驼峰式转换为下划线分割,可以对需要转换的属性上配置 @JsonProperty("<the_underscoe_version_name>"),其序列化结果为:

{
  "id": "123",
  "first_name": "eisen",
  "last_name": "xu"
}

当然,spring boot 中还可以通过对 jackson 对配置实现驼峰到下划线格式的转换,需要在 application.properties 中增加一个 jackson 的配置:

spring.jackson.property-naming-strategy=CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES

对于 CamelExample 这样的类

public class CamelExample {
    private String aVeryLongCamelCaseName;

    public CamelExample(String aVeryLongCamelCaseName) {
        this.aVeryLongCamelCaseName = aVeryLongCamelCaseName;
    }

    public String getaVeryLongCamelCaseName() {
        return aVeryLongCamelCaseName;
    }
}

就会匹配如下的 json 文件:

{
  "a_very_long_camel_case_name": "test"
}

用单一值作为值对象的序列化结果

DDD 中提倡使用 Value Object,那么项目中可能会出现以下这样的类:

public class UserWithEmail {
    private String username;
    private Email email;

    public UserWithEmail(String username, Email email) {
        this.username = username;
        this.email = email;
    }

    public String getUsername() {
        return username;
    }

    public Email getEmail() {
        return email;
    }
}

其中 Email 是一个 Value Object 它有 usernamedomain 两个属性。

在序列化的时候,我们其实不需要 username + domain 的组合,我们想要的就是 username@domain 的样子。这个时候,我们可以用 @JsonValue 注解:

public class Email {
    private String username;
    private String domain;

    public Email(String value) {
        if (value == null) {
            throw new IllegalArgumentException();
        }

        String[] splits = value.split("@");
        if (splits.length != 2) {
            throw new IllegalArgumentException();
        }
        username = splits[0];
        domain = splits[1];
    }

    @JsonValue
    @Override
    public String toString() {
        return String.format("%s@%s", username, domain);
    }

    public String getUsername() {
        return username;
    }

    public String getDomain() {
        return domain;
    }
}

它的意思就是在序列化这个对象的时候采用标有 JsonValue 的方法的值作为其序列化的结果。

当然,另外一个比较通用的办法就是采用自定义的序列化器:

Address:

@JsonSerialize(using = AddressSerializer.class)
public class Address {
    private String City;
    private String Country;

    public Address(String city, String country) {
        City = city;
        Country = country;
    }

    public String getCity() {
        return City;
    }

    public String getCountry() {
        return Country;
    }
}

AddressSerializer:

public class AddressSerializer extends StdSerializer<Address> {

    public AddressSerializer() {
        this(Address.class);
    }

    public AddressSerializer(Class<Address> t) {
        super(t);
    }

    @Override
    public void serialize(Address value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        gen.writeString(String.format("%s, %s", value.getCity(), value.getCountry()));
    }
}

要序列化的类:

public class UserWithAddress {
    private String username;
    private Address address;

    public UserWithAddress(String username, Address address) {
        this.username = username;
        this.address = address;
    }

    public String getUsername() {
        return username;
    }

    public Address getAddress() {
        return address;
    }
}

其序列化的结果为:

{
  "username": "test",
  "address": "Beijing, China"
}

提升内嵌属性的层级

如果所提供的 rest json api 需要支持 hypermedia 那么就需要将通过 GET 获取的暴露给外部的资源的状态迁移以链接的形式提供出来。这个时候在我们 API 的内部实现时需要将从数据库中查询的 Data Object 再次用一个修饰类包装(如在 spring-hateoas 中就提供了一个 Resource 的修饰器用于处理这种情况)并为其提供额外的状态迁移链接:

public class Resource<T> {
    private T content;

    private Map<String, URI> links = new TreeMap<>();

	...

    public T getContent() {
        return content;
    }

    public Map<String, URI> getLinks() {
        return Collections.unmodifiableMap(links);
    }
}

但是这样会有一个问题:原有的 Data Object 成为了 Resource 类中的一个属性。其默认的序列化会成为这个样子:

{
  "content": {
    "property1": "xxx",
    "property2": "xxx",
    ...
  },
  "links": {
    "self": "xxx",
    ...
  }
}

我们不希望这样的代理模式导致原有的对象数据层级下降到 content 中,这里可以采用 @JsonUnwrapped 注解:

public class Resource<T> {
    private T content;

    private Map<String, URL> links = new TreeMap<>();

	...

    @JsonUnwrapped
    public T getContent() {
        return content;
    }

    public Map<String, URL> getLinks() {
        return Collections.unmodifiableMap(links);
    }
}

对之前的 User 进行包裹:

User user = new User("123", "eisen", "xu");
Link link = new Link(
    "self",
    UriComponentsBuilder.newInstance()
        .scheme("http").host("localhost")
        .path("/users/{id}")
        .buildAndExpand(user.getId()).toUri());
userResource = new Resource<>(user, link);

其序列化的结果为:

{
  "id": "123",
  "first_name": "eisen",
  "last_name": "xu",
  "links": {
    "self": "http://localhost/users/123"
  }
}

相关资料


Spring Boot @ConfigurationProperties 与 @Value

2017 August-31

@ConfigurationProperties@Value 都是 Spring 提供的用于从配置文件注入配置信息的方式。很显然,@Value 比较适用于配置比较少的场景,而 @ConfigurationProperties 则更适用于有很多配置的情况。之前写项目的时候从来都没有使用过 @ConfigurationProperties 几乎每次都是使用 @Value。这次遇到了一个比较适合它的场景,在使用的时候还真遇到了一些令人讨厌的小问题,导致开发速度受到了一定的影响。这里记录下来他们之间的使用方式和可能出现的坑,加深一下印象。

注意,我们这里使用 application.yml 而不是 application.properties 不过他们基本是可以相互替代的。

Demo for @Value

sso:
  clientId: clientId
  clientSecret: clientSecret
@Component
@Data
public class ValueConfiguration {
    private String clientId;
    private String clientSecret;

    @Autowired
    public ValueConfiguration(
        @Value("${sso.clientId}") String clientId,
        @Value("${sso.clientSecret}") String clientSecret) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }
}

可以看到 @Value 是使用非常的简单,只要将注解添加在参数前即可。

Demo for @ConfigurationProperties

在有大量参数的时候,一个个添加 @Value 就显得麻烦了一点,Spring 提供了另一种方式。

@SpringBootApplication
@EnableConfigurationProperties // 1
public class DemoForSpringBootConfigurationApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoForSpringBootConfigurationApplication.class, args);
	}

	@Bean
	CommandLineRunner run(ValueConfiguration valueConfiguration) {
		return args -> {
			System.out.println(valueConfiguration.toString());
		};
	}

	@Bean
	CommandLineRunner config(PropertiesConfiguration configuration) {
	    return args -> {
            System.out.println(configuration);
        };
    }
}

@Component
@ConfigurationProperties(prefix = "oauth") // 2
@Getter
@Setter  // 3
@ToString
public class PropertiesConfiguration {
    private String clientId;
    private String clientSecret;
    private String redirectUri;
    private String grantType;
}
sso:
  clientId: clientId
  clientSecret: clientSecret

oauth:
  client_id: id                           // 4
  client-secret: secret
  redirect_uri: http://aisensiy.github.io
  grantType: code
  1. 为了使用 @ConfigurationProperties 需要在 spring boot application 上添加 EnableConfigurationProperties 的注解,这里遇到的第一个坑
  2. @ConfigurationProperties 可以添加前缀,然后其属性就会按照变量的名称默认在 application.* 中寻找指定的变量。这里就是去寻找 oauth.clientId 这样的配置。** 如果想要从其他配置文件获取配置内容,可以添加一个额外的注释 @PropertySource("classpath:xxx.yml")**
  3. 这里的 @Setter 是来自 lombok 的注解,它可以自动的帮助添加默认的属性的 setter 方法。注意,这里的 setter 方法是必须的,如果没有 setter 方法,是无法成功获取配置的,这也是我在使用它的时候遇到的又一个坑
  4. @ConfigurationProperties@Value 的一个重大区别在于它采用比较灵活的方式寻找配置。可以看到这里的配置可以是驼峰形式,也可以是下划线分割的,还可以是中横线分割的

添加参数验证

@ConfigurationProperties 是可以和 validation 注解一起使用的,这样的好处显而易见:对于一些配置是必须的或者是对格式有要求的,在运行开始的时候就能检测到这些问题可以避免上线之后因为配置不符合有找不到头绪而导致的 debug 的痛苦过程。


@Component
@Getter
@Setter
@ToString
@ConfigurationProperties(prefix = "oauth")
public class PropertiesConfiguration {
    @NotBlank
    private String clientId;
    @NotBlank
    private String clientSecret;
    @URL
    private String redirectUri;
    @NotBlank
    private String grantType;
}

直接在成员变量上添加注解就可以了,非常的简单。然后可以去尝试添加一些非法的配置试试效果。

完整的 demo 项目在这里

相关资料