设计模式学习笔记 - 规范与重构 - 8.实践:程序出错返回啥?NULL、异常、错误吗、空对象?重构ID生成器,处理各函数的异常

概述

我们可以把函数的运行结果分为两类。一类是预期结果,也就是正常情况下输出的结果。一类是非预期的结果,也就是函数在异常(或出错)情况下输出的结果。

在正常情况下,函数返回数据的类型非常明确,但是在异常情况下,函数的返回数据类型确非常灵活,有多种选择,比如异常(Exception)、错误码、NULL 值、特殊值(比如 -1)、空对象(比如空字符串、空集合)等。

在异常情况下,函数到底该返回什么样的数据类型,并不那么容易判断。比如,上节课中,本机名获取失败的时候, ID 生成器的 generate() 函数应该返回什么呢? 是异常?空字符串?还是 NULL 值?又或者是其他特殊值呢?


程序出错返回啥?NULL、异常、错误吗、空对象?

从 ID 生成器代码讲起

上篇《规范与重构 - 7.实践:通过一段ID生成器代码,学习如何发现代码质量问题》我们把一份 ID 生成器代码从 "能用" 重构成了 "好用"。最终给出的代码看似以及完美了,但是如果再用心推敲以下,代码中关于出错处理的方式,还有进一步优化的空间,值得我们拿出来再讨论下。

下面是上节课的代码。

java 复制代码
public class RandomIdGenerator implements LogTraceIdIdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
    
    @Override
    public String generate() {
        String substrOfHostName = getLastFieldOfHostName();
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    
    private String getLastFieldOfHostName() {
        String substrOfHostName = null;
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            substrOfHostName = getLastSubstrSplitByDot(hostName);
        } catch (UnknownHostException e) {
            logger.error("failed to get the host name.", e);
        }
        return substrOfHostName;
    }
    
    @VisibleForTesting
    protected String getLastSubstrSplitByDot(String hostName) {
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }
    
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int randomAscii = random.nextInt(122);
            boolean isDigit = randomAscii >= 48 && randomAscii <= 57;
            boolean isUpperCase = randomAscii >= 65 && randomAscii <= 90;
            boolean isLowerCase = randomAscii >= 97 && randomAscii <= 122;
            if (isDigit || isUpperCase || isLowerCase) {
                randomChars[count++] = (char) randomAscii;
            }
        }
        return new String(randomChars);
    }
}

这段代码中的四个函数的出错处理方式,总结出下面这样几个问题:

  1. 对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
  2. 对于 getLastFieldOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)?还是应该将异常抛出?如果抛出的话,是直接把 UnknownHostException 原封不动的抛出,还是封装成新的异常抛出?
  3. 对于 getLastSubstrSplitByDot() 函数,如果 hostName 为 NULL 或者是空字符串,这个函数应该返回什么?
  4. 对于 generateRandomAlphameric() 函数,如果 length 小于或等于 0,这个函数应该返回什么?

函数出错应该返回啥?

函数出错返回的数据,一共有四种情况:错误码、NULL 值、空对象、异常对象。接下来,我们一一分析下。

1.返回错误码

C 语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式。而在 Java 等比较新的编程语言中,大部分情况下,都用异常来处理函数出错的情况,极少会用到错误码。

在 C 语言中,错误码的返回方式有两种:一种是直接占用函数的返回值,函数正常执行的返回值方到出参中;另一种是将错误码定义为全局变量,在函数执行出错时,函数调用者通过这个全局变量来获取错误码。我举个例子进一步解释。

