
前阵子接了个内部需求用VS2015写一个C#的工单管理小系统数据放在SQL Server里界面是WinForm。业务方的要求很朴素——筛选出结果后能导出一份格式工整的Excel报表并且能直接从系统里打印出来。听起来全是基本功实际动手才发现从数据库取数、DataTable中转、NPOI写Excel到PrintDocument分页打印这条链路上的细节足够写一篇长文。这篇就把我这次项目里每一步的选型理由、完整代码和踩过的坑都整理出来给同样在VS2015下做C#数据库开发、Excel报表导出与打印功能的朋友一个参考。1. 这类内部工具需求为什么绕不开VS2015C#1.1 还在用VS2015的真实场景很多人可能觉得VS2015已经是老古董了新项目谁还用这个。但实际在中小企业、制造业、工厂内部系统这个圈子里VS2015的存量项目比例相当高。有的是因为历史项目一直维护到现在有的是因为客户现场只装了对应版本的运行库还有的是团队技术栈固定在了.NET Framework 4.x上没必要为了一个内部小工具去升级整个IDE。这个工单管理系统就是典型的内部工具使用人数不超过二十个部署在局域网内一台服务器上数据量不大不小——一年几万条工单记录。VS2015加C#的WinForm做这种项目非常合适开发现场环境简单发布部署也容易一个安装包打出来就能用。C#操作数据库的生态极其成熟哪怕不引第三方库光用ADO.NET就能把增删改查写得明明白白。项目本身的功能并不复杂工单录入、状态查询、按条件筛选、结果展示在DataGridView里。但业务方提了两个要求一是导出Excel二是打印。这两个要求把项目从简单CRUD拉高到了报表功能的层次也让我在技术选型上认真考虑了一番。1.2 数据库访问技术路线怎么选在VS2015时代C#访问数据库的主流路线有三条原生ADO.NETSqlConnection、SqlCommand、SqlDataAdapter这套代码量大一点但完全可控不依赖任何第三方包。Dapper轻量ORM封装了SQL执行和结果映射写起来舒服但对报表场景帮助不大。Entity Framework重量级ORM适合复杂业务模型但引入负担重在VS2015的老项目里容易被嫌弃。我的建议很明确如果核心需求是查询数据并展示/导出/打印就用ADO.NET加DataTable。理由很简单后续的Excel导出和打印都需要表格化的数据结构DataTable天然就是一张二维表行列结构清晰无论是填充DataGridView还是遍历输出都非常顺手。用List加反射虽然也能做但在导出Excel、打印排版这种场景下会多绕好几道弯。1.3 Excel导出与打印的方案选型Excel导出这块市面常见的方案有三种Microsoft.Office.Interop.Excel直接调Excel COM组件。功能最强但要求目标机器安装Office而且速度慢、容易残留Excel进程部署在服务器上更是麻烦。NPOI开源免费不依赖Office性能好能读写xls和xlsx。语法稍微绕一点但胜在干净利落。EPPlus也是一个好用的库但后来商业授权收紧免费版有功能限制不适合企业内部工具随便用。打印这块方案也分几种PrintDocument.NET自带的打印组件用GDI自己画内容。灵活、可控、不依赖任何第三方控件。Crystal ReportsVS自带的水晶报表功能强但配置繁琐在老版本里还容易出兼容问题。第三方报表控件比如FastReport、Grid功能强大但收费对接也需要学习成本。这个项目里我最终选的是ADO.NET DataTable NPOI PrintDocument的组合。整个链路没有任何商业授权问题部署到客户机器上也不需要额外安装Office一个.NET Framework运行库就够了。这也是我个人做内部工具类项目时的首选组合。2. 数据库取数的正确姿势把数据源变成DataTable2.1 连接串放对地方避免环境切换的坑VS2015项目的连接串建议放在App.config的connectionStrings节点里用ConfigurationManager读取。一定不要硬编码在代码里否则换一台机器部署就要重新编译非常蠢。connectionStrings add nameconnStr connectionStringData Source192.168.1.100;Initial CatalogWorkOrderDB;User IDsa;Password123456; providerNameSystem.Data.SqlClient / /connectionStrings读取的时候注意要引用System.Configuration程序集using System.Configuration; public static class DbConfig { public static string ConnStr ConfigurationManager.ConnectionStrings[connStr].ConnectionString; }这里有个小细节如果项目属性里没有自动引用System.Configuration需要手动添加。VS2015新建的类库或WinForm项目默认不一定引用它编译报找不到ConfigurationManager的时候就先去引用程序集。2.2 参数化查询不是可选是必选写数据库操作时参数化查询是底线。不要为了省事去拼接SQL字符串哪怕是内部系统也一样。万一有同事拿这个工具导出个含有特殊字符的工单编号拼接SQL轻则报错重则被注入。参数化写法没有想象中复杂string sql SELECT * FROM WorkOrders WHERE CreateDate startDate AND CreateDate endDate; SqlParameter[] parameters new SqlParameter[] { new SqlParameter(startDate, startDate), new SqlParameter(endDate, endDate) };有人可能会问既然都是内部项目数据库账号密码都写在配置里了还有必要防注入吗我的回答是防止SQL语句语法错误本身就是参数化的最大好处之一。如果工单内容里出现单引号、中文引号、百分号参数化方式完全不受影响而字符串拼接出来的SQL很可能直接炸掉。2.3 通用封装一处写好处处复用这个项目里我写了一个很轻量的DbHelper没有引入任何第三方ORMusing System.Configuration; using System.Data; using System.Data.SqlClient; public static class DbHelper { public static string ConnStr ConfigurationManager.ConnectionStrings[connStr].ConnectionString; public static DataTable ExecuteDataTable(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(ConnStr)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) { cmd.Parameters.AddRange(parameters); } SqlDataAdapter adapter new SqlDataAdapter(cmd); DataTable dt new DataTable(); adapter.Fill(dt); return dt; } } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(ConnStr)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteNonQuery(); } } } }这里有个很多人忽略的点SqlDataAdapter的Fill方法会自动打开和关闭连接不需要显式调用conn.Open()。这在读操作里非常方便代码也干净。ExecuteNonQuery因为是写操作才需要显式Open。返回值用DataTable而不是List在导出报表的场景里非常合理。DataTable可以直接绑给DataGridView也可以直接作为NPOI导出和PrintDocument打印的数据源一套数据模型贯穿整个流程省去了对象转换的麻烦。如果你用的是MySQL而不是SQL Server只需要把SqlConnection、SqlCommand、SqlDataAdapter、SqlParameter换成MySql.Data.MySqlClient里的对应类逻辑完全一样。VS2015时代这类工具的写法几乎是通用模板。3. NPOI导出Excel最省事的报表落地方案3.1 安装与版本选择NPOI在Visual Studio里直接用NuGet安装就行。VS2015的NuGet包管理器虽然旧但装NPOI没有问题。需要留意的坑是版本差异。NPOI 1.x的老API命名空间是NPOI.HSSF.UserModel和NPOI.XSSF.UserModel2.x之后统一推荐使用NPOI.SS.UserModel接口面向编程。我这里用的是2.4.x或2.5.x建议新项目直接上2.5系列的稳定版API更规整对xlsx的支持也更好。3.2 完整导出方法从DataTable到Excel下面这段是我在这个项目里提炼出来的通用导出方法直接复制到你的项目里就能跑using System; using System.Data; using System.IO; using System.Text; using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using NPOI.HSSF.UserModel; public static class ExcelExporter { public static void Export(DataTable dt, string filePath) { IWorkbook workbook filePath.EndsWith(.xlsx, StringComparison.OrdinalIgnoreCase) ? new XSSFWorkbook() : new HSSFWorkbook(); ISheet sheet workbook.CreateSheet(报表); // 表头样式 ICellStyle headerStyle workbook.CreateCellStyle(); headerStyle.FillForegroundColor IndexedColors.LightBlue.Index; headerStyle.FillPattern FillPattern.SolidForeground; headerStyle.BorderTop BorderStyle.Thin; headerStyle.BorderBottom BorderStyle.Thin; headerStyle.BorderLeft BorderStyle.Thin; headerStyle.BorderRight BorderStyle.Thin; headerStyle.VerticalAlignment VerticalAlignment.Center; IFont headerFont workbook.CreateFont(); headerFont.FontName 微软雅黑; headerFont.IsBold true; headerFont.FontHeightInPoints 11; headerStyle.SetFont(headerFont); // 正文样式 ICellStyle bodyStyle workbook.CreateCellStyle(); bodyStyle.BorderTop BorderStyle.Thin; bodyStyle.BorderBottom BorderStyle.Thin; bodyStyle.BorderLeft BorderStyle.Thin; bodyStyle.BorderRight BorderStyle.Thin; IFont bodyFont workbook.CreateFont(); bodyFont.FontName 微软雅黑; bodyFont.FontHeightInPoints 10; bodyStyle.SetFont(bodyFont); // 表头行 IRow headerRow sheet.CreateRow(0); headerRow.Height 22 * 20; for (int i 0; i dt.Columns.Count; i) { ICell cell headerRow.CreateCell(i); cell.SetCellValue(dt.Columns[i].ColumnName); cell.CellStyle headerStyle; } // 数据行 for (int r 0; r dt.Rows.Count; r) { IRow row sheet.CreateRow(r 1); row.Height 18 * 20; for (int c 0; c dt.Columns.Count; c) { ICell cell row.CreateCell(c); object value dt.Rows[r][c]; if (value null || value DBNull.Value) { cell.SetCellValue(string.Empty); } else if (value is DateTime dtValue) { cell.SetCellValue(dtValue.ToString(yyyy-MM-dd HH:mm:ss)); } else if (value is int || value is long || value is double || value is float || value is decimal) { cell.SetCellValue(Convert.ToDouble(value)); } else { cell.SetCellValue(value.ToString()); } cell.CellStyle bodyStyle; } } // 列宽自适应中文按双字节估算 for (int i 0; i dt.Columns.Count; i) { int maxLen 0; int headerLen Encoding.Default.GetByteCount(dt.Columns[i].ColumnName); if (headerLen maxLen) maxLen headerLen; for (int r 0; r dt.Rows.Count; r) { object val dt.Rows[r][i]; if (val null || val DBNull.Value) continue; int len Encoding.Default.GetByteCount(val.ToString()); if (len maxLen) maxLen len; } int width (maxLen 2) * 256; if (width 255 * 256) width 255 * 256; sheet.SetColumnWidth(i, width); } // 冻结表头 sheet.CreateFreezePane(0, 1); using (FileStream fs new FileStream(filePath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } workbook.Close(); } }这里最值得细说的有两点。第一日期类型的处理。我直接把DateTime转成字符串写入看起来丢掉了Excel日期格式但实际是避免了NPOI里日期显示成数字的坑。如果你在代码里cell.SetCellValue(dateTimeValue)NPOI存进去的是一个OADate数值虽然本质是日期但Excel默认格式可能显示成43000.12345这种天书。要么给单元格设置DataFormat要么干脆用字符串。对于报表场景格式美观比计算能力更重要所以我选择字符串。第二自动列宽。NPOI自带的sheet.AutoSizeColumn在中文环境下经常抽风算出来的列宽要么过宽要么过窄。我这里的做法是用Encoding.Default.GetByteCount来估算字符占位宽度中文字符按双字节算这样列宽基本符合预期。3.3 调用示例和导出体验实际调用非常简单string sql SELECT * FROM WorkOrders WHERE Status status; SqlParameter[] parameters new SqlParameter[] { new SqlParameter(status, 已完成) }; DataTable dt DbHelper.ExecuteDataTable(sql, parameters); string filePath D:\导出\工单报表_ DateTime.Now.ToString(yyyyMMdd_HHmmss) .xlsx; ExcelExporter.Export(dt, filePath); MessageBox.Show(导出成功 filePath);导出体验上NPOI对几千行、一两万行的数据量完全无压力基本秒出。比起COM方式调用Excel动辄停顿两三秒NPOI的响应速度让人舒服得多。4. 打印流程设计从PreviewDialog到真实出纸4.1 PrintDocument的工作机制.NET的打印核心是PrintDocument组件它的关键点不在Print()方法而在于PrintPage事件。这个事件的触发时机是每打印一页纸触发一次你在事件处理函数里把本页内容画到Graphics对象上然后设置HasMorePages告诉系统后面还有没有内容。有点像自己排报纸版面——系统先问你要第一页你画完说要继续系统再问你要第二页直到你说不要了。这个机制的好处是灵活完全掌控打印内容坏处是分页逻辑必须自己算算错了就会出现打印一半内容或者多出空白页。4.2 一个能用的分页打印类下面是我在这个项目里用的打印类支持自动分页、每页重复表头、首页标题、预览using System; using System.Data; using System.Drawing; using System.Drawing.Printing; using System.Windows.Forms; public class ReportPrinter { private PrintDocument _printDocument new PrintDocument(); private DataTable _data; private int _startRow 0; private readonly Font _titleFont new Font(宋体, 16f, FontStyle.Bold); private readonly Font _headerFont new Font(宋体, 10f, FontStyle.Bold); private readonly Font _bodyFont new Font(宋体, 10f, FontStyle.Regular); private readonly float _rowHeight 22f; private readonly float _titleHeight 40f; private readonly float _headerHeight 24f; public ReportPrinter(DataTable data) { _data data; _printDocument.PrintPage OnPrintPage; } // 默认横向打印适合报表 public ReportPrinter SetLandscape(bool landscape) { _printDocument.DefaultPageSettings.Landscape landscape; return this; } public void ShowPreview() { _startRow 0; PrintPreviewDialog dlg new PrintPreviewDialog(); dlg.Document _printDocument; dlg.ShowDialog(); } private void OnPrintPage(object sender, PrintPageEventArgs e) { Graphics g e.Graphics; float marginLeft e.MarginBounds.Left; float marginTop e.MarginBounds.Top; float marginBottom e.MarginBounds.Bottom; float contentWidth e.MarginBounds.Width; float x marginLeft; float y marginTop; // 标题只在第一页显示 if (_startRow 0) { string title 工单数据报表; SizeF titleSize g.MeasureString(title, _titleFont); float titleX marginLeft (contentWidth - titleSize.Width) / 2; g.DrawString(title, _titleFont, Brushes.Black, titleX, y); y _titleHeight; } // 列宽平均分配 int colCount _data.Columns.Count; float colWidth contentWidth / colCount; StringFormat centerFormat new StringFormat(); centerFormat.Alignment StringAlignment.Center; centerFormat.LineAlignment StringAlignment.Center; // 绘制表头每页都画 x marginLeft; for (int i 0; i colCount; i) { RectangleF rect new RectangleF(x, y, colWidth, _headerHeight); g.FillRectangle(Brushes.LightGray, rect); g.DrawRectangle(Pens.Black, rect.X, rect.Y, rect.Width, rect.Height); g.DrawString(_data.Columns[i].ColumnName, _headerFont, Brushes.Black, rect, centerFormat); x colWidth; } y _headerHeight; // 绘制数据行直到填满或数据用完 int rowIndex _startRow; bool hasMoreRows false; while (rowIndex _data.Rows.Count) { if (y _rowHeight marginBottom) { hasMoreRows true; break; } DataRow row _data.Rows[rowIndex]; x marginLeft; for (int col 0; col colCount; col) { RectangleF rect new RectangleF(x, y, colWidth, _rowHeight); g.DrawRectangle(Pens.Black, rect.X, rect.Y, rect.Width, rect.Height); string cellText row[col] DBNull.Value ? : row[col].ToString(); g.DrawString(cellText, _bodyFont, Brushes.Black, rect, centerFormat); x colWidth; } y _rowHeight; rowIndex; } _startRow rowIndex; e.HasMorePages hasMoreRows; if (!hasMoreRows) { _startRow 0; } } }这个类支持横向打印也保留了打印预览入口。需要注意的细节我拆开讲。分页逻辑的核心思路用一个成员变量_startRow记录当前页开始打印的行号。PrintPage每次被触发就从_startRow开始往下画画到marginBottom装不下时把_startRow更新为下一行并设置HasMorePages为true。当所有行画完把_startRow重置为0方便下次打印重新开始。标题只画在第一页通过_startRow是否等于0来判断。第一页开始时_startRow是0画完第一页后_startRow就不是0了所以从第二页开始自动跳过标题。这在打印多页报表时很自然也很重要否则每页顶部一个大标题内容区被挤得很小。表头要每页重复画完标题后紧接着画表头行。由于PrintPage每页都完整执行一次表头在每页顶部都会出现这也是报表打印的基本要求。4.3 打印预览和页面设置预览用PrintPreviewDialog页面设置用PageSetupDialog代码非常简洁ReportPrinter printer new ReportPrinter(dt) .SetLandscape(true); // 如果需要让用户调纸张、边距、方向 PageSetupDialog pageSetupDlg new PageSetupDialog(); pageSetupDlg.Document printer.GetDocument(); // 需要暴露PrintDocument if (pageSetupDlg.ShowDialog() DialogResult.OK) { printer.ShowPreview(); }实际使用中我先让用户点页面设置调整纸张方向和边距再点预览确认无误后点预览窗口里的打印按钮出纸。PageSetupDialog的好处是可以拿到用户调整后的设置因为它是直接修改PrintDocument的DefaultPageSettings。4.4 打印排版的细节心得几个在打印功能里容易踩的细节中文字体PrintDocument里绘制中文必须用宋体、微软雅黑这类中文字体。如果用默认字体或英文字体中文直接变成方块或者烂字符。这个在开发机上可能发现不了换到客户机器上才暴露。横向打印工单报表列数通常比较多如果数据列在6列以上纵向打印会挤成一团。我直接在初始化时SetLandscape(true)省去用户每次手动调方向的麻烦。单元格文字溢出如果某列内容很长平均列宽又有限DrawString默认不会自动换行长文字会超出边框。轻微的溢出可以在DrawString传RectangleF时配合StringFormat处理严重的话需要根据内容单独分配列宽。这个项目里工单备注一般不长平均分配够用就没有做更复杂的自适应。空白页问题如果PrintPage里判断页容量时y rowHeight刚好等于MarginBounds.BottomGDI的浮点精度可能让边界判断出现1像素偏差导致最后一页只画了一个空表头就触发下一页。稳妥做法是给底部留一点余量比如判断条件写成y _rowHeight marginBottom - 5。5. 实测过的坑与排查思路5.1 导出后Excel提示文件损坏第一次写完导出代码兴冲冲双击生成的文件Excel弹了个文件格式和扩展名不匹配的警告或者干脆提示文件已损坏。排查后发现是两个原因叠加。第一文件名后缀和Workbook类型不一致——代码生成了.xlsx后缀但workbook用的是HSSFWorkbook也就是xls格式的数据结构。NPOI的HSSF和XSSF是两套序列化格式写xlsx必须用XSSFWorkbook写xls必须用HSSFWorkbook不能混。所以我在导出方法里先判断后缀再创建对应workbook。第二文件流没有正确关闭。早期版本用workbook.Write(fs)之后直接return没有调用workbook.Close()。NPOI内部有缓存不Close可能导致部分数据没落盘文件结构不完整。现在代码里using包裹FileStream最后再workbook.Close()问题彻底消失。5.2 为什么不建议用Excel COM组件这其实是设计阶段就踩过的坑。最早我在项目里试过Microsoft.Office.Interop.Excel因为代码最直观把DataGridView一行行拷进Excel单元格就行了。但实际用起来遇到了三件烦心事。一是速度。两千行数据导ExcelCOM方式能让你盯着屏幕等三四秒。二是部署。客户机器必须装Office而且Office版本不同引用的Interop程序集兼容性就出问题。三是进程残留。程序崩溃或者用户强制关闭Excel进程会一直驻留在任务管理器里下次再调导出就会报名为的服务器返回错误之类的错或者直接卡死。后来换成NPOI这三个问题全部消失性能还提升了一个量级。所以我强烈建议凡是不需要用到Office宏、图表高级特性的导出一律用NPOI别碰COM。5.3 打印预览时凭空多出空白页这个问题折腾了半天现象是预览报表时最后总有一张空白页纸上只有一个孤独的表头。排查思路是这样的先确认数据行数不是刚好卡在一页边缘排除数据量问题。然后在PrintPage事件里加断点发现最后一次触发时while循环里y rowHeight等于marginBottom没有进入画行循环直接走到了e.HasMorePages赋值逻辑。原因就是前面提到的浮点精度。marginBottom是GDI算出来的像素值y经过多行累加后和marginBottom可能存在零点几像素的偏差导致判断提前进入装不下分支于是又触发了一次空白页。把判断条件改成留5像素余量空白页就消失了。5.4 Access数据库驱动与64位平台不兼容项目里有一部分数据来自一个老旧的Access文件需要在C#程序里读取。VS2015默认的平台目标如果是AnyCPU在64位系统上会以64位进程运行这时访问Access就有讲究了。老式的Microsoft.Jet.OLEDB.4.0驱动只能跑在32位进程里64位进程直接提示未找到提供程序。新式的Microsoft.ACE.OLEDB.12.0驱动虽然支持64位但要求机器必须安装对应的Access数据库引擎运行时而且还有x86/x64两种版本不能混装的问题。我的处理方案是凡是涉及Access数据读取的程序在VS2015的生成—平台目标里直接改成x86。WinForm程序本身没有64位需求强制用32位进程最稳妥一套代码通吃Windows 7到Windows 10。这招也避免了场景里常见的64位引擎不支持dbc数据只支持access数据这类驱动报错。5.5 DataGridView列顺序和DataTable不一致导致导出错位有个很隐蔽的坑差点让我在验收时翻车。用户在界面上把DataGridView里的列拖动了几次或者手动隐藏了某列然后点导出。结果导出来的Excel里列头还是原来的名字但对上的数据是别的列的内容整个报表错位。原因在于DataGridView显示列的顺序可以是用户自定义的而DataTable里的列顺序是固定的。如果直接把DataGridView的列名和DataTable的DataRow值混合使用导出时就会张冠李戴。解决办法是导出时完全不经过DataGridView直接从查询得到的DataTable里取数。DataGridView只作为展示导出永远用原始DataTable。这样无论用户在界面上怎么拖列、隐藏列导出的Excel都保持数据库字段顺序稳定可靠。5.6 空数据导出也要有交代还有一种情况查询结果一条记录都没有。如果直接调用导出方法NPOI会生成一个只有表头的Excel文件。业务方拿到这个文件可能会误以为导出出问题了。更友好的做法是在导出前判断DataTable.Rows.Count为空时弹一个提示问用户是要导出空表还是直接取消。这个小细节能减少不少沟通成本。DataTable dt DbHelper.ExecuteDataTable(sql, parameters); if (dt.Rows.Count 0) { DialogResult result MessageBox.Show(当前筛选条件下没有数据是否导出空报表, 提示, MessageBoxButtons.YesNo); if (result ! DialogResult.Yes) { return; } }写在最后的一点体会这个项目做完我的最大感受是VS2015加C#虽然在现在的技术圈里算不上新潮但做内部管理工具、上位机这类项目它的开发效率依然非常能打。数据库操作、Excel导出、打印这三块都是老技术、老API但老技术不等于没讲究。连接串怎么配置、DataTable为什么比List更适合做报表数据源、NPOI为什么比COM稳定、PrintDocument分页为什么要留边界余量这些细节单拎出来都不难但组合在一起就决定了项目是顺顺利利交付还是在验收前反复修修补补。如果你也正在做类似的功能我建议把上面这套代码直接存成一个种子库数据库封装、Excel导出、打印类三者分开维护。下次遇到报表需求十分钟就能拼出一个能跑的版本再根据业务方的具体格式要求去调样式。做内部工具就是这样基础能力沉淀好了后面每个新需求都能省出一两天的排错时间。