环境准备:
jdk1.8、spring boot、swagger2、poi 3.17、EasyExcel 1.1.2-beta5 ……
目录:
基于XSSF的UserModel 数据导入实现
基于XSSF的 EventModel 数据导入实现
基于阿里EasyExcel的数据导入实现(推荐使用)
三种方案性能对比
关于阿里EasyExcel的简单介绍
demo下载:
整体介绍:
项目代码导入到ide,通过spring boot启动,在浏览器输入地址http://localhost:8888/swagger-ui.html即可看到接口文档:
本文仅实现XSSF模式的Excel数据解析,HSSH模式的就不在重复了,跟XSSF的基本类似;
代码结构:
maven依赖引入:
<!-- POI -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
<version>3.17</version>
</dependency>
<!-- 阿里easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>1.1.2-beta5</version>
</dependency>
基于XSSF的UserModel 数据导入实现
简单介绍:UserModel实现起来比较简单易懂,适合新手使用,通过文件输入流创建Workbook,一次性把文件读入内存,构建一颗Dom树,并且在POI对Excel的抽象中,每一行,每一个单元格都是一个对象;当文件大,数据量多的时候对内存消耗非常大,极容易内存溢出(亲测中等配置计算机,3M左右文件,10w数据导入内存溢出);但是这种模式下,对一些特殊的数据处理比较好;适合数据量少的导入操作;本例支持将Excel解析为List<map>返回跟List<entity>返回;
controller层实现代码:
@Autowired
private ExcelDemoService service;
@ApiOperation(value="apache poi XSSF import", notes="基于POI UserModel 模式的xlsx文件导入")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "数据导入文件路径,本地盘符", dataType = "String", paramType="query", defaultValue="D:\\CodeGenerator\\系统用户信息Easyexcel_1543827647428.xlsx")
})
@PostMapping("/importByPoiXSSFUserModel")
public Object importByPoiXSSFUserModel(String path) {
try {
service.importByPoiXSSFUserModel(path);
} catch (IOException e) {
e.printStackTrace();
}
return Result.ok();
}
service层代码实现:
public Object importByPoiXSSFUserModel(String path) throws IOException {
long begin = System.currentTimeMillis();
ExcelImportUtils<UserEntity> util = new ExcelImportUtils<>(path);
List<UserEntity> list = null;
/*ExcelImportUtils<Map> util = new ExcelImportUtils<>(path);
List<Map> list = null;*/
Integer total = util.getRecordCount();
int limit = 1000;
int loop = total / limit; //计算总数据量可以分几个批次执行
if (total % limit > 0) {
loop++;
}
//分批次执行
for (int i = 0; i < loop; i++) {
int index = i * limit + 1; //加1 跳过标题行
//bean方式返回数据
list = util.importByPoiXSSFUserModel(index, limit, this.getHeadMap(), true, UserEntity.class);
//map方式返回数据
//list = util.importByPoiXSSFUserModel(index, limit, this.getHeadMap(), false, Map.class);
//分批次写入数据库,或者后续其他的处理
userMapper.batchInsert(list);
list.clear();
list = null;
}
//关闭文件输入流
util.closeInputStream();
long end = System.currentTimeMillis();
logger.error("耗时:==>" + (end-begin) + " 毫秒;");
logger.error("耗时:==>" + (end-begin)/1000 + " 秒");
return null;
}
util关键代码实现:
/**
*
* @param index 分批次执行,读取数据行
* @param limit 分批次执行,每个批次读取的记录条数
* @param headMap Excel表头跟实体类属性的关联map
* @param isBean 根据需要,true返回List<bean>, false返回List<Map>
* @param clazz
* @return list
* @throws IOException
*/
@SuppressWarnings({ "unchecked", "deprecation" })
public List<T> importByPoiXSSFUserModel(int index, int limit, Map<String, String> headMap, boolean isBean, Class<T> clazz) throws IOException {
//标题行, 处理Excel中文标题行与实体类属性的对应关系
XSSFRow fistRow = sheet.getRow(0);
Map<Integer, String> columnMap = Maps.newHashMap();
short minColIx = fistRow.getFirstCellNum();
short maxColIx = fistRow.getLastCellNum();
for (short colIx = minColIx; colIx < maxColIx; colIx++) {
XSSFCell cell = fistRow.getCell(colIx);
if (cell == null) {
continue;
}
String head = cell.getStringCellValue();
if(headMap.containsKey(head)) {
columnMap.put((int) colIx, headMap.get(head));
}
}
List result = Lists.newArrayList();
//遍历行,读取结果集
XSSFRow row = null;
for (int j = 0; j < limit; j++) {
if(index > total) {
break;
}
row = sheet.getRow(index);
if(null == row) {
continue;
}
if(isBean) { //结果集返回实体类的集合
T obj = this.getResultByBean(columnMap, row, clazz);
//logger.error(obj.toString());
result.add(obj);
} else { //结果集返回Map的集合
Map<String, String> rowMap = Maps.newHashMap();
for (Entry<Integer, String> entry : columnMap.entrySet()) { //遍历列
Cell cell = row.getCell(entry.getKey());
String property = entry.getValue();
if(property.contains("Time")) { //处理日期
if(cell.getCellType() == Cell.CELL_TYPE_STRING) { //判断日期字段的格式是时间还是字符串格式
rowMap.put(property, cell.getStringCellValue());
} else {
Date value = cell.getDateCellValue();
rowMap.put(property, DateUtil.format(value, DateUtil.PATTERN_DEFAULT));
}
} else {
cell.setCellType(CellType.STRING);
rowMap.put(property, cell.getStringCellValue());
}
}
//logger.error(rowMap.toString());
result.add(rowMap);
}
index ++;
}
return result;
}
/**
* 基于反射机制,将数据反射到对应的实体类
* 参考map2Bean方法
* @param columnMap
* @param row
* @param clazz
* @return
*/
@SuppressWarnings("deprecation")
public T getResultByBean(Map<Integer, String> columnMap, XSSFRow row, Class<T> clazz) {
T ob = null;
try {
ob = clazz.newInstance();//实例化对象
for (Entry<Integer, String> entry : columnMap.entrySet()) { //遍历列
String key = entry.getValue();
//获取实体属性
Field property= clazz.getDeclaredField(key);
//获取属性对应的set方法
String setMethodName = "set" + key.substring(0, 1).toUpperCase() + key.substring(1);
Method method = clazz.getDeclaredMethod(setMethodName, property.getType());
//获取实体属性类型
String propertyType = property.getType().getTypeName();
Cell cell = row.getCell(entry.getKey());
//反射赋值
try {
if(propertyType.equals(Integer.class.getName())) {
method.invoke(ob, cell.getNumericCellValue());
} else if(propertyType.equals(Date.class.getName())) {
if(cell.getCellType() == Cell.CELL_TYPE_STRING) { //判断日期字段的格式是时间还是字符串格式
String value = cell.getStringCellValue();
//当前仅处理标准格式的日期时间数据,其他格式,需要自行处理
value = value.replaceAll("/", "-");
if(value.contains(" ")) {
method.invoke(ob, DateUtil.format(value, DateUtil.PATTERN_DEFAULT));
} else {
method.invoke(ob, DateUtil.format(value, DateUtil.PATTERN_YYYYMMDD));
}
} else {
method.invoke(ob, cell.getDateCellValue());
}
} else if(propertyType.equals(Short.class.getName())) {
method.invoke(ob, cell.getNumericCellValue());
} else {//默认字符串类型
method.invoke(ob, cell.getStringCellValue());
}
} catch (Exception e) {
logger.error("数据反射到实体类异常", e);
}
}
}catch(Exception e){
logger.error("数据反射到实体类异常", e);
return null;
}
return ob;
}
详细代码,请移步下载链接,下载demo查看。
基于XSSF的 EventModel 数据导入实现
简单介绍:EventModel是POI本身针对数据导入内存溢出问题出的解决方案,使用门槛稍微高一点,它是基于xml解析的,使用处理XML事件驱动的Sax推模型,它逐行扫描文档,一边扫描一边解析;由于应用程序只是在读取数据时检查数据,因此不需要将数据存储在内存中,这对于大型文档的解析是个巨大优势;所以在很大程度上解决了内存溢出的问题;本例支持将Excel解析为List<map>返回,List<entity>返回,自行参考Usermodel的反射案例实现即可;
controller层实现代码:
@ApiOperation(value="apache poi XSSF import", notes="基于POI EventModel 模式的xlsx文件导入")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "数据导入文件路径,本地盘符", dataType = "String", paramType="query", defaultValue="D:\\CodeGenerator\\系统用户信息Easyexcel_1543827647428.xlsx")
})
@PostMapping("/importByPoiXSSFEventModel")
public Object importByPoiXSSFEventModel(String path) {
try {
service.importByPoiXSSFEventModel(path);
} catch (IOException e) {
e.printStackTrace();
}
return Result.ok();
}
service层代码实现:
public Object importByPoiXSSFEventModel(String path) throws IOException {
long begin = System.currentTimeMillis();
try {
ParseXlsxExcel excel = new ParseXlsxExcel(this.getHeadMap());
excel.readOneSheet(path, "sheet");
List<Map<String, String>> list = excel.getDataList();
System.err.println("总记录条数:==>" + list.size());
System.err.println("==>" + list.get(0).toString());
Integer total = list.size();
int limit = 1000;
int loop = total / limit; //计算总数据量可以分几个批次执行
if (total % limit > 0) {
loop++;
}
//分批次执行
for (int i = 0; i < loop; i++) {
Integer fromIndex = i * limit;
Integer toIndex = (i + 1) * limit;
if(toIndex > total) {
toIndex = total;
}
//分批次写入数据库,或者后续其他的处理
//userMapper.batchInsert(list.subList(fromIndex, toIndex));
}
list.clear();
list = null;
} catch (Exception e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
logger.error("耗时:==>" + (end-begin) + " 毫秒;");
logger.error("耗时:==>" + (end-begin)/1000 + " 秒");
return null;
}
解析Handler代码实现:
import java.io.InputStream;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFReader.SheetIterator;
import org.apache.poi.xssf.model.SharedStringsTable;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.util.SAXHelper;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
/**
* POI事件驱动读取Excel文件的抽象类
* 该类负责解析文件,将文件的数据转换为可以操作的数据行,基本上不用修改
* 调用optRows回调函数处理行数据,将行数据转为为Map或者实体类
* @author yuxue
* @date 2018-12-05
*/
public abstract class ExcelAbstract extends DefaultHandler {
private SharedStringsTable sst;
private String lastContents;
private boolean nextIsString;
//记录当前执行到的行号
private int curRow = 0;
//记录当前执行到的单元格列名称,如 A1
private String curCellName = "";
/**
* 读取当前行的数据。key是单元格名称如A1,value是单元格中的值。如果单元格式空,则没有数据。
*/
private Map<String, String> rowValueMap = new HashMap<>();
/**
* 处理单行数据的回调方法。
*
* @param curRow 当前行号
* @param rowValueMap 当前行的值
* @throws SQLException
*/
public abstract void optRows(int curRow, Map<String, String> rowValueMap);
/**
* 通过sheet下标,读取Excel指定sheet页的数据。
* @param filePath 文件路径
* @param sheetNum sheet页编号.从1开始。
* @throws Exception
*/
public void readOneSheet(String filePath, int sheetNum) throws Exception {
if (filePath.endsWith(".xlsx")) { //处理excel2007文件
OPCPackage pkg = OPCPackage.open(filePath);
XSSFReader r = new XSSFReader(pkg);
SharedStringsTable sst = r.getSharedStringsTable();
XMLReader parser = getSheetParser(sst);
// 根据 rId# 或 rSheet# 查找sheet
InputStream sheet2 = r.getSheet("rId" + sheetNum);
InputSource sheetSource = new InputSource(sheet2);
parser.parse(sheetSource);
sheet2.close();
} else {
throw new Exception("文件格式错误,文件扩展名只能是xlsx。");
}
}
/**
* 通过sheet名称,读取Excel指定sheet页的数据。
* @param filePath 文件路径
* @param sheetName sheet页名称。
* @throws Exception
*/
public void readOneSheet(String filePath, String sheetName) throws Exception {
if (filePath.endsWith(".xlsx")) { //处理excel2007文件
OPCPackage pkg = OPCPackage.open(filePath);
XSSFReader r = new XSSFReader(pkg);
SharedStringsTable sst = r.getSharedStringsTable();
XMLReader parser = getSheetParser(sst);
// 根据名称查找sheet
int sheetNum = 0;
SheetIterator sheets = (SheetIterator)r.getSheetsData();
while (sheets.hasNext()) {
curRow = 0;
sheetNum++;
InputStream sheet = sheets.next();
if(sheets.getSheetName().equals(sheetName)){
sheet.close();
break;
}
sheet.close();
}
// 根据 rId# 或 rSheet# 查找sheet
InputStream sheet = r.getSheet("rId" + sheetNum);
InputSource sheetSource = new InputSource(sheet);
parser.parse(sheetSource);
sheet.close();
} else {
throw new Exception("文件格式错误,文件扩展名只能是xlsx。");
}
}
@Override
public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
// c => 单元格
if (name.equals("c")) {
// 如果下一个元素是 SST 的索引,则将nextIsString标记为true
String cellType = attributes.getValue("t");
if (cellType != null && cellType.equals("s")) {
nextIsString = true;
} else {
nextIsString = false;
}
}
// 置空
lastContents = "";
//记录当前读取单元格的名称
String cellName = attributes.getValue("r");
if (cellName != null && !cellName.isEmpty()) {
curCellName = cellName;
}
}
@Override
public void endElement(String uri, String localName, String name) throws SAXException {
// 根据SST的索引值的到单元格的真正要存储的字符串
// 这时characters()方法可能会被调用多次
if (nextIsString) {
try {
int idx = Integer.parseInt(lastContents);
lastContents = new XSSFRichTextString(sst.getEntryAt(idx)).toString();
} catch (Exception e) {
}
}
// v => 单元格的值,如果单元格是字符串则v标签的值为该字符串在SST中的索引
// 将单元格内容加入rowlist中,在这之前先去掉字符串前后的空白符
if (name.equals("v")) {
String value = lastContents.trim();
rowValueMap.put(curCellName, value);
} else {
// 如果标签名称为 row ,这说明已到行尾,调用 optRows() 方法
if (name.equals("row")) {
optRows(curRow, rowValueMap);
rowValueMap.clear();
curRow++;
}
}
}
public void characters(char[] ch, int start, int length) throws SAXException {
// 得到单元格内容的值
lastContents += new String(ch, start, length);
}
/**
* 获取单个sheet页的xml解析器。
* @param sst
* @return
* @throws SAXException
*/
private XMLReader getSheetParser(SharedStringsTable sst) throws SAXException {
XMLReader parser = null;
try {
parser = SAXHelper.newXMLReader();
} catch (ParserConfigurationException e) {
e.printStackTrace();
}
this.sst = sst;
parser.setContentHandler(this);
return parser;
}
}
回调代码实现:
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.hssf.usermodel.HSSFDateUtil;
/**
* optRows回调函数的实现类
* 负责将行数据转换为指定的数据,比如List<Entity> 或者List<Map>
* 当前已经实现List<Map>的转换,反射为实体类,有兴趣的可以自己实现,可以参考ExcelImportUtils
* @author yuxue
* @date 2018-12-14
*/
public class ParseXlsxExcel extends ExcelAbstract {
//提取列名称的正则表达式
private static final String DISTILL_COLUMN_REG = "^([A-Z]{1,})";
/**
* 读取excel的每一行记录。map的key是列号(A、B、C...), value是单元格的值。如果单元格是空,则没有值。
* ArrayList 存储上限: Integer.MAX_VALUE 2147483647
*/
private List<Map<String, String>> dataList = new ArrayList<>();
/**
* <标题/列,实体属性/key> 关系
*/
private Map<String, String> headMap;
public ParseXlsxExcel(Map<String, String> headMap){
this.headMap = headMap;
}
/**
* 处理行数据的回调方法
* 将行数据反射为实体对象、日期数字转换为字符串等操作 也在这里实现
*/
@Override
public void optRows(int curRow, Map<String, String> rowValueMap) {
if(curRow == 0) { //处理标题行
this.changeMapKey(rowValueMap);
return;
}
Map<String, String> dataMap = new HashMap<>();
rowValueMap.forEach((k,v)->dataMap.put(removeNum(k), v));
dataList.add(dataMap);
}
/**
* 第一行标题行: {A1=序号, B1=登录名称, C1=姓名, D1=性别, ...}
* 配置的标题对应实体属性,或者map的key, headMap:{序号=id, 登录名称=loginName, 姓名=userName, 性别=sex, ...}
* 转行为Excel列对应的实体属性或者key: {A=id, B=loginName, C=userName, D=sex, ...}
* @param rowValueMap
*/
private void changeMapKey(Map<String, String> rowValueMap) {
rowValueMap.forEach((k, v) -> {
String key = k.replaceAll("[\\d]", ""); //替换掉所有数字
headMap.put(key, headMap.get(v.trim()));
headMap.remove(v.trim());
});
}
/**
* 日期数字转换为字符串。
* @param dateNum excel中存储日期的数字
* @return 格式化后的字符串形式
*/
public static String dateNum2Str(String dateNum) {
Date date = HSSFDateUtil.getJavaDate(Double.parseDouble(dateNum));
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
return formatter.format(date);
}
/**
* 删除单元格名称中的数字,只保留列号
* @param cellName 单元格名称。如:A1 列号。如:A
* @return 返回列号对应的实体属性或者key
*/
private String removeNum(String cellName) {
Pattern pattern = Pattern.compile(DISTILL_COLUMN_REG);
Matcher m = pattern.matcher(cellName);
if (m.find()) {
return headMap.get(m.group(1));
}
return "";
}
public List<Map<String, String>> getDataList() {
return dataList;
}
}
基于阿里EasyExcel的数据导入实现(推荐使用)
简单介绍:EasyExcel是阿里出品的,快速、简单避免OOM的java处理Excel工具;重写了poi对07版Excel的解析,能够原本一个3M的excel用POI sax依然需要100M左右内存降低到KB级别,并且再大的excel不会出现内存溢出,03版依赖POI的sax模式;本例支持将Excel解析为List<entity>返回;
controller层实现代码:
@ApiOperation(value="alibaba easyExcel import", notes="alibaba easyExcel数据导入")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "数据导入文件路径,本地盘符", dataType = "String", paramType="query", defaultValue="D:\\CodeGenerator\\系统用户信息Easyexcel_1543827647428.xlsx")
})
@PostMapping("/importByalbabaEasyExcel")
public Object importByalbabaEasyExcel(String path) {
service.importByalbabaEasyExcel(path);
return Result.ok();
}
service层代码实现:
public Object importByalbabaEasyExcel(String path) {
long begin = System.currentTimeMillis();
try {
InputStream inputStream = new BufferedInputStream(new FileInputStream(path));
//回调函数,用于处理读取Excel生成的数据
ExcelListener excelListener = new ExcelListener();
//实体类不支持lombok注解生成的getter和setter方法
//可能的原因是,导出代码编译在lombok之前,导致获取的class没有setter和setter方法
//@注解的先后执行问题,具体问题我就没有去分析了,使用spring boot的@注解, 经常会遇到这种问题
EasyExcelFactory.readBySax(inputStream, new Sheet(1, 1, UserEntity.class), excelListener);
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
logger.error("耗时:==>" + (end-begin) + " 毫秒;");
logger.error("耗时:==>" + (end-begin)/1000 + " 秒");
return null;
}
实体类:
public class UserEntity extends BaseRowModel implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty(value = { "序号" }, index = 0) // 阿里 easyExcel数据导出
private Integer id;
@ExcelProperty(value = { "登录名称" }, index = 1)
private String loginName;
private String loginPasswd;
@ExcelProperty(value = { "姓名" }, index = 2)
private String userName;
@ExcelProperty(value = { "性别" }, index = 3)
private Integer sex;
private String salt;
@ExcelProperty(value = { "状态" }, index = 4)
private Integer userStatus;
@ExcelProperty(value = { "创建时间" }, index = 5)
private Date createTime;
......
}
回调Listener代码实现:
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.util.ArrayList;
import java.util.List;
public class ExcelListener extends AnalysisEventListener {
private List<Object> data = new ArrayList<Object>();
@Override
public void invoke(Object object, AnalysisContext context) {
if(data.size()<=100){ //分批次处理数据
data.add(object);
}else {
doSomething();
data = new ArrayList<Object>();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
System.out.println(context.getCurrentSheet());
//doSomething();
}
public void doSomething(){
/*for (Object o:data) {
System.err.println(o);
}*/
}
}
三种方案性能对比
1W数据量对比: 13个字段 ,启动内存占45M左右
方案 | 耗时 | CPU | 内存 |
UserModel | 1754 毫秒 | 38.4%左右 | 60M左右 |
EventModel | 739 毫秒 | 12.3%左右 | 55M左右 |
EasyExcel | 918 毫秒 | 10%左右 | 55M左右 |
10w数据量对比:13个字段 ,启动内存占45M左右
方案 | 耗时 | CPU | 内存 |
UserModel | 失败 | 内存溢出 | |
EventModel | 10689ms | 17%左右 | 58M左右 |
EasyExcel | 9753ms | 17%左右 | 58M左右 |
100W数据量对比:13个字段 ,启动内存占45M左右;文件大小61M,全部都失败了,估计跟我的环境有关
方案 | 耗时 | CPU | 内存 |
UserModel | 失败 | 内存溢出 | |
EventModel | 失败 | 内存溢出 | |
EasyExcel | 失败 | 内存溢出 |
User Model的缺点是一次性将文件读入内存,构建一颗Dom树.并且在POI对Excel的抽象中,每一行,每一个单元格都是一个对象.当文件大,数据量多的时候对内存的占用可想而知.
Event Model使用的方式是边读取边解析,并且不会将这些数据封装成Row,Cell这样的对象.而都只是普通的数字或者是字符串.并且这些解析出来的对象是不需要一直驻留在内存中,而是解析完使用后就可以回收
关于阿里EasyExcel简单介绍
poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。easyexcel重写了poi对07版Excel的解析,能够让原本一个3M的excel用POI sax依然需要100M左右内存降低到KB级别,并且再大的excel不会出现内存溢出,03版依赖POI的sax模式。在上层做了模型转换的封装,让使用者更加简单方便;
对比三种方案,阿里出品,确实是精品,使用简单,容易上手;当然,具体使用什么方案,还是要根据自己的业务来决策吧。阿里EasyExcel的github官方地址:https://github.com/alibaba/easyexcel;
下图是1.1.2-beta5版本对应的API截图: