1. 简介

我们在前面的文章中提到了calcite支持csv和json文件的数据源适配, 其实就是将文件解析成表然后以文件夹为schema, 然后将生成的schema注册到RootSehema(RootSchema是所有数据源schema的parent,多个不同数据源schema可以挂在同一个RootSchema下)下, 最终使用calcite的特性进行sql的解析查询返回.

但其实我们的数据文件一般使用excel进行存储,流转, 但很可惜, calcite本身没有excel的适配器, 但其实我们可以模仿calcite-file, 自己搞一个calcite-file-excel, 也可以熟悉calcite的工作原理.

2. 实现思路

因为excel有sheet的概念, 所以可以将一个excel解析成schema, 每个sheet解析成table, 实现步骤如下:

  1. 实现SchemaFactory重写create方法: schema工厂 用于创建schema
  2. 继承AbstractSchema: schema描述类 用于解析excel, 创建table(解析sheet)
  3. 继承AbstractTable, ScannableTable: table描述类 提供字段信息和数据内容等(解析sheet data)

3. Excel样例

excel有两个sheet页, 分别是user_inforole_info如下:





ok, 万事具备.

4. Maven

<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency> <dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.3</version>
</dependency> <dependency>
<groupId>org.apache.calcite</groupId>
<artifactId>calcite-core</artifactId>
<version>1.37.0</version>
</dependency>

5. 核心代码

5.1 SchemaFactory

package com.ldx.calcite.excel;

import com.google.common.collect.Lists;
import org.apache.calcite.schema.Schema;
import org.apache.calcite.schema.SchemaFactory;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils; import java.io.File;
import java.util.List;
import java.util.Map; /**
* schema factory
*/
public class ExcelSchemaFactory implements SchemaFactory {
public final static ExcelSchemaFactory INSTANCE = new ExcelSchemaFactory(); private ExcelSchemaFactory(){} @Override
public Schema create(SchemaPlus parentSchema, String name, Map<String, Object> operand) {
final Object filePath = operand.get("filePath"); if (ObjectUtils.isEmpty(filePath)) {
throw new NullPointerException("can not find excel file");
} return this.create(filePath.toString());
} public Schema create(String excelFilePath) {
if (StringUtils.isBlank(excelFilePath)) {
throw new NullPointerException("can not find excel file");
} return this.create(new File(excelFilePath));
} public Schema create(File excelFile) {
if (ObjectUtils.isEmpty(excelFile) || !excelFile.exists()) {
throw new NullPointerException("can not find excel file");
} if (!excelFile.isFile() || !isExcelFile(excelFile)) {
throw new RuntimeException("can not find excel file: " + excelFile.getAbsolutePath());
} return new ExcelSchema(excelFile);
} protected List<String> supportedFileSuffix() {
return Lists.newArrayList("xls", "xlsx");
} private boolean isExcelFile(File excelFile) {
if (ObjectUtils.isEmpty(excelFile)) {
return false;
} final String name = excelFile.getName();
return StringUtils.endsWithAny(name, this.supportedFileSuffix().toArray(new String[0]));
}
}

schema中有多个重载的create方法用于方便的创建schema, 最终将excel file 交给ExcelSchema创建一个schema对象

5.2 Schema

package com.ldx.calcite.excel;

import org.apache.calcite.schema.Table;
import org.apache.calcite.schema.impl.AbstractSchema;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.testng.collections.Maps; import java.io.File;
import java.util.Iterator;
import java.util.Map; /**
* schema
*/
public class ExcelSchema extends AbstractSchema {
private final File excelFile; private Map<String, Table> tableMap; public ExcelSchema(File excelFile) {
this.excelFile = excelFile;
} @Override
protected Map<String, Table> getTableMap() {
if (ObjectUtils.isEmpty(tableMap)) {
tableMap = createTableMap();
} return tableMap;
} private Map<String, Table> createTableMap() {
final Map<String, Table> result = Maps.newHashMap(); try (Workbook workbook = WorkbookFactory.create(excelFile)) {
final Iterator<Sheet> sheetIterator = workbook.sheetIterator(); while (sheetIterator.hasNext()) {
final Sheet sheet = sheetIterator.next();
final ExcelScannableTable excelScannableTable = new ExcelScannableTable(sheet, null);
result.put(sheet.getSheetName(), excelScannableTable);
}
}
catch (Exception ignored) {} return result;
}
}

