MyBatis 数据表与实体映射的隐藏陷阱
这两天在处理一个线上问题时,发现Mybatis数据表和实体映射的时候会埋一个坑。这个问题看似微小,但却可能在关键时刻给我们带来不小的困扰。接下来,让我们深入剖析这个问题,并探究其发生的根源。
一、问题描述
我们在使用 Mybatis或者Mybatis-plus时,通常会创建一个数据传输对象(DTO)来封装数据库查询结果。假设我们有一个 EmployeeDTO 类,用于存储员工信息:
public class EmployeeDTO{private Integer employeeNo;private Long employeeId;// 其它字段忽略
}
同时,我们有一段 SQL 语句,用于查询员工列表:
<select id="getEmployeeList" resultType="com.demo.api.vo.EmployeeVo">selectemployee_id as employeeId,employee_no as employeeNofrom employee<where><if test="req.employeeId != null">ra.employee_id = #{req.employeeId}</if>and id > 0 and deleted = 0</where>order by id desclimit #{pageNo}, #{pageSize}</select>
最初,数据表中employee_id属性的类型是int, DTO中的employeeId属性类型是Integer。随着业务发展,employeeId 的值超过了 int 类型的最大值,于是我们将 employee_id 字段类型修改为 bigint,DTO 中 employeeId 属性类型也对应修改为 Long。然而,这个看似简单的修改却导致了程序抛出 NumberOutOfRange 异常:
org.springframework.dao.DataIntegrityViolationException: Error attempting to get column 'EmployeeId' from result set. Cause: java.sql.SQLDataException: Value '2536800000' is outside of valid range for type java.lang.Integer
; Value '2536800000' is outside of valid range for type java.lang.Integer; nested exception is java.sql.SQLDataException: Value '2536800000' is outside of valid range for type java.lang.Integer
接下来,让我们一起剖析Mybatis的关于resultSet处理的源码,探究问题发生的本源。
二、MyBatis 核心方法分析
1. selectList(String statement, Object parameter, RowBounds rowBounds)
- 作用
selectList方法主要用于执行数据库查询操作,并返回一个包含查询结果的列表。这个方法在很多场景下都非常有用,例如获取数据库中的多条记录,实现数据的批量查询等。 - 实现
在DefaultSqlSession中,selectList方法会调用query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER)方法执行数据库查询操作。
(中间会有很多拦截器做相关的逻辑处理,如分页的拦截器,sql验证的拦截器等等,后续有时间了出一篇博客来讲解。)
2. query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)方法
- 作用
主要用于执行数据库查询操作,并根据特定的条件决定是从缓存中获取查询结果还是直接从数据库中进行查询。它通过CacheKey是否存在来判断执行哪种查询方式,确保在合适的情况下利用缓存以提高查询效率。 - 实现
在BaseExecutor类中,query方法的实现逻辑如下:
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;if (list != null) {handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);} else {list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);}
这里先判断结果处理器resultHandler是否为null,如果是,则尝试从本地缓存localCache中根据CacheKey获取查询结果列表。
如果获取到的列表不为null,说明缓存中有可用的结果,此时会调用handleLocallyCachedOutputParameters方法来处理本地缓存中的输出参数。
这里假定list为null。因此,该方法会调用queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql)方法执行数据库的查询操作。
3. queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)方法
- 作用
这个方法的主要作用是在缓存中没有可用结果时,发起对数据库的查询操作。它协调了各个组件之间的交互,确保能够从数据库中获取到所需的数据。 - 实现
该方法会调用SimpleExecutor#doQuery(ms, parameter, rowBounds, resultHandler, boundSql)方法执行具体的查询逻辑。
SimpleExecutor是 MyBatis 中的一个执行器,负责实际执行数据库查询操作。
4. doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) 方法
在 MyBatis 框架中,doQuery方法无疑是最核心的方法之一,在数据库查询操作中发挥着至关重要的作用。
- 作用
执行数据库查询操作,并返回查询结果的列表。
它接受多个参数,包括映射语句(MappedStatement)、查询参数、结果集范围、结果处理器和绑定的 SQL 对象等,通过一系列操作创建语句处理器(StatementHandler)、准备数据库语句(Statement)(这个是重中之重,后面所有的方法都会用到这个作为参数),然后执行查询并处理结果。最后,无论查询是否成功,都会确保关闭数据库语句以释放资源。 - 实现
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {Statement stmt = null;try {Configuration configuration = ms.getConfiguration();StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);stmt = prepareStatement(handler, ms.getStatementLog());return handler.query(stmt, resultHandler);} finally {closeStatement(stmt);}}
该方法会调用PreparedStatementHandler类的query(stmt, resultHandler)方法执行具体的查询操作,具体的处理逻辑在handleResultSets(ps)方法中。
5. handleResultSets(Statement stmt)方法
在 MyBatis 框架中,handleResultSets方法也是最核心的方法之一,在数据库查询结果的处理过程中起着至关重要的作用。
- 作用
这个方法主要负责处理数据库查询返回的结果集。它的任务是将数据库中的数据转换为 Java 对象,以便在应用程序中进行进一步的处理和使用。 - 实现
该方法会调用handleRowValues方法来处理具体每一行的数据。这个设计体现了 MyBatis 在处理复杂数据结构时的精细分工和模块化设计理念。
6. handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping)方法
- 作用
这个方法主要负责处理结果集中的行数据,将其转换为 Java 对象的属性值。
它根据传入的参数,如ResultSetWrapper(封装了结果集的相关信息)、ResultMap(描述了数据库结果集与 Java 对象之间的映射关系)、ResultHandler(用于处理查询结果的处理器)等,来确定如何将数据库中的每一行数据映射到 Java 对象中。 - 实现
public void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {if (resultMap.hasNestedResultMaps()) {ensureNoRowBounds();checkResultHandler();handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);} else {handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);}}
该方法首先判断结果映射中是否存在嵌套的结果映射。如果存在嵌套结果映射,会进行一些特殊的处理,如确保没有行范围限制、检查结果处理器等,并调用handleRowValuesForNestedResultMap方法来处理嵌套结果映射的情况。
如果不是嵌套查询,就会调用handleRowValuesForSimpleResultMap方法来处理具体的结果映射。
7. handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping)方法
- 作用
这个方法专注于处理没有嵌套结构的简单结果映射。其主要任务是将数据库查询结果集中的每一行数据,准确地映射到 Java 对象的相应属性上,以实现数据从数据库到应用程序对象的转换。 - 实现
调用getRowValue(rsw, discriminatedResultMap, null)方法获取当前行的 Java 对象值。这个方法根据结果映射规则从结果集中读取数据,并进行必要的类型转换和数据验证,将数据转换为 Java 对象的属性值。
8. getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix)方法
getRowValue方法也是Mybatis框架中DefaultResultSetHandler类中最核心的方法之一。
- 作用
从给定的ResultSetWrapper(包含SQL的相关信息)和ResultMap(包含java DTO的信息)中,根据特定的列前缀,获取结果集中一行数据的值,并进行适当的处理和转换,以便后续能够准确地映射到 Java 对象的属性中。 - 实现
调用createResultObject方法,根据ResultSetWrapper、ResultMap和懒加载映射对象以及列前缀,创建一个结果对象。这个步骤是获取行值的核心,它将从结果集中读取数据,并根据结果映射规则进行类型转换和数据验证,创建一个 Java 对象实例。
9. createResultObject(ResultSetWrapper rsw, ResultMap resultMap, List<Class<?>> constructorArgTypes, List constructorArgs, String columnPrefix)方法
- 作用
通过综合考虑结果映射、结果对象类型、构造函数映射等多种因素,以确定最佳的方式来实例化结果对象,为后续的数据处理和业务逻辑提供准确的对象表示。 - 实现
private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, String columnPrefix)throws SQLException {final Class<?> resultType = resultMap.getType();final MetaClass metaType = MetaClass.forClass(resultType, reflectorFactory);final List<ResultMapping> constructorMappings = resultMap.getConstructorResultMappings();if (hasTypeHandlerForResultObject(rsw, resultType)) {return createPrimitiveResultObject(rsw, resultMap, columnPrefix);} else if (!constructorMappings.isEmpty()) {return createParameterizedResultObject(rsw, resultType, constructorMappings, constructorArgTypes, constructorArgs, columnPrefix);} else if (resultType.isInterface() || metaType.hasDefaultConstructor()) {return objectFactory.create(resultType);} else if (shouldApplyAutomaticMappings(resultMap, false)) {return createByConstructorSignature(rsw, resultType, constructorArgTypes, constructorArgs);}throw new ExecutorException("Do not know how to create an instance of " + resultType);}
会分情况创建结果对象,这里命中的是自动映射,所以会调用createByConstructorSignature方法,这个方法会通过反射根据构造器签名来创建对象,确保数据可以准确地填充到对象的属性中。
10. createByConstructorSignature(ResultSetWrapper rsw, Class<?> resultType, List
- 作用
通过反射机制,根据给定的构造器签名来创建 Java 对象。在处理数据库查询结果集到 Java 对象的映射过程中,这个方法确保了能够正确地实例化 Java 对象,使得数据可以准确地填充到对象的属性中。 - 实现
private Object createByConstructorSignature(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs) throws SQLException {// 使用反射创建Employee对象final Constructor<?>[] constructors = resultType.getDeclaredConstructors();final Constructor<?> defaultConstructor = findDefaultConstructor(constructors);if (defaultConstructor != null) {return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, defaultConstructor);} else {for (Constructor<?> constructor : constructors) {if (allowedConstructorUsingTypeHandlers(constructor, rsw.getJdbcTypes())) {return createUsingConstructor(rsw, resultType, constructorArgTypes, constructorArgs, constructor);}}}throw new ExecutorException("No constructor found in " + resultType.getName() + " matching " + rsw.getClassNames());}
通过反射获取给定结果类型resultType(Employee类)的所有声明构造器,默认取第一个。然后会调用createUsingConstructor方法来创建对象。
11. createUsingConstructor(ResultSetWrapper rsw, Class<?> resultType, List
createUsingConstructor方法也是 MyBatis 中最核心的方法之一。
- 作用
使用特定的构造函数来创建 Java 对象,确保从数据库查询结果集中准确地提取数据并填充到对象的属性中。 - 实现
private Object createUsingConstructor(ResultSetWrapper rsw, Class<?> resultType, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, Constructor<?> constructor) throws SQLException {boolean foundValues = false;for (int i = 0; i < constructor.getParameterTypes().length; i++) {// parameterType.name = Java.lang.Integer;Class<?> parameterType = constructor.getParameterTypes()[i];// columnName = employeeId// 所以会对不上。String columnName = rsw.getColumnNames().get(i);TypeHandler<?> typeHandler = rsw.getTypeHandler(parameterType, columnName);Object value = typeHandler.getResult(rsw.getResultSet(), columnName);constructorArgTypes.add(parameterType);constructorArgs.add(value);foundValues = value != null || foundValues;}return foundValues ? objectFactory.create(resultType, constructorArgTypes, constructorArgs) : null;}
首先,会遍历给定构造器类的参数列表。对于每个参数,确定其参数类型和对应的数据库列名。在这个过程中,通过反射获取到的Employee类的构造器类,构造器中每个参数的顺序和Employee定义时一致。而rsw就是第4个核心方法中提到的Statement,rsw.getColumnNames()[i]获取的就是SQL中写的字段顺序。(到这里其实已经找到问题所在了)。
在这个过程中,还通过获取构造器类的参数类型和对应的数据库列名,找到合适的类型处理器(employeeId的类型处理器是java.lang.Integer)。类型处理器负责将数据库中的数据转换为 Java 对象参数所需的类型。
然后,将参数类型和转换后的值分别添加到构造器类的参数类型列表和参数值列表中,并更新是否找到值的标志。根据是否找到值的情况,决定是否创建对象并返回。
三、解决方案
找到了问题根源,解决方案也就呼之欲出,解决方案很简单。
调整 SQL 语句中列的顺序: 将 employee_no 列放在 employee_id 列之前,确保 MyBatis 能够按照正确的顺序进行映射,或者调整Employee类中属性定义的顺序。
四、总结
通过深入 Mybatis关于resultSet处理的源码 ,我们发现,Mybatis在处理结果集ResultSet时,会按照构造器类的参数顺序和ResultSet的列顺序构造对象。在上面的情况下,SQL查询结果的列顺序和DTO中属性顺序不匹配,导致类型处理器使用了错误的类型即尝试把一个很大的bigint赋值给Integer类型。
解决这个问题的方法很简单,但更重要的是,我们应该从中吸取教训,在日常开发中更加注重细节,避免类似问题的发生。同时,深入理解 Mybatis 的工作原理,也有助于我们更好地掌握和使用 Mybatis 框架。
本文期望能够帮助大家避开 Mybatis数据表与实体映射的 Unexpected 坑的同时,提供一种解决问题的排查思路,这种软实力才是最重要的。
相关文章:
MyBatis 数据表与实体映射的隐藏陷阱
这两天在处理一个线上问题时,发现Mybatis数据表和实体映射的时候会埋一个坑。这个问题看似微小,但却可能在关键时刻给我们带来不小的困扰。接下来,让我们深入剖析这个问题,并探究其发生的根源。 一、问题描述 我们在使用 Mybati…...
leetcode-239. 滑动窗口最大值
题目描述 给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。 返回 滑动窗口中的最大值 。 示例 1: 输入:nums [1,3,-1,-3,5,3,6,…...
springboot项目中开启mybatis的sql日志
在 application.yml 文件中 添加 mybatis-plus 配置,再重启项目,这里用到了mybatis-plus的自带sql日志打印 # application-jdbc.yml mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果只是用的mybatis的话&#x…...
卷积的计算——nn.Conv2d(Torch.nn里的Convolution Layers模块里的Conv2d类)
**前置知识: 1、张量和通道 张量:多维数组,用来表示数据(图像、视频等) 通道:图像数据的一部分,表示不同的颜色或特征层 通道只是张量的其中一个维度 以一张RGB图像为例, 该图像…...
确保接口安全:六大方案有效解决幂等性问题
文章目录 六大方案解决接口幂等问题什么是接口幂等?天然幂等不做幂等会怎么样? 解决方案1)insert前先select2)使用唯一索引3)去重表加悲观锁4)加乐观锁之版本号机制5)使用 Redisson 分布式锁6&a…...
代码随想录算法训练营第二十九天| 93. 复原 IP 地址,78. 子集, 90. 子集 II
93. 复原 IP 地址,78. 子集, 90. 子集 II 93. 复原 IP 地址78. 子集90. 子集 II 93. 复原 IP 地址 有效 IP 地址 正好由四个整数(每个整数位于 0 0 0 到 255 255 255之间组成,且不能含有前导 0 0 0),整…...
【WebGis开发 - Cesium】三维可视化项目教程---初始化场景
系列文章目录 【WebGis开发 - Cesium】三维可视化项目教程—图层管理基础【WebGis开发 - Cesium】三维可视化项目教程—视点管理 目录 系列文章目录引言一、Cesium引入项目1.1 下载资源1.2 项目引入Cesium 二、初始化地球2.1 创建基础文件2.1.1 创建Cesium工具方法文件2.1.2 创…...
点云中ICP算法的详解
ICP(Iterative Closest Point)算法是一种用于刚性点云配准的经典算法。其核心思想是通过迭代地寻找两个点云之间的最近点对,并计算最优的刚性变换(包括旋转和平移),使得源点云在目标点云的坐标系下对齐。IC…...
抽象类Abstart Class
抽象类其实就是一种不完全的设计图 必须用abstract修饰 模板方法:建议使用final修饰,不能被重写。...
Redis:通用命令 数据类型
Redis:通用命令 & 数据类型 通用命令SETGETKEYSEXISTSDELEXPIRETTLTYPEFLUSHALL 数据类型 Redis的客户端提供了很多命令用于操控Redis,在Redis中,key的类型都是字符串,而value有多种类型,每种类型都有自己的操作命…...
【Python高级编程】探索Python库:创建引人入胜的交互界面
1.制作交互界面常用到的库 在 Python 中,有多个库可以用于创建交互界面(GUI)。 以下是一些常用的 Python GUI 库: Tkinter: Python 的标准 GUI 库,通常随 Python 一起安装。简单易用,适合快速开发小型应用…...
OpenCV Canny()函数
OpenCV Canny()函数被用来检测图像物体的边缘。其算法原理如下: 高斯滤波:使用高斯滤波器平滑图像以减少噪声。高斯滤波器是一种线性滤波器,可以消除图像中的高频噪声,同时保留边缘信息。计算梯度强度和方向:使用Sobe…...
Java基础(3)
基本数据类型 Java 中的几种基本数据类型了解么? Java 中有 8 种基本数据类型,分别为: 6 种数字类型: 4 种整数型:byte、short、int、long2 种浮点型:float、double1 种字符类型:char1 种布尔…...
【C语言】VS调试技巧
文章目录 什么是bug什么是调试(debug)debug和releaseVS调试快捷键监视和内存观察编程常见错误归类 什么是bug bug本意是“昆虫”或“虫子”,现在一般是指在电脑系统或程序中,隐藏着的一些未被发现的缺陷或问题,简称程…...
【华为HCIP实战课程七】OSPF邻居关系排错MTU问题,网络工程师
一、MTU MUT默认1500,最大传输单元,一致性检测 [R3-GigabitEthernet0/0/1]mtu 1503//更改R3的MTU为1503 查看R3和SW1之间的OSPF邻居关系正常: 默认华为设备没有开启MTU一致性检测! [R3-GigabitEthernet0/0/1]ospf mtu-enable //手动开启MTU检测 [SW1-Vlanif30]ospf mtu…...
速盾:休闲类游戏如何选择高防cdn?
休闲类游戏的流行度日益增长,越来越多的玩家在业余时间里选择放松自己,享受游戏带来的乐趣。然而,在休闲类游戏中,网络延迟和游戏载入速度的问题常常会影响到玩家的游戏体验。为了解决这些问题,选择一个高防CDN&#x…...
电脑插上U盘不显示怎么回事?怎么解决?
平时使用电脑的时候经常会使用U盘来传输数据或是备份文件,有时候会遇到一个令头疼的问题,比如,将U盘插入电脑的USB口后,设备却显示不出来。电脑上插入U盘后却不显示会影响我们的正常工作。接下来,我们一起分析一下故障…...
Python 如何使用 SQLAlchemy 进行复杂查询
Python 如何使用 SQLAlchemy 进行复杂查询 一、引言 SQLAlchemy 是 Python 生态系统中非常流行的数据库处理库,它提供了一种高效、简洁的方式与数据库进行交互。SQLAlchemy 是一个功能强大的数据库工具,支持结构化查询语言(SQL)…...
nginx主配置文件
Nginx的主配置文件nginx.conf,一般定义了Nginx的基本设置和全局配置。下面是对这个配置文件的详细解释: 文件结构 #user nobody; worker_processes 1;#error_log logs/error.log; #error_log logs/error.log notice; #error_log logs/error.log …...
使用数据库:
数据库: 1.为何需要数据库? 存储数据方法 第一种:用大脑记住数据, 第二种:写纸上, 第三种:写在计算机的内存中, 第四种:写出磁盘文件 2.数据库能做什么࿱…...
Java 语言特性(面试系列2)
一、SQL 基础 1. 复杂查询 (1)连接查询(JOIN) 内连接(INNER JOIN):返回两表匹配的记录。 SELECT e.name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id d.dept_id; 左…...
VB.net复制Ntag213卡写入UID
本示例使用的发卡器:https://item.taobao.com/item.htm?ftt&id615391857885 一、读取旧Ntag卡的UID和数据 Private Sub Button15_Click(sender As Object, e As EventArgs) Handles Button15.Click轻松读卡技术支持:网站:Dim i, j As IntegerDim cardidhex, …...
鸿蒙中用HarmonyOS SDK应用服务 HarmonyOS5开发一个医院挂号小程序
一、开发准备 环境搭建: 安装DevEco Studio 3.0或更高版本配置HarmonyOS SDK申请开发者账号 项目创建: File > New > Create Project > Application (选择"Empty Ability") 二、核心功能实现 1. 医院科室展示 /…...
Auto-Coder使用GPT-4o完成:在用TabPFN这个模型构建一个预测未来3天涨跌的分类任务
通过akshare库,获取股票数据,并生成TabPFN这个模型 可以识别、处理的格式,写一个完整的预处理示例,并构建一个预测未来 3 天股价涨跌的分类任务 用TabPFN这个模型构建一个预测未来 3 天股价涨跌的分类任务,进行预测并输…...
【Web 进阶篇】优雅的接口设计:统一响应、全局异常处理与参数校验
系列回顾: 在上一篇中,我们成功地为应用集成了数据库,并使用 Spring Data JPA 实现了基本的 CRUD API。我们的应用现在能“记忆”数据了!但是,如果你仔细审视那些 API,会发现它们还很“粗糙”:有…...
让AI看见世界:MCP协议与服务器的工作原理
让AI看见世界:MCP协议与服务器的工作原理 MCP(Model Context Protocol)是一种创新的通信协议,旨在让大型语言模型能够安全、高效地与外部资源进行交互。在AI技术快速发展的今天,MCP正成为连接AI与现实世界的重要桥梁。…...
管理学院权限管理系统开发总结
文章目录 🎓 管理学院权限管理系统开发总结 - 现代化Web应用实践之路📝 项目概述🏗️ 技术架构设计后端技术栈前端技术栈 💡 核心功能特性1. 用户管理模块2. 权限管理系统3. 统计报表功能4. 用户体验优化 🗄️ 数据库设…...
Java求职者面试指南:计算机基础与源码原理深度解析
Java求职者面试指南:计算机基础与源码原理深度解析 第一轮提问:基础概念问题 1. 请解释什么是进程和线程的区别? 面试官:进程是程序的一次执行过程,是系统进行资源分配和调度的基本单位;而线程是进程中的…...
BLEU评分:机器翻译质量评估的黄金标准
BLEU评分:机器翻译质量评估的黄金标准 1. 引言 在自然语言处理(NLP)领域,衡量一个机器翻译模型的性能至关重要。BLEU (Bilingual Evaluation Understudy) 作为一种自动化评估指标,自2002年由IBM的Kishore Papineni等人提出以来,…...
WPF八大法则:告别模态窗口卡顿
⚙️ 核心问题:阻塞式模态窗口的缺陷 原始代码中ShowDialog()会阻塞UI线程,导致后续逻辑无法执行: var result modalWindow.ShowDialog(); // 线程阻塞 ProcessResult(result); // 必须等待窗口关闭根本问题:…...