c 复制代码
// 错误码的返回方式一:占用函数的返回值
int open(const char *pathname, int flags, mode_t mode, int *fd) {
	if(/*文件不存在*/) {
		return EEXISTS;
	}
	
	if(/*没有访问权限*/) {
		return EACCESS;
	}
	
	if(/*打开文件成功*/) {
		return SUCCESS;
	}
	// ...
}
// 使用举例
int result = open("c:\test.text", O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if(result  == SUCCESS) {
	// 取出fd使用
} else if(result  == EEXISTS) {
	// ...
} else if(result  == EACCESS) {
	// ...
}

// 错误码的返回方式二
int error; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode) {
	if(/*文件不存在*/) {
		error = EEXISTS;
		return -1;
	}
	if(/*没有访问权限*/) {
		error = EACCESS;
		return -1;
	}
	// ...
}
// 使用举例
int hFile = open("c:\test.text", O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if(hFile == -1) {
	if(error == EEXISTS) {
		// ...
	} else if(error == EACCESS) {
		// ...
	}
	//...
}

如果你熟悉的编程语言中有异常这种语法机制,那就尽量不要使用错误码。异常相对于错误码,有诸多优势,比如可以携带详细的错误信息(exception 中可以有 message、stack trace 等信息)。关于异常,我们待会还会非常详细的讲解。

2.返回 NULL 值

在多数编程语言中,我们用 NULL 来表示 "不存在" 这种语义。不过,很多人不建议函数返回 NULL 值,认为这是一种不好的设计思路,主要理由有以下两个。

  1. 如果某个函数返回 NULL 值,我们在使用它的时候,忘记了做 NULL 值判断,就有可能会抛出空指针异常(NULL Point Exception,缩写为 NPE)。
  2. 如果我们定义了很多返回值可能为 NULL 的函数,那代码中就会充斥着大量的 NULL 值判断逻辑,一方面写起来比较繁琐,另一方面它们跟正常的业务逻辑耦合在一起,会影响代码的可读性。
java 复制代码
// 使用函数 getUser()
User user = userService.getUser("18147452144");
if (user != null) { // 做NULL判断,否则可能会报 NPE
    String email = user.getEmail();
    if (email != null) { // 做NULL判断,否则可能会报 NPE
        String escapedEmail = email.replaceAll("@", "#");
    }
}

那我们是否可以用异常替代 NULL 值,在查找用户不存在的时候,让函数抛出 UserNotFoundException 异常呢?

个人觉得,尽管返回 NULL 值有诸多弊端,但是对于以 get、find、select、search、query 等单词开头的查找函数来说 ,数据不存在,并非是一种异常情况,这是一种正常行为。所以,返回代表不存在语义的 NULL值比返回更加合理

其实上面将的理由,也不是特别有说服力。对于查找数据不存在的情况,函数到底是返回 NULL 还是异常,有一个比较重要的参考标准是,看项目中其他类似查找函数都是如何定义的,只要整个项目遵从两种中的任何一种都可以。你只需要在函数定义的地方解释清楚,让调用者清晰地知道数据不存在的时候会返回什么就可以了。

再补充一点,对于查找函数来说,除了返回数据对象之外,有的还会返回下标位置,比如 java 中的 indexOf() 函数,用来实现某个字符串查找另一个子串第一次出现的位置。函数的返回值类型为基本类型 int。这个时候,我们就无法用 NULL 值来表示不存在的情况了。这种情况,我们有两种处理思路,一种是返回 NotFoundException,一种是返回一个特殊值,比如 -1。不过,显然 -1 更加合理,理由也是同样的,也就是说,"没有查找到" 是一种正常行而非异常的行为。

3.返回空对象

刚刚降到,返回 NULL 值有各种弊端。应对这个问题有一个比较经典的策略,那就是应用空对象设计模式(Null Object Design Pattern)。关于这个设计模式,后面会讲到。不过,今天来讲比较简单、比较特殊的空对象,那就是空字符串空集合

当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 NULL 值判断。

java 复制代码
// 使用空集合替代NULL
public class UserService {
    private UserRepo userRepo; // 依赖注入

    public List<User> getUsers(String telephonePrefix) {
        // 没有查找到数据
        return Collections.emptyList();
    }
}
// 使用函数 getUser()
List<User> users = userService.getUsers("181");
for (User user : users) { // 这里不需要做NULL值判断
    //...
}

// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
    // 如果text中没有大写字母,返回空字符串,而非NULL值
    return "";
}
// retrieveUppercaseLetters() 使用举例
String uppercaseLetters = retrieveUppercaseLetters("abc");
int length = uppercaseLetters.length(); // 不需要做NULL值判断
System.out.println("Contains " + length + " upper case letters");

4.抛出异常对象

受检异常和非受检异常

最常用的函数出错处理方式就是抛出异。异常可以携带更多的错误信息,比如函数调用栈信息。此外,异常可以将正常逻辑和异常逻辑的处理分离开,这样代码的可读性就会更好。