schema类读取Excel file, 并循环读取sheet, 将每个sheet解析成ExcelScannableTable并存储

5.3 Table

package com.ldx.calcite.excel;

import com.google.common.collect.Lists;
import com.ldx.calcite.excel.enums.JavaFileTypeEnum;
import org.apache.calcite.DataContext;
import org.apache.calcite.adapter.java.JavaTypeFactory;
import org.apache.calcite.linq4j.Enumerable;
import org.apache.calcite.linq4j.Linq4j;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.rel.type.RelProtoDataType;
import org.apache.calcite.schema.ScannableTable;
import org.apache.calcite.schema.impl.AbstractTable;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.util.Pair;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.checkerframework.checker.nullness.qual.Nullable; import java.util.List; /**
* table
*/
public class ExcelScannableTable extends AbstractTable implements ScannableTable {
private final RelProtoDataType protoRowType; private final Sheet sheet; private RelDataType rowType; private List<JavaFileTypeEnum> fieldTypes; private List<Object[]> rowDataList; public ExcelScannableTable(Sheet sheet, RelProtoDataType protoRowType) {
this.protoRowType = protoRowType;
this.sheet = sheet;
} @Override
public Enumerable<@Nullable Object[]> scan(DataContext root) {
JavaTypeFactory typeFactory = root.getTypeFactory();
final List<JavaFileTypeEnum> fieldTypes = this.getFieldTypes(typeFactory); if (rowDataList == null) {
rowDataList = readExcelData(sheet, fieldTypes);
} return Linq4j.asEnumerable(rowDataList);
} @Override
public RelDataType getRowType(RelDataTypeFactory typeFactory) {
if (ObjectUtils.isNotEmpty(protoRowType)) {
return protoRowType.apply(typeFactory);
} if (ObjectUtils.isEmpty(rowType)) {
rowType = deduceRowType((JavaTypeFactory) typeFactory, sheet, null);
} return rowType;
} public List<JavaFileTypeEnum> getFieldTypes(RelDataTypeFactory typeFactory) {
if (fieldTypes == null) {
fieldTypes = Lists.newArrayList();
deduceRowType((JavaTypeFactory) typeFactory, sheet, fieldTypes);
}
return fieldTypes;
} private List<Object[]> readExcelData(Sheet sheet, List<JavaFileTypeEnum> fieldTypes) {
List<Object[]> rowDataList = Lists.newArrayList(); for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
Object[] rowData = new Object[fieldTypes.size()]; for (int i = 0; i < row.getLastCellNum(); i++) {
final JavaFileTypeEnum javaFileTypeEnum = fieldTypes.get(i);
Cell cell = row.getCell(i, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
final Object cellValue = javaFileTypeEnum.getCellValue(cell);
rowData[i] = cellValue;
} rowDataList.add(rowData);
} return rowDataList;
} public static RelDataType deduceRowType(JavaTypeFactory typeFactory, Sheet sheet, List<JavaFileTypeEnum> fieldTypes) {
final List<String> names = Lists.newArrayList();
final List<RelDataType> types = Lists.newArrayList(); if (sheet != null) {
Row headerRow = sheet.getRow(0); if (headerRow != null) {
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
Cell cell = headerRow.getCell(i, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
String[] columnInfo = cell
.getStringCellValue()
.split(":");
String columnName = columnInfo[0].trim();
String columnType = null; if (columnInfo.length == 2) {
columnType = columnInfo[1].trim();
} final JavaFileTypeEnum javaFileType = JavaFileTypeEnum
.of(columnType)
.orElse(JavaFileTypeEnum.UNKNOWN);
final RelDataType sqlType = typeFactory.createSqlType(javaFileType.getSqlTypeName());
names.add(columnName);
types.add(sqlType); if (fieldTypes != null) {
fieldTypes.add(javaFileType);
}
}
}
} if (names.isEmpty()) {
names.add("line");
types.add(typeFactory.createSqlType(SqlTypeName.VARCHAR));
} return typeFactory.createStructType(Pair.zip(names, types));
}
}

