Pretty tool 介绍
发布者:
Wenzhe
在软件开发调试过程中,经常会去查看某一对象的取值。但类之间复杂的层次关系,再加上数组(链表)、映射(字典)等多种数据结构,让我们难以一目了然。本文介绍的Pretty工具类,以缩进的方式突出类之间的层次关系,并且将对象一层层的整个结构pretty地打印出来!
在编写单元测试时,经常会去比较某一对象是否符合预先的期望值。但对于一个复杂类的对象,这种单元测试并不好写,容易片面化、复杂化。Pretty工具类,既能够完整的检测复杂类的对象,而且可读性好,便于理解代码。当检测出与期望结构不匹配时,不仅可以输出Diff信息,还能提醒用户是否需要自动更新case,简单易用。
本文实现了3种语言版本的Pretty工具类:Java版,Python版,Groovy版。这里对Java版的Pretty做了重点介绍,而其它版本只是简单带过,因为实现的原理都是一样的,只是换成不同语言而已。最后,对于同样广泛应用的C++,本文虽然没有给出具体实现,但也提供了一个设计思路,有兴趣的朋友可以试一试。
1. Pretty之Java版
1.1 调试中的问题
当我们在调试程序的时候,经常会查看某一变量的值。一般来说,有两种方法被经常用到:
-
用调试器,如Eclipse Debug,或者gdb/pdb。
-
用print函数或者logger,直接将变量值打印出来。
这两种办法都有缺点,调试器需要一层层展开看,而且如果杯具碰到链表结构或者哈希表的时候,就不太容易看明白了。而print函数其实只是toString方法的返回值,取决于toString函数的实现,其实并不可靠。
可能有聪明的读者会想到,那我们在定义类的时候,都override一下toString方法,让它可读,而不是Object类的缺省实现(JVM中的地址)。
这是一个很天真的想法:
-
首先,不是所有的类都能够由我们控制,如Java类库,第三方库,其他开发团队的代码,等等。
-
类的toString方法可能有它的业务价值,而不只是为了方便调试。
-
额外工作量:这不是必须的,若是其中要求每个类都去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 map;
private C c = new C();
// ...
}
class C {
private double v1;
private BigDecimal v2;
// ...
}
现在我们先测试一下类 A 的对象 a 是不是所期待的,一般容易想到下面几个方法:
把所有成员变量都 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,因为那是在给自己找麻烦!而且,不是所有的代码我们都能控制的,比如第三方库。
为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 liuwenzhe2008@qq.com
?*
?*/
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 liuwenzhe2008@qq.com
*
*/
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 liuwenzhe2008@qq.com
*
*/
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: liuwenzhe2008@qq.com
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: liuwenzhe2008@qq.com
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 liuwenzhe2008@qq.com
*
*/
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中“反射”机制,来得到类的成员信息(名字,类型,取值等)。只是一个思路,有兴趣的朋友不妨一试。
睿初科技软件开发技术博客,转载请注明出处
blog comments powered by Disqus
发布日期
标签
最近发表
- volatile与多线程
- TDD practice in UI: Develop and test GUI independently by mockito
- jemalloc源码解析-核心架构
- jemalloc源码解析-内存管理
- boost::bind源码分析
- 小试QtTest
- 一个gtk下的目录权限问题
- Django学习 - Model
- Code snippets from C & C++ Code Capsule
- Using Eclipse Spy in GUI products based on RCP
文章分类
- cpp 3
- wxwidgets 4
- swt/jface 1
- chrome 3
- memory_management 5
- eclipse 1
- 工具 4
- 项目管理 1
- cpplint 1
- 算法 1
- 编程语言 1
- python 5
- compile 1
- c++ 7
- 工具 c++ 1
- 源码分析 c++ 3
- c++ boost 2
- data structure 1
- wxwidgets c++ 1
- template 1
- boost 1
- wxsocket 1
- wxwidget 2
- java 2
- 源码分析 1
- 网路工具 1
- eclipse插件 1
- django 1
- gtk 1
- 测试 1
- 测试 tdd 1
- multithreading 1