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 表数据(只读),用于“只取某张表的几列”这种简单场景。
|
* <p>
|
* 与 UCanAccess 的区别:UCanAccess 打开连接时要把整个库镜像进 HSQLDB(表多时极慢),
|
* 这里只打开文件、顺序扫描一张表、按需取列,完全不经过 SQL 引擎。
|
* <p>
|
* 代价与限制:
|
* <ul>
|
* <li>没有 SQL:按字段过滤、按字段排序都在 Java 侧完成,过滤必然是整表扫描——本库文本索引的排序规则是
|
* 中文 2052,jackcess 不支持(索引只读),索引游标会直接抛异常,所以没有可用的索引查询路径;</li>
|
* <li>结果全部装在内存里,未加过滤条件的超大表要留意内存占用;</li>
|
* <li>设置了打开密码的库会自动用 jackcess-encrypt 打开,密码取请求参数 {@code password}
|
* (Access 的文件加密只有“打开密码”,{@code userName} 不参与;.mdw 工作组级安全性 jackcess 不支持);</li>
|
* <li>Access 中指向外部库的“链接表”行为与 UCanAccess 的 remap 参数不同,遇到问题可切回
|
* {@code mes.access.read-mode=ucanaccess}。</li>
|
* </ul>
|
*/
|
@Slf4j
|
public final class JackcessTableReader {
|
|
private JackcessTableReader() {
|
}
|
|
public static List<Map<String, Object>> 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<Column> 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<String> 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<RowData> 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<String, Object> 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<RowData> comparator = (o1, o2) -> compareValues(o1.sortKey, o2.sortKey);
|
rows.sort(desc ? comparator.reversed() : comparator);
|
}
|
|
List<Map<String, Object>> 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<Column> resolveColumns(Table table, String pointColumns) {
|
List<Column> 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<String, Object> data;
|
private final Object sortKey;
|
|
private RowData(Map<String, Object> data, Object sortKey) {
|
this.data = data;
|
this.sortKey = sortKey;
|
}
|
}
|
}
|