详细说明 POI 的使用(DOM 和 SAX 方式)和缺点
详解POI的使用方法(DOM和SAX的方式)及存在的不足
简介
Apache POI是一套基于 OOXML 标准(Office Open XML)和 OLE2 标准来读写各种格式文件的 Java API,也就是说只要是遵循以上标准的文件,POI 都能够进行读写,而不仅仅只能操作我们熟知的办公程序文件。本文只会涉及到 excel 相关内容,其他文件的操作可以参考poi官方网站。
这里先总结下 POI 的使用体验。POI 面向接口的设计非常巧妙,使用 ss.usermodel 包读写 xls 和 xlsx 时,可以使用同一套代码,即使这两种文件格式采用的是完全不同的标准。POI 提供了SXSSFWorkbook用于解决 xlsx 写大文件时容易出现的 OOM 问题。但是,还是存在以下不足(都只针对读场景):
使用 ss.usermodel 包解析 excel 效率较低、内存占用较大,且容易出现 OOM。类似于 xml 中的 DOM,这种方式会在内存中构建整个文档的结构,在处理大文件时容易出现 OOM。然而,大部分场景我们并不需要随机地去访问 excel 中的节点。
POI 提供的 SAX 解析可以解决第一个问题,但是 API 太过复杂。为了解决第一个问题,POI 提供了基于事件驱动的 SAX 方式,这种方式内存占用小、效率高, 但是 API 太过繁琐,开发者必须在熟知文档规范的前提下才能使用,而且 xls 和 xlsx 使用的是完全不同的两套 API,实际项目中必须针对不同文件类型分别实现。这一点可以从本文的例子看出来。
针对以上问题,阿里的 easyexcel 对 POI 进行高级封装,提供了一套非常简便的 API,其中,读部分只封装了 SAX 部分 API,事实上,使用 easyexcel 读 excel 只会采用 SAX 方式,另外,easyexcel 重写了 POI 对 xlsx 的解析,能够原本一个3M的 excel 用 POI SAX 依然需要100M左右内存降低到几M,easyexcel 的内容本文也会涉及到。
什么是 OLE2 和 OOXML
OLE2 和 OOXML 本质上都是一种文件格式规范或标准,平时看到的 excel 中,有字体、公式、颜色、图片等等,看起来非常复杂,但是在文件结构上都遵循着固定的格式。
OLE2 文件一般包括 xls、doc、ppt 等,是二进制格式的文件。 相关内容可以参考:复合文档Ole对象二进制储存格式。
OOXML文件一般包括 xlsx、docx、pptx 等。该类文件以指定格式的 xml 为基础并以 zip 格式压缩,这里我利用解压工具解压本地的一个 xml 文件,可以看到以下文件结构,在本文例子中,我们会重点关注 sharedStrings.xml 和 sheet1.xml 的内容,因为使用 SAX API 时必须用到:
POI的组件
针对不同应用的文件,使用时需要引入对应的 maven 依赖,这里给出官方给出的指引。如果我们不使用 SAX API 方式读写 excel,一般只会用到这个 org.apache.poi.ss 中的 API,具体的实现类放在 org.apache.poi.hssf 或 org.apache.poi.xssf 。
组件 作用 Maven依赖
POIFS OLE2 Filesystem poi
HPSF OLE2 Property Sets poi
HSSF Excel XLS poi
HSLF PowerPoint PPT poi-scratchpad
HWPF Word DOC poi-scratchpad
HDGF Visio VSD poi-scratchpad
HPBF Publisher PUB poi-scratchpad
HSMF Outlook MSG poi-scratchpad
DDF Escher common drawings poi
HWMF WMF drawings poi-scratchpad
OpenXML4J OOXML poi-ooxml plus either poi-ooxml-schemas or ooxml-schemasand ooxml-security
XSSF Excel XLSX poi-ooxml
XSLF PowerPoint PPTX poi-ooxml
XWPF Word DOCX poi-ooxml
XDGF Visio VSDX poi-ooxml
Common SL PowerPoint PPT 和 PPTX 共用组件 poi-scratchpad and poi-ooxml
Common SS Excel XLS 和 XLSX 共用组件 poi-ooxml
怎么使用POI
工程环境
JDK:1.8.0_201
maven:3.6.1
IDE:Spring Tool Suite 4.3.2.RELEASE
POI:4.1.2
easyexcel:2.1.6
mysql:5.7.28
创建项目
项目类型Maven Project,打包方式 jar。
引入依赖
<!-- junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- poi-ooxml -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<!-- easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>2.1.6</version>
</dependency>
<!-- hikari -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>2.6.1</version>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
读excel--入门案例
需求
读取指定 excel 第一个单元格的内容。该指定文件第一个单元格内容为“测试”。
编写测试方法
建议采用WorkbookFactory来获取Workbook实例,而不是根据文件类型写死具体的实现类。另外,获取单元格对象时建议采用 SheetUtil获取,里面会对行对象进行判空操作。
@Test
public void test01() throws IOException {
// 处理XSSF
String path = "extend\\file\\poi_test_01.xlsx";
// 处理HSSF
//String path = "extend\\file\\poi_test_01.xls";
// 创建工作簿,会根据excel命名选择不同的Workbook实现类
Workbook wb = WorkbookFactory.create(new File(path));
// 获取工作表
Sheet sheet = wb.getSheetAt(0);
// 获取行
Row row = sheet.getRow(0);
// 获取单元格
Cell cell = row.getCell(0);
// 也可以采用以下方式获取单元格
// Cell cell = SheetUtil.getCell(sheet, 0, 0);
// 获取单元格内容
String value = cell.getStringCellValue();
System.err.println("第一个单元格字符:" + value);
// 释放资源
wb.close();
}
测试
运行以上方法,控制台打印出第一个单元格的内容:
写excel--入门案例
需求
生成一个 excel 文件,并给第一个单元格赋值为"测试",并设置列宽 26,行高 20.25,内容居中,下框线,单元格橙色填充。
编写测试方法
CellUtil是 POI 自带的工具类,这里简化了三句代码(创建单元格,设置样式,赋值)。注意,当写入 xlsx 的大文件时,可以考虑使用SXSSFWorkbook来避免 OOM。
@Test
public void test01() throws FileNotFoundException, IOException {
// 处理XSSF
String path = "extend\\file\\poi_test_01.xlsx";
// 处理HSSF
// String path = "extend\\file\\poi_test_01.xls";
// 创建工作簿
boolean flag = path.endsWith(".xlsx");
Workbook wb = WorkbookFactory.create(flag ? true : false);
// Workbook wb = new SXSSFWorkbook(100);//内存仅保留100行数据,可避免OOM
// 创建工作表
Sheet sheet = wb.createSheet(WorkbookUtil.createSafeSheetName("MySheet001"));
// 设置列宽
sheet.setColumnWidth(0, 26 * 256);
// 创建行(索引从0开始)
Row row = sheet.createRow(0);
// 设置行高
row.setHeightInPoints(20.25f);
// 创建单元格样式对象
CellStyle style = wb.createCellStyle();
// 设置样式
style.setAlignment(HorizontalAlignment.CENTER); // 横向居中
style.setVerticalAlignment(VerticalAlignment.CENTER);// 纵向居中
style.setBorderBottom(BorderStyle.THIN);
style.setFillForegroundColor(IndexedColors.ORANGE.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
// 创建单元格、设置样式和内容
CellUtil.createCell(row, 0, "测试", style);
// 保存到本地目录
OutputStream out = new FileOutputStream(new File(path));
wb.write(out);
// 释放资源
out.close();
wb.close();
}
测试
运行以上方法,指定路径下生成了 excel 文件,并填充了第一个单元格:
读excel--批量导入excel数据到数据库
需求
将 excel 中的用户数据导入到数据库(sql 已提供,在当前项目的 extend/sql 下),数据格式如下:
该文件总计1000条数据,xls 大小 128 KB,xlsx 大小 40 KB,两种类型文件内容一致。
编写测试方法
一般 excel 的内容格式是提前约定好的,我们知道用户数据哪一列是用户名,哪一列是电话号码,所以,在获取单元格数据后可以准确地转换,但这种方式需要针对不同的对象分别定义一个转换方法。
@Test
public void test02() throws SQLException, IOException {
// 处理XSSF
String path = "extend\\file\\user_data.xlsx";
// 处理HSSF
//String path = "extend\\file\\user_data.xls";
// 定义集合,用于存放excel中的用户数据
List<UserDTO> list = new ArrayList<>();
InputStream in = new FileInputStream(path);
// 创建工作簿
Workbook wb = WorkbookFactory.create(in);
// 获取工作表
Sheet sheet = wb.getSheetAt(0);
// 获取所有行
Iterator<Row> iterator = sheet.iterator();
int rowNum = 0;
// 遍历行
while(iterator.hasNext()) {
Row row = iterator.next();
// 跳过标题行
if(rowNum == 0 || rowNum == 1) {
rowNum++;
continue;
}
// 将用户对象保存到集合中
list.add(constructUserByRow(row));
}
// 批量保存
new UserService().save(list);
// 释放资源
in.close();
wb.close();
}
/**
* <p>通过行数据构造用户对象</p>
*/
private UserDTO constructUserByRow(Row row) {
UserDTO userDTO = new UserDTO();
Cell cell = null;
// 用户名
cell = row.getCell(1);
userDTO.setName(cell.getStringCellValue());
// 性别
cell = row.getCell(2);
userDTO.setGenderStr(cell.getStringCellValue());
// 年龄
cell = row.getCell(3);
userDTO.setAge(((Double)cell.getNumericCellValue()).intValue());
// 电话
cell = row.getCell(4);
userDTO.setPhone(cell.getStringCellValue());
return userDTO;
}
测试
运行以上方法,可以在数据库看到导入的数据:
写excel--批量导出数据库数据到excel
需求
将数据库的用户数据导出到excel中。这个例子使用模板进行导出,模板如下(如果是 xlsx 的大文件,为了能够使用SXSSFWorkbook最好不要用模板)。
编写测试方法
写入的时候使用样式还是比较繁琐,实际开发能不使用尽量不要用,或者也可以单独封装成一个方法。注意,构造Workbook时不要使用WorkbookFactory.create(file)方式,否则,模板也会被修改。
@Test
public void test02() throws SQLException, IOException {
// 处理XSSF
String templatePath = "extend\\file\\user_data_template.xlsx";
String outpath = "extend\\file\\user_data.xlsx";
// 处理HSSF
// String templatePath = "extend\\file\\user_data_template.xls";
// String path = "extend\\file\\user_data.xls";
InputStream in = new FileInputStream(templatePath);
// 创建工作簿,注意,这里如果传入File对象,模板也会被改写
Workbook wb = WorkbookFactory.create(in);
// 读取工作表
Sheet sheet = wb.getSheetAt(0);
// 定义复用变量
int rowIndex = 0; // 行的索引
int cellIndex = 1; // 单元格的索引
Row nRow = null;
Cell nCell = null;
// 读取大标题行
nRow = sheet.getRow(rowIndex++); // 使用后 +1
// 读取大标题的单元格
nCell = nRow.getCell(cellIndex);
// 设置大标题的内容
nCell.setCellValue("2020年2月用户表");
// 跳过第二行(模板的小标题)
rowIndex++;
// 读取第三行,获取它的样式
nRow = sheet.getRow(rowIndex);
// 读取行高
float lineHeight = nRow.getHeightInPoints();
// 获取第三行的4个单元格中的样式
CellStyle cs1 = nRow.getCell(cellIndex++).getCellStyle();
CellStyle cs2 = nRow.getCell(cellIndex++).getCellStyle();
CellStyle cs3 = nRow.getCell(cellIndex++).getCellStyle();
CellStyle cs4 = nRow.getCell(cellIndex++).getCellStyle();
// 查询用户列表
List<UserDTO> userList = new UserService().findAll().stream().map((x) -> new UserDTO(x)).collect(Collectors.toList());
// 遍历数据
for(UserDTO user : userList) {
// 创建数据行
nRow = sheet.createRow(rowIndex++);
// 设置数据行高
nRow.setHeightInPoints(lineHeight);
// 重置cellIndex,从第一列开始写数据
cellIndex = 1;
// 创建数据单元格,设置单元格内容和样式
// 用户名
nCell = nRow.createCell(cellIndex++);
nCell.setCellStyle(cs1);
nCell.setCellValue(user.getName());
// 性别
nCell = nRow.createCell(cellIndex++);
nCell.setCellStyle(cs2);
nCell.setCellValue(user.getGenderStr());
// 年龄
nCell = nRow.createCell(cellIndex++);
nCell.setCellStyle(cs3);
nCell.setCellValue(user.getAge());
// 手机号
nCell = nRow.createCell(cellIndex++);
nCell.setCellStyle(cs4);
nCell.setCellValue(user.getPhone());
}
// 保存到本地目录
OutputStream out = new FileOutputStream(new File(outpath));
wb.write(out);
// 释放资源
out.close();
wb.close();
}
测试
运行以上方法,在指定文件夹可以看到生成的文件:
读xls--使用SAX方式
需求
使用 SAX 的方式将 xls 中的用户数据导入到数据库,数据与以上例子一样。
编写测试方法
相比前面的例子,使用 SAX 方式内存占用小,效率高,但是 POI 提供的这套 API 用起来非常繁琐,使用时不得不必须去了解 xls 文件的结构。我这里只是简单展示,监听器部分的代码不太严谨,实际项目还是用 easyexcel 来操作吧。
@Test
public void test02() throws Exception {
// 创建POIFSFileSystem
String filename = "extend\\file\\user_data.xls";
POIFSFileSystem poifs = new POIFSFileSystem(new File(filename));
// 创建HSSFRequest,并添加自定义监听器
HSSFRequest req = new HSSFRequest();
EventExample listener = new EventExample();
req.addListenerForAllRecords(listener);
// 解析和触发事件
HSSFEventFactory factory = new HSSFEventFactory();
factory.processWorkbookEvents(req, poifs);
// 保存用户到数据库
new UserService().save(listener.getList());
poifs.close();
}
private static class EventExample implements HSSFListener {
private SSTRecord sstrec;
private int lastCellRow = -1;
private int lastCellColumn = -1;
private List<UserDTO> list = new ArrayList<UserDTO>();
private UserDTO user;
@Override
public void processRecord(Record record) {
switch(record.getSid()) {
// 进入新的sheet
case BoundSheetRecord.sid:
lastCellRow = -1;
lastCellColumn = -1;
break;
// excel中的数值类型和字符存放在不同的位置
case NumberRecord.sid:
NumberRecord numrec = (NumberRecord)record;
// 用户年龄
user.setAge(Double.valueOf(numrec.getValue()).intValue());
lastCellRow = numrec.getRow();
lastCellColumn = numrec.getColumn();
break;
// SSTRecords中存储着excel中使用的字符,重复的会合并为一个
case SSTRecord.sid:
sstrec = (SSTRecord)record;
break;
// 读取到单元格的字符
case LabelSSTRecord.sid:
LabelSSTRecord lrec = (LabelSSTRecord)record;
int thisRow = lrec.getRow();
// 用户数据从第三行开始
if(thisRow >= 2) {
// 进入新行时,原对象放入集合,并创建新对象
if(thisRow != lastCellRow) {
if(user != null) {
list.add(user);
}
user = new UserDTO();
}
// 根据列数为用户对象设置属性
switch(lrec.getColumn()) {
case 1:
// 用户名
user.setName(sstrec.getString(lrec.getSSTIndex()).getString());
break;
case 2:
// 用户性别
user.setGenderStr(sstrec.getString(lrec.getSSTIndex()).getString());
break;
case 4:
// 用户电话
user.setPhone(sstrec.getString(lrec.getSSTIndex()).getString());
break;
default:
break;
}
lastCellRow = thisRow;
lastCellColumn = lrec.getColumn();
}
break;
case EOFRecord.sid:
// 最后一行读取完后直接放入集合
if(lastCellRow != -1 && user != null && lastCellColumn == 4) {
list.add(user);
}
break;
default:
break;
}
}
public List<UserDTO> getList() {
return list;
}
}
测试
运行以上方法,可以在数据库看到导入的数据:
读xlsx--使用SAX方式
需求
使用 SAX 的方式将 xlsx 中的用户数据导入到数据库,数据与以上例子一样。
编写测试方法
POI 针对 xlsx 的 SAX API 也是非常繁琐,属于非常低级的封装,这里竟然需要使用 JDK 原生的 SAX 解析来处理事件,定义事件处理器时,我必须去了解 xml 的节点结构。和上面例子一样,这里也只是简单地演示这套 API 的使用,具体代码不太严谨,当然,实际开发我们不会采用这种方式,建议还是使用 easyexcel 吧。
@Test
public void test01() throws Exception {
String filename = "extend\\file\\user_data.xlsx";
OPCPackage pkg = OPCPackage.open(filename);
XSSFReader r = new XSSFReader(pkg);
// 获取sharedStrings.xml的内容,这里存放着excel中的字符
SharedStringsTable sst = r.getSharedStringsTable();
// 接下来就是采用SAX方式解析xml的过程
// 构造解析器,这里会设置自定义的处理器
XMLReader parser = XMLHelper.newXMLReader();
SheetHandler handler = new SheetHandler(sst);
parser.setContentHandler(handler);
// 解析指定的sheet
InputStream sheet2 = r.getSheet("rId1");
parser.parse(new InputSource(sheet2));
// 保存用户到数据库
new UserService().save(handler.getList());
// handler.getList().forEach(System.err::println);
sheet2.close();
}
private static class SheetHandler extends DefaultHandler {
private SharedStringsTable sst;
private String cellContents;
private boolean cellContentsIsString;
private int cellColumn = -1;
private int cellRow = -1;
List<UserDTO> list = new ArrayList<>();
UserDTO user;
private SheetHandler(SharedStringsTable sst) {
this.sst = sst;
}
@Override
public void startElement(String uri, String localName, String name, Attributes attributes) throws SAXException {
// 读取到行
if("row".equals(name)) {
cellRow++;
if(cellRow >= 2) {
// 换行时重新创建用户实例
user = new UserDTO();
}
}
// 读取到列 c => cell
if("c".equals(name) && cellRow >= 2) {
// 设置当前读取到哪一列
char columnChar = attributes.getValue("r").charAt(0);
switch(columnChar) {
case 'B':
cellColumn = 1;
break;
case 'C':
cellColumn = 2;
break;
case 'D':
cellColumn = 3;
break;
case 'E':
cellColumn = 4;
break;
default:
cellColumn = -1;
break;
}
// 当前单元格中的值是否为字符,是的话对应的值被放在SharedStringsTable中
if("s".equals(attributes.getValue("t"))) {
cellContentsIsString = true;
}
}
// Clear contents cache
cellContents = "";
}
@Override
public void endElement(String uri, String localName, String name) throws SAXException {
// 跳过标题
if(cellRow < 2) {
return;
}
// v节点是c的子节点,表示单元格的值
if(name.equals("v")) {
int idx;
if(cellContentsIsString) {
idx = Integer.parseInt(cellContents);
} else {
idx = Double.valueOf(cellContents).intValue();
}
switch(cellColumn) {
case 1:
user.setName(sst.getItemAt(idx).getString());
break;
case 2:
user.setGenderStr(sst.getItemAt(idx).getString());
break;
case 3:
// 年龄的值是数值类型,不在SharedStringsTable中
user.setAge(idx);
break;
case 4:
user.setPhone(sst.getItemAt(idx).getString());
break;
default:
break;
}
}
// 读取完一行,将用户对象放入集合中
if("row".equals(name) && user != null) {
list.add(user);
}
// 重置参数
if("c".equals(name)) {
cellColumn = -1;
cellContentsIsString = false;
}
}
@Override
public void characters(char[] ch, int start, int length) {
cellContents += new String(ch, start, length);
}
public List<UserDTO> getList() {
return list;
}
}
测试
运行以上方法,可以在数据库看到导入的数据:
使用easyexcel读写excel
通过以上例子,我们会发现,POI SAX 方式的 API 确实非常繁琐,使用时我必须熟悉地掌握 OLE2 或 OOXML 的规范,才能够使用。这是比较低层级的封装。相比之下,ss.usermodel 的 API 要好用很多,但是这套 API 底层解析 方式有点类似 DOM,效率较低,且内存占用较大。
前面已经讲过,easyexcel 对 POI 进行了高级封装,极大地方便了我们读写 excel,而且只会采用 SAX 这种更快的方式来读取,下面补充下如何使用 easyexcel 读写 excel。
创建实体
使用 easyexcel 读写 excel 时,我们不需要自己写 row => entity 或者 entity => row 的方法,只要按照以下注解好就行。被@ExcelProperty注解的属性对应 row 中的具体内容,而被@ExcelIgnore注解表示不需要与 row 进行转换。
@ContentRowHeight(16)
public class UserDTO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelIgnore
private String id;
/**
* <p>用户名</p>
*/
@ExcelProperty(value = { "用户名" }, index = 1)
private String name;
/**
* <p>性别</p>
*/
@ExcelProperty(value = { "性别" }, index = 2)
private String genderStr;
/**
* <p>年龄</p>
*/
@ExcelProperty(value = { "年龄" }, index = 3)
private Integer age;
/**
* <p>电话号码</p>
*/
@ExcelProperty(value = { "手机号" }, index = 4)
@ColumnWidth(14)
private String phone;
@ExcelIgnore
private Integer gender = 0;
// 以下省略setter/getter方法
}
批量导入excel 数据到数据库
easyexcel 封装或重写了 POI SAX 部分的 API,所以也是需要设置回调的监听器,以下方式会采用默认的监听器,并返回封装好的对象。
@Test
public void test02() throws SQLException, IOException {
// XSSF
String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xlsx";
// HSSF
// String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xls";
// 读取excel
List<UserDTO> list = EasyExcel.read(path).head(UserDTO.class).sheet(0).headRowNumber(2).doReadSync();
// 保存
new UserService().save(list);
}
当然,我们也可以采用自定义的监听器,如下:
@Test
public void test01() throws SQLException, IOException {
// XSSF
String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xlsx";
// HSSF
// String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xls";
List<UserDTO> list = new ArrayList<UserDTO>();
// 定义回调监听器
ReadListener<UserDTO> syncReadListener = new AnalysisEventListener<UserDTO>() {
@Override
public void invoke(UserDTO data, AnalysisContext context) {
list.add(data);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// TODO Auto-generated method stub
}
};
// 读取excel
EasyExcel.read(path, UserDTO.class, syncReadListener).sheet(0).headRowNumber(2).doRead();
// 保存
new UserService().save(list);
}
批量导出数据库数据到excel
和读一样,这里也只用了一行代码就完成了对 excel 的操作。
@Test
public void test01() throws SQLException, IOException {
// XSSF
String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xlsx";
// HSSF
// String path = "D:\\growUp\\git_repository\\09-poi-demo\\extend\\file\\user_data.xls";
// 获取用户数据
List<UserDTO> list = new UserService().findAll().stream().map((x) -> new UserDTO(x)).collect(Collectors.toList());
// 写入excel
EasyExcel.write(path, UserDTO.class).sheet(0).relativeHeadRowIndex(1).doWrite(list);
}
参考资料
Apache POI - the Java API for Microsoft Documents
相关源码请移步:https://github.com/ZhangZiSheng001/poi-demo
本文为原创文章,转载请附上原文出处链接: https://www.cnblogs.com/ZhangZiSheng001/p/12329937.html
上一篇: 什么是POI
下一篇: IPV6 地址验证(Java)
推荐阅读
-
epoll简介及触发模式(accept、read、send)-epoll的简单介绍 epoll在LT和ET模式下的读写方式 一、epoll的接口非常简单,一共就三个函数:1. int epoll_create(int size);创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close关闭,否则可能导致fd被耗尽。2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);epoll的事件注册函数,它不同与select是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create的返回值,第二个参数表示动作,用三个宏来表示:EPOLL_CTL_ADD:注册新的fd到epfd中;EPOLL_CTL_MOD:修改已经注册的fd的监听事件;EPOLL_CTL_DEL:从epfd中删除一个fd;第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */};events可以是以下几个宏的集合:EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭); EPOLLIN事件:EPOLLIN事件则只有当对端有数据写入时才会触发,所以触发一次后需要不断读取所有数据直到读完EAGAIN为止。否则剩下的数据只有在下次对端有写入时才能一起取出来了。现在明白为什么说epoll必须要求异步socket了吧?如果同步socket,而且要求读完所有数据,那么最终就会在堵死在阻塞里。 EPOLLOUT:表示对应的文件描述符可以写; EPOLLOUT事件:EPOLLOUT事件只有在连接时触发一次,表示可写,其他时候想要触发,那要先准备好下面条件:1.某次write,写满了发送缓冲区,返回错误码为EAGAIN。2.对端读取了一些数据,又重新可写了,此时会触发EPOLLOUT。简单地说:EPOLLOUT事件只有在不可写到可写的转变时刻,才会触发一次,所以叫边缘触发,这叫法没错的!其实,如果真的想强制触发一次,也是有办法的,直接调用epoll_ctl重新设置一下event就可以了,event跟原来的设置一模一样都行(但必须包含EPOLLOUT),关键是重新设置,就会马上触发一次EPOLLOUT事件。1. 缓冲区由满变空.2.同时注册EPOLLIN | EPOLLOUT事件,也会触发一次EPOLLOUT事件这个两个也会触发EPOLLOUT事件 EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);EPOLLERR:表示对应的文件描述符发生错误;EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);等待事件的产生,类似于select调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。-------------------------------------------------------------------------------------------- 从man手册中,得到ET和LT的具体描述如下EPOLL事件有两种模型:Edge Triggered (ET)Level Triggered (LT)假如有这样一个例子:1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符2. 这个时候从管道的另一端被写入了2KB的数据3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作4. 然后我们读取了1KB的数据5. 调用epoll_wait(2)......Edge Triggered 工作模式:如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。在上面的例子中,会有一个事件产生在RFD句柄上,因为在第2步执行了一个写操作,然后,事件将会在第3步被销毁。因为第4步的读取操作没有读空文件输入缓冲区内的数据,因此我们在第5步调用 epoll_wait(2)完成后,是否挂起是不确定的。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口,在后面会介绍避免可能的缺陷。 i 基于非阻塞文件句柄 ii 只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待。但这并不是说每次read时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。Level Triggered 工作模式相反的,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll(2),并且无论后面的数据是否被使用,因此他们具有同样的职能。因为即使使用ET模式的epoll,在收到多个chunk的数据的时候仍然会产生多个事件。调用者可以设定EPOLLONESHOT标志,在 epoll_wait(2)收到事件后epoll会与事件关联的文件句柄从epoll描述符中禁止掉。因此当EPOLLONESHOT设定后,使用带有 EPOLL_CTL_MOD标志的epoll_ctl(2)处理文件句柄就成为调用者必须作的事情。然后详细解释ET, LT:LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认(这句话不理解)。在许多测试中我们会看到如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当我们遇到大量的idle- connection(例如WAN环境中存在大量的慢速连接),就会发现epoll的效率大大高于select/poll。(未测试)另外,当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,读数据的时候需要考虑的是当recv返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取: 这里只是说明思路(参考《UNIX网络编程》) while(rs) {buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);if(buflen < 0){// 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读// 在这里就当作是该次事件已处理处.if(errno == EAGAIN)break; else return; }else if(buflen == 0) { // 这里表示对端的socket已正常关闭. } if(buflen == sizeof(buf) rs = 1; // 需要再次读取 else rs = 0; } 还有,假如发送端流量大于接收端的流量(意思是epoll所在的程序读比转发的socket要快),由于是非阻塞的socket,那么send函数虽然返回,但实际缓冲区的数据并未真正发给接收端,这样不断的读和发,当缓冲区满后会产生EAGAIN错误(参考man send),同时,不理会这次请求发送的数据.所以,需要封装socket_send的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_send内部,当写缓冲已满(send返回-1,且errno为EAGAIN),那么会等待后再重试.这种方式并不很完美,在理论上可能会长时间的阻塞在socket_send内部,但暂没有更好的办法. ssize_t socket_send(int sockfd, const char* buffer, size_t buflen) { ssize_t tmp; size_t total = buflen; const char *p = buffer; while(1) { tmp = send(sockfd, p, total, 0); if(tmp < 0) { // 当send收到信号时,可以继续写,但这里返回-1. if(errno == EINTR) return -1; // 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满, // 在这里做延时后再重试. if(errno == EAGAIN) { usleep(1000); continue; } return -1; } if((size_t)tmp == total) return buflen; total -= tmp; p += tmp; } return tmp; } 二、epoll在LT和ET模式下的读写方式 在一个非阻塞的socket上调用read/write函数, 返回EAGAIN或者EWOULDBLOCK(注: EAGAIN就是EWOULDBLOCK) 从字面上看, 意思是: * EAGAIN: 再试一次 * EWOULDBLOCK: 如果这是一个阻塞socket, 操作将被block * perror输出: Resource temporarily unavailable 总结: 这个错误表示资源暂时不够, 可能read时, 读缓冲区没有数据, 或者, write时,写缓冲区满了 。 遇到这种情况, 如果是阻塞socket, read/write就要阻塞掉。 而如果是非阻塞socket, read/write立即返回-1, 同 时errno设置为EAGAIN. 所以, 对于阻塞socket, read/write返回-1代表网络出错了. 但对于非阻塞socket, read/write返回-1不一定网络真的出错了. 可能是Resource temporarily unavailable. 这时你应该再试, 直到Resource available. 综上, 对于non-blocking的socket, 正确的读写操作为: 读: 忽略掉errno = EAGAIN的错误, 下次继续读 写: 忽略掉errno = EAGAIN的错误, 下次继续写 对于select和epoll的LT模式, 这种读写方式是没有问题的. 但对于epoll的ET模式, 这种方式还有漏洞. epoll的两种模式 LT 和 ET
-
详细说明 POI 的使用(DOM 和 SAX 方式)和缺点