代码优化重构看这一份资料就够了

164 阅读18分钟

代码优化重构看这一份资料就够了

1. 什么是重构

这里先给重构下一个定义:改善既有代码的设计。

具体来说就是在不改变代码功能行为的情况下,对其内部结构的一种调整。需要注意的是,重构不是代码优化,重构注重的是提高代码的可理解性与可扩展性,对性能的影响可好可坏。而性能优化则让程序运行的更快,当然,最终的代码可能更难理解和维护。

2. 为什么要重构

2.1. 改善程序的内部设计

如果没有重构,在软件不停的版本迭代中,代码的设计只会越来越腐败,导致软件开发寸步难行。 这里的原因主要有两点:

  • 人们只为了短期目的而修改代码时,往往没有完全理解整体的架构设计(在大项目中常有这种情况,比如在不同的地方,使用完全相同的语句做着同样的事情),代码就会失去自己的结构,代码结构的流失具有累积效应,越难看出代码所代表的设计意图,就越难保护其设计。
  • 我们几乎不可能预先做出完美的设计,以面对后续未知的功能开发,只有在实践中才能找到真理。
  • 所以想要体面又快速的开发功能,重构必不可少。

2.2. 使得代码更容易理解

在开发中,我们需要先理解代码在做什么,才能着手修改,很多时候自己写的代码都会忘记其实现,更不论别人的代码。可能在这段代码中有一段糟糕的条件判断逻辑,又或者其变量命名实在糟糕又确实注释,需要花上好一段时间才能明白其真正用意。

合理的重构能让代码“自解释”  ,以方便理解,无论对于协同开发,还是维护先前自己实现的功能,对代码的开发有着立竿见影的效果。

2.3. 提高开发的速度 && 方便定位错误

提高开发的速度可能有点“反直觉”,因为重构在很多时候看来是额外的工作量,并没有新的功能和特性产出,但是减少代码的书写量(复用模块),方便定位错误(代码结构优良),这些能让我们在开发的时候节省大量的时间,在后续的开发中“轻装上阵”。

3. 重构的原则

3.1. 保持当下的编程状态

Kent Beck 提出了“两顶帽子”的比喻,在开发软件时,把自己的时间分配给两种截然不同的行为:添加新功能重构,添加新功能的时候,不应该修改既有的代码,只管添加新功能,并让程序正确运行;在重构时就不能添加新功能,只管调整代码结构,只有在绝对必要时才能修改相关代码。

在开发过程中,我们可能经常变换“帽子”,在新增功能的时候会意识到,如果把程序结构改一下,功能的添加会容易很多,或者实现会更加优雅,于是一会换一顶“帽子”,一边重构,一边新增新功能。这很容易让自己产生混乱,对自己的代码难以理解。

任何时候我们都要清楚自己戴的是哪一顶“帽子”,并专注自己的编程状态,这让我们的目标清晰且过程可控,能对自己编码的进度有掌握。

3.2. 可控制的重构

重构的过程并非一蹴而就,如果因为重构影响了自己对时间的掌控,对函数功能的掌控,那么你就应该及时停下,思考你的行为是否值得。我们必须保证程序的可用性与时间的可控性,并且要保证我们的步伐要小,确保每一步都有 git 管理和代码测试,否则你会陷入程序不可用的中间态,更可怕的是你忘记了之前代码的样子!

使用卫语句

** 反例 **

function getPayAmount() {
  let result
  if (isDead) {
     // do sth and assign to result
  } else {
    if (isSeparated) {
      // do sth and assign to result
    } else {
      if (isRetired) {
        // do sth and assign to result
      } else {
        // do sth and assign to result
      }
    }
  }
  
  return result
}

在阅读该函数时,是否庆幸在 if else 之间的并非代码而是一段注释,如果是一段代码,则让人目眩眼花。那下面的代码呢?

正例

function getPayAmount() {
  if (isDead) return deatAmount()
  if (isSeparated) return serparateAmount()
  if (isRetired) return retiredAmount()
  return normalPayAmount()
}