table类中其中有两个比较关键的方法

scan: 扫描表内容, 我们这里将sheet页面的数据内容解析存储最后交给calcite

getRowType: 获取字段信息, 我们这里默认使用第一条记录作为表头(row[0]) 并解析为字段信息, 字段规则跟csv一样 name:string, 冒号前面的是字段key, 冒号后面的是字段类型, 如果未指定字段类型, 则解析为UNKNOWN, 后续JavaFileTypeEnum会进行类型推断, 最终在结果处理时calcite也会进行推断

deduceRowType: 推断字段类型, 方法中使用JavaFileTypeEnum枚举类对java type & sql type & 字段值转化处理方法 进行管理

5.4 ColumnTypeEnum

package com.ldx.calcite.excel.enums;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.calcite.avatica.util.DateTimeUtils;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.util.CellUtil; import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Optional;
import java.util.TimeZone;
import java.util.function.Function; /**
* type converter
*/
@Slf4j
@Getter
public enum JavaFileTypeEnum {
STRING("string", SqlTypeName.VARCHAR, Cell::getStringCellValue),
BOOLEAN("boolean", SqlTypeName.BOOLEAN, Cell::getBooleanCellValue),
BYTE("byte", SqlTypeName.TINYINT, Cell::getStringCellValue),
CHAR("char", SqlTypeName.CHAR, Cell::getStringCellValue),
SHORT("short", SqlTypeName.SMALLINT, Cell::getNumericCellValue),
INT("int", SqlTypeName.INTEGER, cell -> (Double.valueOf(cell.getNumericCellValue()).intValue())),
LONG("long", SqlTypeName.BIGINT, cell -> (Double.valueOf(cell.getNumericCellValue()).longValue())),
FLOAT("float", SqlTypeName.REAL, Cell::getNumericCellValue),
DOUBLE("double", SqlTypeName.DOUBLE, Cell::getNumericCellValue),
DATE("date", SqlTypeName.DATE, getValueWithDate()),
TIMESTAMP("timestamp", SqlTypeName.TIMESTAMP, getValueWithTimestamp()),
TIME("time", SqlTypeName.TIME, getValueWithTime()),
UNKNOWN("unknown", SqlTypeName.UNKNOWN, getValueWithUnknown()),;
// cell type
private final String typeName;
// sql type
private final SqlTypeName sqlTypeName;
// value convert func
private final Function<Cell, Object> cellValueFunc; private static final FastDateFormat TIME_FORMAT_DATE; private static final FastDateFormat TIME_FORMAT_TIME; private static final FastDateFormat TIME_FORMAT_TIMESTAMP; static {
final TimeZone gmt = TimeZone.getTimeZone("GMT");
TIME_FORMAT_DATE = FastDateFormat.getInstance("yyyy-MM-dd", gmt);
TIME_FORMAT_TIME = FastDateFormat.getInstance("HH:mm:ss", gmt);
TIME_FORMAT_TIMESTAMP = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss", gmt);
} JavaFileTypeEnum(String typeName, SqlTypeName sqlTypeName, Function<Cell, Object> cellValueFunc) {
this.typeName = typeName;
this.sqlTypeName = sqlTypeName;
this.cellValueFunc = cellValueFunc;
} public static Optional<JavaFileTypeEnum> of(String typeName) {
return Arrays
.stream(values())
.filter(type -> StringUtils.equalsIgnoreCase(typeName, type.getTypeName()))
.findFirst();
} public static SqlTypeName findSqlTypeName(String typeName) {
final Optional<JavaFileTypeEnum> javaFileTypeOptional = of(typeName); if (javaFileTypeOptional.isPresent()) {
return javaFileTypeOptional
.get()
.getSqlTypeName();
} return SqlTypeName.UNKNOWN;
} public Object getCellValue(Cell cell) {
return cellValueFunc.apply(cell);
} public static Function<Cell, Object> getValueWithUnknown() {
return cell -> {
if (ObjectUtils.isEmpty(cell)) {
return null;
} switch (cell.getCellType()) {
case STRING:
return cell.getStringCellValue();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
// 如果是日期类型,返回日期对象
return cell.getDateCellValue();
}
else {
// 否则返回数值
return cell.getNumericCellValue();
}
case BOOLEAN:
return cell.getBooleanCellValue();
case FORMULA:
// 对于公式单元格,先计算公式结果,再获取其值
try {
return cell.getNumericCellValue();
}
catch (Exception e) {
try {
return cell.getStringCellValue();
}
catch (Exception ex) {
log.error("parse unknown data error, cellRowIndex:{}, cellColumnIndex:{}", cell.getRowIndex(), cell.getColumnIndex(), e);
return null;
}
}
case BLANK:
return "";
default:
return null;
}
};
} public static Function<Cell, Object> getValueWithDate() {
return cell -> {
Date date = cell.getDateCellValue(); if(ObjectUtils.isEmpty(date)) {
return null;
} try {
final String formated = new SimpleDateFormat("yyyy-MM-dd").format(date);
Date newDate = TIME_FORMAT_DATE.parse(formated);
return (int) (newDate.getTime() / DateTimeUtils.MILLIS_PER_DAY);
}
catch (ParseException e) {
log.error("parse date error, date:{}", date, e);
} return null;
};
} public static Function<Cell, Object> getValueWithTimestamp() {
return cell -> {
Date date = cell.getDateCellValue(); if(ObjectUtils.isEmpty(date)) {
return null;
} try {
final String formated = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
Date newDate = TIME_FORMAT_TIMESTAMP.parse(formated);
return (int) newDate.getTime();
}
catch (ParseException e) {
log.error("parse timestamp error, date:{}", date, e);
} return null;
};
} public static Function<Cell, Object> getValueWithTime() {
return cell -> {
Date date = cell.getDateCellValue(); if(ObjectUtils.isEmpty(date)) {
return null;
} try {
final String formated = new SimpleDateFormat("HH:mm:ss").format(date);
Date newDate = TIME_FORMAT_TIME.parse(formated);
return newDate.getTime();
}
catch (ParseException e) {
log.error("parse time error, date:{}", date, e);
} return null;
};
}
}

