传统案例问题分析
public class User {
Long userId;
String name;
String phone;
String address;
Long repId;
}
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepRepository salesRepRepo;
private UserRepository userRepo;
public User register(String name, String phone, String address)
throws ValidationException {
// 校验逻辑
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
// 此处省略address的校验逻辑
// 取电话号里的区号,然后通过区号找到区域内的SalesRep
String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);
// 最后创建用户,落盘,然后返回
User user = new User();
user.name = name;
user.phone = phone;
user.address = address;
if (rep != null) {
user.repId = rep.repId;
}
return userRepo.save(user);
}
private boolean isValidPhoneNumber(String phone) {
String pattern = "^0[1-9]{2,3}-?\\d{8}$";
return phone.matches(pattern);
}
}
问题1:接口的清晰度
Java代码中,对一个方法来说所有的参数名会在编译时丢失,最后只会留下一个参数类型的列表。所以其实在运行时仅仅是:
User register(String, String, String);
这种情况下在编译器是不会出错的,一般很难通过代码就能发现bug,普通的code review很难发现这种问题,只有在代码运行时才会报错,可能就会导致代码上限之后才会暴露出问题。
service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");
类似的代码情况还有:
// 不得不在方法名上面加上ByXXX来区分
User findByName(String name);
// 不得不在方法名上面加上ByXXX来区分
User findByPhone(String phone);
// 没办法保证参数传递的顺序,如果传递的是 phone, name,编译时也不会出错,在运行时才会报错
User findByNameAndPhone(String name, String phone);
所以,需要有一种办法,让方法入参一目了然,避免入参错误导致的bug。
**
问题2:数据验证和错误处理
传统的做法是直接与业务逻辑代码耦合在一块,就会出现扩展性低,维护困难等问题,违背DRY原则。
if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
throw new ValidationException("phone");
}
# 如果有很多地方用到了phone这个入参,就需要在每个方法里面都加上上面这个验证逻辑,后期调整上也可能会因为某个地方忘记修改而造成bug。
# 如果有个新的需求,需要把入参错误原因返回,那么代码就会变得更复杂
if (phone == null) {
throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
throw new ValidationException("phone格式错误");
}
# 如果有大量这样的代码充斥在项目里面的话,维护成本就很高。
# 最后在这个业务方法里面,会抛出ValidationException,所以需要外部调用try/catch,而业务逻辑异常和数据校验异常被混在一起,是极不合理的。
DRY原则:
在传统的架构里有几个方法能够解决一部分问题,常见的BeanValidation注解或ValidationUtils类,比如:
// Use Bean Validation
User registerWithBeanValidation(
@NotNull @NotBlank String name,
@NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
@NotNull String address
);
// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
ValidationUtils.validateName(name); // throws ValidationException
ValidationUtils.validatePhone(phone);
ValidationUtils.validateAddress(address);
...
}
# 但是传统的方法同样有问题
BeanValidation
- 通常只能解决简单的校验逻辑,复杂的校验逻辑一样要写代码实现定制校验器。
- 在添加了新校验逻辑时,同样会出现某些地方忘记添加注解的情况,DRY原则还是违背了。
ValidationUtil类
- 当大量的校验逻辑集中在一个类里之后,未被了单一职责的原则,导致代码混乱和不可维护。
- 业务异常和校验异常还是会混杂。
问题3:业务代码的清晰度
在这段代码里:
String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);
# 这段代码里,出现一种情况,就是从一些入参里抽取一部分数据,比如上面的phone,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中在抽取部分数据用作其他的作用,比如上面的areaCode。 这种代码叫做:胶水代码。
# 胶水代码:本质是由 外部依赖的服务的入参(areaCode) 并不符合我们 原始的入参(phone)导致的。
# 所以,一个常见的方法是将这段代码抽离出来,编程独立的一个或多个方法
private static String findAreaCode(String phone) {
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}
private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021"};
return Arrays.asList(areas).contains(prefix);
}
# 然后原始代码就变成:
String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);
# 而为了复用以上的方法,可能会抽离出一个静态工具类PhoneUtils。但是需要思考,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件中的时,是否能够找到核心的业务逻辑呢?