Java 除了运行时异常(Runtime Exception)外,还定义了另一种异常类型,编译时异常(Compile Exception)。

  • 对于运行时异常,在编写代码的时候,可以不用主动去 try-catch,编译器在编译时并不会检查代码是否对运行时异常做了处理。
  • 相反,编译时异常,在编写代码时需要主动去 try-catch 或者在函数定义中申明,否则编译就会报错。

所以,运行时异常也叫做非受检异常(Unchecked Exception),编译时异常也叫作受检异常(Checked Exception)。

在 Java 中,定义了两种异常类型,那再异常出现时,我们应该选择抛出哪种异常类型呢?是受检异常还是非受检异常?

对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便捕获了也做不了太多事情,所以,我们倾向于使用非受检异常。对于可恢复异常、业务异常,比如提现金额大于余额的异常,我们更倾向于使用受检异常,明确告知调用者需要捕获处理。

举个例子来解释下,代码如下所示。当 Redis 的地址(参数 address)没有设置的时候,我们直接使用默认的地址(比如本地地址和默认端口);当 Redis 的地址格式不正确的时候,我们希望程序员能 fail-fast,也就是说,把这种情况当成不可恢复的异常,直接抛出运行时异常,将程序终止掉。

java 复制代码
public class RedisProcessor {
    private String host;
    private Integer port;
    
    // address格式:"192.168.1.105:7896"
    public void parseRedisAddress(String address) {
        this.host = RedisConfig.DEFAULT_HOST;
        this.port = RedisConfig.DEFAULT_PORT;
        
        if (StringUtils.isBlank(address)) {
            return;
        }
        
        String[] ipAndPort = address.split(":");
        if (ipAndPort.length != 2) {
            throw new RuntimeException("...");
        }
        
        this.host = ipAndPort[0];
        // parseInt() 解析失败会抛出 NumberFormatException 运行时异常
        this.port = Integer.parseInt(ipAndPort[1]);
    }
}

实际上,Java 支持的受检异常一直被人诟病,很多人主张所有的异常情况都该使用非受检异常。支持这种观点的理由主要有三个。

  1. 受检异常需要显式地在函数中定义声明。如果函数会抛出很多受检异常,那函数的定义就会非常冗长,这就会影响代码的可读性,使用起来不方便。
  2. 编译器强制我们必须显示地捕获所有的受检异常,代码实现会比较繁琐。而非受检异常正好相反,不需要在定义中显示生命,并且是否捕获处理,也可以自由决定。
  3. 受检异常的使用违反开闭原则。如果我们给某个函数新增一个受检异常,这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改,直到调用链中的某个函数将这个新增的异常 try-catch 处理掉为止。而新增非受检异常可以不改动调用链上的代码,我们可以灵活地选择在某个函数中集中处理,比如在 Spring 中的 AOP 切面中集中处理异常。

其实,非受检异常也有弊端,它的优点其实也正是它的缺点。非受检异常非常灵活,怎么处理的主动权交给了程序员。前面我们讲过,过于灵活会带来不可控,非受检异常不需要显式地在函数中定义申明,那我们在使用函数的时候,就需要查看代码才能知道具体会抛出哪些异常。非受检异常不需要强制捕获处理,那程序员就有可能漏掉一些本应该捕获处理的异常。

对于应该用受检异常还是非受检异常,争议有很多,但并没有一个强有力的理由能够说明一个就一定比另一个更好。所以,我们只需根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可。

如何处理函数抛出的异常

一般有下面三种处理方法。

1.直接吞掉

java 复制代码
public void func1() throws Exception1 { /*...*/ }

public void func2() {
	// ...
	try {
		func1();
	} catch (Exception1 e) {
		log.warn("...", e); // 吞掉:try-catch打印日志
	}
	// ...
}

2.原封不动地 re-throw

java 复制代码
public void func1() throws Exception1 { /*...*/ }

//原封不动的re-throw Exception1 
public void func2() throws Exception1 {
	// ...
	func1();
	// ...
}

3.包装成新的异常 re-throw

java 复制代码
public void func1() throws Exception1 { /*...*/ }

public void func2() {
	// ...
	try {
		func1();
	} catch (Exception1 e) {
		throw new Exception2(e); // wrap成新的Exception2,然后re-throw
	}
	// ...
}