该枚举类主要管理了java type& sql type & cell value convert func, 方便统一管理类型映射及单元格内容提取时的转换方法(这里借用了java8 function函数特性)

注: 这里的日期转换只能这样写, 即使用GMT的时区(抄的calcite-file), 要不然输出的日期时间一直有时差...

6. 测试查询

package com.ldx.calcite;

import com.ldx.calcite.excel.ExcelSchemaFactory;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.calcite.config.CalciteConnectionProperty;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.schema.Schema;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.calcite.util.Sources;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testng.collections.Maps; import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.Properties; @Slf4j
public class CalciteExcelTest {
private static Connection connection; private static SchemaPlus rootSchema; private static CalciteConnection calciteConnection; @BeforeAll
@SneakyThrows
public static void beforeAll() {
Properties info = new Properties();
// 不区分sql大小写
info.setProperty(CalciteConnectionProperty.CASE_SENSITIVE.camelName(), "false"); // 创建Calcite连接
connection = DriverManager.getConnection("jdbc:calcite:", info);
calciteConnection = connection.unwrap(CalciteConnection.class);
// 构建RootSchema,在Calcite中,RootSchema是所有数据源schema的parent,多个不同数据源schema可以挂在同一个RootSchema下
rootSchema = calciteConnection.getRootSchema();
} @Test
@SneakyThrows
public void test_execute_query() {
final Schema schema = ExcelSchemaFactory.INSTANCE.create(resourcePath("file/test.xlsx"));
rootSchema.add("test", schema);
// 设置默认的schema
calciteConnection.setSchema("test");
final Statement statement = calciteConnection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT * FROM user_info");
printResultSet(resultSet);
System.out.println("=========");
ResultSet resultSet2 = statement.executeQuery("SELECT * FROM test.user_info where id > 110 and birthday > '2003-01-01'");
printResultSet(resultSet2);
System.out.println("=========");
ResultSet resultSet3 = statement.executeQuery("SELECT * FROM test.user_info ui inner join test.role_info ri on ui.role_id = ri.id");
printResultSet(resultSet3);
} @AfterAll
@SneakyThrows
public static void closeResource() {
connection.close();
} private static String resourcePath(String path) {
final URL url = CalciteExcelTest.class.getResource("/" + path);
return Sources.of(url).file().getAbsolutePath();
} public static void printResultSet(ResultSet resultSet) throws SQLException {
// 获取 ResultSet 元数据
ResultSetMetaData metaData = resultSet.getMetaData(); // 获取列数
int columnCount = metaData.getColumnCount();
log.info("Number of columns: {}",columnCount); // 遍历 ResultSet 并打印结果
while (resultSet.next()) {
final Map<String, String> item = Maps.newHashMap();
// 遍历每一列并打印
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnName(i);
String columnValue = resultSet.getString(i);
item.put(columnName, columnValue);
} log.info(item.toString());
}
}
}

