在軟件開發調試過程中,經常會去查看某一對象的取值。但類之間複雜的層次關係,再加上數組(鏈表)、映射(字典)等多種數據結構,讓我們難以一目瞭然。本文介紹的Pretty工具類,以縮進的方式突出類之間的層次關係,並且將對象一層層的整個結構pretty地打印出來!
在編寫單元測試時,經常會去比較某一對象是否符合預先的期望值。但對於一個複雜類的對象,這種單元測試並不好寫,容易片面化、複雜化。Pretty工具類,既能夠完整的檢測複雜類的對象,而且可讀性好,便於理解代碼。當檢測出與期望結構不匹配時,不僅可以輸出Diff信息,還能提醒用戶是否需要自動更新case,簡單易用。
本文實現了3種語言版本的Pretty工具類:Java版,Python版,Groovy版。這裏對Java版的Pretty做了重點介紹,而其它版本只是簡單帶過,因爲實現的原理都是一樣的,只是換成不同語言而已。最後,對於同樣廣泛應用的C++,本文雖然沒有給出具體實現,但也提供了一個設計思路,有興趣的朋友可以試一試。
1. Pretty之Java版
1.1 調試中的問題
當我們在調試程序的時候,經常會查看某一變量的值。一般來說,有兩種方法被經常用到:
1. 用調試器,如Eclipse Debug,或者gdb/pdb。
2. 用print函數或者logger,直接將變量值打印出來。
這兩種辦法都有缺點,調試器需要一層層展開看,而且如果杯具碰到鏈表結構或者哈希表的時候,就不太容易看明白了。而print函數其實只是toString方法的返回值,取決於toString函數的實現,其實並不可靠。
可能有聰明的讀者會想到,那我們在定義類的時候,都override一下toString方法,讓它可讀,而不是Object類的缺省實現(JVM中的地址)。
這是一個很天真的想法:
1. 首先,不是所有的類都能夠由我們控制,如Java類庫,第三方庫,其他開發團隊的代碼,等等。
2. 類的toString方法可能有它的業務價值,而不只是爲了方便調試。
3. 額外工作量:這不是必須的,若是其中要求每個類都去override toString方法,會增加很多沒有必要的工作量,產生很多不必要的代碼,反而會增大維護代碼的工作量,甚至引起bug。而且,當類每次增加/修改/刪除成員變量時,都要去修改toString方法,否則print出來的信息也就不可靠了,但這是很難保證的。
1.2 單元測試中的問題
有下面一個類A,聚合了類B,而B又聚合類C,如下代碼:
class A {
private int id;
private File path;
private Integer[] array;
private List<String> list;
private B b;
// ...
}
class B {
private String desc;
private Map<String, Date> map;
private C c = new C();
// ...
}
class C {
private double v1;
private BigDecimal v2;
// ...
}
現在我們先測試一下類 A 的對象 a 是不是所期待的,一般容易想到下面幾個方法:
1. 把所有成員變量都 get 出來比較:
assertEquals(xxx, a.getId());
assertEquals(xxx, a.getPath());
...
assertEquals(xxx, a.getB().getDesc());
...
assertEquals(xxx, a.getB().getC().getV1());
...
這種辦法的問題顯而易見:如果不小心漏了一個重要成員變量的get,那測試就不夠全面了。而且並不是所有成員變量都有get方法,需不需要get方法得看具體需要,而不能只爲unit test專門提供。而且,像這麼簡單的類,都需要這麼多行assertEqual,如果有更多的成員變量或者有很深的聚合層次,那將無法想象。如果A類的結夠在做個調整,那改動的地方就很多了。這樣的unit test維護成本之高可想而知,還有誰有動力寫unit test,因爲那是在給自己找麻煩!而且,不是所有的代碼我們都能控制的,比如第三方庫。
2. 爲A類實現equals方法,那assertEquals就只有一個了:
A expected = new A();
expected.setId(xxx);
expected.setPath(xxx);
...
expected.setB(new B());
expected.getB().setDesc(xxx);
...
expected.getB().setC(new C());
expected.getB().getC().setV1(xxx);
...
assertEquals(expected, a);
雖然assertEquals只有一個,但爲了建立一個期待的expected作爲標尺來比較,需要爲提供大量的set方法。這麼多set方法帶來的問題其實並不比那麼多get少。
而且,爲A類實現equals方法也是有風險的,因爲equals方法本身也需要測試(只要是人寫的代碼本質上都需要測試!),也需要時間成本。很多類其實沒必要去override equals方法。寫代碼就得維護,沒必要寫的代碼堅決不寫,否則維護量更多。同樣的,不是所有的代碼都能控制的。
1.3 使用Pretty
Pretty類可以很pretty的解決以上調試和單元測試中的問題。在給出Pretty類之前,先從使用者的角度看看她的pretty:
package org.wenzhe.jvlib.debug.test;
import static org.junit.Assert.*;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.wenzhe.jvlib.debug.Pretty;
/**
* @author [email protected]
*
*/
public class PrettyTest {
private static class A {
private int id = 100;
private File path = new File("/home/wenzhe/code/pretty");
private Integer[] array = {86, 755, 1234, 5678};
private List<String> list = Arrays.asList("My", "name", "is", "Wenzhe");
private B b = new B("This is my Pretty Test");
}
private static class B {
private String desc;
private Map<String, Date> map = new HashMap<String, Date>();
private C c = new C();
public B(String desc) {
this.desc = desc;
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
try {
map.put("Today", dateFormat.parse("2013-06-15"));
map.put("Earth Doomsday", dateFormat.parse("2012-12-21"));
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
private static class C {
private double v1 = 3.14;
private BigDecimal v2 = new BigDecimal(
"3.141592653589793238462643383279502884197169399");
}
@Test
public void test1() throws IOException {
A a = new A();
assertTrue(Pretty.equalsGolden("test1", a));
}
public static void main(String[] args) throws IOException {
Pretty.setDebugMode(true);
PrettyTest test = new PrettyTest();
test.test1();
}
}
1.3.1 Pretty結構
這是一個帶有main方法的單元測試類。先撇開單元測試,我們把它當成一個普通java文件來運行(即從main方法開始運行),在屏幕上會打印出對象 a 的pretty結構:
org.wenzhe.jvlib.debug.test.PrettyTest$A {
array : [86, 755, 1234, 5678]
b : org.wenzhe.jvlib.debug.test.PrettyTest$B {
c : org.wenzhe.jvlib.debug.test.PrettyTest$C {
v1 : 3.14
v2 : 3.141592653589793238462643383279502884197169399
}
desc : This is my Pretty Test
map : {Earth Doomsday=Fri Dec 21 00:00:00 PST 2012, Today=Sat Jun 15 00:00:00 PDT 2013}
}
id : 100
list : [My, name, is, Wenzhe]
path : pretty
}
根據pretty結構的縮進,可以很容易看出,對象a的類是: org.wenzhe.jvlib.debug.test.PrettyTest類的內部類A,其成員array是一個數組,值爲[86, 755, 1234, 5678]。另一個成員 b 是類 (PrettyTest的內部類B)的對象,b 裏面的成員變量c 是類(PrettyTest內部類C)的對象……根據縮進,各種成員變量及其嵌套聚合類的對象也都輕易可見。這在開發調試過程中非常好用!
如果以單元測試的方式運行,屏幕上沒有任何輸出(No news is Good news),JUnit View中出現大家喜愛的綠色條,祝賀你表測試通過了。(一般對於unittest來說,正確的時候是沒輸出信息的。)
那麼程序怎麼知道對象a是期望的呢?注意到第61行,Pretty.equalsGolden("test1", a); 對象a實際上是跟一個名字爲test1的golden文件做了比較。這個golden文件的所在的目錄爲: ${project_root}/src/test/resources/golden/pretty/,這是pretty工具的一個convention,當然也可以改成別的目錄,但我不推薦改,很多時候遵從“約定優於配置”的原則總是更好的。打開test1文件,你會發現這也是一個pretty結構,跟之前屏幕上輸出的完全一樣。
你可能奇怪爲什麼作爲普通java類運行屏幕上會打印,而作爲unit test卻不會打印呢?其實區別並不在於用哪種運行方式,唯一的區別在於是否選擇了Pretty類的debug模式。(一般來說,unit test下不啓動debug模式,而在開發調試過程中啓動)。注意到main函數剛開始的時候(第65行),debug mode設置爲true,當Pretty工具要得到對象a的pretty結構時,會將它打印出來,方便調試,省得在代碼裏面加入print函數的麻煩。debug mode缺省是關的,所以unit test就沒有打印出來了。(有興趣可以閱讀後面的源代碼)。
1.3.2 Pretty Diff
如果unit test測到對象a與golden文件不同,那會怎樣?假如有個大老粗不小心把C類中的成員變量v1刪除了,又不小心增加了成員變量v3(取值爲true),更是不小心把A類的成員變量list裏面insert了一個“NOT”,不管是不是在Pretty的debug模式,屏幕上都會輸出:
Diff from Expected to Actual:
-: v1 : 3.14
+: v3 : true
<: list : [My, name, is, Wenzhe]
>: list : [My, name, is, NOT, Wenzhe]
Pretty工具的錯誤輸出,夠pretty吧,大老粗幹了哪些壞事這裏一目瞭然。
1.3.3 Pretty Golden
如果大老粗是故意這麼修改的(背後有大老闆支持,用軟件行業的語言講就是“需求變了”),那麼golden文件也就過時了,需要更新才能讓unit test通過。
需要手動更改golden文件嗎?我可不幹!因爲Pretty讓我越來越懶了。
懶人都喜歡Pretty,因爲Pretty提供了自動更新golden文件的功能。這時候你開啓Pretty的debug模式,運行,屏幕上除了輸出對象a的pretty結構和Diff信息之外,Pretty還會問你“Overwrite (Y/N)? ”,回答Y即可自動更新golden文件test1。
有了Pretty,你永遠不需要手動寫golden文件:當golden不存在時,Pretty會幫你創建;當golden存在但有Diff時會提醒你是否需要更新。
1.4 Pretty原理及源碼
也許你已經迫不及待地想知道Pretty類是怎麼實現的,原理其實也簡單,就是通過Java的“反射”機制,把類的成員變量拿出來,放進一個Map裏,key爲成員變量名,value爲成員變量的值,然後遞歸地輸出到一個具有縮進層次的代表pretty結構的字符串裏。這是一個既美麗又好用的字符串,在debug模式下打印到標準輸出,在unit test下就是與golden文件進行字符串比較,從而避免了做對象比較的麻煩,同時golden文件的pretty結構記錄了期待對象完整的層層信息,有助於理解代碼,^_^。
package org.wenzhe.jvlib.debug;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.wenzhe.jvlib.file.FileUtil;
/**
* @author [email protected]
*
*/
public class Pretty {
private static final String TAB = " ";
private static boolean debugMode = false;
private static boolean showFileAbsPath = false;
public static void setDebugMode(boolean toDebug) {
debugMode = toDebug;
Golden.setDebugMode(debugMode);
}
public static void setShowFileAbsPath(boolean toShowFileAbsPath) {
showFileAbsPath = toShowFileAbsPath;
}
private static Map<String, Object> obj2map(Object o) {
Map<String, Object> props = new TreeMap<String, Object>();
Class<?> c = o.getClass();
for (Field field : c.getDeclaredFields()) {
String name = field.getName();
Object value = null;
boolean originalAccessible = field.isAccessible();
if (!originalAccessible) {
field.setAccessible(true);
}
try {
value = field.get(o);
} catch (IllegalArgumentException e) {
throw new RuntimeException("Should not reach!", e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Should not reach!", e);
} finally {
if (!originalAccessible) {
field.setAccessible(false);
}
}
props.put(name, value);
}
return props;
}
public static void println(Object obj, int level) {
System.out.println(str(obj, level));
}
public static String str(Object obj, int level) {
return str(obj, level, debugMode);
}
public static String str(Object obj, int level, boolean debugMode) {
String result = str(obj, 0, level);
if (debugMode) {
System.out.println(result);
}
return result;
}
@SuppressWarnings("unchecked")
private static String str(Object obj, int tabCnt, int level) {
if (obj == null) {
return "";
}
else if (tabCnt > level ||
obj instanceof String ||
obj instanceof BigDecimal ||
obj instanceof BigInteger ||
obj instanceof Integer ||
obj instanceof Short ||
obj instanceof Long ||
obj instanceof Float ||
obj instanceof Double ||
obj instanceof Boolean ||
obj instanceof Class ||
obj instanceof Date
) {
return obj.toString();
}
else if (obj instanceof File) {
File file = (File)obj;
if (showFileAbsPath) {
return FileUtil.unixPath(file.getAbsoluteFile());
}
else {
return file.getName();
}
}
else if (obj instanceof Iterable) {
List<String> results = new ArrayList<String>();
for (Object o : (Iterable<?>)obj) {
results.add(str(o, tabCnt + 1, level));
}
return results.toString();
}
else if (obj instanceof Object[]) {
List<String> results = new ArrayList<String>();
for (Object o : (Object[])obj) {
results.add(str(o, tabCnt + 1, level));
}
return results.toString();
}
else if (obj instanceof Map) {
Map<String, String> results = new TreeMap<String, String>();
for (Map.Entry<Object, Object> entry : ((Map<Object, Object>)obj).entrySet()) {
String key = str(entry.getKey(), tabCnt + 1, level);
String value = str(entry.getValue(), tabCnt + 1, level);
results.put(key, value);
}
return results.toString();
}
else {
Map<String, Object> m = obj2map(obj);
StringBuilder sb = new StringBuilder();
sb.append(obj.getClass().getName() + " {\n");
String nTabs = tabs(tabCnt + 1);
for (Map.Entry<String, Object> entry : m.entrySet()) {
sb.append(nTabs);
sb.append(entry.getKey());
sb.append(" : ");
sb.append(str(entry.getValue(), tabCnt + 1, level));
sb.append("\n");
}
sb.append(tabs(tabCnt));
sb.append("}");
return sb.toString();
}
}
private static String tabs(int count) {
StringBuilder sb = new StringBuilder();
while (count-- > 0) {
sb.append(TAB);
}
return sb.toString();
}
public static boolean equalsGolden(String goldenFileName, Object obj) throws IOException {
return equalsGolden(goldenFileName, obj, 5);
}
public static boolean equalsGolden(String goldenFileName, Object obj, int level) throws IOException {
File goldenFile = new File("src/test/resources/golden/pretty", goldenFileName);
return equalsGolden(goldenFile, obj, level);
}
public static boolean equalsGolden(File goldenFile, Object obj, int level) throws IOException {
String actual = str(obj, level, false).trim();
return Golden.equals(goldenFile, actual);
}
}
在調試過程中,Pretty的str方法和println方法是很常用的;而在unit test中,equalsGolden方法更加方便。
1.5 Pretty姐妹篇:Golden原理及源碼
Pretty類用到了另一個相當實用的工具類:Golden,是Pretty的好姐妹,如果golden文件不存在則幫你創建,如果存在了則幫你把字符串跟golden文件做比較,一旦發現差異,則將差異部分打印出來。在Golden類的調試模式下(debugMode=true)還會提示你是否需要overwirte你的golden文件。這是很實用的功能,試想一下如果有上千個golden文件,維護的工作量是很大的。需求變了,代碼結構也變了,原先的golden不再正確時就需要更新。要是每次都得手動去文件裏查找哪些不同,手動去修改golden文件,那也是相當麻煩的事。Golden類可以給你“一鍵搞定”的成就感!
package org.wenzhe.jvlib.debug;
import java.io.File;
import java.io.IOException;
import org.wenzhe.jvlib.diff.DiffUtil;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
/**
* @author [email protected]
*
*/
public class Golden {
private static boolean debugMode = false;
public static void setDebugMode(boolean toDebug) {
debugMode = toDebug;
}
public static boolean equals(String goldenFileName, String actual) throws IOException {
File goldenFile = new File("src/test/resources/golden", goldenFileName);
return equals(goldenFile, actual);
}
public static boolean equals(File goldenFile, String actual) throws IOException {
if (debugMode) {
System.out.println(actual);
}
goldenFile = goldenFile.getAbsoluteFile();
if (!goldenFile.isFile()) {
System.out.println("Generate golden file: " + goldenFile);
Files.createParentDirs(goldenFile);
Files.write(actual, goldenFile, Charsets.UTF_8);
return true;
}
String expected = Files.toString(goldenFile, Charsets.UTF_8);
if (actual.equals(expected)) {
return true;
} else {
// need 3'rd party: diffutils {
System.out.println("Diff from Expected to Actual: ");
System.out.println(DiffUtil.diff(expected, actual));
if (debugMode) {
System.out.print("Overwrite (Y/N)? ");
char in = (char)System.in.read();
if (in == 'Y' || in == 'y') {
Files.write(actual, goldenFile, Charsets.UTF_8);
return true;
}
}
// }
return false;
}
}
}
2. Pretty之Python版
Python的實現方法非常簡單,自帶的pprint方法就可以實現pretty print,因此要做到主要是將object轉換成dict(即Java裏的Map),而Python自帶的vars函數返回的就是成員變量的dict。源碼如下:
# author: [email protected]
import pprint
from StringIO import StringIO
def obj2map(o):
""" if o doesn't have __dict__, not return map """
if hasattr(o, "__dict__"):
m = vars(o)
return obj2map(m)
elif type(o) == dict:
m = {}
for k,v in o.items():
key = obj2map(k)
if not key.__hash__:
key = str(key)
m[key] = obj2map(v)
return m
elif type(o) == list:
arr = []
for item in o:
arr.append(obj2map(item))
return arr
elif type(o) == tuple:
return tuple(obj2map(list(o)))
else:
return o
def printObj(o, stream=None):
"""Pretty-print a mapped Python object to a stream [default is sys.stdout]."""
pprint.pprint(obj2map(o), stream)
def obj2str(o):
s = StringIO()
printObj(o, s)
return s.getvalue()
由於Python的動態腳本語言特性,我們可以在運行時導入Pretty,然後打印感興趣的對象。下面是Pretty在pdb調試中的例子。
pdb> import Pretty
pdb> Pretty.printObj(xxx)
Python版的Pretty,輸出結果也是同樣pretty,請看下面的unit test文件,特別是複雜類A的對象a所對應的pretty結構,即字符串expectedStrA
# author: [email protected]
import unittest
import Pretty
class A:
def __init__(self):
self.a1 = "a"
self.a2 = 2
self.b = B()
self.c = [C(1), C(2)]
class B:
def __init__(self):
self.bm = {3: C(3), "4": C(4), C(7):C(8)}
self.bt = (C(5), C(6))
class C:
def __init__(self, c):
self.c = c
self.cs = str(c)
expectedMapA = \
{'a1': 'a',
'a2': 2,
'b': {'bm': {3: {'c': 3, 'cs': '3'},
'4': {'c': 4, 'cs': '4'},
"{'cs': '7', 'c': 7}": {'c': 8, 'cs': '8'}},
'bt': ({'c': 5, 'cs': '5'}, {'c': 6, 'cs': '6'})},
'c': [{'c': 1, 'cs': '1'}, {'c': 2, 'cs': '2'}]}
expectedStrA = """
{'a1': 'a',
'a2': 2,
'b': {'bm': {3: {'c': 3, 'cs': '3'},
'4': {'c': 4, 'cs': '4'},
"{'cs': '7', 'c': 7}": {'c': 8, 'cs': '8'}},
'bt': ({'c': 5, 'cs': '5'}, {'c': 6, 'cs': '6'})},
'c': [{'c': 1, 'cs': '1'}, {'c': 2, 'cs': '2'}]}
"""
class Test(unittest.TestCase):
def setUp(self):
self.a = A()
def testObj2map(self):
m = Pretty.obj2map(self.a)
self.assertEqual(expectedMapA, m)
self.assertEqual(type(B()), type(self.a.b))
def testObj2Str(self):
s = Pretty.obj2str(self.a)
self.assertEqual(expectedStrA.strip(), s.strip())
Pretty.printObj(self.a)
if __name__ == "__main__":
unittest.main()
3. Pretty之Groovy版
Groovy是語法簡化、但卻功能擴展的Java,思路是一樣的,只是代碼寫起來簡單一些(比如反射、格式化等)。源碼如下:
package org.wenzhe.gvlib
/**
* Pretty print an object detailed to string, console
*
* @author [email protected]
*
*/
class Pretty {
private static final String TAB = " " * 2
static String str(obj) {
return str(obj, false)
}
static String str(obj, boolean recursive) {
return strLevel(obj, (recursive ? 1 : 0))
}
private static String strLevel(obj, int tabLevel) {
if (obj == null ||
obj instanceof String ||
obj instanceof BigDecimal ||
obj instanceof BigInteger ||
obj instanceof Integer ||
obj instanceof Short ||
obj instanceof Long ||
obj instanceof Float ||
obj instanceof Double ||
obj instanceof Boolean ||
obj instanceof Class
) {
return obj.toString()
}
if ( obj instanceof List ||
obj instanceof Object[] ||
obj instanceof Set
) {
List<String> prettyList = obj.collect {
if (tabLevel <= 0) {
return str(it)
} else {
return strLevel(it, tabLevel + 1)
}
}
return prettyList.toString()
}
if (obj instanceof Map) {
if (!obj) {
return obj.toString()
}
List<String> list = obj.collect() { key, val ->
if (tabLevel <= 0) {
return str(key) + ' : ' + str(val)
} else {
return str(key) + ' : ' + strLevel(val, tabLevel + 1)
}
}
return str(list)
}
Map<String, Object> props = obj.getProperties()
if (tabLevel <= 0) {
return props.inject(obj.class.name + ' {\n') { buf, entry ->
if (entry.key == "class") {
return buf;
} else {
return buf + TAB + "$entry.key : $entry.value\n"
}
} + "}"
} else {
return props.inject(obj.class.name + ' {\n') { buf, entry ->
if (entry.key == "class") {
return buf;
} else {
return buf + strFormat(entry.key, entry.value, tabLevel)
}
} + TAB * (tabLevel - 1) + "}"
}
}
private static String strFormat(String key, Object value, int tabLevel) {
String s = strLevel(value, tabLevel + 1)
return TAB * tabLevel + "$key : $s\n";
}
static String listMethodObjs(obj) {
return Pretty.str(obj.metaClass.methods)
}
static String listMethodDescs(obj) {
List<String> methods = obj.metaClass.methods.cachedMethod*.toString()
return formatMethodList(obj, methods)
}
static String listMethodNames(obj) {
List<String> methods = obj.metaClass.methods.cachedMethod*.name.sort().unique()
return formatMethodList(obj, methods)
}
static private String formatMethodList(obj, List<String> methods) {
return """${obj.class.name} {
${methods.join("\n ")}
}"""
}
}
4. Pretty之C++設計思路
由於C++沒有“反射”機制,要想獲取類的所有私有(或公有)成員變量的名字與類型並不容易。但思路還是有的,比如可以通過分析C++類的源代碼來獲得,可以藉助第三方庫,如Clang來實現。Clang由Apple開發,BSD開源授權,支持C,C++,Object C,Object C++等編程語言,能夠對源代碼進行詞法和語意分析,結果爲抽象語法樹。通過抽象語法樹,我們可以模仿類似與Java中“反射”機制,來得到類的成員信息(名字,類型,取值等)。只是一個思路,有興趣的朋友不妨一試。
---------------------- 本博客所有內容均爲原創,轉載請註明作者和出處 -----------------------
作者:劉文哲
聯繫方式:[email protected]
博客:http://blog.csdn.net/liuwenzhe2008