卫语句的精髓就是给予某条分支特别的重视,它告诉阅读者,这种情况并不是本函数的所关心的核心逻辑,如果它真的发生了,会做一些必要的工作然后提前退出。

分开书写特定业务功能的代码(查询函数与修改函数耦合)

明确的分离“有副作用”和“无副作用”两种函数是一个很好的想法,查询函数和修改函数搭配在平常的开发中也经常出现,是时候将它们分离了! 反例


// 给 2 鹅岁以下的五星员工发邮件鼓励
function getTotalAdnSendEmail() {
  const emailList = programmerList
    .filter(item => item.occupationalAge <= 2 && item.stars === 5)
    .map(item => item.email)
  return sendEmail(emailList)
}

正例

// 分离查询函数,这里可以通过传递参数进一步控制查询的语句
function search() {
  return programmerList
    .filter(item => item.occupationalAge <= 2 && item.stars === 5)
    .map(item => item.email)
}

function send() {
  return sendEmail(search())
}

负责的条件进行合并封装

复杂的条件逻辑是导致复杂度上升的地点之一,代码会告诉我们会发生什么事,可我们常常弄不清为什么会发生这样的事,这就证明代码的可读性大大降低了。是时候将它们封装成一个带有说明的函数了,见文知意,一目了然。


// bad
if (!date.isBefore(plan.summberStart) && !date.isAfter(plan.summberEnd)) {
  charge = quantity * plan.summerRate
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}


// good
if (isSummer()) {
  charge = quantity * plan.summerRate
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}

// perfect
isSummer() ? summerCharge() : regularCharge()

散弹式修改

霰弹式修改与重复代码有点像,当我们需要做出一点小修改时,却要去四处一个个的修正,你不仅很难找到它们,也很容易错过某个重要的修改,直至错误发生!

反例

// File Reading.js
const reading = {customer: "ivan", quantity: 10, month: 5, year: 2017}
function acquireReading() { return reading }
function baseRate(month, year) {
    /* */
}

// File 1
const aReading = acquireReading()
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity

// File 2
const aReading = acquireReading()
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity)
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year))
function taxThreshold(year) { /* */ }

// File 3
const aReading = acquireReading()
const basicChargeAmount = calculateBaseCharge(aReading)
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity
}

在上面的代码中,如果 reading 的逻辑发生了改变,我们需要跨越好几个文件去调整它,这很容易造成遗漏的发生。

由于每个地方都对 reading 进行了操作,那么我们可以将其封装起来,统一在一个文件中进行管理。

正例

// File Reading.js

class Reading {
 constructor(data) {
  this.customer = data.customer
  this.quantity = data.quantity
  this.month = data.month
  this.year = data.year
 }

 get baseRate() {
  /* ... */
 }

 get baseCharge() { 
  return baseRate(this.month, this.year) * this.quantity
 }

 get taxableCharge() {
  return Math.max(0, base - taxThreshold())
 }

 get taxThreshold() {
  /* ... */
 }
}

const reading = new Reading({ customer: 'Evan You', quantity: 10, month: 8, year: 2021 })

所有的相关逻辑在一起,不仅能提供一个共用的环境,也可以简化调用逻辑,更加清晰。

发散式变化

当某个函数会因为不同原因在不同方向上发生变化时,发散式变化就诞生了。这听起来有点迷糊,那么就用代码来解释吧。

function getPrice(order) {
  // 获取基础价格
  const basePrice = order.quantity * order.itemPrice
  // 获取折扣
  const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05
  // 获取运费
  const shipping = Math.min(basePrice * 0.1100)
  // 计算价格
  return basePrice - quantityDiscount + shipping
}

const orderPrice = getPrice(order);
复制代码

这个函数用于计算商品的价格,它的计算包含了基础价格 + 数量折扣 + 运费,如果基础价格的计算规则改变,我们需要修改这个函数;如果折扣规则发生改变,我们需要修改这个函数;如果运费计算规则改变了,我们还是要修改这个函数。

这种修改容易造成混乱,我们当然也希望程序一旦需要修改,我们就够跳到系统的某一点,所以是时候抽离它们了。

