代码优化之代码重复
背景
//可维护性是大型项目成熟度的一个重要指标,而提升可维护性非常重要的一个手段就是减少代码重复 1. 如果多处重复代码实现完全相同的功能,很容易修改一处忘记修改另一处,造成 Bug; 2. 有一些代码并不是完全重复,而是相似度很高,修改这些类似的代码容易改(复制粘贴)错,把原本有区别的地方改为了一样。
工厂模式+模板方法模式实现对修改关闭对扩展开放
目标需求
//开发一个购物车下单功能,针对不同用户进行不同处理 1. 普通用户需要收取运费,运费是商品价格的 10%,无商品折扣; 2. VIP 用户同样需要收取商品价格 10% 的快递费,但购买两件以上相同商品时,第三件开始享受一定折扣; 3. 内部用户可以免运费,无商品折扣。 //目标实现三种类型的购物车业务逻辑,把入参Map对象(key时商品ID,value是商品数量),转换为出参购物车类型cart。
初始代码实现
//普通用户购物车处理逻辑 //购物车 @Data public class Cart { //商品清单 private List<Item> items = new ArrayList<>(); //总优惠 private BigDecimal totalDiscount; //商品总价 private BigDecimal totalItemPrice; //总运费 private BigDecimal totalDeliveryPrice; //应付总价 private BigDecimal payPrice; } //购物车中的商品 @Data public class Item { //商品ID private long id; //商品数量 private int quantity; //商品单价 private BigDecimal price; //商品优惠 private BigDecimal couponPrice; //商品运费 private BigDecimal deliveryPrice; } //普通用户购物车处理 public class NormalUserCart { public Cart process(long userId, Map<Long, Integer> items) { Cart cart = new Cart(); //把Map的购物车转换为Item列表 List<Item> itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); item.setId(entry.getKey()); item.setPrice(Db.getItemPrice(entry.getKey())); item.setQuantity(entry.getValue()); itemList.add(item); }); cart.setItems(itemList); //处理运费和商品优惠 itemList.stream().forEach(item -> { //运费为商品总价的10% item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1"))); //无优惠 item.setCouponPrice(BigDecimal.ZERO); }); //计算商品总价 cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算运费总价 cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总优惠 cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //应付总价=商品总价+运费总价-总优惠 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); return cart; } } //VIP用户购物车逻辑:与普通购物车逻辑不同在于VIP用户能享受同类商品多买的折扣,执行额外处理多买折扣部分; public class VipUserCart { public Cart process(long userId, Map<Long, Integer> items) { ... itemList.stream().forEach(item -> { //运费为商品总价的10% item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1"))); //购买两件以上相同商品,第三件开始享受一定折扣 if (item.getQuantity() > 2) { item.setCouponPrice(item.getPrice() .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100"))) .multiply(BigDecimal.valueOf(item.getQuantity() - 2))); } else { item.setCouponPrice(BigDecimal.ZERO); } }); ... return cart; } } //免运费、无折扣内部用户:只是处理商品折扣和运费时的逻辑差异 public class InternalUserCart { public Cart process(long userId, Map<Long, Integer> items) { ... itemList.stream().forEach(item -> { //免运费 item.setDeliveryPrice(BigDecimal.ZERO); //无优惠 item.setCouponPrice(BigDecimal.ZERO); }); ... return cart; } } //通过代码量可以发现,三种购物车70%代码重复。 //因为不同类型用户计算运费和优惠的方式不同, //但整个购物车初始化、统计总价、总运费、总优惠和支付价格的逻辑一样。 //代码重复本身不可怕,可怕的时漏改或改错,比如VIP用户购物车商品总价计算bug //不应该是把所有 Item 的 price 加在一起,而是应该把所有 Item 的 price*quantity 加在一起。 //这时,可能会只修改 VIP 用户购物车的代码,而忽略了普通用户、内部用户的购物车中,重复的逻辑实现也有相同的 Bug。 //根据不同用户类型使用不同购物车,使用三个if实现不同类型用户调用不同购物车的process方法 @GetMapping("wrong") public Cart wrong(@RequestParam("userId") int userId) { //根据用户ID获得用户类型 String userCategory = Db.getUserCategory(userId); //普通用户处理逻辑 if (userCategory.equals("Normal")) { NormalUserCart normalUserCart = new NormalUserCart(); return normalUserCart.process(userId, items); } //VIP用户处理逻辑 if (userCategory.equals("Vip")) { VipUserCart vipUserCart = new VipUserCart(); return vipUserCart.process(userId, items); } //内部用户处理逻辑 if (userCategory.equals("Internal")) { InternalUserCart internalUserCart = new InternalUserCart(); return internalUserCart.process(userId, items); } return null; } //这里若是后续添加更多用户类型和购物车类,将会重复的购物车逻辑和if逻辑
模板方法模式与工厂模式改造
//在父类中实现了购物车处理的流程模板,然后把需要特殊处理的地方留空白也就是留抽象方法定义,让子类去实现其中的逻辑。 //由于父类的逻辑不完整无法单独工作,因此需要定义为抽象类。 //AbstractCart 抽象类实现了购物车通用的逻辑,额外定义了两个抽象方法让子类去实现。 //其中,processCouponPrice 方法用于计算商品折扣,processDeliveryPrice 方法用于计算运费。 public abstract class AbstractCart { //处理购物车的大量重复逻辑在父类实现 public Cart process(long userId, Map<Long, Integer> items) { Cart cart = new Cart(); List<Item> itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); item.setId(entry.getKey()); item.setPrice(Db.getItemPrice(entry.getKey())); item.setQuantity(entry.getValue()); itemList.add(item); }); cart.setItems(itemList); //让子类处理每一个商品的优惠 itemList.stream().forEach(item -> { processCouponPrice(userId, item); processDeliveryPrice(userId, item); }); //计算商品总价 cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总运费 cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总折扣 cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算应付价格 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); return cart; } //处理商品优惠的逻辑留给子类实现 protected abstract void processCouponPrice(long userId, Item item); //处理配送费的逻辑留给子类实现 protected abstract void processDeliveryPrice(long userId, Item item); } //普通用户的购物车 NormalUserCart,实现的是 0 优惠和 10% 运费的逻辑 @Service(value = "NormalUserCart") public class NormalUserCart extends AbstractCart { @Override protected void processCouponPrice(long userId, Item item) { item.setCouponPrice(BigDecimal.ZERO); } @Override protected void processDeliveryPrice(long userId, Item item) { item.setDeliveryPrice(item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity())) .multiply(new BigDecimal("0.1"))); } } //VIP 用户的购物车 VipUserCart,直接继承了 NormalUserCart,只需要修改多买优惠策略 @Service(value = "VipUserCart") public class VipUserCart extends NormalUserCart { @Override protected void processCouponPrice(long userId, Item item) { if (item.getQuantity() > 2) { item.setCouponPrice(item.getPrice() .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100"))) .multiply(BigDecimal.valueOf(item.getQuantity() - 2))); } else { item.setCouponPrice(BigDecimal.ZERO); } } } //内部用户购物车 InternalUserCart 是最简单的,直接设置 0 运费和 0 折扣即可: @Service(value = "InternalUserCart") public class InternalUserCart extends AbstractCart { @Override protected void processCouponPrice(long userId, Item item) { item.setCouponPrice(BigDecimal.ZERO); } @Override protected void processDeliveryPrice(long userId, Item item) { item.setDeliveryPrice(BigDecimal.ZERO); } } //定义三个购物车子类,在@Service注解中对Bean进行命名。 //使用Spring IOC容器,通过Bean名称直接获取到AbstractCart,调用其process方法实现通用。 //工厂模式(只不过是借助Spring容器实现罢了) @GetMapping("right") public Cart right(@RequestParam("userId") int userId) { String userCategory = Db.getUserCategory(userId); AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + "UserCart"); return cart.process(userId, items); } //利用工厂模式 + 模板方法模式,不仅消除了重复代码,还避免了修改既有代码的风险。这就是设计模式中的开闭原则:对修改关闭,对扩展开放。
注解+反射去除重复代码
目标需求
1. 按照银行提供的 API 文档的顺序,把所有参数构成定长的数据,然后拼接在一起作为整个字符串 2. 因为每一种参数都有固定长度,未达到长度时需要做填充处理: 2.1 字符串类型的参数不满长度部分需要以下划线右填充,也就是字符串内容靠左; 2.2 数字类型的参数不满长度部分以 0 左填充,也就是实际数字靠右; 2.3 货币类型的表示需要把金额向下舍入 2 位到分,以分为单位,作为数字类型同样进行左填充。 3. 对所有参数做 MD5 操作作为签名(为了方便理解,Demo 中不涉及加盐处理)
初始实现
// 创建用户和支付方法 public class BankService { //创建用户方法 public static String createUser(String name, String identity, String mobile, int age) throws IOException { StringBuilder stringBuilder = new StringBuilder(); //字符串靠左,多余的地方填充_ stringBuilder.append(String.format("%-10s", name).replace(' ', '_')); //字符串靠左,多余的地方填充_ stringBuilder.append(String.format("%-18s", identity).replace(' ', '_')); //数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%05d", age)); //字符串靠左,多余的地方用_填充 stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_')); //最后加上MD5作为签名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://localhost:45678/reflection/bank/createUser") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } //支付方法 public static String pay(long userId, BigDecimal amount) throws IOException { StringBuilder stringBuilder = new StringBuilder(); //数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%020d", userId)); //金额向下舍入2位到分,以分为单位,作为数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); //最后加上MD5作为签名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://localhost:45678/reflection/bank/pay") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } } //三种标准数据类型的处理逻辑有重复,稍有不慎就会出现Bug; //处理流程中字符串拼接、加签和发请求的逻辑,在所有方法重复; //实际方法的入参的参数类型和顺序,不一定和接口要求一致,容易出错; //代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错概率极大。
注解+反射改造
// 接口逻辑与逻辑实现的剥离,定义接口参数 @Data public class CreateUserAPI { private String name; private String identity; private String mobile; private int age; }
//自定义注解为接口和所有参数增加一些元数据 @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 ""; }
//使用注解,定义Create UserAPI类描述创建用户接口,增加@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; }
//支付API @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; } //继承的AbstractAPI类是一个空实现,因为没有公共数据可以抽象到基类
//通过注解实现对API参数的描述,反射如何配合注解实现动态的接口参数组装? //1. 从类上获取BankAPI注解得到URL属性后续远程调用 //2. 使用stream快速实现获取类中所有带BankAPIField注解的字段,并把字段按照order属性排序,然后设置私有字段反射可访问; //3. 实现反射获取注解的值后,根据BankAPIField得到参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中于一处; //4. 实现参数加签和请求调用 private static String remoteCall(AbstractAPI api) throws IOException { //从BankAPI注解获取请求地址 BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class); bankAPI.url(); StringBuilder stringBuilder = new StringBuilder(); Arrays.stream(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(DigestUtils.md2Hex(stringBuilder.toString())); String param = stringBuilder.toString(); long begin = System.currentTimeMillis(); //发请求 String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url()) .bodyString(param, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); log.info("调用银行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); } //总结:涉及类结构的通用处理可以按照这个模式减少重复代码 //反射实现在不知道类结构时按照固定的逻辑处理类的成员 //注解实现成员补充元数据的能力 //所以我们利用反射实现通用逻辑的时候,可以从外部获得更多关心的数据。
属性拷贝工具消除重复代码
//实体间的转换复制 //手动写实体间的赋值代码容易出错 //对于复杂的业务系统,实体几百正常 //如描述一个订单中的几十个属性,要把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()); //对象错误 orderDO.setDeliveryManName(orderDTO.getDeliveryManName()); orderDO.setDistance(orderDTO.getDistance()); orderDO.setExpectDate(orderDTO.getExpectDate()); orderDO.setFirstDeal(orderDTO.isFirstDeal()); orderDO.setHasPaid(orderDTO.isHasPaid()); orderDO.setHeadPic(orderDTO.getHeadPic()); orderDO.setLongitude(orderDTO.getLongitude()); orderDO.setLatitude(orderDTO.getLongitude()); //属性赋值错误 orderDO.setMerchantAddress(orderDTO.getMerchantAddress()); orderDO.setMerchantHeadPic(orderDTO.getMerchantHeadPic()); orderDO.setMerchantId(orderDTO.getMerchantId()); orderDO.setMerchantAddress(orderDTO.getMerchantAddress()); orderDO.setMerchantName(orderDTO.getMerchantName()); orderDO.setMerchantPhone(orderDTO.getMerchantPhone()); orderDO.setOrderNo(orderDTO.getOrderNo()); orderDO.setOutDate(orderDTO.getOutDate()); orderDO.setPayable(orderDTO.isPayable()); orderDO.setPaymentAmount(orderDTO.getPaymentAmount()); orderDO.setPaymentDate(orderDTO.getPaymentDate()); orderDO.setPaymentMethod(orderDTO.getPaymentMethod()); orderDO.setPaymentTimeLimit(orderDTO.getPaymentTimeLimit()); orderDO.setPhone(orderDTO.getPhone()); orderDO.setRefundable(orderDTO.isRefundable()); orderDO.setRemark(orderDTO.getRemark()); orderDO.setStatus(orderDTO.getStatus()); orderDO.setTotalQuantity(orderDTO.getTotalQuantity()); orderDO.setUpdateTime(orderDTO.getUpdateTime()); orderDO.setName(orderDTO.getName()); orderDO.setUid(orderDTO.getUid()); //需要赋值的属性个数;重复赋值;赋值错误等问题 //使用BeanUtils改造 把DTO赋值到DO ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); BeanUtils.copyProperties(orderDTO, orderDO, "id"); return orderDO;
//工具包hutool:https://hutool.cn/
//映射工具:MapStruct:基于 JSR 269 的 Java 注解处理器实现(你可以理解为,它是编译时的代码生成器),使用的是纯 Java 方法而不是反射进行属性赋值,并且做到了编译时类型安全
//IDEA中可以安装 插件MapStruct Support,实现映射配置自动完成、跳转到定义等功能。
原文链接:https://time.geekbang.org/column/article/228964
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)