上一篇介绍了三个用于 jackson 自定义序列化的场景。这一篇继续介绍其他一些实践。同样,所有的代码都可以在GitHub找到。
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
相对应。
那么对于 UserCreatedEvent
和 ArticleCreatedEvent
类型,其解析的 json 如下:
{
"type": "user_created"
}
{
"type": "article_created"
}
这是我非常喜欢的一个功能,第一次见到是在 spring restbucks 的例子里。它有点像是 ruby 里面的 mixin 的概念,就是在不修改已知类代码、甚至是不添加任何注释的前提下为其提供 jackson 序列化的一些设定。在两种场景下比较适用 mixin:
这里就提供一个解析 joda time
的 mixin 的示例,它提供了一个 DateTimeSerializer
将 joda.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({ "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
}
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;
}
}
为了将其属性 firstName
和 lastName
在展示时从驼峰式转换为下划线分割,可以对需要转换的属性上配置 @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 它有 username
和 domain
两个属性。
在序列化的时候,我们其实不需要 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"
}
}
@ConfigurationProperties
和 @Value
都是 Spring 提供的用于从配置文件注入配置信息的方式。很显然,@Value
比较适用于配置比较少的场景,而 @ConfigurationProperties
则更适用于有很多配置的情况。之前写项目的时候从来都没有使用过 @ConfigurationProperties
几乎每次都是使用 @Value
。这次遇到了一个比较适合它的场景,在使用的时候还真遇到了一些令人讨厌的小问题,导致开发速度受到了一定的影响。这里记录下来他们之间的使用方式和可能出现的坑,加深一下印象。
注意,我们这里使用 application.yml
而不是 application.properties
不过他们基本是可以相互替代的。
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
是使用非常的简单,只要将注解添加在参数前即可。
在有大量参数的时候,一个个添加 @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
@ConfigurationProperties
需要在 spring boot application 上添加 EnableConfigurationProperties
的注解,这里遇到的第一个坑@ConfigurationProperties
可以添加前缀,然后其属性就会按照变量的名称默认在 application.*
中寻找指定的变量。这里就是去寻找 oauth.clientId
这样的配置。** 如果想要从其他配置文件获取配置内容,可以添加一个额外的注释 @PropertySource("classpath:xxx.yml")
**@Setter
是来自 lombok 的注解,它可以自动的帮助添加默认的属性的 setter 方法。注意,这里的 setter 方法是必须的,如果没有 setter 方法,是无法成功获取配置的,这也是我在使用它的时候遇到的又一个坑@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 项目在这里。