当我们面对函数抛出异常时,应该选择上面的哪种处理方式呢? 我总结了下面三个参考原则:

  • 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉。
  • 如果 func1() 抛出的异常对 func2() 调用方来说,是可以理解的、关心的,并且在业务概念上有一定的相关性,我们可以直接选择将 func1() 抛出的异常 re-throw。
  • 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的异常,然后 re-throw。

总之,是否网上继续抛出,要看上层代码是否关心这个异常。关系就将它抛出,否则就直接吞掉。是否需要包装成新的异常抛出,看上层代码是否理解这个异常、是否业务相关。如果能理解、业务相关就可以直接抛出,否则就封装成新的异常抛出。

程序出错返回知识回顾

1.返回错误码

C语言没有异常这样的语法机制,返回错误码便是最常用的出错处理方式。而 Java 等比较新的编程语言中,大部分情况下,都抛出异常来处理程序出错的情况,极少会用到错误码。

2.返回 NULL 值

大多数编程语言中,都用 NULL 值表示 "不存在" 这种语义。对于查找函数来说,数据不存在并非一种异常情况,是一种正常行为,所以返回表示不存在语义的 NULL 值比返回异常更加合理。

3.返回空对象

返回 NULL 值有各种弊端,对此有一个比较经典的应对策略,那就是应用空对象设计模式。当函数返回的数据是字符串或集合类型时,我们可以使用空对象或空集合替代 NULL 值,来表示不存在的情况。这样,我们在使用的时候,就可以不用做 NULL 值判断。

4.抛出异常对象

尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式是抛出异常。异常有两种类型:受检异常和非受检异常。

对于应用受检异常还是非受检异常,争论有很多,但也并没有一个非常强有力的理由,说明一个就比另一个更好。所以,我们只需要根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可。

对于函数抛出的异常,我们有三种处理方式:直接吞掉、直接向上抛出、包装成新的异常抛出。

重构ID生成器,处理各函数的异常

我们在进行软件设计的时候,除了要保证正常情况下的逻辑运行正确之外,还需要编写大量额外的代码,来处理可能出现的异常情况,以保证代码在任何情况下,都在我们的掌控之内,不会出现非预期的运行结果。程序的 bug 往往出现在一些边界条件和异常情况下,所以说,异常处理得好坏直接影响了代码的健壮性。全面、合理地处理各种异常能有效地减少代码 bug,也是保证代码质量的一个重要手段。

重构 generate() 函数

对于 generate() 函数,如果本机名获取失败,函数安徽什么?这样的返回值是否合理?

java 复制代码
public String generate() {
    String substrOfHostName = getLastFieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
}

ID 由三部分构成:本机名、时间戳和随机数。时间戳和随机数的生成函数不会出错,主机名有可能获取失败。在目前的代码中,如果主机名获取失败,substrOfHostName 为 NULL,那 generate() 会返回类似 "null-1710480822005-33Ab3uK6" 这样的数据。如果主机名获取失败, substrOfHostName 为空字符串,那 generate() 会返回类似 "-1710480822005-33Ab3uK6" 这样的数据。

在异常情况下,返回上面两种特殊的 ID 数据格式,这样的做法是否合理呢? 这其实很难讲,我们要看具体的业务是怎么设计的。不过,个人更倾向于将异常告知调用者。所以,这里最好是抛出受检异常,而非特殊值。

按照这个思路,我们对 generate() 函数进行重构。重构之后的代码如下所示:

java 复制代码
    public String generate() throws IdGenerationFailureException {
        String substrOfHostName = getLastFieldOfHostName();
        if (substrOfHostName == null || substrOfHostName.isEmpty()) {
            throw new IdGenerationFailureException();
        }
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }

重构 getLastFieldOfHostName() 函数

getLastFieldOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉,还是应该将异常继续网上抛出?如果网上抛出的话,是直接把 UnknownHostException 异常原封不动的抛出,还是封装成新的异常抛出?

java 复制代码
    private String getLastFieldOfHostName() {
        String substrOfHostName = null;
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            substrOfHostName = getLastSubstrSplitByDot(hostName);
        } catch (UnknownHostException e) {
            logger.error("failed to get the host name.", e);
        }
        return substrOfHostName;
    }

