10k

设计模式之美-课程笔记23-编程规范&代码质量实战(ID生成器)2 - 异常处理

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

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastFiledOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(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 maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
  • 对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
  • 对于 getLastFiledOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)?还是应该将异常继续往上抛出?如果往上抛出的话,是直接把 UnknownHostException 异常原封不动地抛出,还是封装成新的异常抛出?
  • 对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者是空字符串,这个函数应该返回什么?
  • 对于 generateRandomAlphameric(int length) 函数,如果 length 小于 0 或者等于 0,这个函数应该返回什么?

函数出错返回什么?

函数出错一般有四种情况:异常对象、空、null、错误码

1. 返回错误码

C语言中没有异常处理机制,所以一般用错误码处理异常。Java个Python因为有异常处理机制所以不用错误码。

C中错误码返回有两种:一种是直接作为函数返回返回,另一种是错误码定义为全局变量,执行出错时函数调用者用这个全局变量获取错误码。

// 错误码的返回方式一:pathname/flags/mode为入参;fd为出参,存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
  if (/*文件不存在*/) {
    return EEXIST;
  }
  
  if (/*没有访问权限*/) {
    return EACCESS;
  }
  
  if (/*打开文件成功*/) {
    return SUCCESS; // C语言中的宏定义:#define SUCCESS 0
  }
  // ...
}
//使用举例
int fd;
int result = open(c:\test.txt, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
  // 取出fd使用
} else if (result == EEXIST) {
  //...
} else if (result == EACESS) {
  //...
}

// 错误码的返回方式二:函数返回打开的文件句柄,错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode{
  if (/*文件不存在*/) {
    errno = EEXIST;
    return -1;
  }
  
  if (/*没有访问权限*/) {
    errno = EACCESS;
    return -1;
  }
  
  // ...
}
// 使用举例
int hFile = open(c:\test.txt, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
  printf("Failed to open file, error no: %d.\n", errno);
  if (errno == EEXIST ) {
    // ...        
  } else if(errno == EACCESS) {
    // ...    
  }
  // ...
}
2. 返回null值

不太建议:

  1. 如果某个函数有可能返回null值而调用者在使用的时候没有做null判断处理,可能会导致空指针异常NPE;
  2. 如果我们定义了这种函数,代码中会充斥着大量NULL值判断逻辑,写起来繁琐而且和业务代码耦合,影响可读性。
public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public User getUser(String telephone) {
    // 如果用户不存在,则返回null
    return null;
  }
}

// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判断,否则有可能会报NPE
  String email = user.getEmail();
  if (email != null) { // 做NULL值判断,否则有可能会报NPE
    String escapedEmail = email.replaceAll("@", "#");
  }
}

那是否可以用异常代替呢?

对于get、find、query之类的函数,没找到就是返回null并不是异常。null应该更合理。当然这个事情还是要整个项目的规范一致。

对于查找这种函数,处理返回数据对象,还会返回下标位置。比如Java的indexOf函数。这个时候就不好用null表示值不存在的情况(对比找数据,这个是找位置);我们对于这种情况两种处理:1. 返回位置没找到的异常 2. 是返回一个特殊值,例如-1。-1更合理一点因为没找到不是异常而是正常情况。

3. 返回空对象

一个经典的策略是应用空对象设计模式,后面讲。

对于比较简单的情况,当返回的数据是字符串或者是空集合类型的时候, 可以用空字符串和空集合。这样我们在使用函数的时候就不需要做null值判断。

// 使用空集合替代NULL
public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public List<User> getUsers(String telephonePrefix) {
   // 没有查找到数据
    return Collections.emptyList();
  }
}
// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //这里不需要做NULL值判断
  // ...
}

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

最常见的还是抛出异常。异常会带有更多的信息,比如函数调用栈,而且异常处理和正常的逻辑还是分开的,所以可读性会好点。

大部分动态语言只有运行时异常(非受检异常),而Java还有编译时异常,也叫受检异常。