// 计算基础价格
function calBasePrice(order) {
    return order.quantity * order.itemPrice
}
// 计算折扣
function calDiscount(order) {
    return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05
}
// 计算运费
function calShipping(basePrice) {
    return Math.min(basePrice * 0.1100)
}
// 计算商品价格
function getPrice(order) {
    return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order))
}

const orderPrice = getPrice(order)
复制代码

虽然该函数行数不多,当其重构的过程与先前的过长函数一致,但是将各个功能抽离处理,有利于更清晰的定位问题与修改。所以过长函数拥有多重臭味道!需要及时消灭。

让代码的性能更高

需要 Map 的主键和取值时,应该迭代 entrySet()

当循环中只需要 Map 的主键时,迭代 keySet() 是正确的。但是,当需要主键和取值时,迭代 entrySet() 才是更高效的做法,比先迭代 keySet() 后再去 get 取值性能更佳。

反例

Map<String, String> map = ...;
for (String key : map.keySet()) {
    String value = map.get(key);
    ...
}

正例:

Map<String, String> map = ...;
for (Map.Entry<String, String> entry : map.entrySet()) {
    String key = entry.getKey();
    String value = entry.getValue();
    ...


}

应该使用Collection.isEmpty()检测空

使用 Collection.size() 来检测空逻辑上没有问题,但是使用 Collection.isEmpty()使得代码更易读,并且可以获得更好的性能。任何 Collection.isEmpty() 实现的时间复杂度都是 O(1) ,但是某些 Collection.size() 实现的时间复杂度可能是 O(n) 。

反例:

if (collection.size() == 0) {
    ...
}

正例:

if (collection.isEmpty()) {
    ...
}

如果需要还需要检测 null ,可采用CollectionUtils.isEmpty(collection)和CollectionUtils.isNotEmpty(collection)。

集合初始化尽量指定大小

java 的集合类用起来十分方便,但是看源码可知,集合也是有大小限制的。每次扩容的时间复杂度很有可能是 O(n) ,所以尽量指定可预知的集合大小,能减少集合的扩容次数。

反例:

int[] arr = new int[]{1, 2, 3};
List<Integer> list = new ArrayList<>();
for (int i : arr) {
    list.add(i);
}

正例:

int[] arr = new int[]{1, 2, 3};
List<Integer> list = new ArrayList<>(arr.length);
for (int i : arr) {


    list.add(i);

}

字符串拼接使用 StringBuilder

一般的字符串拼接在编译期 java 会进行优化,但是在循环中字符串拼接, java 编译期无法做到优化,所以需要使用 StringBuilder 进行替换。

反例:

String s = "";
for (int i = 0; i < 10; i++) {
    s += i;
}

正例:

String a = "a";
String b = "b";
String c = "c";
String s = a + b + c; // 没问题,java编译器会进行优化
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
    sb.append(i);  // 循环中,java编译器无法进行优化,所以要手动使用StringBuilder
}

List 的随机访问

大家都知道数组和链表的区别:数组的随机访问效率更高。当调用方法获取到 List 后,如果想随机访问其中的数据,并不知道该数组内部实现是链表还是数组,怎么办呢?可以判断它是否实现* RandomAccess 接口。

正例:

// 调用别人的服务获取到list
List<Integer> list = otherService.getList();
if (list instanceof RandomAccess) {
    // 内部数组实现,可以随机访问
    System.out.println(list.get(list.size() - 1));
} else {
    // 内部可能是链表实现,随机访问效率低
}

频繁调用 Collection.contains 方法请使用 Set

在 java 集合类库中,List 的 contains 方法普遍时间复杂度是 O(n) ,如果在代码中需要频繁调用 contains 方法查找数据,可以先将 list 转换成 HashSet 实现,将 O(n) 的时间复杂度降为 O(1) 。 反例:

ArrayList<Integer> list = otherService.getList();
for (int i = 0; i <= Integer.MAX_VALUE; i++) {
    // 时间复杂度O(n)
    list.contains(i);
}

正例:

ArrayList<Integer> list = otherService.getList();
Set<Integer> set = new HashSet(list);
for (int i = 0; i <= Integer.MAX_VALUE; i++) {
    // 时间复杂度O(1)
    set.contains(i);
}

