用Java编写更好的不可变DTO的技巧 - Seb


为了使用来自外部服务的数据,我们通常将JSON有效负载转换为数据传输对象(DTO)。快速处理DTO的代码变得很复杂,但是一些技巧可以有所帮助。我们可以编写易于交互的DTO,使客户端代码更易于编写和阅读的DTO。这些技巧一起使用,有助于使其保持简单。
让我们从使用JSON的典型方法开始:


  "name": "Regina"
 
"ingredients": ["Ham", "Mushrooms", "Mozzarella", "Tomato purée"]
}

创建了一个名为的简单DTO PizzaDto:
import java.util.List;

public static class PizzaDto {
  private String name;
  private List<String> ingredients;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
  
  public List<String> getIngredients() {
    return ingredients;
  }
  
  public void setIngredients(List<String> ingredients) {
    this.ingredients = ingredients;
  }
}

PizzaDto是一个普通的Java旧对象:带有属性,获取器,设置器的对象,仅此而已。它反映了JSON结构,因此对象和JSON之间的转换只是一种形式。这是Jackson库的示例:

String json = """
  { 
   
"name": "Regina",
   
"ingredients": [ "Ham", "Mushrooms", "Mozzarella", "Tomato purée" ]
  }
""";

// from JSON to Object
PizzaDto dto = new ObjectMapper().readValue(json, PizzaDto.class);

// from Object to JSON
json = new ObjectMapper().writeValueAsString(dto);

转换很简单。所以有什么问题?
在现实生活中,DTO可能非常复杂。创建和初始化DTO的代码可能非常庞大:有时需要数十行代码。有时更多。这是一个问题,因为复杂的代码包含更多的错误,并且对更改的响应较慢。
我简化DTO创建的第一步是使用不可变的 DTO:创建后无法修改的DTO。
 
创建不可变的DTO
当对象在构造后无法更改时,它是不可变的。
让我们重写PizzaDto使其不可变
import java.util.List;

public class PizzaDto {

    private final String name;              
    private final List<String> ingredients;

    public PizzaDto(String name, List<String> ingredients) {
        this.name = name;
        if (ingredients != null) {
            ingredients = List.copyOf(ingredients);
        }
        this.ingredients = ingredients;
    }

    public String getName() {
        return name;
    }
    
    public List<String> getIngredients() {
        return ingredients;
    }
}

不可变版本没有设置器setter。所有属性均为最终属性,必须在构造时进行初始化。
Effective Java的作者Joshua Bloch提出了创建不可变类的建议:
“如果您的类具有引用可变对象的任何字段,请确保该类的客户端无法获取对这些对象的引用。” 约书亚·布洛赫(Joshua Bloch)

如果DTO的任何属性是可变的,则需要制作防御性副本。使用防御性副本,可以保护DTO免受外部修改。
好的。现在,我们有了一个不变的DTO。但是,它如何简化代码?
 
不变性的好处
不变性带来很多好处,但这是我的最爱:不变变量没有副作用。
我们来看一个例子。此代码段中有一个错误:

var pizza = make();
verify(pizza);
serve(pizza);

运行此代码后,pizza没有达到预期的状态。哪里引起了问题?

我们将考虑2个答案:首先是一个可变变量,然后是一个不可变变量。
第一个答案,加上可变的比萨饼。pizza由make()创建,但可以在verify()和serve()中进行修改。因此,该错误可能来自3条可能中的任何一个。
现在,第二个答案:如果是一个不变的披萨。make()返回一个比萨饼,但verify()并serve()不能对其进行修改。问题只能来自make()。在这里,查错范围要小得多。该错误更容易找到。
由于比萨饼是不变的,verify()因此不能仅将其修复。它必须创建并返回一个修改过的披萨,并且必须修改客户端代码:

var pizza = make();
pizza = verify(pizza);
serve(pizza);

在这个新版本中,很明显会verify()返回一个新的不变披萨。不变性使您的代码更加明确。它变得更容易阅读和发展。

您可能不知道,但是我们已经每天都在使用不可变的对象。java.lang.String,java.math.BigDecimal,java.io.File是不可改变的。不变性还具有许多其他优点。约书亚·布洛赫(Joshua Bloch)在他的《有效Java》中只是建议“最小化可变性”。
不可变的类比可变的类更容易设计,实现和使用。它们不太容易出错,并且更安全。约书亚·布洛赫(Joshua Bloch)
 

序列化DTO
Jackson是Java中最常见的JSON库。当你DTO有getters和setters时, ,Jackson无需任何额外配置即可将对象映射到JSON。但是对于不可变的物体,杰克逊需要一点帮助。它需要知道如何构造对象。
必须使用注释对象的构造函数@JsonCreator,并使用注释每个参数。
杰克逊还有另一个好处。如果我们在构造函数中放入一些逻辑,则无论DTO是由应用程序代码创建还是由JSON生成,都将始终调用该逻辑。
我们可以利用这一点,避免使用null值。我们可以改进构造函数以使用非空值初始化字段。

/ new import :
// import static org.apache.commons.lang3.ObjectUtils.firstNonNull;

@JsonCreator
public PizzaDto(
        @JsonProperty("name") String name,
        @JsonProperty("ingredients")
List<String> ingredients) {
    this.name = firstNonNull(name, "");  // replace null by empty String 
    this.ingredients = List.copyOf(
        firstNonNull(ingredients, List.of())  // replace null by empty List
    );
}
如果我们用“”值替换空null值,则客户端可以使用DTO属性,而无需先检查它是否不为空。另外,它降低了获得NullPointerExceptions的机会。
有了这个技巧,您可以减少编写代码,并提高鲁棒性。我们如何做得更好?
 
使用Builders创建DTO
构建器提供了一个流畅的API,以促进DTO初始化。

var pizza = new PizzaDto.Builder()
        .name("Regina")
        .ingredients(
"Mozzarella cheese", "Basil leaves", "Olive oil", "Tomato purée")
        .build();

使用复杂的DTO,构建器可以使代码更具表现力。这种模式是如此出色.

public static final class Builder {
    
    private String name;
    private List<String> ingredients;

    public Builder name(String name) {
        this.name = name;
        return this;
    }

    public Builder ingredients(List<String> ingredients) {
        this.ingredients = ingredients;
        return this;
    }
    
    /**
      * overloads {@link Builderingredients(List)} to accept String varargs
      */

    public Builder ingredients(String... ingredients) { 
        return ingredients(List.of(ingredients));
    }

    public PizzaDto build() {
        return new PizzaDto(name, ingredients);
    }
}

有些人在编译时使用Lombok来创建构建器。这使DTO变得简单。
我更喜欢使用Builder生成器IntelliJ插件生成生成器代码。然后,可以像上一片段一样添加方法重载。构建器更灵活,客户端代码更精简。
 
结论
这些是我用来编写DTO的主要技巧。一起使用,它们确实可以改善您的代码。该代码库更易于阅读,更易于维护,并且最终更易于与您的团队共享。