Clean Code Tips #2 -- 你写的方法够专一么?

133 阅读10分钟

程序员真正拥有一段代码的时间是从打出第一个字符到合并进master分支之前,在这之后这段代码就是遗留代码(legacy code),它将会被其他人维护,理解和吐槽。这也是clean code所存在的价值,它让我们的每一行遗留代码都变得易于维护和理解。我并不打算把clean code tips写成《clean code》的读书笔记,我认为知识只有付诸于实践之后才能成为自己的,所以,clean code tips是用来记录我工作中经常会碰到的一些代码异味和我是如何重构他们的。

​这是Clean Code Tips的第二个tip,查看第一个tip可以点击这里

不论是面向对象编程还是面向过程编程,方法(method)是组织代码的最基本单元。所以,一个方法是否足够专一也决定了你的代码的可维护性及健壮性。关于判断一个方法是否足够专一,我认为其标准和判断男女朋友是否专一很接近,专一的男女朋友要看同一时间是否只喜欢一个人,而方法则要看是否只做一件事情(do one thing)。

如何判断方法只做了一件事情?其实这个问题,我也会经常在面试的时候问一下候选人,而最匪夷所思的是,很多人都会给出一个具体数,比如,15行、30行甚至50行等等,然后解释道说如果一个方法的实现代码超过这个数,就说明这个方法没达到只做一件事情的标准。

-w300

其实,判断一个方法是否只做一件事情有一个比较容易的准则,那就是看这个方法是否因为一种以上的原因不得不进行修改,比如,商品搜索逻辑功能因为数据存储系统的更换而不得不进行修改代码做适配。

在过往的工作中,我碰到过很多人都认为方法的单职责设计原则(do one thing)只能停留在理论层面,而难于在实际工作中实践,尤其是在面对需要实现一段复杂逻辑的时候,他们更多会选择一头扎下去直接开始埋头写代码。但是不要忘记,作为一名软件工程师我们最基本的能力恰恰是要知道如何把复杂逻辑拆解为相对独立的简单逻辑来进行实现。所以,一个方法做一件事情并不是单纯的一种代码组织能力,而更多的是折射了一名软件工程师是否有足够的能力通过系统化的方式解决一个复杂的问题。

那如何在实际开发工作中做到每一个方法都是单职责的呢?我最常用的一种方法叫“包工头”原则,简单来讲就是在实现的过程中不断的把需要完成的具体工作外包出去给别人(方法)来做,你只需协调工作和聚合结果。还是没太明白?没关系,接下来我用一个实际场景来说明。

我们现在要实现一个功能用来在每个月末把用户所订阅的服务和所购买的App生成一个PDF格式的电子发票然后通过电子邮件发送给用户。

当然,电子发票内容本身也要有一定的格式要求:

  1. 订阅类商品和单次购买类商品要分开组织
  2. 订阅类商品只需要打印名称和
  3. 单次购买类商品打印的内容要包含名字,图片,简介和价格
  4. 先展示单次购买类商品,之后是订阅类商品,分别要有两类商品的子价格统计和数量统计。
  5. 最后要包含本月最终消费几个统计,以及结算日期。

首先,对于这个功能我们需要先要声明一个方法。

/**
* @param userId, 用户id
* @param yearAndMonth, yyyyMM
*/
public void deliverReceipt(String userId, int yearAndMonth);

接下来,我来一步一步的讲解如何用包工头原则实现这个方法。

第一步,我们要做的就是拿到这个用户对应月份的消费记录。按照包工头原则,我们不要着急去实现这个逻辑,而是把这个工作外包给一个叫做getPurchaseRecords的方法。作为包工头的你唯一要关心的是如何定义和getPurchaseRecords的契约 —— 入参和出参。

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = getPurchaseRecords(userId, yearAndMonth);
}

接下来就是对getPurchaseRecords的工作结果(返回值)进行一个处理,看看是否有消费记录返回,这部分工作我们依然外包出去,给isContinue方法,如果这个方法告诉我们false,那我们就不继续后面的工作了。

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = getPurchaseRecords(userId, yearAndMonth);
    if (!isContinue(records)) {
        return;
    }
}

private boolean isContinue(List<PurchaseRecord> records) {
    return records.size() > 0;
}

接下来,拿到了所有的消费记录之后我们现在需要的把这个记录生成一个PDF版本的电子收据文件。同样的,我们把这部分工作外包给一个叫做generatePDFReceipt的方法来做。他会搞定所有事情,最终只是把文件路径返回给我们。

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = getPurchaseRecords(userId, yearAndMonth);
    if (!isContinue(records)) {
        return;
    }
    String receiptPath = generatePDFReceipt(records);
}