让代码更优雅

长整型常量后添加大写 L

在使用长整型常量值时,后面需要添加 L ,必须是大写的 L ,不能是小写的 l ,小写 l 容易跟数字 1 混淆而造成误解。

反例:

long value = 1l;
long max = Math.max(1L, 5);

正例:

long value = 1L;
long max = Math.max(1L, 5L);

不要使用魔法值

当你编写一段代码时,使用魔法值可能看起来很明确,但在调试时它们却不显得那么明确了。这就是为什么需要把魔法值定义为可读取常量的原因。但是,-1、0 和 1不被视为魔法值。 反例:

for (int i = 0; i < 100; i++){
    ...
}
if (a == 100) {
    ...
}

正例:

private static final int MAX_COUNT = 100;
for (int i = 0; i < MAX_COUNT; i++){
    ...
}
if (count == MAX_COUNT) {
    ...


}

不要使用集合实现来赋值静态成员变量

对于集合类型的静态成员变量,不要使用集合实现来赋值,应该使用静态代码块赋值。 反例:

private static Map<String, Integer> map = new HashMap<String, Integer>() {
    {
        put("a", 1);
        put("b", 2);
    }
};

private static List<String> list = new ArrayList<String>() {
    {
        add("a");
        add("b");
    }
};

正例:

private static Map<String, Integer> map = new HashMap<>();
static {
    map.put("a", 1);
    map.put("b", 2);
};

private static List<String> list = new ArrayList<>();
static {
    list.add("a");
    list.add("b");
};

建议使用 try-with-resources 语句

Java 7 中引入了 try-with-resources 语句,该语句能保证将相关资源关闭,优于原来的 try-catch-finally 语句,并且使程序代码更安全更简洁。

反例:

private void handle(String fileName) {
    BufferedReader reader = null;
    try {
        String line;
        reader = new BufferedReader(new FileReader(fileName));
        while ((line = reader.readLine()) != null) {
            ...
        }
    } catch (Exception e) {
        ...
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                ...
            }
        }
    }
}

正例:

private void handle(String fileName) {
    try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
        String line;
        while ((line = reader.readLine()) != null) {
            ...
        }
    } catch (Exception e) {
        ...
    }
}

删除未使用的私有方法和字段

删除未使用的私有方法和字段,使代码更简洁更易维护。若有需要再使用,可以从历史提交中找回。

反例:

public class DoubleDemo1 {
    private int unusedField = 100;
    private void unusedMethod() {
        ...
    }
    public int sum(int a, int b) {
        return a + b;
    }
}

正例:

public class DoubleDemo1 {
    public int sum(int a, int b) {
        return a + b;
    }
}

删除未使用的局部变量

删除未使用的局部变量,使代码更简洁更易维护。

反例:

public int sum(int a, int b) {
    int c = 100;
    return a + b;
}

正例:

public int sum(int a, int b) {
    return a + b;
}

删除未使用的方法参数

未使用的方法参数具有误导性,删除未使用的方法参数,使代码更简洁更易维护。但是,由于重写方法是基于父类或接口的方法定义,即便有未使用的方法参数,也是不能删除的。

反例:

public int sum(int a, int b, int c) {
    return a + b;
}

正例:

public int sum(int a, int b) {
    return a + b;
}

删除表达式的多余括号

对应表达式中的多余括号,有人认为有助于代码阅读,也有人认为完全没有必要。对于一个熟悉 Java 语法的人来说,表达式中的多余括号反而会让代码显得更繁琐。
反例:

return (x);
return (x + 2);
int x = (y * 3) + 1;
int m = (n * 4 + 2);

正例:

return x;
return x + 2;
int x = y * 3 + 1;
int m = n * 4 + 2;

工具类应该屏蔽构造函数

工具类是一堆静态字段和函数的集合,不应该被实例化。但是,Java 为每个没有明确定义构造函数的类添加了一个隐式公有构造函数。所以,为了避免 java "小白"使用有误,应该显式定义私有构造函数来屏蔽这个隐式公有构造函数。 反例:

