767 重构字符串
去年,我加入了一个项目,该项目从另一个软件公司接手,但未能满足客户需求。 如您所知,在“继承”的项目及其代码库中,有许多事情可以并且应该加以改进。 可悲的是(但并不奇怪)领域模型就是这样一个孤零零,被遗忘已久的领域之一,它大声呼救。
我们知道我们需要动手,但是您如何在一个陌生的项目中改进领域模型,在该项目中,所有事情都是如此混杂,纠结和杂草丛生,并具有偶然的复杂性? 您设置边界(分而治之!),在一个区域中进行较小的改进,然后移至另一区域,同时了解景观,并发现隐藏在那些可怕的显而易见的事物背后的更大问题,这些事物乍一看会伤害您的眼睛。 您可能会感到惊讶,您可以通过进行一些小的改进并选择低挂的水果来取得多少成就,但同时您也会傻傻地认为它们可以解决由于缺少(或没有足够)从项目开始之初就开始进行建模工作。 但是,如果没有这些小的改进,将很难解决大多数主要的领域模型问题。
对我来说,通过引入简单的值对象将更多的表现力和类型安全性带入代码中,始终是挂在最下面的成果之一。 这是一个总能奏效的技巧,尤其是在处理散布着原始痴迷代码气味的代码库时,所提到的系统是一个字符串类型的系统。 到处都是这样的代码:
public void verifyAccountOwnership(String accountId, String customerId) {...}
虽然我敢打赌,每个人都希望它看起来像这样:
public void verifyAccountOwnership(AccountId accountId, CustomerId customerId) {...}
这不是火箭科学! 我会说这是不费吹灰之力的,这总是让我感到惊讶的是,找到在模糊,无上下文的BigDecimals而不是Amounts,Quantities或Percentages上运行的实现是多么容易。
使用域特定值对象而不是无上下文基元的代码是:
- 更具表现力(您无需将字符串映射到脑海中的客户标识符,也不必担心这些字符串中的任何一个都是空字符串)
- 更容易掌握(不变式被保护在一个地方,而不是分散在各处的if语句中的代码库中)
- 越野车少(我是否将所有这些字符串按正确的顺序排列?)
- 更容易开发(显式定义更明显,不变量在您期望的位置得到保护)
- 开发速度更快(IDE提供了更多帮助,编译器提供了快速的反馈周期)
而这些只是您几乎免费获得的一些东西(您只需要使用常识^^)即可。
对价值对象的重构听起来简直是小菜一碟(这里没有考虑命名),您只需在这里提取类,在那儿迁移类型,没有什么特别的。 通常就是这么简单,尤其是当您要处理的代码位于单个代码存储库中并在单个进程中运行时。 但这一次并不那么琐碎。 并不是说它复杂得多,它只需要一点点思考(这使得描述一件不错的工作^^)。
这是一个分布式系统,其服务边界设置在错误的位置,并且在服务之间共享了过多的代码(包括模型)。 边界设置得如此糟糕,以至于系统中的许多关键操作都需要与多种服务进行多次交互(大多数情况下是同步的)。 在描述的上下文中应用提到的重构存在一个挑战(不是那么大),但这种挑战不会最终成为创建不必要的层并在服务边界引入意外复杂性的练习。 在跳到重构之前,我必须设置一些规则,或者甚至是一个关键规则:服务(包括后备服务)外部应该看不到任何更改。 简而言之,所有已发布的合同都保持不变,并且在支持服务方面不需要进行任何更改(例如,无需更改数据库架构)。 坦率地说,轻而易举地完成了一些枯燥的工作。
让我们以String accountId
,并演示必要的步骤。 我们要转这样的代码:
public class Account {
private String accountId;
// rest omitted for brevity
}
到这个:
public class Account {
private AccountId accountId;
// rest omitted for brevity
}
这可以通过引入AccountId值对象来实现:
@ToString
@EqualsAndHashCode
public class AccountId {
private final String accountId;
private AccountId(String accountId) {
if (accountId == null || accountId.isEmpty()) {
throw new IllegalArgumentException("accountId cannot be null nor empty");
}
// can account ID be 20 characters long?
// are special characters allowed?
// can I put a new line feed in the account ID?
this.accountId = accountId;
}
public static AccountId of(String accountId) {
return new AccountId(accountId);
}
public String asString() {
return accountId;
}
}
AccountId只是一个值对象,没有身份,不会随时间变化,因此是不可变的。 它在单个位置执行所有验证,并且由于无法实例化AccountId而在错误输入上快速失败,而不是随后在隐藏在调用堆栈下几层的if语句上失败。 如果需要保护任何不变式,您就会知道将它们放在哪里以及在哪里寻找它们。
到目前为止一切顺利,但是如果Account
是一个实体怎么办? 好吧,您只需实现一个属性转换器:
public class AccountIdConverter implements AttributeConverter<AccountId, String> {
@Override
public String convertToDatabaseColumn(AccountId accountId) {
return accountId.asString();
}
@Override
public AccountId convertToEntityAttribute(String accountId) {
return AccountId.of(accountId);
}
}
然后,您可以通过直接在转换器实现上设置的@Converter(autoApply = true)
或在实体字段上设置的@Convert(converter = AccountIdConverter.class)
启用@Convert(converter = AccountIdConverter.class)
。
当然,并非所有事物都围绕数据库旋转,幸运的是,在提到的项目中应用的许多不太好的设计决策中,也有很多好的决策。 如此好的决定之一就是标准化用于进程外通信的数据格式。 在上述情况下,它是JSON,因此我需要使JSON有效负载不受执行的重构的影响。 最简单的方法(如果使用Jackson的话)是在实现中添加几个Jackson注释:
public class AccountId {
@JsonCreator
public static AccountId of(@JsonProperty("accountId") String accountId) {
return new AccountId(accountId);
}
@JsonValue
public String asString() {
return accountId;
}
// rest omitted for brevity
}
我从最简单的解决方案开始。 这不是理想的,但已经足够好了,那时我们还有更多重要的问题要处理。 在不到3小时的时间里,就完成了JSON序列化和数据库类型转换的工作,我已经将前两个服务从字符串类型的标识符移到了基于值对象的服务中,这些值是系统中最常用的标识符。 花了很长时间有两个原因。
第一个很明显:在此过程中,我必须检查是否无法使用null值(以及是否可以明确声明该值)。 没有这个,整个重构将仅仅是代码完善的练习。
第二个是我几乎想念的东西–您还记得从外部看不到更改的要求吗? 在将帐户ID转换为值对象后,草签定义也发生了变化,现在帐户ID不再是字符串而是对象。 这也很容易修复,只需要指定摇摇欲坠的模型替换即可。 对于swagger-maven-plugin,您需要做的只是将其包含模型替换映射的文件提供给它 :
com.example.AccountId: java.lang.String
重构的结果是否有明显的改善? 并非如此,但是您可以通过进行许多小的改进来改善很多。 尽管如此,这并不是一个小小的改进,它使代码更加清晰,并使进一步的改进变得更加容易。 值得付出努力–我肯定会说:是的。 一个很好的指标是其他团队也采用了这种方法。
快速完成一些冲刺,解决了一些更重要的问题,并开始将继承的,缠结得很乱的混乱变成一个基于六角形体系结构的更好的解决方案,现在是时候应对采用最简单方法进行支持的缺点了JSON序列化。 我们需要做的是将AccountId域对象与与该域无关的事物分离。 也就是说,我们必须移出定义如何序列化此值对象并删除耦合到Jackson的域的部分。 为了实现这一点,我们创建了处理AccountId序列化的Jackson模块:
class AccountIdSerializer extends StdSerializer<AccountId> {
AccountIdSerializer() {
super(AccountId.class);
}
@Override
public void serialize(AccountId accountId, JsonGenerator generator, SerializerProvider provider) throws IOException {
generator.writeString(accountId.asString());
}
}
class AccountIdDeserializer extends StdDeserializer<AccountId> {
AccountIdDeserializer() {
super(AccountId.class);
}
@Override
public AccountId deserialize(JsonParser json, DeserializationContext cxt) throws IOException {
String accountId = json.readValueAs(String.class);
return AccountId.of(accountId);
}
}
class AccountIdSerializationModule extends Module {
@Override
public void setupModule(SetupContext setupContext) {
setupContext.addSerializers(createSerializers());
setupContext.addDeserializers(createDeserializers());
}
private Serializers createSerializers() {
SimpleSerializers serializers = new SimpleSerializers();
serializers.addSerializer(new AccountIdSerializer());
return serializers;
}
private Deserializers createDeserializers() {
SimpleDeserializers deserializers = new SimpleDeserializers();
deserializers.addDeserializer(AccountId.class, new AccountIdDeserializer());
return deserializers;
}
// rest omitted for brevity
}
如果您正在使用Spring Boot进行配置,则只需在应用程序上下文中注册该模块即可:
@Configuration
class JacksonConfig {
@Bean
Module accountIdSerializationModule() {
return new AccountIdSerializationModule();
}
}
实现自定义序列化器也是我们所需要的,因为在所有改进中,我们发现了更多的价值对象,其中一些对象更加复杂-但这是另一篇文章。
翻译自: https://www.javacodegeeks.com/2018/01/refactoring-stringly-typed-systems.html
767 重构字符串