点击关注公众号,实用技术文章随时了解 软件工程师和码农最大的区别就是他们写代码的习惯。码农喜欢写重复的代码,而软件工程师则会用各种技巧去掉重复、冗余的代码。 商科同学抱怨业务开发没有技术含量,不使用设计模式,java高级特性,OOP,平时写代码有大量CRUD,无从谈起个人成长。 其实我不这么认为。设计模式和OOP是前辈在大型项目中积累的经验。这些方法用于提高大型项目的可维护性。反射、注解、泛型等高级特性之所以在框架中被广泛使用,是因为框架往往需要使用同一套算法来处理不同的数据结构,而这些特性可以帮助减少代码的重复,提高项目的可维护性。 在我看来,可维护性是大型项目成熟度的重要指标,而提高可维护性的一个非常重要的方法就是减少代码重复。那么为什么这么说呢? 如果多处重复代码实现同一个功能,很容易修改一处而忘记修改另一处,造成Bug 有些代码并不完全重复,但非常相似。修改这些相似的代码,将原来的差异变成相同,很容易出错(复制粘贴)。 今天就从业务代码中最常见的三个需求开始,讲一下如何利用Java中的一些高级特性、设计模式以及一些工具来消除重复代码,从而能够既优雅又优雅高端。通过今天的学习,也希望能改变大家认为业务代码没有技术含量的看法。 1。使用工厂模式+模板方法模式消除if…else和重复代码 假设你要开发一个购物车下单功能,针对不同的用户进行不同的处理: 普通用户需要支付运费,运费为产品价格的10%,无产品折扣; VIP用户还需收取产品价格10%的快递费,但购买两件及以上相同产品时,第三件将享受一定折扣; 内部用户可享受免运费且无产品折扣。 我们的目标是实现三种类型的购物车业务逻辑,并将输入参数Map对象(Key为商品ID,Value为商品数量)转换为输出购物车类型Cart。 首先实现普通用户的购物车处理逻辑://购物车 @Data public class购物车 { //产品列表 私有列表项目=新ArrayList<> (); //总折扣 private BigDecimal TotalDiscount; //商品总价 private BigDecimaltotalItemPrice; //总运费 private BigDecimaltotalDeliveryPrice; //应付总价 private BigDecimal payPrice; } //购物车中的商品 @Data public class 项目 { //产品ID private long id; //商品数量 私人 int数量; //商品单价 private BigDecimal 价格; //产品折扣 私人 BigDecimal couponPrice; //商品运费 私人大十进制送货价格; } //普通用户购物车处理public class NormalUserCart { 公共购物车进程(long用户ID,地图项目) { 购物车 cart = new Cart(); //将Map购物车转换为Item列表 List itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); Item.setId(entry.getKey()) ; Item.setPrice(Db.getItemPrice(entry.getKey() ))); .getValue()); //处理运输和产品折扣 www.sychzs.cn().forEach(item -> { item.getPrice().multiply(BigDecimal.valueOf(item. getQuantity())).multiply(new BigDecimal("0.1"))); }); cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(www.sychzs.cn, BigDecimal: ; ERO,BigDecimal: ; BigDecimal:: 添加)); //应付总价=商品总价+运费总价-折扣总额 ( cart.getTotalDiscount())); 返回购物车; 然后实现VIP用户的购物车逻辑。与普通用户的购物车逻辑不同的是,VIP用户购买更多同款商品可以享受折扣。所以这部分代码只需要对多买折扣部分进行额外处理:公共类VipUserCart{(... www.sychzs.cn().forEach(item - > { 为费 // 商品总价的10% Item.setDeliveryPrice(item.getprice().Multiply(bigdecimal.Valueof(item.getQuality())).BigDecimal(" 0.1"))); if (item .getQuantity() > 2) { 。(BigDecimal.value) Of(100 - Db.getUserCouponPercent(userId) ).divide(new BigDecimal("100"))) - 2))); { } }); 返回购物车; } } 最后,免运费,没有折扣的内部用户在处理产品折扣和运费时也只是逻辑上的区别: 公共 类InternalUserCart{(... item.setCouponPrice(www.sychzs.cn); . .. 返回购物车; } 对比代码量可以发现,三个购物车中有70%的代码是重复的。原因很简单。虽然不同类型的用户计算运费和折扣的方式不同,但初始化整个购物车、统计总价、总运费、总折扣和支付价格的逻辑是相同的。 正如我们一开始提到的,代码重复本身并不可怕,可怕的是遗漏或出错。比如,有同学写了一个VIP用户购物车,发现商品总价的计算有bug。不要将所有商品的价格加在一起,而应将所有商品的价格*数量加在一起。 这个时候他可能只会修改VIP用户购物车的代码,而忽略普通用户和内部用户购物车中同样的bug。 我们有了三个购物车后,我们需要根据不同的用户类型使用不同的购物车。如下代码所示,通过三个if来实现不同类型用户调用不同购物车的处理方法:@GetMapping(“错误”)公共购物车错误 (@RequestParam("用户ID")int userId) { userCategory = Db.getUserCategory(userId); //普通用户处理逻辑 if (userCategory.equals("普通" )) { NormalUserCart normalUserCart = 新NormalUserCart(); 返回 normalUserCart.process(userId, items); } //VIP用户处理逻辑 if (userCategory.equals() “VIP”)) { VipUserCart vipUserCart = newVipUserCart(); 返回 vipUserCart.process(userId, items); } //内部用户处理逻辑 if (userCategory.equals() “内部”)) { InternalUserCart 内部用户购物车 = 新InternalUserCart(); 返回internalUserCart.process(使用rId,项目); } 返回 null; } 电商营销方式多种多样。未来必然会有更多的用户类型,需要更多的购物车。我们是否可以继续添加更多的购物车类,编写重复的购物车逻辑,并一遍又一遍地编写更多的 if 逻辑? 当然不是,相同的代码只能出现在一个地方! 如果我们记住了抽象类和抽象方法的定义,这个时候我们可能会想,是否可以在抽象类中定义重复的逻辑,让三个购物车只需要实现不同的逻辑呢? 其实这个模式就是模板方法模式。我们在父类中实现了购物车处理流程模板,然后将需要特殊处理的区域留空,即留下抽象方法定义,让子类实现逻辑。由于父类的逻辑不完整,无法单独工作,因此需要将其定义为抽象类。 如下代码所示,AbstractCart抽象类实现了购物车的通用逻辑,并额外定义了两个抽象方法供子类实现。其中,processCouponPrice方法用于计算产品折扣,processDeliveryPrice方法用于计算运费。 公共抽象类AbstractCart { //处理购物的大量重复逻辑cart 在父类中 Implement public Cart process(long userId, Map 项) { = 新 购物车(); ArrayList<>(); items.entrySet().stream() .forEach(entry -> { Item item = new Item(); Item.setId (entry.getKey()) ; .getKey()); ; Cart.setItems(itemList); //让子类处理每件商品的折扣 processDeliveryPrice(userId, item); }); //计算商品总价cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(www.sychzs.cn, BigDecimal::add );); ; //计算应付价格 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); 返回 购物车; } 受保护 抽象 void processCouponPrice(long userId, Item item ); //处理运费的逻辑留给子类实现 protected abstract void 流程发货价格(长用户ID,物品项目); } 有了这个抽象类,三个子类的实现就非常简单了。普通用户的购物车NormalUserCart实现0折扣10%运费的逻辑: @Service(值 = “NormalUserCart”)公共 类 NormalUserCart 扩展 AbstractCart { 受保护 void 处理优惠券价格(long userId , 商品商品) { item.setCouponPrice(www.sychzs.cn); } @Override 受保护 void 处理发货价格(长) userId, 商品商品) { item.setDeliveryPrice(item .getPrice() .multiply(BigDecimal.valueOf(item.getQuantity())) .乘(新 BigDecimal("0.1"))); } } VIP用户的购物车VipUserCart,直接继承NormalUserCart,只需修改多买折扣策略: @服务(值 = “VipUserCart”) 公共 类 VipUserCart 延伸 NormalUserCart { 受保护 void processCouponPrice( Slong userid, item item) { IF (item.getquantity ()> 2) { ivem.setCouponprice (item.getprice ( ) .multiply(BigDecimal.valueOf( 100 - Db.getUserCouponPercent(userId)).divide(new BigDe cimal("100"))) .multiply(BigDecimal.valueOf(item.getQuantity( ) - 2))); } else { Item.setCouponPrice(www.sychzs.cn); } } } 内部用户购物车InternalUserCart最简单,只需设置0运费和0折扣:@Service(值= “InternalUserCart”) 公共 类 InternalUserCart 扩展 AbstractCart { @Override 受保护 void 处理优惠券价格(长) 用户 ID, 商品 商品) { item.setCouponPrice(www.sychzs.cn); } @Override 受保护 void 流程交货价格(长) userId, Item item) { item.setDeliveryPrice(www.sychzs.cn); } } 抽象类和三个子类的实现关系图,如下所示: 是不是比三个独立的购物车程序简单了很多呢?接下来,我们再看看如何能避免三个if逻辑。或许你注意到了,在定义三个购物车子类时,我们在@Service注解中命名了Bean。由于三个购物车都被称为XXXUserCart,我们可以将用户类型字符串UserCart串联起来形成购物车bean的名称,然后使用spring的IoC容器来传递豆子的名称。直接获取AbstractCart并调用其处理方法即可实现通用。 其实这就是工厂模式,只是借助Spring容器实现的: @GetMapping(“右”)公共购物车右(@RequestParam("用户ID")int userId) { String userCategory = Db.getUserCategory(userId); AbstractCart 购物车 = (AbstractCart) applicationContext.getBean(userCategory + "UserCart"); 返回 cart.process(userId, items); } 试想一下,如果以后有新的用户类型、新的用户逻辑,不需要对代码做任何修改。只需添加一个新的XXXUserCart类继承AbstractCart,是否足以实现特殊折扣和运费处理逻辑? 这样,我们采用工厂模式+模板方法模式,既消除了重复代码,又避免了修改现有代码的风险。这就是设计模式中的开闭原则:对修改封闭,对扩展开放。 2。使用注解+反射消除重复代码你是不是有点兴奋?业务代码其实可以是OOP的。我们再看另外一个调用第三方接口的案例,这也是一个普通的业务逻辑。 假设银行提供了一些API接口,参数的序列化有点特殊。我们需要将参数放在一起以形成一个大字符串,而不是使用 JSON。 按照银行提供的API文档的顺序,将所有参数组成定长数据,然后拼接在一起作为整个字符串。 因为每个参数的长度都是固定的,所以当未达到长度时需要填充: 如果字符串类型参数长度不够,需要用下划线右补,即字符串内容在左边; 如果数字类型参数长度不够,则向左补0,即实际数字在右边; 货币类型的表示需要将金额向下舍入2位数为分,左填充也按数字类型进行。 对所有参数进行MD5运算作为签名(为了便于理解,Demo中不涉及salt处理)。 例如创建用户方式和支付方式的定义如下: 代码很容易实现。可以根据接口定义直接实现填充操作、添加签名、请求调用操作:公共 类 银行服务 { //创建用户方法 public 静态字符串createUser(字符串名称,字符串身份,字符串移动,int年龄) 抛出 IOException { StringBuilder stringBuilder = new StringBuilder(); 字符串生成器 stringBuilder.append(String.format("%- 10s", name).replace(' ', '_ ') ); //字符串向左,多余部分补齐_ stringBuilder.append(String.format("%-18s",identity).replace ( '','_')); //数字在右边,多余的地方补0 年龄)); //字符串向左移动,多余的空格用_填充,'_')); //最后添加MD5作为签名 stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); ion/bank/createUser") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } //付款方式 public static String pay( long userId, BigDecimal 金额) 抛出 IOException ? "%020d", userId)); //金额四舍五入为分,单位为分,作为右侧的数字,多余的部分用 0 stringBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN)) 填充。 multiply(new BigDecimal("100")).longValue())); //最后加上MD 5作为签名stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); /reflection/bank/pay") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } } 可以看一下,这段代码的重复粒度更细: 三种标准数据类型的处理逻辑重复,一不小心可能会出现bug; 所有方法中都会重复处理过程中的字符串拼接、签名、请求的逻辑; 实际方法的参数类型和输入参数的顺序不一定与接口要求一致,容易出错; 代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错的概率极大。 那应该如何改造这段代码呢?没错,就是要用注解和反射! 使用注解和反射这两个武器,就可以针对银行请求的所有逻辑均使用一套代码实现,不会出现任何重复。 要实现接口逻辑和逻辑实现的剥离,首先需要以 POJO 类(只有属性没有任何业务逻辑的数据类)的方式定义所有的接口参数。比如,下面这个创建用户 API 的参数: @Data public class CreateUserAPI { private String name; private String identity; private String mobile; private int age; } 有了接口参数定义,我们就能通过自定义注解为接口和所有参数增加一些元数据。如下所示,我们定义一个接口 API 的注解 BankAPI,包含接口 URL 地址和接口说明: @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented @Inherited public @interface BankAPI { String desc() default ""; String url() default ""; } 然后,我们再定义一个自定义注解 @BankAPIField,用于描述接口的每一个字段规范,包含参数的次序、类型和长度三个属性: @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @Documented @Inherited public @interface BankAPIField { int order() default -1; int length() default -1; String type() default ""; } 接下来,注解就可以发挥威力了。 如下所示,我们定义了 CreateUserAPI 类描述创建用户接口的信息,通过为接口增加 @BankAPI 注解,来补充接口的 URL 和描述等元数据;通过为每一个字段增加 @BankAPIField 注解,来补充参数的顺序、类型和长度等元数据: @BankAPI(url = "/bank/createUser", desc = "创建用户接口") @Data public class CreateUserAPI extends AbstractAPI { @BankAPIField(order = 1, type = "S", length = 10) private String name; @BankAPIField(order = 2, type = "S", length = 18) private String identity; @BankAPIField(order = 4, type = "S", length = 11) //注意这里的order需要按照API表格中的顺序 private String mobile; @BankAPIField(order = 3, type = "N", length = 5) private int age; } 另一个 PayAPI 类也是类似的实现: @BankAPI(url = "/bank/pay", desc = "支付接口") @Data public class PayAPI extends AbstractAPI { @BankAPIField(order = 1, type = "N", length = 20) private long userId; @BankAPIField(order = 2, type = "M", length = 10) private BigDecimal amount; } 这 2 个类继承的 AbstractAPI 类是一个空实现,因为这个案例中的接口并没有公共数据可以抽象放到基类 通过这 2 个类,我们可以在几秒钟内完成和 API 清单表格的核对。理论上,如果我们的核心翻译过程(也就是把注解和接口 API 序列化为请求需要的字符串的过程)没问题,只要注解和表格一致,API 请求的翻译就不会有任何问题。 以上,我们通过注解实现了对 API 参数的描述。接下来,我们再看看反射如何配合注解实现动态的接口参数组装: 第 3 行代码中,我们从类上获得了 BankAPI 注解,然后拿到其 URL 属性,后续进行远程调用。 第 6~9 行代码,使用 stream 快速实现了获取类中所有带 BankAPIField 注解的字段,并把字段按 order 属性排序,然后设置私有字段反射可访问。 第 12~38 行代码,实现了反射获取注解的值,然后根据 BankAPIField 拿到的参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中在了这一处。 第 41~48 行代码,实现了参数加签和请求调用。 private static String remoteCall(AbstractAPI api) throws IOException { //从BankAPI注解获取请求地址 BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class); bankAPI.url(); StringBuilder stringBuilder = new StringBuilder(); www.sychzs.cn(api.getClass().getDeclaredFields()) //获得所有字段 .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段 .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根据注解中的order对字段排序 .peek(field -> field.setAccessible(true)) //设置可以访问私有字段 .forEach(field -> { //获得注解 BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class); Object value = ""; try { //反射获取字段值 value = field.get(api); } catch (IllegalAccessException e) { e.printStackTrace(); } //根据字段类型以正确的填充方式格式化字符串 switch (bankAPIField.type()) { case "S": { stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ', '_')); break; } case "N": { stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ', '0')); break; } case "M": { if (!(value instanceof BigDecimal)) throw new RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field)); stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); break; } default: break; } }); //签名逻辑 stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); String param = stringBuilder.toString(); long begin = System.currentTimeMillis(); //发请求 String result = www.sychzs.cn("http://localhost:45678/reflection" + bankAPI.url()) .bodyString(param, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); www.sychzs.cn("调用银行API {} url:{} 参数:{} 耗时:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin); return result; } 可以看到,所有处理参数排序、填充、加签、请求调用的核心逻辑,都汇聚在了 remoteCall 方法中。有了这个核心方法,BankService 中每一个接口的实现就非常简单了,只是参数的组装,然后调用 remoteCall 即可。 //创建用户方法 public static String createUser(String name, String identity, String mobile, int age) throws IOException { CreateUserAPI createUserAPI = new CreateUserAPI(); createUserAPI.setName(name); createUserAPI.setIdentity(identity); createUserAPI.setAge(age); createUserAPI.setMobile(mobile); return remoteCall(createUserAPI); } //支付方法 public static String pay(long userId, BigDecimal amount) throws IOException { PayAPI payAPI = new PayAPI(); payAPI.setUserId(userId); payAPI.setAmount(amount); return remoteCall(payAPI); } 其实,许多涉及类结构性的通用处理,都可以按照这个模式来减少重复代码。 反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。 3. 利用属性拷贝工具消除重复代码 最后,我们再来看一种业务代码中经常出现的代码逻辑,实体之间的转换复制。 对于三层架构的系统,考虑到层之间的解耦隔离以及每一层对数据的不同需求,通常每一层都会有自己的 POJO 作为数据实体。比如,数据访问层的实体一般叫作 DataObject 或 DO,业务逻辑层的实体一般叫作 Domain,表现层的实体一般叫作 Data Transfer Object 或 DTO。 这里我们需要注意的是,如果手动写这些实体之间的赋值代码,同样容易出错。 对于复杂的业务系统,实体有几十甚至几百个属性也很正常。就比如 ComplicatedOrderDTO 这个数据传输对象,描述的是一个订单中的几十个属性。如果我们要把这个 DTO 转换为一个类似的 DO,复制其中大部分的字段,然后把数据入库,势必需要进行很多属性映射赋值操作。就像这样,密密麻麻的代码是不是已经让你头晕了? ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); orderDO.setAcceptDate(orderDTO.getAcceptDate()); orderDO.setAddress(orderDTO.getAddress()); orderDO.setAddressId(orderDTO.getAddressId()); orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCommentable(orderDTO.isComplainable()); //属性错误 orderDO.setComplainable(orderDTO.isCommentable()); //属性错误 orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCouponAmount(orderDTO.getCouponAmount()); orderDO.setCouponId(orderDTO.getCouponId()); orderDO.setCreateDate(orderDTO.getCreateDate()); orderDO.setDirectCancelable(orderDTO.isDirectCancelable()); orderDO.setDeliverDate(orderDTO.getDeliverDate()); orderDO.setDeliverGroup(orderDTO.getDeliverGroup()); orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus()); orderDO.setDeliverMethod(orderDTO.getDeliverMethod()); orderDO.setDeliverPrice(orderDTO.getDeliverPrice()); orderDO.setDeliveryManId(orderDTO.getDeliveryManId()); orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //对象错误 如果不是代码中有注释,你能看出其中的诸多问题吗? 如果原始的 DTO 有 100 个字段,我们需要复制 90 个字段到 DO 中,保留 10 个不赋值,最后应该如何校验正确性呢?数数吗?即使数出有 90 行代码,也不一定正确,因为属性可能重复赋值。 有的时候字段命名相近,比如 complainable 和 commentable,容易搞反(第 7 和第 8 行),或者对两个目标字段重复赋值相同的来源字段(比如第 28 行) 明明要把 DTO 的值赋值到 DO 中,却在 set 的时候从 DO 自己取值(比如第 20 行),导致赋值无效。 这段代码并不是我随手写出来的,而是一个真实案例。有位同学就像代码中那样把经纬度赋值反了,因为落库的字段实在太多了。这个 Bug 很久都没发现,直到真正用到数据库中的经纬度做计算时,才发现一直以来都存错了。 修改方法很简单,可以使用类似 BeanUtils 这种 Mapping 工具来做 Bean 的转换,copyProperties 方法还允许我们提供需要忽略的属性: ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); BeanUtils.copyProperties(orderDTO, orderDO, "id"); return orderDO; 总结 第一种代码重复是,有多个并行的类实现相似的代码逻辑。我们可以考虑提取相同逻辑在父类中实现,差异逻辑通过抽象方法留给子类实现。使用类似的模板方法把相同的流程和逻辑固定成模板,保留差异的同时尽可能避免代码重复。同时,可以使用 Spring 的 IoC 特性注入相应的子类,来避免实例化子类时的大量 if…else 代码。 第二种代码重复是,使用硬编码的方式重复实现相同的数据处理算法。我们可以考虑把规则转换为自定义注解,作为元数据对类或对字段、方法进行描述,然后通过反射动态读取这些元数据、字段或调用方法,实现规则参数和规则定义的分离。也就是说,把变化的部分也就是规则的参数放入注解,规则的定义统一处理。 第三种代码重复是,业务代码中常见的 DO、DTO、VO 转换时大量字段的手动赋值,遇到有上百个属性的复杂类型,非常非常容易出错。我的建议是,不要手动进行赋值,考虑使用 Bean 映射工具进行。此外,还可以考虑采用单元测试对所有字段进行赋值正确性校验。 来源:我是程序汪 推荐 Java面试题宝典 技术内卷群,一起来学习!! PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!
软件工程师和码农最大的区别就是他们写代码的习惯。码农喜欢写重复的代码,而软件工程师则会用各种技巧去掉重复、冗余的代码。
商科同学抱怨业务开发没有技术含量,不使用设计模式,java高级特性,OOP,平时写代码有大量CRUD,无从谈起个人成长。
其实我不这么认为。设计模式和OOP是前辈在大型项目中积累的经验。这些方法用于提高大型项目的可维护性。反射、注解、泛型等高级特性之所以在框架中被广泛使用,是因为框架往往需要使用同一套算法来处理不同的数据结构,而这些特性可以帮助减少代码的重复,提高项目的可维护性。
在我看来,可维护性是大型项目成熟度的重要指标,而提高可维护性的一个非常重要的方法就是减少代码重复。那么为什么这么说呢?
如果多处重复代码实现同一个功能,很容易修改一处而忘记修改另一处,造成Bug
有些代码并不完全重复,但非常相似。修改这些相似的代码,将原来的差异变成相同,很容易出错(复制粘贴)。
今天就从业务代码中最常见的三个需求开始,讲一下如何利用Java中的一些高级特性、设计模式以及一些工具来消除重复代码,从而能够既优雅又优雅高端。通过今天的学习,也希望能改变大家认为业务代码没有技术含量的看法。
假设你要开发一个购物车下单功能,针对不同的用户进行不同的处理:
普通用户需要支付运费,运费为产品价格的10%,无产品折扣;
VIP用户还需收取产品价格10%的快递费,但购买两件及以上相同产品时,第三件将享受一定折扣;
内部用户可享受免运费且无产品折扣。
我们的目标是实现三种类型的购物车业务逻辑,并将输入参数Map对象(Key为商品ID,Value为商品数量)转换为输出购物车类型Cart。
首先实现普通用户的购物车处理逻辑:
//购物车 @Data public class购物车 { //产品列表 私有列表项目=新ArrayList<> (); //总折扣 private BigDecimal TotalDiscount; //商品总价 private BigDecimaltotalItemPrice; //总运费 private BigDecimaltotalDeliveryPrice; //应付总价 private BigDecimal payPrice; } //购物车中的商品 @Data public class 项目 { //产品ID private long id; //商品数量 私人 int数量; //商品单价 private BigDecimal 价格; //产品折扣 私人 BigDecimal couponPrice; //商品运费 私人大十进制送货价格; } //普通用户购物车处理public class NormalUserCart { 公共购物车进程(long用户ID,地图项目) { 购物车 cart = new Cart(); //将Map购物车转换为Item列表 List itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); Item.setId(entry.getKey()) ; Item.setPrice(Db.getItemPrice(entry.getKey() ))); .getValue()); //处理运输和产品折扣 www.sychzs.cn().forEach(item -> { item.getPrice().multiply(BigDecimal.valueOf(item. getQuantity())).multiply(new BigDecimal("0.1"))); }); cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(www.sychzs.cn, BigDecimal: ; ERO,BigDecimal: ; BigDecimal:: 添加)); //应付总价=商品总价+运费总价-折扣总额 ( cart.getTotalDiscount())); 返回购物车; 然后实现VIP用户的购物车逻辑。与普通用户的购物车逻辑不同的是,VIP用户购买更多同款商品可以享受折扣。所以这部分代码只需要对多买折扣部分进行额外处理:公共类VipUserCart{(... www.sychzs.cn().forEach(item - > { 为费 // 商品总价的10% Item.setDeliveryPrice(item.getprice().Multiply(bigdecimal.Valueof(item.getQuality())).BigDecimal(" 0.1"))); if (item .getQuantity() > 2) { 。(BigDecimal.value) Of(100 - Db.getUserCouponPercent(userId) ).divide(new BigDecimal("100"))) - 2))); { } }); 返回购物车; } }
然后实现VIP用户的购物车逻辑。与普通用户的购物车逻辑不同的是,VIP用户购买更多同款商品可以享受折扣。所以这部分代码只需要对多买折扣部分进行额外处理:
公共类VipUserCart{(... www.sychzs.cn().forEach(item - > { 为费 // 商品总价的10%
最后,免运费,没有折扣的内部用户在处理产品折扣和运费时也只是逻辑上的区别:
公共 类InternalUserCart{(... item.setCouponPrice(www.sychzs.cn); . .. 返回购物车; }
对比代码量可以发现,三个购物车中有70%的代码是重复的。原因很简单。虽然不同类型的用户计算运费和折扣的方式不同,但初始化整个购物车、统计总价、总运费、总折扣和支付价格的逻辑是相同的。
正如我们一开始提到的,代码重复本身并不可怕,可怕的是遗漏或出错。比如,有同学写了一个VIP用户购物车,发现商品总价的计算有bug。不要将所有商品的价格加在一起,而应将所有商品的价格*数量加在一起。
价格*数量
这个时候他可能只会修改VIP用户购物车的代码,而忽略普通用户和内部用户购物车中同样的bug。
我们有了三个购物车后,我们需要根据不同的用户类型使用不同的购物车。如下代码所示,通过三个if来实现不同类型用户调用不同购物车的处理方法:
@GetMapping(“错误”)公共购物车错误 (@RequestParam("用户ID")int userId) { userCategory = Db.getUserCategory(userId); //普通用户处理逻辑 if (userCategory.equals("普通" )) { NormalUserCart normalUserCart = 新NormalUserCart(); 返回 normalUserCart.process(userId, items); } //VIP用户处理逻辑 if (userCategory.equals() “VIP”)) { VipUserCart vipUserCart = newVipUserCart(); 返回 vipUserCart.process(userId, items); } //内部用户处理逻辑 if (userCategory.equals() “内部”)) { InternalUserCart 内部用户购物车 = 新InternalUserCart(); 返回internalUserCart.process(使用rId,项目); } 返回 null; }
电商营销方式多种多样。未来必然会有更多的用户类型,需要更多的购物车。我们是否可以继续添加更多的购物车类,编写重复的购物车逻辑,并一遍又一遍地编写更多的 if 逻辑?
当然不是,相同的代码只能出现在一个地方!
如果我们记住了抽象类和抽象方法的定义,这个时候我们可能会想,是否可以在抽象类中定义重复的逻辑,让三个购物车只需要实现不同的逻辑呢?
其实这个模式就是模板方法模式。我们在父类中实现了购物车处理流程模板,然后将需要特殊处理的区域留空,即留下抽象方法定义,让子类实现逻辑。由于父类的逻辑不完整,无法单独工作,因此需要将其定义为抽象类。
如下代码所示,AbstractCart抽象类实现了购物车的通用逻辑,并额外定义了两个抽象方法供子类实现。其中,processCouponPrice方法用于计算产品折扣,processDeliveryPrice方法用于计算运费。
AbstractCart
processCouponPrice
processDeliveryPrice
公共抽象类AbstractCart { //处理购物的大量重复逻辑cart 在父类中 Implement public Cart process(long userId, Map 项) { = 新 购物车(); ArrayList<>(); items.entrySet().stream() .forEach(entry -> { Item item = new Item(); Item.setId (entry.getKey()) ; .getKey()); ; Cart.setItems(itemList); //让子类处理每件商品的折扣 processDeliveryPrice(userId, item); }); //计算商品总价cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(www.sychzs.cn, BigDecimal::add );); ; //计算应付价格 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); 返回 购物车; } 受保护 抽象 void processCouponPrice(long userId, Item item ); //处理运费的逻辑留给子类实现 protected abstract void 流程发货价格(长用户ID,物品项目); }
有了这个抽象类,三个子类的实现就非常简单了。普通用户的购物车NormalUserCart实现0折扣10%运费的逻辑:
NormalUserCart
@Service(值 = “NormalUserCart”)公共 类 NormalUserCart 扩展 AbstractCart { 受保护 void 处理优惠券价格(long userId , 商品商品) { item.setCouponPrice(www.sychzs.cn); } @Override 受保护 void 处理发货价格(长) userId, 商品商品) { item.setDeliveryPrice(item .getPrice() .multiply(BigDecimal.valueOf(item.getQuantity())) .乘(新 BigDecimal("0.1"))); } }
VIP用户的购物车VipUserCart,直接继承NormalUserCart,只需修改多买折扣策略:
VipUserCart
@服务(值 = “VipUserCart”) 公共 类 VipUserCart 延伸 NormalUserCart { 受保护 void processCouponPrice( Slong userid, item item) { IF (item.getquantity ()> 2) { ivem.setCouponprice (item.getprice ( ) .multiply(BigDecimal.valueOf( 100 - Db.getUserCouponPercent(userId)).divide(new BigDe cimal("100"))) .multiply(BigDecimal.valueOf(item.getQuantity( ) - 2))); } else { Item.setCouponPrice(www.sychzs.cn); } } }
内部用户购物车InternalUserCart最简单,只需设置0运费和0折扣:
InternalUserCart
@Service(值= “InternalUserCart”) 公共 类 InternalUserCart 扩展 AbstractCart { @Override 受保护 void 处理优惠券价格(长) 用户 ID, 商品 商品) { item.setCouponPrice(www.sychzs.cn); } @Override 受保护 void 流程交货价格(长) userId, Item item) { item.setDeliveryPrice(www.sychzs.cn); } }
抽象类和三个子类的实现关系图,如下所示:
是不是比三个独立的购物车程序简单了很多呢?接下来,我们再看看如何能避免三个if逻辑。
或许你注意到了,在定义三个购物车子类时,我们在@Service注解中命名了Bean。由于三个购物车都被称为XXXUserCart,我们可以将用户类型字符串UserCart串联起来形成购物车bean的名称,然后使用spring的IoC容器来传递豆子的名称。直接获取AbstractCart并调用其处理方法即可实现通用。
@Service
XXXUserCart
其实这就是工厂模式,只是借助Spring容器实现的:
@GetMapping(“右”)公共购物车右(@RequestParam("用户ID")int userId) { String userCategory = Db.getUserCategory(userId); AbstractCart 购物车 = (AbstractCart) applicationContext.getBean(userCategory + "UserCart"); 返回 cart.process(userId, items); }
试想一下,如果以后有新的用户类型、新的用户逻辑,不需要对代码做任何修改。只需添加一个新的XXXUserCart类继承AbstractCart,是否足以实现特殊折扣和运费处理逻辑?
这样,我们采用工厂模式+模板方法模式,既消除了重复代码,又避免了修改现有代码的风险。这就是设计模式中的开闭原则:对修改封闭,对扩展开放。
你是不是有点兴奋?业务代码其实可以是OOP的。我们再看另外一个调用第三方接口的案例,这也是一个普通的业务逻辑。
假设银行提供了一些API接口,参数的序列化有点特殊。我们需要将参数放在一起以形成一个大字符串,而不是使用 JSON。
例如创建用户方式和支付方式的定义如下:
代码很容易实现。可以根据接口定义直接实现填充操作、添加签名、请求调用操作:
公共 类 银行服务 { //创建用户方法 public 静态字符串createUser(字符串名称,字符串身份,字符串移动,int年龄) 抛出 IOException { StringBuilder stringBuilder = new StringBuilder(); 字符串生成器 stringBuilder.append(String.format("%- 10s", name).replace(' ', '_ ') ); //字符串向左,多余部分补齐_ stringBuilder.append(String.format("%-18s",identity).replace ( '','_')); //数字在右边,多余的地方补0 年龄)); //字符串向左移动,多余的空格用_填充,'_')); //最后添加MD5作为签名 stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); ion/bank/createUser") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } //付款方式 public static String pay( long userId, BigDecimal 金额) 抛出 IOException ? "%020d", userId)); //金额四舍五入为分,单位为分,作为右侧的数字,多余的部分用 0 stringBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN)) 填充。 multiply(new BigDecimal("100")).longValue())); //最后加上MD 5作为签名stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); /reflection/bank/pay") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } }
可以看一下,这段代码的重复粒度更细:
三种标准数据类型的处理逻辑重复,一不小心可能会出现bug;
所有方法中都会重复处理过程中的字符串拼接、签名、请求的逻辑;
实际方法的参数类型和输入参数的顺序不一定与接口要求一致,容易出错;
代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错的概率极大。
那应该如何改造这段代码呢?没错,就是要用注解和反射!
使用注解和反射这两个武器,就可以针对银行请求的所有逻辑均使用一套代码实现,不会出现任何重复。
要实现接口逻辑和逻辑实现的剥离,首先需要以 POJO 类(只有属性没有任何业务逻辑的数据类)的方式定义所有的接口参数。比如,下面这个创建用户 API 的参数:
@Data public class CreateUserAPI { private String name; private String identity; private String mobile; private int age; }
有了接口参数定义,我们就能通过自定义注解为接口和所有参数增加一些元数据。如下所示,我们定义一个接口 API 的注解 BankAPI,包含接口 URL 地址和接口说明:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented @Inherited public @interface BankAPI { String desc() default ""; String url() default ""; }
然后,我们再定义一个自定义注解 @BankAPIField,用于描述接口的每一个字段规范,包含参数的次序、类型和长度三个属性:
@BankAPIField
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @Documented @Inherited public @interface BankAPIField { int order() default -1; int length() default -1; String type() default ""; }
接下来,注解就可以发挥威力了。
如下所示,我们定义了 CreateUserAPI 类描述创建用户接口的信息,通过为接口增加 @BankAPI 注解,来补充接口的 URL 和描述等元数据;通过为每一个字段增加 @BankAPIField 注解,来补充参数的顺序、类型和长度等元数据:
CreateUserAPI
@BankAPI(url = "/bank/createUser", desc = "创建用户接口") @Data public class CreateUserAPI extends AbstractAPI { @BankAPIField(order = 1, type = "S", length = 10) private String name; @BankAPIField(order = 2, type = "S", length = 18) private String identity; @BankAPIField(order = 4, type = "S", length = 11) //注意这里的order需要按照API表格中的顺序 private String mobile; @BankAPIField(order = 3, type = "N", length = 5) private int age; }
另一个 PayAPI 类也是类似的实现:
@BankAPI(url = "/bank/pay", desc = "支付接口") @Data public class PayAPI extends AbstractAPI { @BankAPIField(order = 1, type = "N", length = 20) private long userId; @BankAPIField(order = 2, type = "M", length = 10) private BigDecimal amount; }
这 2 个类继承的 AbstractAPI 类是一个空实现,因为这个案例中的接口并没有公共数据可以抽象放到基类
通过这 2 个类,我们可以在几秒钟内完成和 API 清单表格的核对。理论上,如果我们的核心翻译过程(也就是把注解和接口 API 序列化为请求需要的字符串的过程)没问题,只要注解和表格一致,API 请求的翻译就不会有任何问题。
以上,我们通过注解实现了对 API 参数的描述。接下来,我们再看看反射如何配合注解实现动态的接口参数组装:
第 3 行代码中,我们从类上获得了 BankAPI 注解,然后拿到其 URL 属性,后续进行远程调用。
第 6~9 行代码,使用 stream 快速实现了获取类中所有带 BankAPIField 注解的字段,并把字段按 order 属性排序,然后设置私有字段反射可访问。
第 12~38 行代码,实现了反射获取注解的值,然后根据 BankAPIField 拿到的参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中在了这一处。
第 41~48 行代码,实现了参数加签和请求调用。
private static String remoteCall(AbstractAPI api) throws IOException { //从BankAPI注解获取请求地址 BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class); bankAPI.url(); StringBuilder stringBuilder = new StringBuilder(); www.sychzs.cn(api.getClass().getDeclaredFields()) //获得所有字段 .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段 .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根据注解中的order对字段排序 .peek(field -> field.setAccessible(true)) //设置可以访问私有字段 .forEach(field -> { //获得注解 BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class); Object value = ""; try { //反射获取字段值 value = field.get(api); } catch (IllegalAccessException e) { e.printStackTrace(); } //根据字段类型以正确的填充方式格式化字符串 switch (bankAPIField.type()) { case "S": { stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ', '_')); break; } case "N": { stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ', '0')); break; } case "M": { if (!(value instanceof BigDecimal)) throw new RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field)); stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); break; } default: break; } }); //签名逻辑 stringBuilder.append(www.sychzs.cn2Hex(stringBuilder.toString())); String param = stringBuilder.toString(); long begin = System.currentTimeMillis(); //发请求 String result = www.sychzs.cn("http://localhost:45678/reflection" + bankAPI.url()) .bodyString(param, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); www.sychzs.cn("调用银行API {} url:{} 参数:{} 耗时:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin); return result; }
可以看到,所有处理参数排序、填充、加签、请求调用的核心逻辑,都汇聚在了 remoteCall 方法中。有了这个核心方法,BankService 中每一个接口的实现就非常简单了,只是参数的组装,然后调用 remoteCall 即可。
//创建用户方法 public static String createUser(String name, String identity, String mobile, int age) throws IOException { CreateUserAPI createUserAPI = new CreateUserAPI(); createUserAPI.setName(name); createUserAPI.setIdentity(identity); createUserAPI.setAge(age); createUserAPI.setMobile(mobile); return remoteCall(createUserAPI); } //支付方法 public static String pay(long userId, BigDecimal amount) throws IOException { PayAPI payAPI = new PayAPI(); payAPI.setUserId(userId); payAPI.setAmount(amount); return remoteCall(payAPI); }
其实,许多涉及类结构性的通用处理,都可以按照这个模式来减少重复代码。
反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。
最后,我们再来看一种业务代码中经常出现的代码逻辑,实体之间的转换复制。
对于三层架构的系统,考虑到层之间的解耦隔离以及每一层对数据的不同需求,通常每一层都会有自己的 POJO 作为数据实体。比如,数据访问层的实体一般叫作 DataObject 或 DO,业务逻辑层的实体一般叫作 Domain,表现层的实体一般叫作 Data Transfer Object 或 DTO。
Data Transfer Object
这里我们需要注意的是,如果手动写这些实体之间的赋值代码,同样容易出错。
对于复杂的业务系统,实体有几十甚至几百个属性也很正常。就比如 ComplicatedOrderDTO 这个数据传输对象,描述的是一个订单中的几十个属性。如果我们要把这个 DTO 转换为一个类似的 DO,复制其中大部分的字段,然后把数据入库,势必需要进行很多属性映射赋值操作。就像这样,密密麻麻的代码是不是已经让你头晕了?
ComplicatedOrderDTO
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); orderDO.setAcceptDate(orderDTO.getAcceptDate()); orderDO.setAddress(orderDTO.getAddress()); orderDO.setAddressId(orderDTO.getAddressId()); orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCommentable(orderDTO.isComplainable()); //属性错误 orderDO.setComplainable(orderDTO.isCommentable()); //属性错误 orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCouponAmount(orderDTO.getCouponAmount()); orderDO.setCouponId(orderDTO.getCouponId()); orderDO.setCreateDate(orderDTO.getCreateDate()); orderDO.setDirectCancelable(orderDTO.isDirectCancelable()); orderDO.setDeliverDate(orderDTO.getDeliverDate()); orderDO.setDeliverGroup(orderDTO.getDeliverGroup()); orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus()); orderDO.setDeliverMethod(orderDTO.getDeliverMethod()); orderDO.setDeliverPrice(orderDTO.getDeliverPrice()); orderDO.setDeliveryManId(orderDTO.getDeliveryManId()); orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //对象错误
如果不是代码中有注释,你能看出其中的诸多问题吗?
如果原始的 DTO 有 100 个字段,我们需要复制 90 个字段到 DO 中,保留 10 个不赋值,最后应该如何校验正确性呢?数数吗?即使数出有 90 行代码,也不一定正确,因为属性可能重复赋值。
有的时候字段命名相近,比如 complainable 和 commentable,容易搞反(第 7 和第 8 行),或者对两个目标字段重复赋值相同的来源字段(比如第 28 行)
complainable
commentable
明明要把 DTO 的值赋值到 DO 中,却在 set 的时候从 DO 自己取值(比如第 20 行),导致赋值无效。
这段代码并不是我随手写出来的,而是一个真实案例。有位同学就像代码中那样把经纬度赋值反了,因为落库的字段实在太多了。这个 Bug 很久都没发现,直到真正用到数据库中的经纬度做计算时,才发现一直以来都存错了。
修改方法很简单,可以使用类似 BeanUtils 这种 Mapping 工具来做 Bean 的转换,copyProperties 方法还允许我们提供需要忽略的属性:
copyProperties
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); BeanUtils.copyProperties(orderDTO, orderDO, "id"); return orderDO;
第一种代码重复是,有多个并行的类实现相似的代码逻辑。我们可以考虑提取相同逻辑在父类中实现,差异逻辑通过抽象方法留给子类实现。使用类似的模板方法把相同的流程和逻辑固定成模板,保留差异的同时尽可能避免代码重复。同时,可以使用 Spring 的 IoC 特性注入相应的子类,来避免实例化子类时的大量 if…else 代码。
if…else
第二种代码重复是,使用硬编码的方式重复实现相同的数据处理算法。我们可以考虑把规则转换为自定义注解,作为元数据对类或对字段、方法进行描述,然后通过反射动态读取这些元数据、字段或调用方法,实现规则参数和规则定义的分离。也就是说,把变化的部分也就是规则的参数放入注解,规则的定义统一处理。
第三种代码重复是,业务代码中常见的 DO、DTO、VO 转换时大量字段的手动赋值,遇到有上百个属性的复杂类型,非常非常容易出错。我的建议是,不要手动进行赋值,考虑使用 Bean 映射工具进行。此外,还可以考虑采用单元测试对所有字段进行赋值正确性校验。
来源:我是程序汪
推荐
Java面试题宝典
技术内卷群,一起来学习!!
PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!