public class MathUtils {
    public static final double PI = 3.1415926D;
    public static int sum(int a, int b) {
        return a + b;
    }
}

正例:

public class MathUtils {
    public static final double PI = 3.1415926D;
    private MathUtils() {}
    public static int sum(int a, int b) {
        return a + b;
    }
}

删除多余的异常捕获并抛出

用 catch 语句捕获异常后,什么也不进行处理,就让异常重新抛出,这跟不捕获异常的效果一样,可以删除这块代码或添加别的处理。

反例:

private static String readFile(String fileName) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
        String line;
        StringBuilder builder = new StringBuilder();
        while ((line = reader.readLine()) != null) {
            builder.append(line);
        }
        return builder.toString();
    } catch (Exception e) {
        throw e;
    }
}

正例:

private static String readFile(String fileName) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
        String line;
        StringBuilder builder = new StringBuilder();
        while ((line = reader.readLine()) != null) {
            builder.append(line);
        }
        return builder.toString();
    }
}

公有静态常量应该通过类访问

虽然通过类的实例访问公有静态常量是允许的,但是容易让人它误认为每个类的实例都有一个公有静态常量。所以,公有静态常量应该直接通过类访问。

反例:

public class User {
    public static final String CONST_NAME = "name";
    ...
}

User user = new User();
String nameKey = user.CONST_NAME;

正例:

public class User {
    public static final String CONST_NAME = "name";
    ...
}

String nameKey = User.CONST_NAME;

不要用NullPointerException判断空

空指针异常应该用代码规避(比如检测不为空),而不是用捕获异常的方式处理。

反例:

public String getUserName(User user) {
    try {
        return user.getName();
    } catch (NullPointerException e) {
        return null;
    }
}

正例:

public String getUserName(User user) {
    if (Objects.isNull(user)) {
        return null;
    }
    return user.getName();
}

使用String.valueOf(value)代替""+value

当要把其它对象或类型转化为字符串时,使用 String.valueOf(value) 比""+value 的效率更高。

反例:

int i = 1;
String s = "" + i;

正例:

int i = 1;
String s = String.valueOf(i);

过时代码添加 @Deprecated 注解

当一段代码过时,但为了兼容又无法直接删除,不希望以后有人再使用它时,可以添加 @Deprecated 注解进行标记。在文档注释中添加 @deprecated 来进行解释,并提供可替代方案

正例:


/**
 * 保存
 *
 * @deprecated 此方法效率较低,请使用{@link newSave()}方法替换它
 */
@Deprecated
public void save(){
    // do something
}

让代码远离 bug

禁止使用构造方法 BigDecimal(double)

BigDecimal(double) 存在精度损失风险,在精确计算或值比较的场景中可能会导致业务逻辑异常。反例:

BigDecimal value = new BigDecimal(0.1D); // 0.100000000000000005551115...

正例:

BigDecimal value = BigDecimal.valueOf(0.1D);; // 0.1

反例:

BigDecimal value = new BigDecimal(0.1D); // 0.100000000000000005551115...

正例:

BigDecimal value = BigDecimal.valueOf(0.1D);; // 0.1
System.out.println(new BigDecimal("0.1"));//0.1

返回空数组和空集合而不是 null

返回 null ,需要调用方强制检测 null ,否则就会抛出空指针异常。返回空数组或空集合,有效地避免了调用方因为未检测 null 而抛出空指针异常,还可以删除调用方检测 null 的语句使代码更简洁。 **
反例:**

public static Result[] getResults() {
    return null;
}

public static List<Result> getResultList() {
    return null;
}

public static Map<String, Result> getResultMap() {
    return null;
}

public static void main(String[] args) {
    Result[] results = getResults();
    if (results != null) {
        for (Result result : results) {
            ...
        }
    }

    List<Result> resultList = getResultList();
    if (resultList != null) {
        for (Result result : resultList) {
            ...
        }
    }

    Map<String, Result> resultMap = getResultMap();
    if (resultMap != null) {
        for (Map.Entry<String, Result> resultEntry : resultMap) {
            ...
        }
    }
}

正例:

public static Result[] getResults() {
    return new Result[0];
}

