一、简介
NPOI是一个基于c#语言的,开源的,能够在不安装Microsoft Office组件的条件下读写Microsoft Office 的库。前身是Java的POI库,有"先贤"将其翻译成了c#语言的库,而这种由java到c#库的演变并非个例,比如DotNetty 之于Netty,NetTopologySuite 之于TopologySuite
在我的有限认知里面它算是c#里面读写excel最好的库(主观)。开源协议宽松和读写速度快。
C#编程中引用NPOI类库的方法也很简单,通过Nuget进行包管理。项目中需要引用第三方库时,强烈建议优先使用nuget方式进行引用。

二、使用NPOI操作Excel文件
1、概述
类库中主要的对象逻辑层级是按Book->Sheet->Row->Cell这种方式展开的,其他类比如样式,公式等都是对这个骨架的扩充。其实类似的表格都可以组织成这样的数据结构,如果我们自己有类似的显示需求,也可以按照此种方式展开设计。接下来两节2、读流程,3、写流程会以一些代码形式去串讲简单的续写逻辑。为什么会在文中放置写代码,甚至代码占了相当的篇幅,为了使读者在读的过程中在大脑中去过一遍这个库的常用方法的使用,增加对这个库的数据度,虽然这个熟悉度还是很浅的级别,但还是有其作用的,我尽量让NPOI在读者大脑中留下点儿东西。

2、读流程
读excel要比写excel容易一些,这是由【读】的场景决定的,读excel的读更多的应用场景是读自己的配置文件、读程序间交换的数据。在这样的大前提下,我们不需要对excel的样式字体进行太多的关注,数据组织方式也可以是最简单的形式,类似数据库的那种关系表格。搞程序,一定要在工程和技术上做好权衡,保持足够的简单,足够和简单要慢慢读,不要连起来,要细品。没必要的事尽量少做,代码多,出bug的可能就会大,但要适当保持代码的灵活性。
2.1 初始化IWorkbook对象
IWorkbook的子类有两个XSSFWorkbook(对应Excel2007以上版本,.xlsx)、HSSFWorkbook(对应Excel2003版本,xls)。现在估计没人再使用2003版本了,所以我们在封装自己的工具类库时,也没有什么必要再去兼容HSSFWorkbook了。
一定要提供通过【流】方式初始化IWorkbook的方法,因为在有些用户的电脑上,办公文件是自动加密的,如果你的某些配置文件是以Excel的格式发布的,那么大概率程序就不能正常运行了。所为我建议,当读程序自身excel格式的配置文件时,处理策略是,将excel文件以嵌入资源的形式编译到dll中,使用的时候直接通过资源流的形式初始化IWorkbook。
            
            
              cs
              
              
            
          
          public static IWorkbook GetWorkbook(string filePath)
{
     if (!File.Exists(filePath))
           return null;
      var file = new FileStream(filePath, FileMode.Open, FileAccess.Read);
      if (filePath.IndexOf(".xlsx") > 0)
      {
         //2007版本
          return new XSSFWorkbook(file);
      }
      return null;
}
 public static IWorkbook GetWorkbook(Stream stream)
 {
     try
     {
         return new XSSFWorkbook(stream);      
     }
     catch (Exception ex)
     {
         Debug.Write(ex.Message);
     }
     return null;
 }
        2.2 获取ISheet
获取sheet可以根据名称获取,也可以根据索引获取,索引是基于0的;
            
            
              cs
              
              
            
          
          ISheet IWorkbook.GetSheet(string name);
ISheet  IWorkbook.GetSheetAt(int index);
        如果需要取出所有所有sheet,可以结合IWorkbook.NumberOfSheets属性进行遍历
            
            
              cs
              
              
            
          
          for (int sheetIndex = 0; sheetIndex < workbook.NumberOfSheets; sheetIndex++)
{
    try
    {
       var sheet = workbook.GetSheetAt(sheetIndex);
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex.Message);
    }
}
        获取Sheet中最大使用行数,sheet.LastRowNum是基于0的
            
            
              cs
              
              
            
          
          rowCount=sheet.LastRowNum + 1
        获取Sheet中最大使用列数,最大使用列数没有直接提供,需要便利所有IRow的Cell数去求最大值:
            
            
              cs
              
              
            
          
          /// <summary>
 /// 获取最后可用的列数
 /// </summary>
 /// <param name="sheet"></param>
 /// <returns></returns>
 public static int LastColumnNum(this ISheet sheet)
 {
     int cellCount = 0;
     for (int i = 0; i <= sheet.LastRowNum; i++)
     {
         IRow row = sheet.GetRow(i);
         if (row != null && cellCount < row.LastCellNum)
         {
             cellCount = row.LastCellNum;
         }
     }
     return cellCount;
 }
        2.3 获取IRow
sheet通过行索引获取指定的行信息,索引基于0,如果行不存在则返回null
            
            
              cs
              
              
            
          
          IRow ISheet.GetRow(int rownum)
        2.4 获取ICell