最后一步,我们要做的就是把这份生成好的收据发送给这个用户了。你已经猜到了,这个工作我们还是会外包出去,给一个叫sendReceipt方法。他要做的就是拿到userId和上面生成的PDF收据文件地址然后发送一封电子邮件给这个用户。

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = getPurchaseRecords(userId, yearAndMonth);
    if (!isContinue(records)) {
        return;
    }
    String receiptPath = generatePDFReceipt(records);
    sendReceipt(userId, receiptUrl);
}

好了,目前为止,我们已经把整个功能都”实现了“,当然,确切地讲只是外包出去了。接下来我们需要做一点角色转换 —— 把自己分别转换成为getPurchaseRecords、generatePDFReceipt和sendReceipt三个方法的包工头,然后再重复之前的外包任务的分配和组装。

为了简化阅读,getPurchaseRecords中对于用户信息和支付记录的获取的实现细节就通过依赖注入的UserRepository和PurchaseRecordRepository两个帮助类来完成了,不再逐一展开。

private List<PurchaseRecord> getPurchaseRecords(String userId, int yearAndMonth) {   
    User user = getEnsuredUserById(userId);
    int year = readYear(yearAndMonth);
    int month = readMonth(yearAndMonth);
    return purchaseRecordRepository.findByMonth(user, year, month);
}

private User getEnsuredUserById(String id) {
    User user = userRepository.findById(userId);
    if (Objects.isNull(user)) {
        throw new NoSuchUserFoundException("User with id, " + id + " doesn't exist");
    }
    return user;
}

private int readYear(int yearAndMonth) {
    return String.valueOf(yearAndMonth).substring(0, 4);
}

private int readMonth(int yearAndMonth) {
    return String.valueOf(yearAndMonth).substring(4, 6);
}

从上面的代码不难看出,作为getPurchaseRecords的包工头,我们又再次的把这部分逻辑拆分为了更小的工作,然后,分别外包给了getEnsuredUserById、readYear,readMonth,以及之前提到的注入进来的帮助类userRepository和purchaseRecordRepository。

接下来,在generatePDFReceipt方法的实现中,依然为了简化阅读,我们假设有一个PDFBuilder的帮助类(你可以理解为是StringBuilder的PDF版本),通过这个帮助类,我们可以填充内容,并且最终生成一个pdf文件。

private String generatePDFReceipt(List<PurchaseRecord> records) {
    PDFBuilder content = new PDFBuilder();
    List<PurchaseRecord> apps = getAppPurchaseRecords(records);
    printAppPurchaseRecords(apps, content);
    List<PurchaseRecord> subscriptions = getSubscriptionPurchaseRecords(records);
    printSubscriptionPurchaseRecords(subscriptions, content);
    printSummary(records, content);
    return content.toPDF("../receipts/");
}

private List<PurchaseRecord> getAppPurchaseRecords(List<PurchaseRecord> records) {
    List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
    for (PurchaseRecord record: records) {
        if (record.getProduct().getType() == ProductTypes.APP) {
            result.add(record);
        }
    }
    return result;
}

private void printAppPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
    long subTotal = 0;
    for (PurchaseRecord record: records) {
        content.append(record.getProduct().getName());
        content.append(record.getProduct().getPhotos());        
        content.append(record.getProduct().getDescription());
        content.append(record.getProduct().getPrice());
        subTotal += record.getProduct().getPrice();
    }
    content.append(subTotal);
}

private List<PurchaseRecord> getSubscriptionPurchaseRecords(List<PurchaseRecord> records) {
    List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
    for (PurchaseRecord record: records) {
        if (record.getProduct().getType() == ProductTypes.SUBSCRIPTION) {
            result.add(record);
        }
    }
    return result;
}

private void printSubscriptionPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
    long subTotal = 0;
    for (PurchaseRecord record: records) {
        content.append(record.getProduct().getName());
        content.append(record.getProduct().getPrice());
        subTotal += record.getProduct().getPrice();
    }
    content.append(subTotal);
}

private void printSummary(List<PurchaseRecord> records, PDFBuilder content) {
    long total = 0;
    for (PurchaseRecord record: records) {
        total += record.getProduct().getPrice();
    }
    content.append(total);    
    content.append(new Date());
}