public static List<Result> getResultList() {
    return Collections.emptyList();
}

public static Map<String, Result> getResultMap() {
    return Collections.emptyMap();
}

public static void main(String[] args) {
    Result[] results = getResults();
    for (Result result : results) {
        ...
    }

    List<Result> resultList = getResultList();
    for (Result result : resultList) {
        ...
    }

    Map<String, Result> resultMap = getResultMap();
    for (Map.Entry<String, Result> resultEntry : resultMap) {
        ...
    }


}

优先使用常量或确定值来调用 equals 方法

对象的 equals 方法容易抛空指针异常,应使用常量或确定有值的对象来调用 equals 方法。当然,使用 java.util.Objects.equals() 方法是最佳实践。

**
反例:**

public void isFinished(OrderStatus status) {
    return status.equals(OrderStatus.FINISHED); // 可能抛空指针异常
}

正例:

public void isFinished(OrderStatus status) {
    return OrderStatus.FINISHED.equals(status);
}

public void isFinished(OrderStatus status) {
    return Objects.equals(status, OrderStatus.FINISHED);
}

枚举的属性字段必须是私有不可变

枚举通常被当做常量使用,如果枚举中存在公共属性字段或设置字段方法,那么这些枚举常量的属性很容易被修改。理想情况下,枚举中的属性字段是私有的,并在私有构造函数中赋值,没有对应的 Setter 方法,最好加上 final 修饰符。

反例:

public enum UserStatus {    DISABLED(0, "禁用"),    ENABLED(1, "启用");
    public int value;    private String description;
    private UserStatus(int value, String description) {        this.value = value;        this.description = description;    }
    public String getDescription() {        return description;    }
    public void setDescription(String description) {        this.description = description;    }}



正例: 
public enum UserStatus {
    DISABLED(0, "禁用"),
    ENABLED(1, "启用");

    private final int value;
    private final String description;

    private UserStatus(int value, String description) {
        this.value = value;
        this.description = description;
    }

    public int getValue() {
        return value;
    }

    public String getDescription() {
        return description;
    }


}

小心String.split(String regex)

字符串 String 的 split 方法,传入的分隔字符串是正则表达式!部分关键字(比如.| 等)需要转义

反例:

"a.ab.abc".split("."); // 结果为[]
"a|ab|abc".split("|"); // 结果为["a", "|", "a", "b", "|", "a", "b", "c"]

正例:

"a.ab.abc".split("\."); // 结果为["a", "ab", "abc"]
"a|ab|abc".split("\|"); // 结果为["a", "ab", "abc"]

代码规范

拆分合理的目录结构

受传统的 MVC 模式影响,传统做法大多是几个固定的文件夹 controller、service、mapper、entity,然后无限制添加,到最后你就会发现一个 service 文件夹下面有几十上百个 Service 类,根本没法分清业务模块。正确的做法是在写 service 上层新建一个 modules 文件夹,在 moudles 文件夹下根据不同业务建立不同的包,在这些包下面写具体的 service、controller、entity、enums 包或者继续拆分。

封装方法形参

反例

public void updateCustomerDeviceAndInstallInfo(long customerId, String channelKey, String androidId, String imei, String gaId, String gcmPushToken, String instanceId) {

}

正例

public class CustomerDeviceRequest{
    private Long customerId;
    // ……  省略属性
}

封装业务逻辑

如果你看过“屎山”你就会有深刻的感触,这特么一个方法能写几千行代码,还无任何规则可言......往往负责的人会说,这个业务太复杂,没有办法改善,实际上这都是懒的借口。不管业务再复杂,我们都能够用合理的设计、封装去提升代码可读性。下面贴两段高级开发(假装自己是高级开发)写的代码

