Java POI读取Excel所需全部依赖包的实战指南
简介:
Java POI是处理Microsoft Office文件的开源库,尤其适用于读取和操作Excel文件。本文详细介绍了使用Java POI读取Excel时必须导入的核心JAR包及其作用,包括支持.xls和.xlsx格式的主库、XML解析依赖、OOXML架构支持以及辅助工具包。正确配置这些依赖(如poi-3.7.jar、poi-ooxml-schemas、xmlbeans、dom4j等)对程序正常运行至关重要,缺失任一组件可能导致运行时异常。此外,还提供了示例与扩展功能包,便于学习和集成到构建流程中。
Java POI 深度解析:从基础操作到企业级实战的全链路指南
在现代企业级应用中,Excel 已不仅是办公工具,更成为数据流转的核心载体。无论是财务报表、用户行为日志,还是供应链清单,这些结构化数据常常以 .xlsx 或 .xls 的形式穿梭于系统之间。而 Java 作为后端开发的主力语言,如何高效、稳定地处理这些文件?Apache POI 正是这一问题的标准答案。
但现实远比“调用 API”复杂得多。你是否曾遇到过这样的场景:
- 导入一个 50MB 的订单表,JVM 瞬间抛出
OutOfMemoryError; - 解析单元格时发现本该是日期的数字变成了科学计数法字符串;
- 多个模块依赖不同版本的 POI,运行时报出
NoSuchMethodError; - 明明代码逻辑正确,却因为某个隐藏的 XML 节点导致解析失败……
这些问题背后,往往不是 API 使用不当,而是对 POI 底层机制缺乏深度理解。今天,我们就来彻底拆解这套被无数项目依赖的技术栈——从最基础的对象模型,到流式处理的性能瓶颈,再到辅助依赖包之间的隐秘协作,带你走进一个真正“可控”的 Excel 自动化世界。
核心架构设计:HSSF 与 XSSF 的双生子之谜
当我们说“用 Java 读写 Excel”,其实是在和两个截然不同的技术体系打交道: HSSF 和 XSSF 。它们分别对应两种完全不同的文件格式—— .xls 和 .xlsx ,虽然对外提供相似的接口,但内部实现天差地别。
HSSFWorkbook vs XSSFWorkbook:二进制与 XML 的分水岭
// 处理老式 .xls 文件
Workbook hssf = new HSSFWorkbook(new FileInputStream("data.xls"));
// 处理新版 .xlsx 文件
Workbook xssf = new XSSFWorkbook(OPCPackage.open("data.xlsx"));
看起来很像?别被表象迷惑了。这两行代码背后的加载过程完全不同。
| 维度 | HSSF(.xls) | XSSF(.xlsx) |
|---|---|---|
| 存储结构 | 二进制 BIFF 格式 | ZIP 压缩包 + XML |
| 最大行数 | 65,536 行 | 1,048,576 行 |
| 内存模型 | 半懒加载 | 全量 DOM 加载 |
| 初始化速度 | 快(直接解析字节流) | 慢(需解压并解析多个 XML) |
HSSF 面对的是 Excel 97–2003 年代遗留下来的二进制格式(BIFF),它的优点是紧凑、解析快;缺点也很明显——功能受限、容量小、不支持现代特性如图表或条件格式。
而 XSSF 则基于 Office Open XML (OOXML) 标准构建,本质上是一个符合 OPC(Open Packaging Conventions)规范的 ZIP 归档文件。这意味着你可以把它当成普通压缩包打开:
unzip -l report.xlsx
你会看到类似这样的目录结构:
xl/ ├── workbook.xml # 工作簿元信息 ├── worksheets/sheet1.xml # 实际表格数据 ├── sharedStrings.xml # 全局字符串池 └── styles.xml # 样式定义 [Content_Types].xml # MIME 类型映射 _rels/.rels # 关系描述符
这正是 XSSF 强大之处:它将复杂的电子文档分解为可独立访问的组件。但也正因如此,当你使用 new XSSFWorkbook() 时,POI 会一次性把这些 XML 文件全部加载进内存,并转换成 DOM 树节点。哪怕你只想读取第一行数据,整个文档也得完整驻留堆中。
小贴士:如果你正在维护一个老系统,且只处理小于 10MB 的 .xls 文件,HSSF 依然是轻量高效的首选。但对于任何新项目,尤其是涉及大数据量的场景,请果断转向 .xlsx + XSSF/SXSSF 技术栈。
对象模型探秘:Workbook → Sheet → Row → Cell 的层级迷宫
无论你是操作 .xls 还是 .xlsx ,POI 都抽象出统一的对象模型:
Workbook → Sheet → Row → Cell
这个看似简单的四层结构,构成了所有 Excel 操作的基础路径。我们来看一段典型的遍历代码:
Sheet sheet = workbook.getSheetAt(0);
for (int i = 0; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) continue;
for (int j = 0; j < row.getLastCellNum(); j++) {
Cell cell = row.getCell(j, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
System.out.print(getCellValue(cell) + "\t");
}
System.out.println();
}
这段代码虽然简洁,但藏着不少坑。比如:
getLastRowNum()返回的是 物理存在的最后一行索引 ,中间可能有空行;getRow(i)可能返回null,必须判空;getCell(j)默认策略是返回null,如果不设置MissingCellPolicy,极易引发 NPE;- 单元格类型不确定,直接调用
getStringCellValue()在非字符串类型上会抛异常!
所以更健壮的做法是封装一个通用的取值方法:
public static Object getCellValue(Cell cell) {
if (cell == null) return "";
switch (cell.getCellType()) {
case STRING:
return cell.getStringCellValue().trim();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
return cell.getDateCellValue();
} else {
return BigDecimal.valueOf(cell.getNumericCellValue())
.stripTrailingZeros()
.toPlainString(); // 避免科学计数法
}
case BOOLEAN:
return cell.getBooleanCellValue();
case FORMULA:
FormulaEvaluator evaluator = cell.getSheet()
.getWorkbook()
.getCreationHelper()
.createFormulaEvaluator();
return evaluateFormula(evaluator.evaluate(cell));
default:
return "";
}
}
注意这里用了 BigDecimal 而不是 double ,特别适用于金额字段,避免浮点精度丢失问题。同时通过 DataFormatter 或公式求值器进一步提升兼容性。
下面这张 Mermaid 图清晰展示了对象间的导航关系:
graph TD
A[Workbook] --> B[Sheet 1]
A --> C[Sheet 2]
B --> D[Row 0]
B --> E[Row 1]
D --> F[Cell A1]
D --> G[Cell B1]
E --> H[Cell A2]
E --> I[Cell B2]
style A fill:#f9f,stroke:#333
style B fill:#bbf,stroke:#333,color:#fff
style D fill:#ffd,stroke:#333
style F fill:#dfd,stroke:#333
每一层都可以通过索引或名称访问,例如 workbook.getSheet("销售明细") 。这种树状结构非常符合人类对表格的认知习惯,但在大规模数据下却成了内存杀手——每个 Cell 对象都占用几十字节,百万级单元格轻松吃掉数百 MB 堆空间。
性能危机:当 XSSFWorkbook 遇上百万行数据
设想这样一个需求:客户上传一份包含 10 万行商品信息的 Excel 表,要求系统自动导入数据库。
新手可能会这么写:
XSSFWorkbook wb = new XSSFWorkbook(fileInputStream);
XSSFSheet sheet = wb.getSheetAt(0);
for (Row row : sheet) {
List<String> values = new ArrayList<>();
for (Cell cell : row) {
values.add(formatCellValue(cell));
}
saveToDatabase(values); // 批量插入
}
运行一次试试?很可能几秒后 JVM 直接崩溃:
java.lang.OutOfMemoryError: Java heap space
为什么?
因为 XSSFWorkbook 是基于 DOM 模型的——它会把整个 .xlsx 文件解析成内存中的对象树。实验数据显示,一个 10MB 的 .xlsx 文件,在加载后可能消耗 超过 500MB 的堆内存!原因包括:
- XML 解析膨胀 :DOM 解析本身会产生大量临时对象;
- SharedStringsTable 全量缓存 :所有文本字符串一次性加载进内存;
- 样式表复制 :每种样式都被映射为 Java 对象;
- 空单元格占位 :即使为空,也会创建
XSSFCell实例。
这就引出了一个关键抉择: UserModel vs EventModel
| 特性 | UserModel ( XSSFWorkbook) | EventModel ( XSSFReader) |
|---|---|---|
| 编程模型 | 面向对象,直观易用 | 事件驱动,需自定义处理器 |
| 内存占用 | 高(O(n)) | 极低(O(1)) |
| 适用场景 | 小文件读写、模板填充 | 大文件流式解析 |
| 是否支持修改 | 是 | 否(仅读取) |
要解决大文件问题,我们必须放弃“先把文件装进来再处理”的思维定式,转而采用 SAX 流式解析 。
流式革命:用 SAX 模式实现恒定内存解析
SAX(Simple API for XML)是一种事件驱动的 XML 解析方式。它不像 DOM 那样一次性加载全文,而是边读边触发回调函数。对于 .xlsx 来说,这意味着我们可以一边从 ZIP 流中读取 sheet1.xml ,一边提取数据,全程只需几十 KB 内存。
以下是核心实现步骤:
try (OPCPackage pkg = OPCPackage.open("huge.xlsx")) {
XSSFReader reader = new XSSFReader(pkg);
SharedStringsTable sst = reader.getSharedStringsTable();
XSSFReader.SheetIterator iter = (XSSFReader.SheetIterator) reader.getSheetsData();
while (iter.hasNext()) {
try (InputStream stream = iter.next()) {
String sheetName = iter.getSheetName();
System.out.println("Processing: " + sheetName);
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
xmlReader.setContentHandler(new StreamingSheetHandler(sst));
xmlReader.parse(new InputSource(stream));
}
}
} catch (Exception e) {
e.printStackTrace();
}
其中 StreamingSheetHandler 是我们的自定义处理器:
class StreamingSheetHandler extends DefaultHandler {
private boolean inCell = false;
private boolean inValue = false;
private StringBuilder contents = new StringBuilder();
private String currentCellRef;
private final SharedStringsTable sst;
public StreamingSheetHandler(SharedStringsTable sst) {
this.sst = sst;
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) {
if ("c".equals(qName)) {
currentCellRef = attributes.getValue("r"); // 如 A1, B2
inCell = true;
} else if (inCell && "v".equals(qName)) {
inValue = true;
}
}
@Override
public void characters(char[] ch, int start, int length) {
if (inValue) {
contents.append(ch, start, length);
}
}
@Override
public void endElement(String uri, String localName, String qName) {
if ("v".equals(qName)) {
inValue = false;
String value = contents.toString().trim();
// 若单元格类型为 's',表示引用共享字符串表
String cellType = attributes.getValue("t");
if ("s".equals(cellType) && !value.isEmpty()) {
int idx = Integer.parseInt(value);
value = sst.getItemAt(idx).getString();
}
System.out.printf("%s: %s%n", currentCellRef, value);
contents.setLength(0);
} else if ("c".equals(qName)) {
inCell = false;
}
}
}
这种方式的优势显而易见:
✅ 内存恒定 :不管文件多大,内存占用基本不变
✅ 速度快 :无需构建完整对象树,解析效率提升 3–5 倍
✅ 可实时输出 :边解析边写库,无需等待
不过也有局限:不能随机访问某一行,也不适合做写操作。如果既要高性能又要可写能力怎么办?那就轮到 SXSSF 登场了。
终极方案:SXSSF —— 大规模导出的秘密武器
如果你需要生成一个包含百万行数据的 Excel 报告,传统的 XSSFWorkbook 会让你的服务器瘫痪。而 SXSSFWorkbook 就是为此类场景量身打造的解决方案。
它是 XSSF 的流式变体,采用“滑动窗口”机制:只将最近 N 行保留在内存中,其余溢出至磁盘临时文件。当最终写入时,再合并输出。
// 创建基于 XSSF 的流式工作簿,保留 100 行在内存
XSSFWorkbook xwb = new XSSFWorkbook();
SXSSFWorkbook sxwb = new SXSSFWorkbook(xwb, 100);
SXSSFSheet sheet = sxwb.createSheet("数据导出");
for (int i = 0; i < 1_000_000; i++) {
Row row = sheet.createRow(i);
for (int j = 0; j < 10; j++) {
Cell cell = row.createCell(j);
cell.setCellValue("Row" + i + "-Col" + j);
}
}
try (FileOutputStream out = new FileOutputStream("output.xlsx")) {
sxwb.write(out);
} finally {
sxwb.dispose(); // 删除临时文件
}
关键参数说明:
new SXSSFWorkbook(xwb, 100):前 100 行常驻内存,后续行写入临时文件;sxwb.dispose():务必调用,否则临时文件不会自动清理;- 支持设置压缩、临时目录等高级选项;
注意: poi-3.7.jar 原生不包含 SXSSF(首次出现在 3.8-beta),若无法升级主版本,可尝试单独引入较新的 poi-ooxml 模块以获得支持。
底层支撑:那些你忽略却至关重要的辅助依赖包
很多人以为 POI 只是一个 poi.jar ,实际上它是一套精密协作的生态系统。特别是当你处理 .xlsx 文件时,以下三个 JAR 包缺一不可:
poi-ooxml-schemas-3.7.jar:XML Schema 的 Java 映射
Open XML 规范由数百个 XSD 文件定义,涵盖 <workbook> 、 <worksheet> 、 <table> 等所有元素。POI 团队使用 Apache XmlBeans 工具链将这些 XSD 预编译为 Java 类,打包进 poi-ooxml-schemas-3.7.jar 。
例如, CTWorkbook.java 对应 <workbook> 元素:
public interface CTWorkbook extends XmlObject {
List<CTSheet> getSheetsList();
CTSheets addNewSheets();
boolean isSetSheets();
}
这些类以 CT 开头(意为 Complex Type),实现了强类型的 XML 操作。相比原始 DOM,它们提供了更好的 IDE 提示和编译期检查。
xmlbeans-2.3.0.jar:运行时绑定引擎
有了生成的类还不够,还需要一个运行时引擎来完成 XML ↔ Java 对象的序列化/反序列化。这就是 xmlbeans 的职责。
当你执行:
CTWorksheet ct = sheet.getCTWorksheet(); ct.addNewSheetViews().addNewSheetView().setRightToLeft(true);
XmlBeans 会在幕后将这些调用转化为对应的 XML 修改,并最终持久化到 .xlsx 文件中。
然而,这也带来了潜在风险:由于 poi-ooxml-schemas 包体积巨大(约 20MB),且与其他库可能存在版本冲突(如旧版 Spring Boot 自带 xmlbeans),很容易引发 LinkageError 或 NoClassDefFoundError 。
推荐排除策略(Maven):
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
<exclusions>
<exclusion>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.xmlbeans</groupId>
<artifactId>xmlbeans</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 单独引入受控版本 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
<version>3.17</version>
</dependency>
<dependency>
<groupId>org.apache.xmlbeans</groupId>
<artifactId>xmlbeans</artifactId>
<version>2.6.0</version>
</dependency>
这样可以确保类路径唯一,避免重复加载。
dom4j-1.6.1.jar:灵活补充非标准内容解析
尽管 POI 主力使用 XmlBeans,但在面对 customXML、VBA 宏或 Visio 图形元数据时,其原生支持有限。此时,轻量级的 dom4j 成为理想补充。
例如,读取嵌入式业务数据:
ZipFile zip = new ZipFile("report.xlsx");
ZipEntry entry = zip.getEntry("customXml/item1.xml");
if (entry != null) {
InputStream is = zip.getInputStream(entry);
SAXReader reader = new SAXReader();
Document doc = reader.read(is);
Element root = doc.getRootElement();
String bizId = root.elementText("businessId");
String status = root.element("metadata").attributeValue("status");
log.info("Found biz context: id={}, status={}", bizId, status);
}
还可以结合 XPath 实现复杂查询:
Map<String, String> ns = Map.of("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main");
reader.getDocumentFactory().setXPathNamespaceContext(new SimpleNamespaceContext(ns));
List<Node> redCells = doc.selectNodes("//x:c[x:v/@color='red']");
合理划分职责边界,才能兼顾性能与灵活性:
| 场景 | 推荐工具 |
|---|---|
| 标准 Sheet/Cell 操作 | POI (XSSF/HSSF) |
| SharedStrings 处理 | POI + XmlBeans |
| 自定义 XML 数据读取 | dom4j |
| 图表结构分析 | dom4j + XPath |
| 大文件流式解析 | POI EventModel |
生产级实践:Spring Boot 中的安全集成模式
在真实项目中,我们不仅要考虑功能实现,更要关注资源管理、异常恢复和可观测性。以下是一个基于 Spring Boot 的通用 Excel 导入服务模板:
@Service
@Slf4j
public class ExcelImportService {
public List<Map<String, Object>> importFromStream(InputStream inputStream, int sheetIndex) {
List<Map<String, Object>> result = new ArrayList<>();
try (OPCPackage pkg = OPCPackage.open(inputStream);
XSSFReader reader = new XSSFReader(pkg)) {
ReadOnlySharedStringsTable strings = new ReadOnlySharedStringsTable(pkg);
XSSFReader.SheetIterator iter = (XSSFReader.SheetIterator) reader.getSheetsData();
int currentIndex = 0;
while (iter.hasNext()) {
if (currentIndex++ == sheetIndex) {
try (InputStream sheetStream = iter.next()) {
parseSheetStream(sheetStream, strings, result);
break;
}
} else {
iter.next().close(); // 跳过未选中的 sheet
}
}
} catch (NotOfficeXmlFileException e) {
throw new IllegalArgumentException("无效的 .xlsx 文件", e);
} catch (OutOfMemoryError e) {
log.error("内存不足,建议启用流式解析", e);
throw new RuntimeException("文件过大,无法处理");
} catch (Exception e) {
log.error("Excel 解析失败", e);
throw new RuntimeException("解析异常:" + e.getMessage(), e);
}
return result;
}
private void parseSheetStream(InputStream stream,
ReadOnlySharedStringsTable strings,
List<Map<String, Object>> result) throws Exception {
XMLInputFactory factory = XMLInputFactory.newInstance();
XMLStreamReader xr = factory.createXMLStreamReader(stream);
Map<String, Object> currentRow = null;
StringBuilder cellValue = new StringBuilder();
String cellRef = "";
String cellType = "";
while (xr.hasNext()) {
int event = xr.next();
switch (event) {
case START_ELEMENT:
String tagName = xr.getLocalName();
if ("row".equals(tagName)) {
currentRow = new LinkedHashMap<>();
} else if ("c".equals(tagName)) {
cellRef = xr.getAttributeValue(null, "r");
cellType = xr.getAttributeValue(null, "t");
cellValue.setLength(0);
} else if ("v".equals(tagName) || "t".equals(tagName)) {
// 准备接收字符数据
}
break;
case CHARACTERS:
cellValue.append(xr.getText());
break;
case END_ELEMENT:
tagName = xr.getLocalName();
if ("v".equals(tagName) || "t".equals(tagName)) {
String value = cellValue.toString().trim();
if (!value.isEmpty() && "s".equals(cellType)) {
try {
int idx = Integer.parseInt(value);
value = strings.getEntryAt(idx).getString();
} catch (NumberFormatException | ArrayIndexOutOfBoundsException ignored) {}
}
if (currentRow != null && cellRef.matches("[A-Z]+\\d+")) {
currentRow.put(extractColumnName(cellRef), value);
}
} else if ("row".equals(tagName) && currentRow != null) {
result.add(currentRow);
}
break;
}
}
}
private String extractColumnName(String ref) {
return ref.replaceAll("\\d+", "");
}
}
亮点解析:
自动资源管理 : try-with-resources 确保 OPCPackage 和流被及时关闭
异常分级处理 :区分文件格式错误、内存溢出、解析失败等不同级别异常
列名提取 :将 A1 , B2 转换为 A , B 作为键存储
跳过无关 sheet :避免加载不需要的工作表,节省内存
配合单元测试验证边界情况:
@Test
void should_parse_small_excel_correctly() throws Exception {
try (InputStream is = getClass().getResourceAsStream("/test-data.xlsx")) {
List<Map<String, Object>> data = service.importFromStream(is, 0);
assertThat(data).hasSize(3);
assertThat(data.get(0)).containsKey("姓名");
}
}
构建工具最佳配置:Maven & Gradle 实战指南
最后,别忘了依赖管理的规范化。强烈建议统一版本,避免混合引入 poi-3.7.jar 和 poi-ooxml-3.5-beta6 这种严重错配的情况。
Maven 推荐配置
<properties>
<poi.version>3.17</poi.version> <!-- 或 5.2.3 最新版 -->
</properties>
<dependencies>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>1.6.1</version>
</dependency>
</dependencies>
Gradle 推荐配置
ext {
poiVersion = '3.17'
}
dependencies {
implementation "org.apache.poi:poi:${poiVersion}"
implementation "org.apache.poi:poi-ooxml:${poiVersion}"
implementation "org.apache.poi:poi-ooxml-schemas:${poiVersion}"
implementation 'dom4j:dom4j:1.6.1'
}
并通过以下命令检查依赖树:
mvn dependency:tree | grep poi
确保所有 POI 模块来自同一版本系列,杜绝类冲突隐患。
结语:让 Excel 自动化真正服务于业务
Java POI 不只是一个工具库,它是一整套关于 结构化数据流动 的设计哲学。从最初的简单读写,到如今的大规模流式处理,每一次演进都在回应现实世界的复杂挑战。
掌握它的关键,从来不只是记住几个 API,而是理解:
- 什么时候该用 UserModel,什么时候必须切换 EventModel?
- 如何平衡开发效率与系统稳定性?
- 当出现
OutOfMemoryError时,是调大堆内存,还是重构解析逻辑? - 第三方依赖冲突了,你是盲目排除,还是深入分析类加载机制?
这才是资深工程师与初级开发者之间的真正差距所在。
希望这篇长达七千字的深度剖析,能帮你建立起一套完整的 POI 认知框架。下次当你面对那个“又大又慢”的 Excel 文件时,心里会多一份从容与底气。
毕竟,真正的自动化,不是让机器干活,而是让我们掌控机器的方式变得更聪明。
以上就是Java POI读取Excel所需全部依赖包的实战指南的详细内容,更多关于Java POI读取Excel所需依赖包的资料请关注脚本之家其它相关文章!
相关文章
SpringBoot整合redis+Aop防止重复提交的实现
Spring Boot通过AOP可以实现防止表单重复提交,本文主要介绍了SpringBoot整合redis+Aop防止重复提交的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧2023-07-07
为什么 Java 8 中不需要 StringBuilder 拼接字符串
java8中,编辑器对“+”进行了优化,默认使用StringBuilder进行拼接,所以不用显示的使用StringBuilder了,直接用“+”就可以了。下面我们来详细了解一下2019-05-05
Springboot配置管理Externalized Configuration深入探究
这篇文章主要介绍了Springboot配置管Externalized Configuration深入探究,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪2024-01-01
使用 Spring Boot 实现 WebSocket实时通信
本篇文章主要介绍了使用 Spring Boot 实现 WebSocket实时通信,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧2017-10-10


最新评论