测试结果如下:

4. 使用sql查询excel内容的更多相关文章

  1. 64位环境中使用SQL查询excel的方式解决

    --64位环境中使用SQL查询excel的方式 环境: OS:Windows Server 2008 R2 Enterprise MSSQL:Microsoft SQL Server 2008 R2 ...

  2. 从SQL查询分析器中读取EXCEL中的内容

    很早以前就用sql查询分析器来操作过EXCEL文件了. 由于对于excel公式并不是很了解,所以很多时候处理excel中的内容,常常是用sql语句来处理的.[什么样的人有什么样的办法吧 :)] 今又要 ...

  3. 在Delphi中动态地使用SQL查询语句 Adoquery sql 参数 冒号

    在Delphi中动态地使用SQL查询语句 在一般的数据库管理系统中,通常都需要应用SQL查询语句来提高程序的动态特性.下面介绍如何在Delphi中实现这种功能.在Delphi中,使用SQL查询语句的途 ...

  4. 你的sql查询为什么这么慢?

    做后台开发的程序猿通常需要写各种各样的sql,可很多时候写出来的sql虽然能满足功能性需求,性能上却不尽人意.如果业务复杂,表结构和索引设计又不合理的话,写出来的sql执行时间可能会达到几十甚至上百秒 ...

  5. 如何用SQL语句查询Excel数据?

    如何用SQL语句查询Excel数据?Q:如何用SQL语句查询Excel数据? A:下列语句可在SQL SERVER中查询Excel工作表中的数据. 2007和2010版本: SELECT*FROMOp ...

  6. Hibernate查询之SQL查询,查询结果用new新对象的方式接受,hql查询,通过SQL查询的结果返回到一个实体中,查询不同表中内容,并将查到的不同表中的内容放到List中

     package com.ucap.netcheck.dao.impl; import java.util.ArrayList;import java.util.List; import org. ...

  7. SQL查询获得指定格式内容

    Oracle中通过修改SQL语句,达到将查询的内容拼接为指定的字符串格式 eg: select '<ta:datagridItem id="' || lower(column_name ...

  8. 将sql 查询结果导出到excel

    在平时工作中经常会遇到,sql 查询数据之后需要发送给业务人员,每次都手工执行脚本然后拷贝数据到excel中,比较耗时耗力,可以考虑自动执行查询并将结果邮件发送出来. 分两步实现: 1.执行查询将结果 ...

  9. 在Excel VBA中将SQL查询的结果赋值给变量的方法

    直接上代码示例: nowdate为日期型变量 strSql = "select DISTINCT 日期 from new_ubi_data ORDER BY 日期 DESC Limit 0, ...

  10. 记一个简单的sql查询

    在我们做各类统计和各类报表的时候,会有各种各样的查询要求.条件 这篇主要记录一个常见的统计查询 要求如下: 统计一段时间内,每天注册人数,如果某天没有人注册则显示为0 现在建个简单的表来试试 建表语句 ...

随机推荐

  1. 鸿蒙NEXT开发案例:二维码的生成与识别

    [引言] 在本篇文章中,我们将探讨如何在鸿蒙NEXT平台上实现二维码的生成与识别功能.通过使用ArkUI组件库和相关的媒体库,我们将创建一个简单的应用程序,用户可以生成二维码并扫描识别. [环境准备] ...

  2. Java线程中断的本质和编程原则

    在历史上,Java试图提供过抢占式限制中断,但问题多多,例如前文介绍的已被废弃的Thread.stop.Thread.suspend和 Thread.resume等.另一方面,出于Java应用代码的健 ...

  3. Contrastive Learning 对比学习 | RL 学 representation 时的对比学习

    记录一下读的三篇相关文章. 01. Representation Learning with Contrastive Predictive Coding arxiv:https://arxiv.org ...

  4. RabbitMQ的四种交换机类型

    前言 这是相关技能的详解系列,是将东西整理归纳总结,系列的进行记录与分享,这种方式更有完善性,更能成体系的学习一个技能,方便我们掌握他,这也是我们这种系列的目标,希望在跟着学习了解完这个系列后,就能将 ...

  5. Linux系统之Ubuntu

    常用命令: #查看安装包 dpkg -l 1)切换镜像源 本身的镜像都是国外的,下载依赖包太慢, 需要替换成国内镜像地址 国内镜像源推荐阿里 OPSX 源: https://opsx.alibaba. ...

  6. Spring Boot 使用 slf4j 进行日志记录

    SLF4J,即简单日志门面(Simple Logging Facade forJava),不是具体的日志解决方案,它只服务于各种各样的日志系统.按照官方的说法,SLF4J 是一个用于日志系统的简单Fa ...

  7. ECharts 标题组件

    1.标题组件的基本使用 图标组件使用title节点进行配置. 标题分为主标题和副标题, 主标题的文本内容使用 'text' 属性进行设置 副标题使用 'subtext' 属性进行设置 var opti ...

  8. uni app 封装接api接口

    创建文件  base.js let baseURL = ''; // 是否在控制台显示接口请求日志,本地环境启用,打包环境禁用 let showHttpLog = false; // 测试环境 bas ...

  9. 认识Redis集群

    概述 Redis单实例的架构,从最开始的一主N从,到读写分离,再到Sentinel哨兵机制,单实例的Redis缓存足以应对大多数的使用场景,也能实现主从故障迁移. 但是,在某些场景下,单实例存Redi ...

  10. Skyvern – AI浏览器自动化测试工具

    Skyvern – AI浏览器自动化测试工具 ​​ ‍ Skyvern是什么 Skyvern是开源的浏览器自动化工具,结合大型语言模型(LLMs)和计算机视觉技术实现复杂的网页交互和数据提取.与传统的 ...