目前为止,我们的代码不只是展示了方法是如何把工作外包出去的,也同样看到有一些“工人”方法 —— 实现具体功能的方法。

最后,在sendReceipt方法里面,我们依然会有一个emailService这样的帮助类来简化发送email的实现逻辑。

private void sendReceipt(String userId, String recieptPath) {
    User user = getEnsuredUserById(userId);
    emailService.send(
        user.getEmailAddress(), 
        "email content", 
        receiptPath);
}

到了这里,基本上这个需求就算完成了。其实可以看出来,包工头原则的核心做法就是在实现每一个方法的时候,把局部的实现细节通过声明新的方法来进行封装并滞后处理,这样做的好处是促使你把思维聚焦在整体的逻辑流程上面,而不会因为过多的细节思考丢失了全局。之后,在用同样的方式处理每一个新声明的方法,直到最终完成所有的局部实现。

下面是整体的代码效果

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = getPurchaseRecords(userId, yearAndMonth);
    if (!isContinue(records)) {
        return;
    }
    String receiptPath = generatePDFReceipt(records);
    sendReceipt(userId, receiptUrl);
}

private List<PurchaseRecord> getPurchaseRecords(String userId, int yearAndMonth) {   
    User user = getEnsuredUserById(userId);
    int year = readYear(yearAndMonth);
    int month = readMonth(yearAndMonth);
    return purchaseRecordRepository.findByMonth(user, year, month);
}

private User getEnsuredUserById(String id) {
    User user = userRepository.findById(userId);
    if (Objects.isNull(user)) {
        throw new NoSuchUserFoundException("User with id, " + id + " doesn't exist");
    }
    return user;
}

private int readYear(int yearAndMonth) {
    return String.valueOf(yearAndMonth).substring(0, 4);
}

private int readMonth(int yearAndMonth) {
    return String.valueOf(yearAndMonth).substring(4, 6);
}

private String generatePDFReceipt(List<PurchaseRecord> records) {
    PDFBuilder content = new PDFBuilder();
    List<PurchaseRecord> apps = getAppPurchaseRecords(records);
    printAppPurchaseRecords(apps, content);
    List<PurchaseRecord> subscriptions = getSubscriptionPurchaseRecords(records);
    printSubscriptionPurchaseRecords(subscriptions, content);
    printSummary(records, content);
    return content.toPDF("../receipts/");
}

private List<PurchaseRecord> getAppPurchaseRecords(List<PurchaseRecord> records) {
    List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
    for (PurchaseRecord record: records) {
        if (record.getProduct().getType() == ProductTypes.APP) {
            result.add(record);
        }
    }
    return result;
}

private void printAppPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
    long subTotal = 0;
    for (PurchaseRecord record: records) {
        content.append(record.getProduct().getName());
        content.append(record.getProduct().getPhotos());        
        content.append(record.getProduct().getDescription());
        content.append(record.getProduct().getPrice());
        subTotal += record.getProduct().getPrice();
    }
    content.append(subTotal);
}

private List<PurchaseRecord> getSubscriptionPurchaseRecords(List<PurchaseRecord> records) {
    List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
    for (PurchaseRecord record: records) {
        if (record.getProduct().getType() == ProductTypes.SUBSCRIPTION) {
            result.add(record);
        }
    }
    return result;
}

private void printSubscriptionPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
    long subTotal = 0;
    for (PurchaseRecord record: records) {
        content.append(record.getProduct().getName());
        content.append(record.getProduct().getPrice());
        subTotal += record.getProduct().getPrice();
    }
    content.append(subTotal);
}

private void printSummary(List<PurchaseRecord> records, PDFBuilder content) {
    long total = 0;
    for (PurchaseRecord record: records) {
        total += record.getProduct().getPrice();
    }
    content.append(total);    
    content.append(new Date());
}

private void sendReceipt(String userId, String receiptPath) {
    User user = getEnsuredUserById(userId);
    emailService.send(
        user.getEmailAddress(), 
        "email content", 
        receiptPath);
}

可以看到,通过这种方式编写代码最大程度的保证了每一个方法都只做一件事情,结果就是你所实现的每一个方法都变的更容易被复用和被测试,整个代码的维护性也大大提高。

你可能已经发现了,复用性比较好理解,比如,getEnsuredUserById分别被用在了getPurchaseRecords和sendReceipt的实现里,但是,易于被测试好像并没有体现出来,尤其是从写单元测试的角度来看,对于其他方法的测试我们好像依然只能是通过测试deliverReceipt方法来完成,并没有减少测试deliverReceipt所需要mock的场景数量。

