邹裕
2 天以前 0120a42fc52d9e55bc22a450e35a24b2d844b8ad
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
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;
        }
    }
}