# obj-compare **Repository Path**: smartboot/obj-compare ## Basic Information - **Project Name**: obj-compare - **Description**: 一款基于Java反射的对象比较工具,适用于单测参数匹配、数据结果对比,快照落存等场景 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 37 - **Forks**: 7 - **Created**: 2023-01-17 - **Last Updated**: 2025-05-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: 反射, 对象比较 ## README # 工具介绍 obj-compare是一款基于Java反射的轻量级对象比较工具。不仅支持常见的POJO对象、集合对象、数组类型比较功能,也支持高度自定义比较定制等功能。 ## 适用场景 * 单测结果对比以及单测mock框架中参数匹配 * 业务领域中快照对比记录 * 测试领域数据对比 * 文本/json内容对比 * 一些需要自定义比较的场景 ## 功能列表 - [X] 普通POJO、Boolean、Date、数组类型比较 - [X] 容器类List、Set、Map及其子类比较 - [X] 支持自定义类型比较 - [X] 支持自定义属性过滤 - [X] 循环依赖检查、宽松模式等特性 # 5分钟快速开始 ## 引入Maven依赖 ```xml io.github.smartboot.compare obj-compare 1.1.3 ``` ## 示例代码 ```java @Test public void testCompareSimple() { User expect = new User(); expect.setId("1"); expect.setPassword("111212"); expect.setUsername("Tom"); expect.setSex("male"); User target = new User(); target.setId("2"); target.setPassword("1112112"); target.setUsername("Jerry"); target.setSex("male"); CompareResult result = CompareHelper.compare(expect, target); System.out.println(result); Assert.assertFalse(result.isSame()); } ``` ## 结果输出 ```text ================ ProcessId : 71ea6da9-977a-4777-a0cc-b362258d4284 =============== ● Result : false ● Options : ● Differences : 3 ● Recycle : 0 ● Escaped : 128 ● MaxDepth : 1 - difference details : 1. Based, ROOT.id, 期望值 [1], 实际值 [2] 2. Based, ROOT.username, 期望值 [Tom], 实际值 [Jerry] 3. Based, ROOT.password, 期望值 [111212], 实际值 [1112112] ================ ProcessId : 71ea6da9-977a-4777-a0cc-b362258d4284 =============== ``` # Collections/Map/Array Builtin Rules ## List/Array Rules 先比较size,size相等再按照index逐一进行对比 ## Map/Set rules * Set 先比较size,size相等遍历期望Set,进行key的有无比较 * Map 先比较size,size相等遍历期望Map,同时比较key对应的value是否相等 # Features ## 自定义属性过滤 比较过程中,如果需要跳过部分属性的比较,例如时间、随机字符串等字段比较。有以下2种方式供您选择: * 使用自定义字段过滤器 * 使用自定义字段忽略器 字段忽略器最终会被转化为字段过滤器执行,所以您不需要担心2者有太大差异。 ### 自定义字段过滤器 * 注册全局自定义过滤器 ```java // 过滤对象Field上未标注@Snapshoted的字段 FieldFilters.registerGlobal((f, context) -> f.getType() == Kind.FIELD && f.getField().getAnnotation(Snapshoted.class) == null); ``` * 当次比较自定义过滤器 ```java CompareHelper.compare(expect, actual, 0, null, fieldFilter1,fieldFilter2); ``` ### 自定义字段忽略器 * 单个字段过滤器 ```java List ignoreFields = new ArrayList<>(); // 忽略类型为String.class 字段名为id的 属性比较 ignoreFields.add(new IgnoreField("id", String.class)); CompareHelper.compare(expect, actual, ignoreFields); ``` * Pattern字段过滤器 ```java List ignoreFields = new ArrayList<>(); // 忽略任何以word结尾的属性,类型为任意类型 IgnorePatternField ignorePatternField = new IgnorePatternField("[\\s\\d]*word"); ignoreFields.add(ignorePatternField); CompareHelper.compare(expect, actual, ignoreFields); ``` ### 结果输出 ```text ================ ProcessId : a96cbaa7-d4e5-42dc-be52-5765df1487d6 =============== ● Result : true ● Options : ● Differences : 0 ● Recycle : 0 ● Escaped : 98 ● MaxDepth : 0 skipped fields : 1:ROOT.id 2:ROOT.password ================ ProcessId : a96cbaa7-d4e5-42dc-be52-5765df1487d6 =============== ``` ## 自定义类型/属性比较 如果需要对对象的某些字段进行自定义比较,例如某字段具有特殊的格式,需要展开进行对比,这种场景有2种方式可以实现。 * 注册全局类型比较器, 如果类型重复,会覆盖默认已有的比较器 ```java ComparatorRegister.register(String.class, new CustomizedStringComparator()); ``` * 注册Name-Type类型比较器(推荐) ```java // 匹配名为attribute,值类型为String的字段项进行比较 ComparatorRegister.register(NameType.of("attribute", String.class), AttributesComparator.getInstance()); // 匹配名为c或者d,值类型为Integer的字段项进行比较 ComparatorRegister.register(NamesType.of(Integer.class, "c", "d"), new AbstractComparator() { @Override public Difference compare(Integer expect, Integer actual, ComparatorContext context) { return Difference.SAME; } }); ``` **自定义类型/属性比较**不仅适用于POJO对象,也适用于Map中某个key的比较。 ## 多线程自定义比较器隔离 如果多线程下比较的情况,针对以下case * 线程A,针对User的某个String属性需要进行自定义比较1 * 线程B,针对User的某个String属性需要进行自定义比较2 * 线程C,针对String类型需要做特殊比较处理3 使用常规的`ComparatorRegister`无法满足要求,它们之前注册的会相互进行影响,根据此情况,提供了`ThreadLocalComparatorRegister`来支持。 * 使用`ComparatorRegister`注册的比较器仍然会全局生效,覆盖了自带的比较器在比较完后也不会自动重置,需要手动处理 * `ThreadLocalComparatorRegister`与`ComparatorRegister`同理,但作用范围在当前线程内 * 使用`ThreadLocalComparatorRegister`注册的比较器不会在子线程中派生出的线程生效(因为非`InheritableThreadLocal`实现) * 使用`ThreadLocalComparatorRegister`如果需要移除,通过方法`removeAll`移除所有或者使用`removeAll`单个移除注册的比较器 ## 循环依赖检测 对于两个对象之间的循环依赖字段(非基本类型及其包装类型),对比过程中将会一一记录 ### 示例代码 对象的User字段相互引用 ```java @Test public void testRecycle() { RecycleUser user = new RecycleUser(); RecycleUser _user = new RecycleUser(); user.setName("qinluo"); user.setPassword("haolo2"); user.setUser(_user); _user.setName("qinluo"); _user.setPassword("haolo1"); _user.setUser(user); CompareResult result = CompareHelper.compare(user, _user); System.out.println(result); } ``` ### 结果输出 ```text ================ ProcessId : a4b6b15b-a229-4224-9f0d-2ffa145ec673 =============== ● Result : false ● Options : ● Differences : 1 ● Recycle : 1 ● Escaped : 85 ● MaxDepth : 1 messages : 1:detect recycle reference in path ROOT.user difference details : 1:CommonDifference@ROOT.password, 期望值为 [haolo2], 实际值为 [haolo1] ================ ProcessId : a4b6b15b-a229-4224-9f0d-2ffa145ec673 =============== ``` ## 输出格式优化 json输出格式能够更直观清晰地观察结果,但需要自行引入相关json序列化包并完成序列器初始化 * fastjson2、fastjson、gson 引入依赖即可,默认会自动初始化 * 其他json框架,需要手动初始化 ### 使用示例 ```java // 初始化gson的json序列化 JsonSerializer.setDefaultInstance(new GsonSerializer()); JsonSerializer.setDefaultInstance(new GsonSerializer()); Map expect = new HashMap<>(); expect.put("a", 1); expect.put("b", "123456888"); expect.put("c", "hhaks"); Map actual = new HashMap<>(); actual.put("a", 1); actual.put("b", "1234567890"); actual.put("c", "hhaklsa"); actual.put("d", 5); CompareResult result = CompareHelper.compare(expect, actual); // 使用json序列化输出结果 System.out.println(new JsonResultViewer(result)); ``` ### 输出示例 ```json { "skippedFields": [], "differences": 3, "differenceDetails": { "ROOT.(key)b": { "expect": "123456888", "actual": "1234567890", "level": 1, "type": "Based" }, "ROOT.(key)c": { "expect": "hhaks", "actual": "hhaklsa", "level": 1, "type": "Based" }, "ROOT.(key)d": { "expect": "null", "actual": "5", "level": 1, "type": "Based" } }, "maxDepth": 1, "escaped": 64, "result": false, "options": "", "messages": [], "id": "ed48edb8-e41b-443a-922f-283838b35679", "recycleCnt": 0 } ``` ## Configuration接口 1.1.1版本新增Configuration接口,用户可实现接口后,将自定义配置信息放入里面 ```java import org.smartboot.compare.Configuration; class CustomConfiguration implements Configuration { // 自定义配置 } ``` ## FeatureFunction 1.1.1版本为了增加比较过程中的自定义处理,新增FeatureFunction功能。目前1.1.1版本中支持以下几个特性 * 判断在某个路径下的option是否生效(默认生效) ```java default Boolean isEffectOption(ComparatorContext ctx, Option option) { return true; } ``` * string比较中将string转换为其他对象进行比较,例如json ```java default Object convertString2Object(ComparatorContext ctx, String value) { return value; } ``` * 数组比较前的排序 ```java default void sort(ComparatorContext ctx, Object expectArray, Object actualArray, int type) { } ``` 具体使用可以参照以下示例 ```java ComparatorContext ctx = new ComparatorContext<>(options); ctx.setExpect(expect); ctx.setActual(actual); ctx.setFeatureFunction(new FeatureFunction() { private boolean supportConvert2Json(Path path) { return true; } public Object convertString2Object(ComparatorContext ctx, String value) { if (!supportConvert2Json(ctx.getPath())) { return null; } return JSON.toJSONObject(value); } }); ``` # 模型解析 ## 比较结果解析 | 属性 | 类型 | 释义 | | ------------- | ---------------- | -------------------------------- | | differences | List\ | 差异项列表,为空说明比较结果一致 | | skippedFields | List\ | 比较过程中跳过比较的属性列表 | | messages | List\ | 比较过程中添加的一些信息 | | recycleCnt | int | 循环依赖次数 | | options | long | 比较选项bits | | escaped | long | 比较耗时,单位ms | | id | String | 比较id,默认为UUID | | maxDepth | int | 比较层级的最大深度 | ## Difference解析 默认所有自带的Difference都继承AbstractDifference, 自带路径path | Type | 类型 | 释义 | | ------------- | ---------------- | -------------------------------- | | Based | BaseDifference | 基本差异对象,包括expect和actual | | Size | SizeDifference | Array/List/Set/Map 期望大小不一致,expect和actual为对应size | | NullOfOne | NullOfOneObject | expect和actual其中一个对象为空 | | ComplementSet | ComplementSetDifference | 用于集合/数组之间的差异,会记录期望集合与实际集合各自的补集 | | TypeUnmatched | TypeDifference | expect和actual的类型不一致 | | Error | DifferenceError | 调用POJO自带的equals方法出现异常 | | CustomizedAttribute | AttributeCompareDifference | 默认提供的attribute比较不一致结果 | # Options 在正常比较之于,obj-compare提供了一些可选项,每个可选项都会对对比过程产生影响。您可以使用以下方式进行设置一个或多个Option ```java CompareHelper.compare(expect, actual, Option.mix(LOOSE_MODE, DISABLE_EQUALS)); ``` ## 宽松模式LOOSE_MODE 正常模式下,会严格对比对象的class以及值是否一致。而在宽松模式下,将会放宽一些比对处理: * 如果是List、Map、Set类型,只比较数据本身的差异,size == 0与null视为相等 * String类型,""与null视为相等 * Boolean类型,值null视为false ### 示例代码 ```java @Test public void testLooseMode() { List names = new ArrayList<>(100); names.add("tom"); names.add("Jerry"); names.add(null); List linkedNames = new LinkedList<>(); linkedNames.add("tom"); linkedNames.add("Jerry"); linkedNames.add(""); // default not strict mode CompareResult result = CompareHelper.compare(names, linkedNames, Option.mix(Option.LOOSE_MODE)); System.out.println(result); System.out.println("===================================\n"); result = CompareHelper.compare(names, linkedNames); System.out.println(result); } ``` ### 结果输出 LOOSE_MODE ```text ================ ProcessId : e31d9b79-d4d5-4e97-b520-e21e662671c7 =============== ● Result : true ● Options : LOOSE_MODE ● Differences : 0 ● Recycle : 0 ● Escaped : 15 ● MaxDepth : 1 ================ ProcessId : e31d9b79-d4d5-4e97-b520-e21e662671c7 =============== ``` ### 结果输出 无LOOSE_MODE ```text ================ ProcessId : 5856c594-97a9-4fc8-9b55-63373af0abdf =============== ● Result : false ● Options : ● Differences : 1 ● Recycle : 0 ● Escaped : 5 ● MaxDepth : 0 difference details : 1:typeDifference ,比较路径 ROOT, 期望类型为 [java.util.ArrayList, [tom, Jerry, null]], 实际类型为 [java.util.LinkedList, [tom, Jerry, ]] ================ ProcessId : 5856c594-97a9-4fc8-9b55-63373af0abdf =============== ``` ## 禁用自定义equals方法 DISABLE_EQUALS 默认情况下,如果比较的POJO对象定义了`equals`方法,默认会调用该方法进行比较。 但一些情况下,例如`equals`方法由lombok生成,即使2个对象各个属性一致,但也可能会不相等。 此时可以使用以下示例禁用`equals`比较。 ### 测试POJO对象 只要idCard属性相等就认为2个对象相等。 ```java private class EqualsUser { private String name; private String sex; private String idCard; @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } EqualsUser that = (EqualsUser) o; // idCard相等就相等 return Objects.equals(idCard, that.idCard); } } ``` ### 示例代码 ```java @Test public void testCustomizedEquals() { EqualsUser expect = new EqualsUser(); expect.name = "Tom"; expect.sex = "man"; expect.idCard = "123456"; EqualsUser actual = new EqualsUser(); actual.name = "Jerry"; actual.sex = "woman"; actual.idCard = "123456"; // Use equals CompareResult result = CompareHelper.compare(expect, actual); System.out.println(result); Assert.assertTrue(result.isSame()); // Disable equals method. result = CompareHelper.compare(expect, actual, Option.mix(Option.DISABLE_EQUALS)); System.out.println(result); Assert.assertFalse(result.isSame()); Assert.assertEquals(result.getDifferences().size(), 2); } ``` ### 结果输出 未禁用equals ```text ================ ProcessId : 0a1a6fb4-135e-4c8b-a612-5d5060242240 =============== ● Result : true ● Options : ● Differences : 0 ● Recycle : 0 ● Escaped : 14 ● MaxDepth : 0 ================ ProcessId : 0a1a6fb4-135e-4c8b-a612-5d5060242240 =============== ``` ### 结果输出 禁用equals ```text ================ ProcessId : b0e2506b-63e0-476d-a607-8a421938c456 =============== ● Result : false ● Options : DISABLE_EQUALS ● Differences : 2 ● Recycle : 0 ● Escaped : 140 ● MaxDepth : 1 difference details : 1:CommonDifference@ROOT.name, 期望值为 [Tom], 实际值为 [Jerry] 2:CommonDifference@ROOT.sex, 期望值为 [man], 实际值为 [woman] ================ ProcessId : b0e2506b-63e0-476d-a607-8a421938c456 =============== ``` ## 立即中断 IMMEDIATELY_INTERRUPT 默认情况下,会逐一比对两个对象之间的所有差异,例如一个POJO对象/Map有10项属性,每项属性都会进行对比,如果不关心对象之间的所有差异细节, 只想知道是否有差异,那么推荐您使用IMMEDIATELY_INTERRUPT ### 示例代码 ```java @Test public void testCompareMapWithImmediatelyInterrupt() { Map expect = new HashMap<>(); expect.put("a", 1); expect.put("b", 2); expect.put("c", 3); expect.put("d", 3); Map actual = new HashMap<>(); actual.put("a", 1); actual.put("b", 2); actual.put("c", 4); actual.put("d", 5); CompareResult result = CompareHelper.compare(expect, actual); System.out.println(result); Assert.assertFalse(result.isSame()); Assert.assertEquals(result.getDifferences().size(), 2); result = CompareHelper.compare(expect, actual, Option.mix(Option.IMMEDIATELY_INTERRUPT)); System.out.println(result); Assert.assertFalse(result.isSame()); Assert.assertEquals(result.getDifferences().size(), 1); } ``` ### 结果输出 无IMMEDIATELY_INTERRUPT ```text ================ ProcessId : b8b68db9-ad42-4868-91fa-3e2a2d80b33a =============== ● Result : false ● Options : ● Differences : 2 ● Recycle : 0 ● Escaped : 16 ● MaxDepth : 1 difference details : 1:CommonDifference@ROOT.(key)c, 期望值为 [3], 实际值为 [4] 2:CommonDifference@ROOT.(key)d, 期望值为 [3], 实际值为 [5] ================ ProcessId : b8b68db9-ad42-4868-91fa-3e2a2d80b33a =============== ``` ### 结果输出 指定IMMEDIATELY_INTERRUPT ```text ================ ProcessId : 35329eb0-2acd-4fc4-8938-8f719f6b6fec =============== ● Result : false ● Options : IMMEDIATELY_INTERRUPT ● Differences : 1 ● Recycle : 0 ● Escaped : 1 ● MaxDepth : 1 difference details : 1:CommonDifference@ROOT.(key)c, 期望值为 [3], 实际值为 [4] ================ ProcessId : 35329eb0-2acd-4fc4-8938-8f719f6b6fec =============== ``` ## 优化数组比较结果 BEAUTIFUL_ARRAY_RESULT 默认情况下,数组的比较输出如下: 按照index逐一进行比对并输出差异项 ```java ================ ProcessId : 76deec02-8e8b-4b3b-9311-31a02b723f34 =============== ● Result : false ● Options : ● Differences : 5 ● Recycle : 0 ● Escaped : 48 ● MaxDepth : 0 difference details : 1:CommonDifference@ROOT.(index)0, 期望值为 [0], 实际值为 [9] 2:CommonDifference@ROOT.(index)1, 期望值为 [1], 实际值为 [8] 3:CommonDifference@ROOT.(index)2, 期望值为 [2], 实际值为 [7] 4:CommonDifference@ROOT.(index)3, 期望值为 [3], 实际值为 [6] 5:CommonDifference@ROOT.(index)4, 期望值为 [4], 实际值为 [5] ================ ProcessId : 76deec02-8e8b-4b3b-9311-31a02b723f34 =============== ``` 如果想要简化下结果输出,可以在比较时,指定Option: BEAUTIFUL_ARRAY_RESULT ```text ================ ProcessId : a3c6467c-e6eb-40fc-b8af-1e6450a84f7c =============== ● Result : false ● Options : BEAUTIFUL_ARRAY_RESULT ● Differences : 1 ● Recycle : 0 ● Escaped : 24 ● MaxDepth : 0 difference details : 1:CommonDifference@ROOT, 期望值为 [[0, 1, 2, 3, 4]], 实际值为 [[9, 8, 7, 6, 5]] ================ ProcessId : a3c6467c-e6eb-40fc-b8af-1e6450a84f7c =============== ``` 如果指定了Option: BEAUTIFUL_ARRAY_RESULT, 数组的比较也相当于指定了Option: IMMEDIATELY_INTERRUPT ## 转换List/Array为Set进行比较 TRANS_AS_SET List/Array比较策略为先判断size,然后再按照index逐一进行对比,如果期望转换为Set进行比较:即两个数组中包含的元素一样,但对应的位置不同,也认为相同 ```java @Test public void testCompareInt4() { int[] expect = new int[12]; int[] actual = new int[10]; for (int i = 0; i < 10; i++) { expect[i] = i; actual[i] = 10 - i - 1; } CompareResult result = CompareHelper.compare(expect, actual, Option.mix(Option.TRANS_AS_SET)); System.out.println(result); } ``` * 结果输出 ```text ================ ProcessId : ce4206a7-6f1e-450d-97df-440d79ea457f =============== ● Result : true ● Options : TRANS_AS_SET ● Differences : 0 ● Recycle : 0 ● Escaped : 45 ● MaxDepth : 0 ================ ProcessId : ce4206a7-6f1e-450d-97df-440d79ea457f =============== ``` ## USE_EXPECT_TYPE_IN_MAP 为了解决Map中,相同key对应的值属性不同的类型表现形式的时比较,例如如下case ```json { "age": 1 } ``` 不同形式比较 ```json { "age": "1" } ``` 正常情况下,比较将会返回不一致。但其实通过人工比较的方式可以知道它们其实是一致的,所以可以这样进行比较 ```java Map v1 = new HashMap<>(); Map v2 = new HashMap<>(); ComparatorRegister.register(NameType.of(Object.class, "age"), new AbstractComparator { @Override public Difference compare(Object v1, Object v2, ComparatorContext ctx) { String v1s = String.valueOf(v1); String v2s = String.valueOf(v2); if (Objects.equals(v1s, v2s)) { return null; } return new BaseDifference(ctx.getPath(), v1, v2); } }); CompareResult result = CompareHelper.compare(v1, v2, Option.mix(Option.USE_EXPECT_TYPE_IN_MAP)); ``` # 测试报告 ## 单测覆盖率 ![img.png](unit-coverage.jpg) # 其他功能 欢迎提Issue