package com.hwtd.mes.collect.util; import com.healthmarketscience.jackcess.Column; import com.healthmarketscience.jackcess.Cursor; import com.healthmarketscience.jackcess.CursorBuilder; import com.healthmarketscience.jackcess.DataType; import com.healthmarketscience.jackcess.Database; import com.healthmarketscience.jackcess.DatabaseBuilder; import com.healthmarketscience.jackcess.Row; import com.healthmarketscience.jackcess.Table; import com.healthmarketscience.jackcess.crypt.CryptCodecProvider; import com.healthmarketscience.jackcess.crypt.InvalidCredentialsException; import com.healthmarketscience.jackcess.crypt.InvalidCryptoConfigurationException; import com.hwtd.mes.collect.dto.DatabaseDTO; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import java.io.File; import java.io.IOException; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * 用 jackcess 直读 .mdb/.accdb 表数据(只读),用于“只取某张表的几列”这种简单场景。 *

* 与 UCanAccess 的区别:UCanAccess 打开连接时要把整个库镜像进 HSQLDB(表多时极慢), * 这里只打开文件、顺序扫描一张表、按需取列,完全不经过 SQL 引擎。 *

* 代价与限制: *

*/ @Slf4j public final class JackcessTableReader { private JackcessTableReader() { } public static List> read(DatabaseDTO databaseDTO) { File file = new File(StringUtils.trimToEmpty(databaseDTO.getFilePath())); if (!file.isFile()) { throw new RuntimeException("Access数据库文件不存在:" + databaseDTO.getFilePath()); } if (StringUtils.isBlank(databaseDTO.getTableName())) { throw new RuntimeException("Access数据库表名不能为空"); } // setReadOnly(true):只读打开文件,不产生锁文件,不会干扰 MES 等其他程序写入 // setAutoSync(false):刷盘策略只在写库时生效,只读场景保持关闭 DatabaseBuilder builder = new DatabaseBuilder(file) .setReadOnly(true) .setAutoSync(false); // 有密码就用 jackcess-encrypt 打开:Access 的文件加密只有“打开密码”,用户名不参与 String password = StringUtils.trimToNull(databaseDTO.getPassword()); if (password != null) { builder.setCodecProvider(new CryptCodecProvider(password)); } try (Database database = builder.open()) { Table table = findTable(database, databaseDTO.getTableName()); List columns = resolveColumns(table, databaseDTO.getPointColumns()); // 过滤字段:盘号 Column filterColumn = null; String batchCode = null; if (StringUtils.isNotBlank(databaseDTO.getMainColumn()) && StringUtils.isNotBlank(databaseDTO.getBatchCode())) { filterColumn = requireColumn(table, databaseDTO.getMainColumn(), "字段"); batchCode = databaseDTO.getBatchCode().trim(); } // 排序字段 Column orderColumn = null; if (StringUtils.isNotBlank(databaseDTO.getOrderColumn())) { orderColumn = requireColumn(table, databaseDTO.getOrderColumn(), "排序字段"); } // 投影:只把需要的列读出来(未请求的列、尤其是 OLE/MEMO 这类长值列不会被读取) Set projection = new LinkedHashSet<>(); for (Column column : columns) { if (!isOle(column)) { projection.add(column.getName()); } } if (filterColumn != null) { projection.add(filterColumn.getName()); } if (orderColumn != null) { projection.add(orderColumn.getName()); } long start = System.currentTimeMillis(); int scanned = 0; List rows = new ArrayList<>(); // CursorBuilder.createCursor(Table) 是“无索引”的顺序扫描游标,不依赖任何索引 Cursor cursor = CursorBuilder.createCursor(table); while (cursor.moveToNextRow()) { scanned++; Row row = cursor.getCurrentRow(projection); if (filterColumn != null && !valueMatches(row.get(filterColumn.getName()), batchCode)) { continue; } Map data = new HashMap<>(); for (Column column : columns) { // OLE 字段与原来一样返回 null,且不读长值 data.put(column.getName(), isOle(column) ? null : normalize(row.get(column.getName()))); } rows.add(new RowData(data, (orderColumn == null) ? null : row.get(orderColumn.getName()))); } // 排序(Access 升序时 null 在前,降序时按相反顺序) if (orderColumn != null) { boolean desc = "DESC".equalsIgnoreCase(databaseDTO.getOrderRule()); Comparator comparator = (o1, o2) -> compareValues(o1.sortKey, o2.sortKey); rows.sort(desc ? comparator.reversed() : comparator); } List> list = new ArrayList<>(rows.size()); for (RowData row : rows) { list.add(row.data); } log.info("jackcess 直读完成:表 {},扫描 {} 行、返回 {} 行,耗时 {} ms", table.getName(), scanned, list.size(), System.currentTimeMillis() - start); return list; } catch (InvalidCredentialsException e) { // jackcess-encrypt 校验打开密码失败时抛这个(IllegalStateException 子类) throw new RuntimeException("Access 文件密码不正确:" + file.getName(), e); } catch (InvalidCryptoConfigurationException e) { throw new RuntimeException("Access 文件的加密方式不受支持:" + e.getMessage(), e); } catch (IOException e) { // .mdb(Jet 加密)没有密码校验位,密码不对通常表现为页面/记录读取异常,这里补一句提示 String hint = (password == null) ? "" : "(该文件设置了打开密码,请确认密码是否正确)"; throw new RuntimeException("Access数据库读取异常:" + e.getMessage() + hint, e); } } /** * 表名匹配:先按名字取,取不到再做一次忽略大小写的匹配(Access 的表名不区分大小写) */ private static Table findTable(Database database, String tableName) throws IOException { String name = cleanName(tableName); Table table = database.getTable(name); if (table == null) { for (String candidate : database.getTableNames()) { if (candidate.equalsIgnoreCase(name)) { table = database.getTable(candidate); break; } } } if (table == null) { throw new RuntimeException("Access数据库中没有找到表:" + tableName); } return table; } /** * 解析采集点位字段,支持逗号分隔、*、以及 [字段名] / `字段名` 这种带转义的写法 */ private static List resolveColumns(Table table, String pointColumns) { List columns = new ArrayList<>(); if ("*".equals(StringUtils.trimToEmpty(pointColumns))) { columns.addAll(table.getColumns()); return columns; } if (StringUtils.isBlank(pointColumns)) { throw new RuntimeException("采集点位字段不能为空"); } for (String item : pointColumns.split(",")) { String name = cleanName(item); if (name.isEmpty()) { continue; } columns.add(requireColumn(table, name, "字段")); } if (columns.isEmpty()) { throw new RuntimeException("采集点位字段不能为空"); } return columns; } private static Column requireColumn(Table table, String columnName, String what) { String name = cleanName(columnName); for (Column column : table.getColumns()) { if (column.getName().equalsIgnoreCase(name)) { return column; } } throw new RuntimeException("表 " + table.getName() + " 中没有找到" + what + ":" + columnName); } private static String cleanName(String name) { String result = StringUtils.trimToEmpty(name); if (result.length() > 1 && ((result.charAt(0) == '[' && result.endsWith("]")) || (result.charAt(0) == '`' && result.endsWith("`")))) { result = result.substring(1, result.length() - 1).trim(); } return result; } private static boolean isOle(Column column) { return column.getType() == DataType.OLE; } /** * 值比较:文本按“去空格 + 忽略大小写”,与原来 UCanAccess 连接(TRIM(字段) = '值'、ignoreCase 默认 true)保持一致 */ private static boolean valueMatches(Object columnValue, String expected) { if (columnValue == null || expected == null) { return false; } if (columnValue instanceof CharSequence) { return columnValue.toString().trim().equalsIgnoreCase(expected); } return String.valueOf(columnValue).trim().equals(expected); } /** * 类型归一:jackcess 默认返回 java.util.Date,这里统一转成 java.sql.Timestamp, * 保持与原来 JDBC 路径返回的时间类型一致(JSON 里同样是 "yyyy-MM-dd HH:mm:ss.SSS") */ private static Object normalize(Object value) { if (value instanceof Date) { return new Timestamp(((Date) value).getTime()); } if (value instanceof LocalDateTime) { return Timestamp.valueOf((LocalDateTime) value); } return value; } @SuppressWarnings({"unchecked", "rawtypes"}) private static int compareValues(Object value1, Object value2) { if (value1 == null && value2 == null) { return 0; } if (value1 == null) { return -1; } if (value2 == null) { return 1; } if (value1 instanceof Number && value2 instanceof Number) { if (value1 instanceof Double || value2 instanceof Double || value1 instanceof Float || value2 instanceof Float) { return Double.compare(((Number) value1).doubleValue(), ((Number) value2).doubleValue()); } return Long.compare(((Number) value1).longValue(), ((Number) value2).longValue()); } if (value1 instanceof Date && value2 instanceof Date) { return Long.compare(((Date) value1).getTime(), ((Date) value2).getTime()); } if (value1.getClass() == value2.getClass() && value1 instanceof Comparable) { return ((Comparable) value1).compareTo(value2); } return String.valueOf(value1).compareTo(String.valueOf(value2)); } /** * 一行数据 + 排序键(排序字段可能不在返回结果里,所以单独存一份) */ private static final class RowData { private final Map data; private final Object sortKey; private RowData(Map data, Object sortKey) { this.data = data; this.sortKey = sortKey; } } }