那是因为要做到可易于测试,我们还需要再多做一步简单的重构 —— 把这些private方法移到另一个类中(比如,ReceiptDeliveryHelper),然后以最小原则把需要的方法变成public,之后,再把这个类注入到deliverReceipt所属得类里面进行使用,下面就是一个重构后的效果。

public void deliverReceipt(String userId, int yearAndMonth) {   
    List<PurchaseRecord> records = helper.getPurchaseRecords(userId, yearAndMonth);
    if (!isContinue(records)) {
        return;
    }
    String receiptPath = helper.generatePDFReceipt(records);
    helper.sendReceipt(userId, receiptUrl);
}

public ReceiptDeliveryHelper {

    private UserRepository userRepository;
    private PurchaseRecordRepository purchaseRecordRepository;

    public List<PurchaseRecord> getPurchaseRecords(String userId, int yearAndMonth) {   
    User user = getEnsuredUserById(userId);
    int year = readYear(yearAndMonth);
    int month = readMonth(yearAndMonth);
    return purchaseRecordRepository.findByMonth(user, year, month);
}

    public User getEnsuredUserById(String id) {
        User user = userRepository.findById(userId);
        if (Objects.isNull(user)) {
            throw new NoSuchUserFoundException("User with id, " + id + " doesn't exist");
        }
        return user;
    }
    
    private int readYear(int yearAndMonth) {
        return String.valueOf(yearAndMonth).substring(0, 4);
    }
    
    private int readMonth(int yearAndMonth) {
        return String.valueOf(yearAndMonth).substring(4, 6);
    }
    
    private String generatePDFReceipt(List<PurchaseRecord> records) {
        PDFBuilder content = new PDFBuilder();
        List<PurchaseRecord> apps = getAppPurchaseRecords(records);
        printAppPurchaseRecords(apps, content);
        List<PurchaseRecord> subscriptions = getSubscriptionPurchaseRecords(records);
        printSubscriptionPurchaseRecords(subscriptions, content);
        printSummary(records, content);
        return content.toPDF("../receipts/");
    }
    
    private List<PurchaseRecord> getAppPurchaseRecords(List<PurchaseRecord> records) {
        List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
        for (PurchaseRecord record: records) {
            if (record.getProduct().getType() == ProductTypes.APP) {
                result.add(record);
            }
        }
        return result;
    }
    
    public void printAppPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
        long subTotal = 0;
        for (PurchaseRecord record: records) {
            content.append(record.getProduct().getName());
            content.append(record.getProduct().getPhotos());        
            content.append(record.getProduct().getDescription());
            content.append(record.getProduct().getPrice());
            subTotal += record.getProduct().getPrice();
        }
        content.append(subTotal);
    }
    
    private List<PurchaseRecord> getSubscriptionPurchaseRecords(List<PurchaseRecord> records) {
        List<PurchaseRecord> result = new LinkedList<PurchaseRecord>();
        for (PurchaseRecord record: records) {
            if (record.getProduct().getType() == ProductTypes.SUBSCRIPTION) {
                result.add(record);
            }
        }
        return result;
    }
    
    private void printSubscriptionPurchaseRecords(List<PurchaseRecord> records, PDFBuilder content) {
        long subTotal = 0;
        for (PurchaseRecord record: records) {
            content.append(record.getProduct().getName());
            content.append(record.getProduct().getPrice());
            subTotal += record.getProduct().getPrice();
        }
        content.append(subTotal);
    }
    
    private void printSummary(List<PurchaseRecord> records, PDFBuilder content) {
        long total = 0;
        for (PurchaseRecord record: records) {
            total += record.getProduct().getPrice();
        }
        content.append(total);    
        content.append(new Date());
    }
    
    public void sendReceipt(String userId, String receiptPath) {
        User user = getEnsuredUserById(userId);
        emailService.send(
            user.getEmailAddress(), 
            "email content", 
            receiptPath);
    }
}

现在我们可以分别对getPurchaseRecords、generatePDFReceipt和sendReceipt进行分别的单元测试了,而当测试deliverReceipt的时候,我们只需要专注在测试deliverReceipt本身的流程上,而通过mock的方式来模拟对应场景,比如,getPurchaseRecords是否返回了消费记录。

最后,希望这篇文章对你有一点点的启发,也希望在代码的世界中我们也能做到“不因善小而不为”。

本文所涉及到的Clean code相关概念

Do one thing

Descriptive coding

Testable

Interface-based programming