教你如何写Bug:Google Guava源码分析之——Joiner

我们在码砖的过程中,经常会遇到List转字符串、字符串转List这类需求,当然这不仅仅是单纯的转字符串,而是加入了一个连接符。比如:将一个list转换成以","分隔的字符传,这个时候仅仅使用list.toString()是做不到的。初级的猩猩会想到循环list,然后用StringBuilder来拼装字符串,这样最后一般会多一个字符,再切分。大概代码如下:

		List list = new ArrayList();
		list.add("a");
		list.add("b");
		list.add("c");
		String separator = ",";
		StringBuilder stringBuilder = new StringBuilder();
		list.forEach(str -> {
			if (str != null)
				stringBuilder.append(str).append(separator);
		});
		stringBuilder.setLength(stringBuilder.length() - delimiter.length());
		System.out.println(stringBuilder.toString());

说句题外话,面试当中也问过很多人,就上面这段代码来说,如果你还在用substring或者干脆用String+String。那只能说你写代码写的真是太随意(Low)了!

上面代码实现了list转string的功能,并且按照逗号分隔,但是大家也看到了,实现起来代码量还是很多的,这对于工程来说并不友好,所以中级的猩猩一般会使用Guava工具中的Joiner来帮助我们实现这一功能。具体代码如下:

		List list = new ArrayList();
		list.add("a");
		list.add("b");
		list.add("c");
		String separator = ",";
		String result = Joiner.on(separator).join(list);
		System.out.println(result);

使用Joiner,我们可以实现输出a,b,c。功能已经实现,现在进入本篇文章的核心,这段代码其实没有任何问题,但是这么写是有一个隐藏的bug,而且你怎么测试一般都测不出来。高级的猩猩,经验丰富,写代码的时候会很自然的绕过去,但是初级和中级的可能就会留下坑。

上述代码在实际应用中肯定不会是这样写,大多数情况是传进来一个list变量或者调用方法返回一个list,数据来源可能是数据库或其他,list的内容、长度其实对我们来说并不那么直观。假如,数据库中存在一个null值,这个null值最终被传入list中,那么再执行这段代码会怎样呢?

		list.add(null);

再次执行上面的代码会发现,程序报错了:

Exception in thread "main" java.lang.NullPointerException
	at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:770)
	at com.google.common.base.Joiner.toString(Joiner.java:454)
	at com.google.common.base.Joiner.appendTo(Joiner.java:109)
	at com.google.common.base.Joiner.appendTo(Joiner.java:154)
	at com.google.common.base.Joiner.join(Joiner.java:197)
	at com.google.common.base.Joiner.join(Joiner.java:187)
	at com.wf.test.main(test.java:62)

这个报错是checkNotNull判断空指针异常,为什么会null指针呢,就是因为我插入了一个null值,是这个null导致的嘛?答案是的,请注意我文章开头的那段代码,循环list的中是有一个非null判断的,所以,我们是要屏蔽掉null值得,但是我一个初级猩猩都能想到得问题Google那帮大神难道会忽略掉这个情况?那你可能是想多了。但是为什么没有屏蔽掉null值,反而报错了呢?

下面我们开始分析Joiner源码

首先我们调用Joiner.on(delimiter).join(list);on方法是返回一个初始化得Joiner

  public static Joiner on(String separator) {
    return new Joiner(separator);
  }

并且初始化Joiner得时候将分隔符传参进去,注意这里得checkNotNull判断,判断的是传入得分隔符,而不是list

  private Joiner(String separator) {
    this.separator = checkNotNull(separator);
  }

回到第一步,join方法,传入list,并使用迭代器

  public final String join(Iterable parts) {
    return join(parts.iterator());
  }

进入join方法,初始化一个StringBuilder

  public final String join(Iterator parts) {
    return appendTo(new StringBuilder(), parts).toString();
  }

进入appendTo方法,开始进行转换。好吧,还没开始转换

  @CanIgnoreReturnValue
  public final StringBuilder appendTo(StringBuilder builder, Iterator parts) {
    try {
      appendTo((Appendable) builder, parts);
    } catch (IOException impossible) {
      throw new AssertionError(impossible);
    }
    return builder;
  }

再次进入appendTo方法,这次开始转换了

  @CanIgnoreReturnValue
  public  A appendTo(A appendable, Iterator parts) throws IOException {
    checkNotNull(appendable);
    if (parts.hasNext()) {
      appendable.append(toString(parts.next()));
      while (parts.hasNext()) {
        appendable.append(separator);
        appendable.append(toString(parts.next()));
      }
    }
    return appendable;
  }

看上去任然没有任何毛病,根据上面得报错,我们可以发现是appendable.append(toString(parts.next()));这行报错,并且是toString这个方法

  CharSequence toString(Object part) {
    checkNotNull(part); // checkNotNull for GWT (do not optimize).
    return (part instanceof CharSequence) ? (CharSequence) part : part.toString();
  }

好的,终于发现你了,就是checkNotNull(part); 这里报出了null指针,从上面代码我们可以看出,这里判断得part就是list中的一个元素,因为我们插入了一个null值,所以这里报错了。但是有的猩猩就说了,我明明判断while (parts.hasNext()),既然是null值为什么还是会进入循环,关于这个问题这里就不详细说了,百度一堆一堆的。

从上面的源码可以看出,对于list中的null元素问题,Joiner是没有进行过判断的,就是说如果我们的list中有null值,使用Joiner就会出现这个异常。但是我也说了,Google的大神未必多牛逼但肯定不会比你菜,他们肯定考虑到这个问题,所以如果我们享用Joiner并且避免这种情况,我们应该这样使用:

Joiner.on(separator).skipNulls().join(list);

加一个skipNulls()方法来跳过null值,我们来看skipNulls方法:

  public Joiner skipNulls() {
    return new Joiner(this) {
      @Override
      public  A appendTo(A appendable, Iterator parts) throws IOException {
        checkNotNull(appendable, "appendable");
        checkNotNull(parts, "parts");
        while (parts.hasNext()) {
          Object part = parts.next();
          if (part != null) {
            appendable.append(Joiner.this.toString(part));
            break;
          }
        }
        while (parts.hasNext()) {
          Object part = parts.next();
          if (part != null) {
            appendable.append(separator);
            appendable.append(Joiner.this.toString(part));
          }
        }
        return appendable;
      }

      @Override
      public Joiner useForNull(String nullText) {
        throw new UnsupportedOperationException("already specified skipNulls");
      }

      @Override
      public MapJoiner withKeyValueSeparator(String kvs) {
        throw new UnsupportedOperationException("can't use .skipNulls() with maps");
      }
    };
  }

这里我们可以看到又初始化了一个Joiner,并且重写了appendTo方法,在这个appendTo方法中,加入了part != null的判断,这样就完美的解决null值问题了。

总结:为什么说这个问题是一个bug呢。因为我们不使用skipNulls,一样可以实现我们的功能,甚至测试过程中也不会出现问题,因为我们的数据中有没有null值谁也不清楚,如果碰巧测试数据没有空值,那这个问题就不会被发现,并且,这个bug一旦拿到线上,查找起来也是要消耗一定功夫的。我相信大部分的人都知道使用Joiner,但是知道使用skipNulls的应该不多,非常繁忙之中写下这篇博客希望能够帮到大家。

你可能感兴趣的:(java)