@Transactional
public ChildOrder submit(Long orderId, OrderSubmitRequest.Shop shop) {
    ChildOrder childOrder = this.generateOrder(shop);
    childOrder.setOrderId(orderId);
    //订单来源 APP/微信小程序
    childOrder.setSource(userService.getOrderSource());
    // 校验优惠券
    orderAdjustmentService.validate(shop.getOrderAdjustments());
    // 订单商品
    orderProductService.add(childOrder, shop);
    // 订单附件
    orderAnnexService.add(childOrder.getId(), shop.getOrderAnnexes());
    // 处理订单地址信息
    processAddress(childOrder, shop);
    // 最后插入订单
    childOrderMapper.insert(childOrder);
    this.updateSkuInventory(shop, childOrder);
    // 发送订单创建事件
    applicationEventPublisher.publishEvent(new ChildOrderCreatedEvent(this, shop, childOrder));
    return childOrder;
}

这段两代码里面其实业务很复杂,内部估计保守干了五万件事情,但是不同水平的人写出来就完全不同,不得不赞一下这个注释,这个业务的拆分和方法的封装。一个大业务里面有多个小业务,不同的业务调用不同的 service 方法即可,后续接手的人即使没有流程图等相关文档也能快速理解这里的业务,而很多初级开发写出来的业务方法就是上一行代码是 A 业务的,下一行代码是 B业务的,在下面一行代码又是 A 业务的,业务调用之间还嵌套这一堆单元逻辑,显得非常混乱,代码还多。

映射数据库的属性尽量不要用基本类型

我们都知道 int/long 等基本数据类型作为成员变量默认值是 0。现在流行使用 mybatisplus 、mybatis 等 ORM 框架,在进行插入或者更新的时候很容易会带着默认值插入更新到数据库。我特么真想砍了之前的开发,重构的项目里面实体类里面全都是基本数据类型。当场裂开......

封装判断条件