row通过列索引获取指定的单元格信息,索引基于0,如果行不存在则返回null
            
            
              cs
              
              
            
          
          ICell IRow.GetCell(int cellnum)
        sheet直接通过行索引和列索引去取cell
            
            
              cs
              
              
            
          
          /// <summary>
/// 获取指定索引的Cell(如果索引无效则返回null)
/// </summary>
/// <param name="sheet"></param>
/// <param name="rowIndex"></param>
/// <param name="columnIndex"></param>
/// <returns></returns>
public static ICell GetCell(this ISheet sheet, int rowIndex, int columnIndex)
{
    if (rowIndex >= 0 && sheet.LastRowNum > rowIndex)
    {
        IRow row = sheet.GetRow(rowIndex);
        if (row == null)
            return null;
        if (columnIndex >= 0 && row.LastCellNum > columnIndex)
        {
            ICell cell = row.GetCell(columnIndex);
            return cell;
        }
    }
    return null; ;
}
        ICell有可能是合并的单元格,合并的单元格的有效显示值实际上是整个合并区(CellRangeAddress)的左上角索引单元格的值
            
            
              cs
              
              
            
          
          public static CellRangeAddress GetCell(this ISheet sheet, int rowIndex, int columnIndex)
{
   for (int i = 0; i < sheet.NumMergedRegions; i++)
 {
     CellRangeAddress range = sheet.GetMergedRegion(i);
     if(!sheet.IsMergedRegion(range)
        continue;
     if (range.InRange(rowIndex, columnIndex))
     {
         return range;
     }
 }
    return null; ;
}
        ICell取值,需要按照ICell的数据类型去处理,粗略参考:
            
            
              cs
              
              
            
          
          public static object GetCellValue(this ICell cell)
 {
     if (cell == null)
         return null;
     if (cell.IsMergedCell)
     {
         var useCell = 【通过合并区域获取左上角显示单元格值】;
         cell = useCell ?? cell;
     }
     switch (cell.CellType)
     {
         case CellType.Blank: //BLANK:  
             return null;
         case CellType.Boolean: //BOOLEAN:  
             return cell.BooleanCellValue;
         case CellType.Numeric: //NUMERIC:  
             return cell.NumericCellValue;
         case CellType.String: //STRING:  
             return cell.StringCellValue;
         case CellType.Error: //ERROR:  
             return cell.ErrorCellValue;
         case CellType.Formula:
             cell.SetCellType(CellType.String);
             return cell.StringCellValue;
         default:
             return "=" + cell.CellFormula;
     }
 }
        至此,通过NPOI读取excel数据流程涉及的到的基本环节算是介绍完了,在实际项目使用过程中还需要多加尝试,封装自己顺手的方法。
3、写流程
写流程与上述的读流程大体类似,毕竟拿到ICell才能对他进行写入嘛。写操作的难点在于去按需求去设置各种配置,如字号,字体,列宽,行高,公式等,这些就不展开说了,随需随查知道有这么格事儿就行了
3.1构造IWorkbook
直接安普通类方式初始化就行了。
            
            
              cs
              
              
            
          
           IWorkbook workBook = new XSSFWorkbook();
        3.2 创建ISheet
先获取sheet,看看是否存在,如果不存在就创建一个。后面涉及到的IRow,和ICell都是这样的操作
            
            
              cs
              
              
            
          
          /// <summary>
/// 获取sheet,存在返回,没有创建
/// </summary>
/// <param name="book"></param>
/// <param name="sheetName"></param>
/// <returns></returns>
public static ISheet GetSheet2(this IWorkbook book, string sheetName)
{
    return book.GetSheet(sheetName) ?? book.CreateSheet(sheetName);
}
        3.3创建IRow
通用创建Row的方法
            
            
              cs
              
              
            
          
           var useRow = sheet.GetRow(rowIndex);
 if (useRow == null)
 {
     useRow = sheet.CreateRow(rowIndex);
 }
        3.4创建ICell
通用创建Cell的方法
            
            
              cs
              
              
            
          
          var useCell = useRow.GetCell(columnIndex);
 if (useCell == null)
 {
     useCell = useRow.CreateCell(columnIndex);
 }
        给ICell赋值的通用方式
            
            
              cs
              
              
            
          
          /// <summary>
 /// 设置Excel的值信息
 /// </summary>
 /// <param name="cell"></param>
 /// <param name="value"></param>
 /// <param name="valueType">0:真实Value值,1:公式表达式;其他不合法值,按0处理</param>
 public static void SetCellValue(ICell cell,object value,int valueType)
 {
     if (cell == null)
         return;
     if (valueType == 1)
     {
         cell.SetCellFormula(value?.ToString());
     }
     else
     {
         if (value == null)
         {
             cell.SetBlank();
         }
         else if (value is string strValue)
         {
             cell.SetCellValue(strValue);
         }
         else if (value is double dValue)
         {
             cell.SetCellValue(dValue);
         }
         else if (value is bool bValue)
         {
             cell.SetCellValue(bValue);
         }
         else if (value is DateTime dtValue)
         {
             cell.SetCellValue(dtValue);
         }
         else
         {
             cell.SetCellValue(value.ToString());
         }
     }
 }
        如果需要给ICell设置特殊样式的话,要通过IWorkbok创建,这个应该是为了方便管理和复用
            
            
              cs
              
              
            
          
          var headCellStyle = workBook.CreateCellStyle();
  var f = workBook.CreateFont();
  f.IsBold = true;
  f.FontName = "等线";
  headCellStyle.SetFont(f);
        三、注意事项
- NPOI导出的excel文件,打开提示需要修复的问题。
 
两个可能注意的点
1、当使用stream转换成数组时,使用ToArray()方法。
2 、文件保存前,如果文件存在,先将旧文件删除,再创建新文件
            
            
              cs
              
              
            
          
          /// <summary>
/// 保存excel信息
/// </summary>
/// <param name="book"></param>
/// <param name="filePath"></param>
/// <param name="closeBook">保存前,是否关闭流。如果使用文件被当前excel打开,必须关闭才能写入</param>
/// <returns></returns>
public static bool Save(this IWorkbook book, string filePath, bool closeBook)
{
    if (string.IsNullOrWhiteSpace(filePath))
        throw new ArgumentNullException(nameof(filePath));
    using (MemoryStream ms = new MemoryStream())
    {
        book.Write(ms);
        if (closeBook)
        {
            book.Close();
        }
        var directory = Directory.GetParent(filePath);
        if (!directory.Exists)
        {
            directory.Create();
        }
        //不要使用OpenCreate形式,如果原始文件存在,可能出现需要修复的错误
        using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
        {
            byte[] data = ms.ToArray();
            fs.Write(data, 0, data.Length);
            fs.Flush();
        }
        return true;
    }
}
        - 创建单元格时,直接设置Cell的公式,然后随即取值可能不生效,需要手动触发计算
 
            
            
              cs
              
              
            
          
          HSSFFormulaEvaluator e = new HSSFFormulaEvaluator(iworkbook);
 var icell = e.EvaluateInCell(icell);
        - 行高和列宽问题
 
1.行高
Height 属性后面的值的单位是:1/20个点,所以要想得到一个点的话,需要乘以20。
HeightInPoints后面的单位是点,可以不用乘。
sheet.DefaultRowHeight = 23*20;
2.列宽
SetColumnWidth方法里的第二个参数要乘以256,因为这个参数的单位是1/256个字符宽 度,所以要乘以256才是一整个字符宽度。
sheet.SetColumnWidth(0, 15*256);
但是这个计算出来的值并不是excel实际的类款有偏差,这个没有再深入的了解,涉及的 列宽单位应该也是PT形式的,按字符计算可能从本质上将就是不对的。
pt:英文中的磅值,自号中的那个数字也是这个意思。
参考资料:px,pt,em换算表 | 菜鸟教程 (runoob.com)
- 列索引转成列名形式(A,B,C形式)这个可能在编辑公式时用的到
 
            
            
              cs
              
              
            
          
          /// <summary>
  /// 列索引转列名,索引从0开始计算
  /// </summary>
  /// <param name="columnIndex"></param>
  /// <returns></returns>
  public static string GetColumnName(int columnIndex)
  {
      columnIndex = Math.Max(0, columnIndex);
      int calc = columnIndex + 1;
      int system = 26;
      StringBuilder sb = new StringBuilder();
      do
      {
          calc--;
          int mod = calc % system;
          int div = calc / system;
          sb.Insert(0, (char)(mod + 'A'));
          calc = div;
      } while (calc > 0);
      return sb.ToString();
  }
        - ICSharpCode.SharpZipLib报错:
 
NPOI引用ICSharpCode.SharpZipLib对文件进行压缩处理,该压缩库做为很多第三方库的基础组件,所需要需要特别注意版本的一致性
当文件进行了加密处理时,同样会报与ICSharpCode.SharpZipLib相关的错误,下图是一个原始错误形式:这个错误困扰了我很久
Wrong Local header signature: 0x65231462: 在 ICSharpCode.SharpZipLib.Zip.ZipInputStream.GetNextEntry()
后来发现是我研发环境下的电脑会自动加密文件导致文件读取识别不了的,同样,读有损坏的文件应该也会报这个错。
- 替代产品
 
由于NPOI类库使用较广,在进行二次开发的项目时如果和其他插件引用的NPOI库冲突了,是一个很麻烦的事情,尤其是当自身开发的软件产品地位不如对方时,就需要咱们做出妥协。要么和别人保持一致的版本,要么替换一个不太常见的库。EPPlus是一个选择,当然还有其它选择,这里只是把这类问题抛砖引玉。