现在的处理方式是当主机名获取失败的时候,getLastFieldOfHostName() 函数返回 NULL 值。我们前面讲过,是返回 NULL 值还是异常对象,要看获取不到数据是正常行为还是异常行为。获取主机名失败会影响后续逻辑处理,并不是我们期望的,所以它是一种异常行为。这里最好是抛出异常,而非返回 NULL 值。

至于是将 UnknownHostException 异常抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFieldOfHostName() 函数用来获取主机名的最后一个字段, UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹新的异常。

按照上面的思路,我们对代码进行重构。

java 复制代码
    private String getLastFieldOfHostName() throws UnknownHostException {
        String substrOfHostName = null;
        String hostName = InetAddress.getLocalHost().getHostName();
        substrOfHostName = getLastSubstrSplitByDot(hostName);
        return substrOfHostName;
    }

getLastFieldOfHostName() 函数修改之后, generate() 函数也要做相应的修改。我们需要在 generate() 函数中捕获 getLastFieldOfHostName() 函数抛出的 UnknownHostException 异常。当捕获到异常之后,应该怎么处理呢?

按照之前的分析, ID 获取失败的时候,要明确的告知调用者。所以,我们不能再 generate() 函数中,将 UnknownHostException 异常吞掉。那是应该原封不动的抛出,还是封装成新的异常抛出呢?

需要选择后者。在 generate() 函数中,我们需要捕获 UnknownHostException 异常,并包裹成新的异常 IdGenerationFailureException 网上抛出。这么的原因有三个:

  • 调用者在使用 generate() 函数时,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。即这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节。
  • 从代码封装的角度来说,我们不希望将 UnknownHostException 异常这个比较底层的异常,暴露给更上层的代码。而且,调用者拿到这个异常时,并不能理解这个异常到底代表了什么,也不知道该如何处理。
  • UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性。

按照上面的设计思路,我们对 generate() 函数再次进行重构。

java 复制代码
    public String generate() throws IdGenerationFailureException {
        String substrOfHostName = null;
        try {
            substrOfHostName = getLastFieldOfHostName();
        } catch (UnknownHostException e) {
            throw new IdGenerationFailureException();
        }
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }

重构 getLastSubstrSplittedByDot() 函数

对于 getLastSubstrSplittedByDot() 函数,如果 hostName 为 NULL 或者空字符串,这个函数应该返回什么?

java 复制代码
    @VisibleForTesting
    protected String getLastSubstrSplitByDot(String hostName) {
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }

理论上讲,参数传递的正确性应该由程序员保证,我们无需做 NULL 值或空字符串的判断。但是,话说回来,谁也保证不了程序员就一定不会传递 NULL 值或者空字符串。我们到底该不该做 NULL 值或空字符串的判断呢?

如果函数是 private 私有的,只在类内部被调用,完全在你自己的掌控之下,自己保证在调用这个 private 函数的时候,不要传递 NULL 值或者空字符串就可以了。所以,可以不在 private 函数中做 NULL 值或者空字符串的判断。如果是 public 的,你无法掌控会被谁调用以及如何调用,为了尽可能提高代码的健壮性,我们最好是在 public 函数中做 NULL 值或者空字符串的判断。

getLastSubstrSplittedByDot() 是 protected 的,既不是 private 函数,也不是 public 函数,那要不要做 NULL 值或者空字符串的判断逻辑。虽然加上有些冗余,但多加些校验总归是不错的。

按照这个设计思路,我们对 getLastSubstrSplittedByDot() 函数进行重构。

java 复制代码
    @VisibleForTesting
    protected String getLastSubstrSplitByDot(String hostName) {
        if (hostName == null || hostName.isEmpty()) {
            throw new IllegalArgumentException("..."); //运行时异常
        }
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }

按照上面将的,我们在使用这个函数的时候,自己也要保证不传递 NULL 值或者空字符串进去。所以,getLastFieldOfHostName() 函数的代码也要做相应的修改。

java 复制代码
    private String getLastFieldOfHostName() throws UnknownHostException {
        String substrOfHostName = null;
        String hostName = InetAddress.getLocalHost().getHostName();
        if (hostName == null || hostName.isEmpty()) {
            throw new IllegalArgumentException("..."); //此处做判断
        }
        substrOfHostName = getLastSubstrSplitByDot(hostName);
        return substrOfHostName;
    }