public void method(LoanAppEntity loanAppEntity, long operatorId) {
  if (LoanAppEntity.LoanAppStatus.OVERDUE != loanAppEntity.getStatus()
          && LoanAppEntity.LoanAppStatus.CURRENT != loanAppEntity.getStatus()
          && LoanAppEntity.LoanAppStatus.GRACE_PERIOD != loanAppEntity.getStatus()) {
    //...
    return;
  }

这段代码的可读性很差,这 if 里面谁知道干啥的?我们用面向对象的思想去给 loanApp 这个对象里面封装个方法不就行了么?

public void method(LoanAppEntity loan, long operatorId) {
  if (!loan.finished()) {
    //...
    return;
  }

控制方法复杂度

推荐一款 IDEA 插件 CodeMetrics ,它能显示出方法的复杂度,它是对方法中的表达式进行计算,布尔表达式if/else 分支循环等。

使用 @ConfigurationProperties 代替 @Value

之前居然还看到有文章推荐使用 @Value 比 @ConfigurationProperties 好用的,吐了,别误人子弟。列举一下 @ConfigurationProperties 的好处。

  • 在项目 application.yml 配置文件中按住 ctrl + 鼠标左键点击配置属性可以快速导航到配置类。写配置时也能自动补全、联想到注释。需要额外引入一个依赖 org.springframework.boot:spring-boot-configuration-processor
  • @ConfigurationProperties 支持 NACOS 配置自动刷新,使用 @Value 需要在 BEAN 上面使用 @RefreshScope 注解才能实现自动刷新
  • @ConfigurationProperties 可以结合 Validation 校验,@NotNull、@Length 等注解,如果配置校验没通过程序将启动不起来,及早的发现生产丢失配置等问题。
  • @ConfigurationProperties 可以注入多个属性,@Value 只能一个一个写
  • @ConfigurationProperties 可以支持复杂类型,无论嵌套多少层,都可以正确映射成对象

相比之下我不明白为什么那么多人不愿意接受新的东西,裂开......你可以看下所有的 springboot-starter 里面用的都是 @ConfigurationProperties 来接配置属性。

不要在 AService 调用 BMapper

我们一定要遵循从 AService -> BService -> BMapper,如果每个 Service 都能直接调用其他的 Mapper,那特么还要其他 Service 干嘛?老项目还有从 controller 调用 mapper 的,把控制器当 service 来处理了。

不要跨服务循环访问数据库

跨服务查询时,如果有批量数据查询的场景,直接写一个批量的 Feign 查询接口,不要像下面这样

list.foreach(id -> {
    UserResponse user = userClient.findById(id);
});
复制代码

因为每一次 OpenFeign 的请求都是一个 Http 请求、一个数据库 IO 操作,还要经过各种框架内的拦截器、解码器等等,全都是损耗。

直接定义一个批量查询接口

@PostMapping("/user/batch-info")
List<UserResponse> batchInfo(@RequestBody List<Long> userIds);
复制代码

这就结束了吗?并没有,如果你遇到这种 userIds 的数量非常大,在 2000 以上,那么你在实现方不能在数据库中直接用 in() 去查询。在实现方要拆分这个 useIds 。有索引的情况下 in() 1000 个元素以下通常问题不大

public List<XxxResponse> list(List<Long> userIds) {
  List<List<Long>> partition = Lists.partition(userIds, 500); //拆分 List

  List<XxxResponse> list = new ArrayList<>();
  partition.forEach(item -> list.addAll(xxxMapper.list(item)));
  return list;
}

不要包裹 OpenFeign 接口返回值

搞不懂为什么那么多人喜欢把接口的返回值用 Response 包装起来......加个 code、message、success 字段,然后每次调用方就变成这样

CouponCommonResult bindResult = couponApi.useCoupon(request.getCustomerId(), order.getLoanId(), coupon.getCode());
if (Objects.isNull(bindResult) || !bindResult.getResult()) {
  throw new AppException(CouponErrorCode.ERR_REC_COUPON_USED_FAILED);
}
复制代码

这样就相当于

  1. 在 coupon-api 抛出异常
  2. 在 coupon-api 拦截异常,修改 Response.code
  3. 在调用方判断 response.code 如果是 FAIELD 再把异常抛出去......

你直接在服务提供方抛异常不就行了么。。。而且这样一包装 HTTP 请求永远都是 200,没法做重试和监控。当然这个问题涉及到接口响应体该如何设计,目前网上大多是三种流派

  • 接口响应状态一律 200
  • 接口响应状态遵从HTTP真实状态
  • 佛系开发,领导怎么说就怎么做

不接受反驳,我推荐使用 HTTP 标准状态。特定场景包括参数校验失败等一律使用 400 给前端弹 toast。下篇文章会阐述一律 200 的坏处。

OpenFeign 接口不建议打成 jar

见过很多使用 OpenFeign 的接口是这样用的,将 OpenFeign 接口写在服务提供方,打成 jar。比如服务 A 调用 B,在 B 项目单独开一个 module 写接口定义,打出一个 jar 包让 A 引入依赖。

让我们来感受一下调用一个 Feign 接口实现的步骤:

  1. 在 B 服务中写 Controller 实现
  2. 在 B 服务中定义 OpenFeign 接口定义
  3. 在 B 服务中修改 jar 版本 +1,打一个 jar 包到本地仓库
  4. 在 A 服务中修改依赖 jar 版本,刷新 maven/gradle

乍一看不麻烦是吧?但是你要知道我们开发中经常会出现丢参数、缺响应属性等情况,一旦有任何小问题,都要重新走一遍上述流程。。。。

建议将 OpenFeign 接口定义在消费端 AB 只需要提供一个接口实现即可。所不好的地方无非是 XxxRequest、XxxResponse 类冗余了一份,但其实并没有什么问题,因为对于 Feign 来说请求和响应的 BO 类并不需要字段完全一致,它的解码器会智能的解析响应并封装到你的 XxxResponse 接收类中。

你这么理解就明白了,这个类 XxxRequest、XxxResponse 等,仅仅是你的 A 服务为了映射请求结果而本地自定义的一个映射数据结构,这个映射数据结构和 B 服务可以说是没关系的。所以你当然应该放在 A 这里。

你很纠结无非是你觉得这个东西似乎是可以复用的,所以纠结放 A 还是放 B,以及是不是要抽出来做个公共依赖。我很久以前也很纠结这个东西,但是踩了太多坑以后我的想法就变了,高内聚低耦合本质的意义,就是把和一个服务(组件,应用,包,等等等等)相关的代码全部包在一起,不要和外界有牵扯,你有牵扯就会引发修改时的依赖地狱。