Java POI读取Excel实战:从基础API到大数据优化与异常处理
1. 从“能打开”到“读明白”:POI读取Excel的实战起点
最近在后台看到不少朋友在搜Java POI读取Excel,特别是
.xlsx
文件。这确实是个高频需求,无论是处理业务报表、导入用户数据,还是做数据清洗,都绕不开它。很多人上手的第一感觉是:“这不就是几行代码的事吗?” 确实,网上随便一搜,都能找到用
XSSFWorkbook
打开文件、遍历
Sheet
和
Row
的示例代码。但真到了自己动手,尤其是面对格式复杂、数据量稍大的实际文件时,各种问题就冒出来了:内存溢出(OOM)、日期数字读出来是浮点数、合并单元格处理不当导致数据错位、空行空列判断失误…… 这些坑,我都一个不落地踩过。
所以,今天我们不聊那些“Hello World”级别的入门代码。我想和你深入聊聊,在真实的项目环境中,如何稳健、高效且正确地使用Apache POI的
XSSFWorkbook
来读取
.xlsx
文件。这不仅仅是调用API,更是一套包含环境搭建、核心对象理解、数据精准提取、性能优化和异常处理在内的完整解决方案。无论你是正在处理一个紧急的数据导入需求,还是想系统性地掌握POI,这篇文章都能给你提供可直接“抄作业”的实践指南。
2. 环境准备与核心依赖:别在第一步就卡住
在开始写代码之前,正确的环境配置是基石。很多初学者遇到的第一个拦路虎往往是依赖问题或环境冲突。
2.1 依赖引入:选对版本,避免冲突
对于Maven项目,在
pom.xml
中添加POI依赖是最常见的方式。但这里有个关键点:Apache POI是一个模块化的项目,我们需要引入一组相关的依赖,而不仅仅是一个
poi
的JAR包。
<dependencies>
<!-- POI核心库 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.3</version> <!-- 建议使用较新稳定版 -->
</dependency>
<!-- 处理.xlsx格式的OOXML实现 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
<!-- 处理OOXML的Schemas,某些复杂操作需要 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
<version>4.1.2</version>
</dependency>
</dependencies>
注意 :务必保持
poi和poi-ooxml的版本一致,否则可能会引发难以排查的NoClassDefFoundError或ClassNotFoundException,比如热词里提到的NoClassDefFoundError: org/apache/poi/POIXMLDocumentPart,很多时候就是版本不匹配导致的。poi-ooxml-schemas的版本可以独立,但建议使用与核心库兼容的版本。
如果你用的是Gradle,对应的依赖声明如下:
implementation 'org.apache.poi:poi:5.2.3'
implementation 'org.apache.poi:poi-ooxml:5.2.3'
implementation 'org.apache.poi:poi-ooxml-schemas:4.1.2'
2.2 理解HSSF与XSSF:老司机与新高速
Apache POI提供了两套主要的API来处理Excel:
-
HSSF (Horrible SpreadSheet Format)
: 用于处理旧的
.xls格式(Excel 97-2003)。它是基于二进制流的,在处理超大文件时有限制(行数约65535,列数256)。 -
XSSF (XML SpreadSheet Format)
: 用于处理新的
.xlsx格式(Excel 2007+)。它基于OOXML(Office Open XML)标准,本质上是一个ZIP压缩包,里面包含了一系列XML文件。它支持更大的数据量(行数超过100万,列数16384),但内存消耗通常比HSSF高。
我们的主题是
.xlsx
,所以核心类就是
XSSFWorkbook
。它代表整个Excel工作簿,是我们所有操作的入口。
2.3 基础读取代码骨架:先让程序跑起来
让我们先写一个最基础的、能跑通的读取示例,建立直观感受:
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.FileInputStream;
import java.io.IOException;
public class BasicExcelReader {
public static void main(String[] args) {
// 文件路径,请替换为你的实际文件路径
String filePath = "your_file.xlsx";
// 使用try-with-resources确保资源自动关闭,避免内存泄漏
try (FileInputStream fis = new FileInputStream(filePath);
Workbook workbook = new XSSFWorkbook(fis)) { // 关键:创建XSSFWorkbook对象
// 1. 获取第一个工作表(Sheet)
Sheet sheet = workbook.getSheetAt(0);
// 2. 遍历每一行(Row)
for (Row row : sheet) {
// 3. 遍历行中的每一个单元格(Cell)
for (Cell cell : row) {
// 4. 根据单元格类型读取数据
switch (cell.getCellType()) {
case STRING:
System.out.print(cell.getStringCellValue() + "\t");
break;
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
System.out.print(cell.getDateCellValue() + "\t");
} else {
System.out.print(cell.getNumericCellValue() + "\t");
}
break;
case BOOLEAN:
System.out.print(cell.getBooleanCellValue() + "\t");
break;
case FORMULA:
System.out.print(cell.getCellFormula() + "\t");
break;
default:
System.out.print("[空或未知]\t");
}
}
System.out.println(); // 换行,表示一行结束
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这段代码构成了读取Excel的骨架。但如果你直接拿它去读一个复杂的生产环境文件,大概率会出问题。接下来,我们就逐一拆解这里面每一个环节可能遇到的“坑”和最佳实践。
3. 核心对象模型与数据提取:精准获取每一个值
POI的对象模型与Excel的结构是对应的:
Workbook
->
Sheet
->
Row
->
Cell
。理解每一层的特性和API是精准提取数据的前提。
3.1 Sheet的获取与遍历:不只是getSheetAt(0)
workbook.getSheetAt(0)
获取的是索引为0(第一个)的工作表。但在实际文件中,我们可能需要按名称获取,或者遍历所有工作表。
// 按名称获取Sheet(更常用,更稳定)
Sheet targetSheet = workbook.getSheet("订单数据");
if (targetSheet == null) {
throw new IllegalArgumentException("未找到名为'订单数据'的工作表!");
}
// 遍历所有Sheet
for (Sheet sheet : workbook) {
System.out.println("正在处理Sheet: " + sheet.getSheetName());
// ... 处理每个sheet的数据
}
遍历行(Row)的注意事项
:
POI的
Sheet
实现了
Iterable<Row>
,所以可以用
for (Row row : sheet)
来遍历。但要注意,
这个迭代器只会遍历有数据的行(即创建过的行)
。如果Excel中第1、3、5行有数据,第2、4行是空的(完全未编辑过),那么迭代器只会返回第1、3、5行。
sheet.getLastRowNum()
返回的是最后一个有数据的行的索引(从0开始),
sheet.getPhysicalNumberOfRows()
返回的是有数据的行的实际数量。
3.2 Cell类型判断与值获取:避免ClassCastException的陷阱
上面基础代码中的
switch (cell.getCellType())
在POI 3.17版本后已经过时。从POI 4.0开始,推荐使用
getCellType()
返回
CellType
枚举,并结合
getCellType()
方法来判断。这是代码健壮性的关键。
import org.apache.poi.ss.usermodel.CellType;
// ... 在遍历Cell的循环内
CellType cellType = cell.getCellType();
switch (cellType) {
case STRING:
String strValue = cell.getStringCellValue();
// 处理前可trim()去除首尾空格
break;
case NUMERIC:
// 难点:NUMERIC类型既可能是数字,也可能是日期
if (DateUtil.isCellDateFormatted(cell)) {
// 是日期
Date dateValue = cell.getDateCellValue();
// 使用SimpleDateFormat或Java 8+的DateTimeFormatter格式化
} else {
// 是纯数字
double numValue = cell.getNumericCellValue();
// 注意:Excel中存储的整数(如100)读出来也是double(100.0)
// 如果需要整数,可以强转或判断
if (numValue == Math.floor(numValue) && !Double.isInfinite(numValue)) {
long intValue = (long) numValue;
}
}
break;
case BOOLEAN:
boolean boolValue = cell.getBooleanCellValue();
break;
case FORMULA:
// 公式单元格:可以获取公式字符串,或计算后的值
String formula = cell.getCellFormula();
// 获取公式计算后的值(注意:POI默认不一定计算,可能需要evaluate)
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
CellValue formulaValue = evaluator.evaluate(cell);
// 然后根据formulaValue的cellType再处理
break;
case BLANK:
// 单元格存在但内容为空
break;
case ERROR:
byte errorCode = cell.getErrorCellValue();
break;
default:
// _NONE或未知类型
break;
}
一个极其常见的坑:空单元格的处理
如果某个单元格从未被编辑过,通过
row.getCell(columnIndex)
获取到的可能是
null
。直接对其调用
getCellType()
会抛出
NullPointerException
。安全的写法是:
Cell cell = row.getCell(j, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
// 或者
Cell cell = row.getCell(j);
if (cell == null) {
// 按空值处理
value = "";
} else {
// 正常读取
value = ...;
}
使用
Row.MissingCellPolicy.CREATE_NULL_AS_BLANK
策略,即使单元格不存在,也会返回一个
CellType
为
BLANK
的单元格对象,避免了空指针,简化了逻辑。
3.3 处理特殊单元格:合并单元格、样式与公式
合并单元格
:这是数据错位的重灾区。比如一个标题跨了A1到D1,如果你遍历第1行的A、B、C、D列,会发现只有A1有值,B1、C1、D1都是空或
null
。POI提供了
sheet.getMergedRegions()
来获取所有合并区域。
List<CellRangeAddress> mergedRegions = sheet.getMergedRegions();
for (CellRangeAddress mergedRegion : mergedRegions) {
int firstRow = mergedRegion.getFirstRow();
int lastRow = mergedRegion.getLastRow();
int firstCol = mergedRegion.getFirstColumn();
int lastCol = mergedRegion.getLastColumn();
// 通常,只有左上角(firstRow, firstCol)的单元格有值
// 在遍历时,如果当前单元格(rowIndex, colIndex)位于某个合并区域内,且不是左上角,则应取左上角的值
}
在实际处理中,我通常会先遍历所有合并区域,建立一个快速查找的映射(例如,用一个
Map<String, Cell>
,key是
“行-列”
,value是合并区域左上角单元格),在读取每个单元格时先查这个映射。
单元格样式
:通过
cell.getCellStyle()
可以获取字体、颜色、对齐方式、数据格式等。
cell.getCellStyle().getDataFormatString()
可以拿到Excel中设置的格式代码(如“yyyy-mm-dd”、“0.00%”),这对于判断数字的显示格式非常有用。
公式计算
:如前所述,对于
FORMULA
类型的单元格,如果需要获取计算结果,必须使用
FormulaEvaluator
。对于
.xlsx
,对应的类是
XSSFFormulaEvaluator
。注意,如果Excel文件是在不依赖Excel应用的环境下生成的,某些复杂公式可能无法被POI计算。
4. 性能优化与内存管理:应对大数据文件的挑战
XSSFWorkbook
在内存中维护了整个Excel文档的DOM树,对于几兆几十兆的文件没问题,但遇到几十上百列、几十万行数据的文件,很容易导致JVM堆内存溢出(OOM)。这是使用POI处理
.xlsx
最严峻的挑战。
4.1 使用SXSSF(流式API)进行读取
POI提供了
SXSSF
(Streaming Usermodel API)用于写入超大Excel文件,但
请注意,SXSSF本身并不直接提供对应的流式读取API
。对于读取,POI提供了另一种模式:
事件模型(EventModel)
,对应
XSSF
和
SAX
(Simple API for XML)解析器。这是处理超大文件读取的正确姿势。
核心类是
org.apache.poi.xssf.eventusermodel.XSSFReader
和
org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable
。它的原理类似于SAX解析XML,不需要将整个文件加载到内存,而是边读边处理,内存中只保留当前正在处理的部分数据。
下面是一个简化的事件模型读取示例框架:
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFReader.SheetIterator;
import org.apache.poi.xssf.model.SharedStringsTable;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.XMLReaderFactory;
import java.io.InputStream;
public class LargeExcelReader {
public void processLargeFile(String filePath) throws Exception {
try (OPCPackage pkg = OPCPackage.open(filePath)) {
XSSFReader reader = new XSSFReader(pkg);
SharedStringsTable sst = new SharedStringsTable(pkg); // 处理共享字符串
// 获取样式(如果需要)
// StylesTable styles = reader.getStylesTable();
// 自定义的SAX事件处理器,这是核心
MySheetHandler handler = new MySheetHandler(sst);
XMLReader parser = XMLReaderFactory.createXMLReader();
parser.setContentHandler(handler);
// 遍历所有Sheet
SheetIterator sheets = (SheetIterator) reader.getSheetsData();
while (sheets.hasNext()) {
InputStream sheetStream = sheets.next();
String sheetName = sheets.getSheetName();
System.out.println("Processing Sheet: " + sheetName);
handler.setSheetName(sheetName); // 告诉处理器当前sheet名
InputSource sheetSource = new InputSource(sheetStream);
parser.parse(sheetSource); // 开始SAX解析
sheetStream.close();
}
}
}
// 你需要实现一个继承自org.xml.sax.helpers.DefaultHandler的类
// 在startElement, characters, endElement等方法中解析单元格数据
static class MySheetHandler extends DefaultHandler {
private SharedStringsTable sst;
private String sheetName;
private StringBuilder cellValue;
private boolean isString;
// ... 其他状态变量,用于追踪当前行、列、单元格类型等
public MySheetHandler(SharedStringsTable sst) { this.sst = sst; }
public void setSheetName(String name) { this.sheetName = name; }
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) {
// 解析到 <row> 标签,开始新的一行
// 解析到 <c> 标签,开始一个新的单元格,从attributes中获取单元格类型(t属性)和引用(r属性,如A1)
// 如果 t="s",说明是共享字符串索引,将isString置为true
// 如果 t="n",是数字;t="b",是布尔;t="str",是内联字符串;t="e",是错误
// 初始化cellValue StringBuilder
}
@Override
public void characters(char[] ch, int start, int length) {
// 累积单元格内的文本内容
if (cellValue != null) {
cellValue.append(ch, start, length);
}
}
@Override
public void endElement(String uri, String localName, String qName) {
// 解析到 </c> 标签,一个单元格结束。根据isString和cellValue,从sst中获取实际字符串或直接使用数字。
// 将解析出的单元格数据存储到你定义的数据结构(如List<Map>)中。
// 解析到 </row> 标签,一行结束。可以将整行数据批量处理(如存入数据库)。
}
}
}
事件模型代码更复杂,但它是处理百MB甚至GB级别Excel文件的唯一可行方案。如果你的文件不大(例如小于50MB),使用标准的
XSSFWorkbook
并配合以下技巧更简单。
4.2 标准XSSFWorkbook的优化技巧
如果坚持使用
XSSFWorkbook
,可以尝试这些优化:
-
设置JVM堆内存
:通过启动参数
-Xmx2048m或-Xmx4096m增加最大堆内存。 -
及时关闭资源
:使用
try-with-resources确保Workbook、FileInputStream被关闭,释放资源。 -
避免频繁创建对象
:例如,
DataFormatter、FormulaEvaluator可以在循环外创建并复用。 -
按需读取
:如果只需要特定Sheet或特定区域的数据,不要遍历整个文件。用
sheet.getRow()按索引获取特定行,而不是遍历所有行。 -
处理完即丢弃
:对于超大文件,在读取并处理完一行或一批数据后,可以尝试将对应的Java对象(如
Row、Cell)的引用置为null,虽然POI内部缓存可能不会立即释放,但有助于GC。 - 考虑文件拆分 :如果可能,在业务上游将大文件拆分成多个小文件处理。
5. 实战中的疑难杂症与解决方案
掌握了基础和性能,我们来看看那些让人头疼的具体问题。
5.1 日期与数字的“傻傻分不清”
这是最高频的问题。Excel内部将所有日期和时间存储为数字(自1900年或1904年日期系统以来的天数加上小数部分)。POI读取时,如果单元格格式是日期格式,
DateUtil.isCellDateFormatted(cell)
会返回
true
。
坑点1:格式丢失 。如果Excel中一个单元格看起来是“2023-10-27”,但实际格式是“常规”或“文本”,POI会将其读为数字(如45214)。解决方案是 优先依赖Excel的格式信息 。
// 更健壮的日期判断
if (cell.getCellType() == CellType.NUMERIC) {
CellStyle style = cell.getCellStyle();
String dataFormat = style.getDataFormatString();
// 判断格式字符串是否包含日期元素
if (DateUtil.isCellDateFormatted(cell) ||
dataFormat.contains("yy") || dataFormat.contains("mm") || dataFormat.contains("dd") ||
dataFormat.contains("h") || dataFormat.contains("s")) {
// 尝试作为日期解析
Date date = cell.getDateCellValue();
// 使用DataFormatter可以按单元格格式化为字符串,更接近Excel显示的样子
DataFormatter formatter = new DataFormatter();
String dateAsString = formatter.formatCellValue(cell);
} else {
// 作为数字处理
double num = cell.getNumericCellValue();
}
}
坑点2:1904日期系统
。Mac版Excel默认使用1904日期系统(基准日期是1904年1月1日),而Windows版默认使用1900系统。POI的
DateUtil
默认使用1900系统。如果文件来自Mac,日期可能会差4年零1天。可以通过
workbook.isDate1904()
判断,并进行校正。
5.2 公式单元格的取值策略
对于
FORMULA
类型的单元格,你有几种选择:
-
cell.getCellFormula():获取公式字符串,如“SUM(A1:A10)”。 -
cell.getNumericCellValue()/cell.getStringCellValue(): 直接获取缓存的计算结果 。如果Excel文件被POI打开前已经计算并保存了,这里能拿到值。否则可能得到默认值或抛出异常。 -
使用
FormulaEvaluator.evaluate(cell): 让POI重新计算公式 。这是最可靠的方式,但性能开销大,且POI支持的函数有限。
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
CellValue cellValue = evaluator.evaluate(cell);
switch (cellValue.getCellType()) {
case NUMERIC:
double num = cellValue.getNumberValue();
break;
case STRING:
String str = cellValue.getStringValue();
break;
// ... 处理其他类型
}
提示 :对于大量公式单元格,建议在读取前,让用户在Excel中手动保存一次(确保公式结果已计算缓存),或者评估使用
FormulaEvaluator的性能影响。对于非常复杂的公式或外部链接,POI可能无法计算。
5.3 自定义数据格式与富文本处理
Excel单元格可以设置自定义格式,如“0.00%”、“#,##0.00”、“yyyy年mm月dd日”。使用
DataFormatter
可以按照单元格的格式将值格式化成字符串,这是最接近Excel显示效果的方式。
DataFormatter formatter = new DataFormatter();
// 设置区域,影响日期/数字格式(可选)
// formatter.setDefaultNumberFormat(new java.text.DecimalFormat("#,##0.00"));
String formattedValue = formatter.formatCellValue(cell);
// 对于公式单元格,formatter会先尝试计算
String formattedFormulaValue = formatter.formatCellValue(cell, evaluator);
对于单元格内包含富文本(部分文字加粗、变色),
cell.getRichStringCellValue()
返回的是
XSSFRichTextString
,它包含了所有格式信息。
cell.getStringCellValue()
只返回纯文本。
5.4 空行、隐藏行与筛选状态的处理
-
空行判断
:不要只判断
row == null。一行可能被创建但所有单元格都为空。更准确的判断是遍历该行所有单元格,检查是否都为空白或空。public boolean isRowEmpty(Row row) { if (row == null) return true; for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) { Cell cell = row.getCell(c); if (cell != null && cell.getCellType() != CellType.BLANK) { return false; } } return true; } -
隐藏行/列
:
row.getZeroHeight()返回true表示行被隐藏。sheet.isColumnHidden(columnIndex)判断列是否隐藏。 -
筛选状态
:POI可以读取筛选区域(
sheet.getAutoFilter()),但判断某行是否被筛选掉需要更复杂的逻辑,通常需要结合单元格值进行业务判断。
6. 封装与工具类:构建可复用的读取组件
在实际项目中,我们很少会每次都写一遍完整的解析循环。封装一个健壮、可配置的Excel读取工具类是更佳实践。这个工具类应该考虑:
- 配置化 :允许指定要读取的Sheet(名称或索引)、起始行(跳过表头)、要读取的列索引等。
- 数据类型映射 :定义如何将单元格值转换为Java类型(String, Integer, Double, Date, Boolean等)。
- 数据校验 :在读取时进行简单的非空、格式、范围校验。
- 批处理与回调 :支持每读取一行或一批数据,通过回调函数进行处理(如存入数据库),避免在内存中累积所有数据。
- 异常处理与日志 :清晰地记录解析过程中的错误(如某行某列格式错误),是跳过整行还是记录错误继续,需要有策略。
下面是一个高度简化的工具类设计思路:
public class ExcelReader<T> {
public interface RowMapper<T> {
T mapRow(int rowIndex, List<Object> cellValues);
}
public List<T> read(String filePath, int sheetIndex, int startRow, RowMapper<T> rowMapper) throws IOException {
List<T> result = new ArrayList<>();
try (Workbook workbook = WorkbookFactory.create(new File(filePath))) {
Sheet sheet = workbook.getSheetAt(sheetIndex);
DataFormatter formatter = new DataFormatter();
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
for (int i = startRow; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (isRowEmpty(row)) continue;
List<Object> rowData = new ArrayList<>();
for (int j = 0; j < row.getLastCellNum(); j++) {
Cell cell = row.getCell(j, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
Object value = getCellValue(cell, formatter, evaluator); // 封装好的取值方法
rowData.add(value);
}
T obj = rowMapper.mapRow(i, rowData);
if (obj != null) {
result.add(obj);
}
}
}
return result;
}
// ... 实现 isRowEmpty, getCellValue 等方法
}
使用方式:
List<Order> orders = reader.read("orders.xlsx", 0, 1, (rowIdx, cellVals) -> {
if (cellVals.size() < 5) return null; // 跳过列数不足的行
Order order = new Order();
order.setOrderId((String)cellVals.get(0));
order.setAmount(((Double)cellVals.get(1)).doubleValue());
// ... 其他字段映射,注意类型转换异常处理
return order;
});
7. 从读取到应用:结合业务场景的思考
掌握了技术细节,最终还是要为业务服务。不同的场景对Excel读取的要求截然不同。
- 数据导入 :强调 准确性和健壮性 。需要严格的校验(格式、必填、唯一性、业务逻辑),清晰的错误报告(哪一行哪一列有什么问题),以及事务性(全部成功或全部回滚,或部分成功部分失败记录)。通常需要先读取到内存列表,整体校验后再批量入库。
-
报表解析
:可能更关注
特定区域的数据
(如某个命名区域
NamedRange),需要处理复杂的 合并单元格和表格结构 。POI可以通过sheet.getMergedRegions()和workbook.getName(“RangeName”)来定位。 - 数据核对与清洗 :需要 高性能地遍历和比较 。事件模型(SAX)在这里可能是必须的。同时,可能需要将读取的数据与数据库或其他来源的数据进行比对。
-
模板填充
:读取可能只是第一步,更多是定位到模板中的占位符,然后用POI的写入API(
XSSFWorkbook,Cell.setCellValue)将新数据填进去。
无论哪种场景,在开始编码前,花时间分析Excel文件的结构(用Excel软件打开,查看Sheet、表头、数据区域、格式、公式、合并单元格),设计好对应的Java数据模型(POJO)和映射规则,往往能事半功倍,减少后期调试的麻烦。记住,处理Excel文件,一半是技术,另一半是对数据本身的理解。
更多推荐
所有评论(0)