对于代码bug以及不可恢复的异常(数据库连接失败),即使我们捕获了也做不了什么,我们就用非受检异常;对于可恢复异常,比如提额现金大于余额,我们使用受检异常明确告知调用者需要捕获处理。

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

// address格式:"192.131.2.33: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]);
}
如何处理抛出的异常
  • 直接吞掉
public void func1() throws Exception1 {
  // ...
}

public void func2() {
  //...
  try {
    func1();
  } catch(Exception1 e) {
    log.warn("...", e); //吐掉:try-catch打印日志
  }
  //...
}
  • 原封不动re-throw
public void func1() throws Exception1 {
  // ...
}

public void func2() throws Exception1 {//原封不动的re-throw Exception1
  //...
  func1();
  //...
}
  • 包装成新的异常然后抛出
public void func1() throws Exception1 {
  // ...
}

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

当我们面对异常的时候,

  • 如果func1抛出的异常是可恢复的,而且func2的调用方不关心此异常,那么我们可以在func2内将func1的异常吞掉;
  • 如果func1的异常对于func2的调用方来说,可以理解、关心的,并且在业务概念上有一定的相关性,可以选择继续抛出;
  • 如func1的异常太底层,func2的调用方缺乏背景去理解、业务无关,我们就把他包装成调用方可以理解的新异常,re-throw。

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

重构ID生成器项目中各函数的异常处理代码

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastFiledOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(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 maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
  • 对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
  • 对于 getLastFiledOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)?还是应该将异常继续往上抛出?如果往上抛出的话,是直接把 UnknownHostException 异常原封不动地抛出,还是封装成新的异常抛出?
  • 对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者是空字符串,这个函数应该返回什么?
  • 对于 generateRandomAlphameric(int length) 函数,如果 length 小于 0 或者等于 0,这个函数应该返回什么?

重构generate函数

如何处理空字符和null,本身是要看业务是否允许。不过就个人判断来讲,更倾向于抛出异常给调用者。

public String generate() throws IdGenerationFailureException {
String substrOfHostName = getLastFieldOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()) {
  throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString);
return id;
}

重构getLastFieldOfHostName函数

private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
 }

当主机名获取失败的时候,返回null还是会影响后续的逻辑,所以抛出一个异常会比较好。而上层调用者和他业务相关,所以也不需要对这个异常进行重新包装。

这个函数的调用者(generate)获取到的UnknownHostException我们需要包装:

  • 上层调用者调用generate的时候,不知道细节,面向接口而非实现。所以在出异常的时候也不能暴露细节。
  • 从封装的角度,我们不希望低层的异常暴露给上层,调用者拿到这异常并不知道如何处理。
  • UnknownHostExceptiongenerate在业务上没有相关性。
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
  substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
  throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
        substrOfHostName, currentTimeMillis, randomString);
return id;
}

重构 getLastSubstrSplittedByDot() 函数

@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
   String[] tokens = hostName.split("\\.");
   String substrOfHostName = tokens[tokens.length - 1];
   return substrOfHostName;
}

理论上hostName参数传递应该程序员来保证没有code bug。

所以如果是私有方法你自己调用,你可以把握传递的参数是ok的,那就不需要做空值或者null的判断;反之是public,别人可以调用,他就有可能会会发生这种误用的情况。所以为了提高健壮性,需要加上。

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

在调用者时候,我们也需要保证传参不是null或者空值,所以在getLastFieldOfHostName() 函数的代码也要作相应的修改

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

重构 generateRandomAlphameric() 函数

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

对于入参length,非正数的时候应该怎么处理,首先负数肯定是不合理的,所以可以抛出异常,而0可以看业务需要,不过这种corner case不管是把他当成异常还是正常输入,在注释说明是很有必要的。

重构之后的完整代码

public class RandomIdGenerator implements IdGenerator {
  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("...", e);
    }
    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 UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(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 maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
Thoughts? Leave a comment