重构 generateRandomAlphameric() 函数

对于 generateRandomAlphameric() 函数,如果 length <= 0,这个函数应该返回什么?

java 复制代码
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int randomAscii = random.nextInt(122);
            boolean isDigit = randomAscii >= 48 && randomAscii <= 57;
            boolean isUpperCase = randomAscii >= 65 && randomAscii <= 90;
            boolean isLowerCase = randomAscii >= 97 && randomAscii <= 122;
            if (isDigit || isUpperCase || isLowerCase) {
                randomChars[count++] = (char) randomAscii;
            }
        }
        return new String(randomChars);
    }

我们先来看一下 length < 0 的情况。生成一个长度为负值的随机字符串是不合常规逻辑的,是一种异常行为。所以,当出传入的参数 length < 0 的时候,我们抛出 IllegalArgumentException 异常。

我们再来看下 length = 0 的情况。length = 0 是否是异常行为呢?这就看你自己怎么定义了。我们既可以把它定义为一种异常行为,抛出 IllegalArgumentException 异常,也可以把它定义为一种正常行为,让函数入参 length = 0 的情况下,直接返回字符串。不管选择哪种处理方式,最关键的一点是,要在函数注释中,明确告知 length = 0 的情况下,会返回什么样的数据。

java 复制代码
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        if (length <= 0) {
            throw new IllegalArgumentException("..."); //运行时异常
        }
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int randomAscii = random.nextInt(122);
            boolean isDigit = randomAscii >= 48 && randomAscii <= 57;
            boolean isUpperCase = randomAscii >= 65 && randomAscii <= 90;
            boolean isLowerCase = randomAscii >= 97 && randomAscii <= 122;
            if (isDigit || isUpperCase || isLowerCase) {
                randomChars[count++] = (char) randomAscii;
            }
        }
        return new String(randomChars);
    }

重构之后的 RandomIdGenerator 代码

java 复制代码
public class RandomIdGenerator implements LogTraceIdIdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() throws IdGenerationFailureException {
        String substrOfHostName = null;
        try {
            substrOfHostName = getLastFieldOfHostName();
        } catch (UnknownHostException e) {
            throw new IdGenerationFailureException();
        }
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }

    private String getLastFieldOfHostName() throws UnknownHostException {
        String substrOfHostName = null;
        String hostName = InetAddress.getLocalHost().getHostName();
        if (hostName == null || hostName.isEmpty()) {
            throw new IllegalArgumentException("..."); //此处做判断
        }
        substrOfHostName = getLastSubstrSplitByDot(hostName);
        return substrOfHostName;
    }

    @VisibleForTesting
    protected String getLastSubstrSplitByDot(String hostName) {
        if (hostName == null || hostName.isEmpty()) {
            throw new IllegalArgumentException("..."); //运行时异常
        }
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }

    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        if (length <= 0) {
            throw new IllegalArgumentException("..."); //运行时异常
        }
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int randomAscii = random.nextInt(122);
            boolean isDigit = randomAscii >= 48 && randomAscii <= 57;
            boolean isUpperCase = randomAscii >= 65 && randomAscii <= 90;
            boolean isLowerCase = randomAscii >= 97 && randomAscii <= 122;
            if (isDigit || isUpperCase || isLowerCase) {
                randomChars[count++] = (char) randomAscii;
            }
        }
        return new String(randomChars);
    }
}

重构 ID 生成器的总结

这里总结了三点经验:

  • 在简单的代码,看上去再完美,只要我们下功夫去推敲,总有可以优化的空间,就看你愿不愿意把事情做到极致。
  • 如果你内功不够深厚,理论知识不够扎实,那你就很难参透开源项目的代码到底优秀在哪里。就像如果我们没有之前的理论学习,没有今天一点一点重构、分析,只是给出最后重构好的 RandomIdGenerator 代码,你真的能学到它的设计精髓吗?
  • 对比最开始小王写的 IdGenerator 代码和最终的 RandomIdGenerator,它们一个是能用,一个是好用,天壤之别。作为程序员,我们对代码要有追求哈。