1. 先把这个需求拆到骨头里
1.1 信创环境到底特殊在哪
收到一个挺典型的信创需求:一套跑了十年的 ASP.NET WebForm 老系统,现在要迁移进信创环境,业务上有个硬需求——用户要能一次上传整个文件夹,文件里有大体积的设计图纸、扫描件和视频素材,合计能到几个GB,传输过程要求加密,还得把这些能力封装成控件,让页面像拖拽普通控件一样直接用。这个标题看着像是一句话需求,实际拆开来全是硬骨头。
先别急着写代码,得搞清楚"信创环境"对这套 WebForm 系统到底意味着什么。信创改造落到实处,机房呈现的特征通常是这样的:服务器从传统的通用x86平台换成国产CPU平台,操作系统从 Windows Server 变成国产Linux发行版;客户端从 Windows 加某款浏览器,变成国产操作系统加国产浏览器;数据库、中间件逐步替换为国产产品;安全合规层面的要求明显收紧,对传输加密和密码算法都有明确约束。
这个环境跟传统的"Windows + IIS + SQL Server + 浏览器"组合相比,最大的变化不在后端语言,而在运行载体。.NET Framework 本身是 Windows 的产物,WebForm 又强依赖 IIS 的请求管道,天然跟 Linux 隔着一条河。这条河怎么过,是后面所有设计的前提。我在实际项目里见过三种过河方式:第一种,服务器端保留 Windows Server,很多信创项目对服务器端的国产化进度是有梯度的,业务系统不强制立刻迁到国产操作系统,只要CPU平台合规即可,这时候 WebForm 该跑还是能跑;第二种,客户端全面国产化,服务器端逐步国产化,这是当前最常见的一种形态,业务不变,登录终端换成国产操作系统加国产浏览器;第三种,服务器端直接换成国产Linux,用兼容层硬跑 WebForm,这条路我劝你趁早打消念头,性能损失大、坑多到数不清,属于最后手段。三种方式里,第二种占比最高,也是下文方案的主战场。
1.2 为什么 .NET WebForm 还大量存在
这其实是个经济账。WebForm 的拖控件开发模式虽然被不少人吐槽,但它当年确实解决了一大批"快速搭建业务表单系统"的需求,沉淀下来的业务逻辑、存储过程、页面交互少说也有十几年。重构一套核心业务系统,光业务梳理就要一两年,而信创改造往往有明确的时间窗口,所以"在原有 WebForm 技术上做适应性和功能增强"成为绝大多数单位的选择。
带来的技术后果是,你必须在一个老架构里加入一个"新式"的大附件加密上传能力。这块没法靠传统的<asp:FileUpload>控件解决——那控件面对几百MB文件基本就是全员超时、内存爆掉、请求被IIS直接掐断,更别提文件夹选择和加密了。FileUpload 控件的底层是表单整包提交,文件一进内存再写磁盘,大文件场景下服务器内存瞬间被打满,ASP.NET 的工作进程直接挂掉都是常事。所以这个需求本质上不是"给老控件打个补丁",而是"在老系统里植入一套新的传输引擎"。
1.3 需求核心拆解
把标题拆开看,其实是五个能力叠加:大文件,单文件超过几百MB甚至到GB级;文件夹,一次勾选整个目录,递归上传子目录并保持目录结构;加密传输,数据在网络上以密文形态传输,不是裸跑HTTPS就完事,应用层还要有加密逻辑;封装成控件,业务页面引用时不关心底层细节,设置几个属性就能用;信创适配,在国产操作系统、国产浏览器环境下表现一致。
这五个能力不是五个独立模块,它们互相牵扯。"大文件+文件夹"决定了你没法用传统的表单整包提交,必须走分片。"加密传输"决定了你不能直接把原始文件内容扔到请求体里。"封装成控件"决定了前端脚本和后端处理必须内聚成一套可交付的组件,而不是散落在各个页面里重复粘贴。我见过一个反面案例,有团队把这些逻辑直接写在某个页面的代码后置文件里,结果第二个页面要用时只能复制粘贴,后面加密算法一升级,七八个页面挨个改,改漏一个就出安全问题。所以组件化封装不是可选项,是必须项。
2. 整体技术方案选型与架构设计
2.1 加密传输的两条路线怎么选
加密不是只有一种做法。方案梳理阶段我列过两条路线。路线A:依赖HTTPS做链路加密,应用层不处理。优点是实现成本最低,IIS上装好证书、配强制HTTPS跳转就行。缺点是数据到了服务端落地时是明文,一旦合规评审提出"存储安全"要求就过不了关;而且不少内网环境的证书管理比较混乱,浏览器频繁报不受信,用户体验很差。路线B:HTTPS打底,应用层再做国密加密。前端用SM4对文件分片做对称加密,用SM2完成密钥协商或信封加密,SM3做完整性校验,服务端收到密文后解密再落盘。优点是既能防链路窃听,又能保证数据在服务端不落地为明文,符合国产密码算法的应用要求。缺点是要在前后端同时引入密码算法库,加密解密有CPU开销。
我最后选的是路线B。理由是这类业务系统面对的评审和合规检查越来越严格,明文存储迟早会被挑战,与其后续返工,不如一步到位。性能这块不用太担心,SM4是分组算法,加解密速度足够快,我实测单线程能做到几百MB每秒的量级,对分片上传场景不是瓶颈。真正需要花心思的是密钥怎么管理、IV怎么传、摘要怎么校验,这些细节比算法本身更容易出错。
2.2 大附件上传为什么必须分片
确认过需求后,"分片上传+断点续传"基本没有替代方案。理由很直接:WebForm传统整包上传会触发 httpRuntime 的 maxRequestLength 限制,IIS 的 maxAllowedContentLength 也会拦截超大请求体;浏览器和服务端内存双双吃紧,整包上传一个大文件时可能同时吃掉客户端几个GB内存和服务端同样量级的内存;没有断点续传的情况下,网络抖一下,几个GB从头再来,用户心态直接崩。
分片方案的核心思路是:前端把文件按固定大小切成若干块,比如每片8MB;每片单独发起一个POST请求,服务端接收后先写到以"会话标识+文件标识+分片序号"命名的临时文件;全部片传完后,服务端按序号合并,做完整性校验,生成最终文件;断点续传靠前端记录"已上传分片列表"实现,再次上传时只传缺失的片。这个方案把"一个大请求"拆成"几十个小请求",单请求体量可控、内存可控,而且天然支持并发上传提升吞吐。缺点是多了一次合并逻辑,以及需要管理分片临时文件的清理,这些在实现时都要设计到位。
2.3 控件的封装形态怎么定
WebForm 里封装能力有两种主流形态:自定义服务器控件(继承 WebControl 或 CompositeControl)和用户控件(.ascx)。这个场景我强烈建议用自定义服务器控件。原因有三:自定义控件编译进一个独立程序集,交付物就是"DLL+脚本资源",业务系统引用后即可使用,适合跨项目复用;自定义控件可以把前端脚本、样式、隐藏域、回调逻辑全部内聚,页面里看起来就是一行标签;用户控件依赖具体站点的目录结构,迁移和分发都不方便。
控件内部结构上,我用的是"服务端控件外壳+前端组件内核"的做法。服务端控件负责输出容器div、注册脚本资源、配置初始参数;前端则是一个独立的JavaScript对象,负责文件夹遍历、分片、加密、并发上传、进度展示。两者通过隐藏字段和专用 HttpHandler 通信。这样前端逻辑不跟 WebForm 生命周期强耦合,以后就算把整套逻辑搬到别的技术栈也能直接复用这套前端代码。
2.4 环境兼容性矩阵
信创环境的兼容性不能拍脑袋,建议先列一个矩阵逐项验证。我当时的验证表长这样:
| 环节 | 传统环境 | 信创环境 | 关注点 |
|---|---|---|---|
| 运行载体 | Windows Server + IIS | Windows Server(部分保留)/ 国产Linux | WebForm运行方式 |
| 客户端OS | Windows 7/10 | 国产操作系统 | 浏览器内核支持情况 |
| 浏览器 | Chrome/IE11 | 国产浏览器(Chromium内核为主) | File API、slice、webkitdirectory |
| 算法库 | 国际算法为主 | 需支持SM2/SM3/SM4 | 国密算法实现 |
| 存储 | 本地磁盘 | 本地磁盘/国产存储 | 临时文件与最终落盘 |
这个矩阵给出的结论是:只要服务器端还能跑在 Windows + IIS 上,WebForm 本身不用换平台;客户端浏览器只要是 Chromium 内核,H5 的文件API就都能用。真正需要较真的反而是"算法库在 .NET Framework 上对SM系列的支持程度"和"大文件读写性能",这两块要提前做技术验证。
3. 核心实现细节与关键代码
3.1 前端:文件夹遍历与分片读取
文件夹选择用的是 HTML5 的 webkitdirectory 属性,这是国产 Chromium 内核浏览器都支持的特性。加上 multiple 和 directory 两个开关,用户就能在文件选择框里直接勾选整个目录。
<input type="file" id="folderPicker" webkitdirectory multiple />选择之后通过 FileList 拿到所有文件,目录结构靠 webkitRelativePath 字段还原。这里有个新手容易踩的点:FileList 不是数组,必须转成数组才能用 map 之类的数组方法。
var files = Array.prototype.slice.call(folderPicker.files); files.forEach(function (f) { // f.webkitRelativePath 形如 "子目录/子目录/文件名.ext" });分片读取用 File.slice 方法。这里有一个很多人踩过的坑:不同版本 Chromium 内核的 slice 方法有过带前缀的时期,早期版本用 webkitSlice,现代版本统一用 slice,而且 API 签名在新旧版本中有差异,老版本用 begin/end,新版本用 start/end。为了兼容,推荐这样封装:
function sliceFile(file, start, end) { if (file.slice) return file.slice(start, end); if (file.webkitSlice) return file.webkitSlice(start, end); // 做兜底 return null; }分片大小的选择也有讲究。我实测对比过几组参数:1MB 太小,请求数量暴增,每个请求都有HTTP握手开销,整体吞吐上不去;64MB 又太大,单请求内存峰值过高,浏览器端容易卡顿,服务端接收时内存压力也大;8MB 是一个比较稳的平衡点,请求数量可控,内存峰值可控,配合并发上传能跑出不错的吞吐。这个值可以做成控件属性,不同项目按服务器磁盘性能微调。
3.2 加密环节:SM4分片加密与SM2密钥信封
加密这层的设计是"SM4对称加密数据+SM2非对称加密会话密钥"。整体流程如下:
- 前端在初始化时请求服务端接口获取 SM2 公钥,并生成一个随机的 SM4 会话密钥(16字节);
- 每个文件分片用 SM4 密钥加密,加密后的分片附上分片序号;
- 会话密钥用 SM2 公钥加密后随首个分片一起发送,服务端用 SM2 私钥解开得到 SM4 密钥;
- 后续分片直接用 SM4 密钥解密;
- 文件所有分片到达后,服务端计算 SM3 摘要,与前端上传时携带的摘要比对,校验完整性。
这样设计的核心目的有两个:一是保证密钥不裸奔,二是每个文件使用独立的会话密钥,就算某个文件泄密也不会波及其他文件。大家常说的"信封加密"就是这个意思,数据用对称密钥加密效率高,密钥用非对称加密传递安全性好。
代码层面,.NET Framework 直接写SM算法不现实,我用的 BouncyCastle 的 C# 版本,它有 SM2、SM3、SM4 的完整实现,在 .NET Framework 4.x 下运行稳定。前端加密这块有个实际经验:如果能用 Web Crypto API 当然优先用,原生性能好,但 Web Crypto 对SM系列算法没有内置实现,所以前端必须用纯 JavaScript 实现的 SM4 库。纯JS 的 SM4 加解密速度比原生Crypto慢不少,实测 8MB 分片加密在普通办公电脑上大约要几百毫秒到一秒。
这里有个性能优化的关键思路:把"加密"和"上传"做成流水线,别串行等着。一个分片在加密的时候,另一个已完成加密的分片正在传输。如果串行执行,CPU 加密期间网络是空闲的,网络传输期间 CPU 是空闲的,整体吞吐直接腰斩。流水线化之后,加密和传输可以重叠,吞吐能明显提升。
3.3 服务端:专用 HttpHandler 接收与合并
WebForm 页面本身不适合处理上传分片,页面生命周期太重,每个分片请求都走一整套页面管线纯属浪费。正确做法是单独写一个 HttpHandler,用 .ashx 文件暴露上传接口。
核心接口设计如下:
- POST /upload.ashx?action=init:初始化上传会话,返回会话ID、SM2公钥、过期时间;
- POST /upload.ashx?action=chunk&sessionId=xxx&fileId=xxx&index=0:上传单个分片;
- POST /upload.ashx?action=complete&sessionId=xxx&fileId=xxx&digest=xxx:所有分片传完后通知服务端合并;
- GET /upload.ashx?action=progress&sessionId=xxx&fileId=xxx:查询已接收分片列表,供断点续传使用。
分片接收的核心代码如下:
public class UploadHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { string action = context.Request["action"] ?? "chunk"; switch (action) { case "init": DoInit(context); break; case "chunk": DoChunk(context); break; case "complete": DoComplete(context); break; case "progress": DoProgress(context); break; default: context.Response.StatusCode = 400; break; } } private void DoChunk(HttpContext context) { string sessionId = context.Request["sessionId"]; string fileId = context.Request["fileId"]; int index = int.Parse(context.Request["index"]); // 读取分片密文 byte[] encryptedBlock; using (var ms = new MemoryStream()) { context.Request.InputStream.CopyTo(ms); encryptedBlock = ms.ToArray(); } // 用会话对应的SM4密钥解密得到明文分片 byte[] plainBlock = Sm4Helper.Decrypt(encryptedBlock, GetSessionKey(sessionId)); // 落盘为临时片段文件,命名规则很关键 // "{sessionId}_{fileId}_{index:D6}.part" // D6格式化保证合并时字典序排序不会乱 WritePartFile(sessionId, fileId, index, plainBlock); context.Response.Write("ok"); } }有几个关键细节必须说清。第一,读取请求体时要用Request.InputStream,不要用Request.Form或Request.Files,因为前面带了 query string 参数,身体是原始二进制流,用表单解析器会把数据搞坏。第二,写分片文件时建议直接用 FileStream,不要在内存里攒完再写,分片本来就该边收边写。第三,分片文件名里的序号必须用固定位数格式化,这是合并时顺序正确的保障,后面我会专门讲这个坑。
合并逻辑实现:
private void DoComplete(HttpContext context) { string sessionId = context.Request["sessionId"]; string fileId = context.Request["fileId"]; string clientDigest = context.Request["digest"]; // 1. 找到该文件的part目录 string partDir = GetPartDir(sessionId, fileId); // 2. 按序号排序合并 var partFiles = Directory.GetFiles(partDir, "*.part") .OrderBy(f => f, StringComparer.OrdinalIgnoreCase); using (var finalStream = new FileStream(GetFinalPath(fileId), FileMode.Create, FileAccess.Write)) { foreach (var part in partFiles) { using (var fs = File.OpenRead(part)) { fs.CopyTo(finalStream); } } } // 3. 计算SM3摘要并比对 string serverDigest = Sm3Helper.HashFile(GetFinalPath(fileId)); if (serverDigest != clientDigest) { // 校验失败,删除合并文件,返回错误状态 } // 4. 删除part临时文件 Directory.Delete(partDir, true); // 5. 记录文件元数据:原始相对路径、大小、时间等 }合并这块还要注意:如果前端传破了(开多线程并发请求,个别分片乱序到达),服务端无需处理乱序,因为落盘就是按分片序号独立写入的,合并时才统一排序。这里考验的是文件系统IO性能,part 文件非常多时,建议按会话ID哈希散列到多个子目录,避免单个目录下文件数过多导致索引变慢。
3.4 控件封装:把上传能力打包成一行标签
自定义服务器控件的设计目标,是让业务页面能这样使用:
<my:EncryptedUploadControl runat="server" ID="uploader1" UploadApiUrl="/upload.ashx" ChunkSize="8388608" Algorithm="SM4" AllowFolder="True" MaxConcurrentUploads="3" OnUploadComplete="uploader1_UploadComplete" />控件类继承 WebControl,重写 Render 方法输出容器和初始化脚本:
public class EncryptedUploadControl : WebControl { public string UploadApiUrl { get; set; } public long ChunkSize { get; set; } = 8 * 1024 * 1024; public bool AllowFolder { get; set; } = true; protected override void Render(HtmlTextWriter writer) { writer.Write("<div id=\"" + ClientID + "\" class=\"enc-uploader\"></div>"); // 注册嵌入式JS资源 Page.ClientScript.RegisterClientScriptResource( typeof(EncryptedUploadControl), "MyControls.Uploader.js"); // 传递初始配置 var config = new { containerId = ClientID, apiUrl = UploadApiUrl, chunkSize = ChunkSize, allowFolder = AllowFolder }; string jsonConfig = new JavaScriptSerializer().Serialize(config); Page.ClientScript.RegisterStartupScript( GetType(), ClientID + "_init", "new EncryptedUploader(" + jsonConfig + ");", true); } }一个容易忽略的细节:嵌入式JavaScript资源文件必须把实际编译属性设为"嵌入的资源",并且资源名的命名空间要跟 typeof 所在的程序集命名空间对得上,否则 RegisterClientScriptResource 会抛资源找不到的异常。这个坑我见过太多人踩了,明明JS文件就在项目里,一运行就报错,最后发现是资源名没对上。
前端组件启动后做三件事:渲染上传区域(支持拖拽文件夹)、遍历文件、创建加密上传任务队列。上传全部完成后,前端向页面发起一个异步回发调用,服务端控件检查本次会话是否有新上传完成,有则触发 OnUploadComplete 事件。这样业务开发写起来还是 WebForm 习惯的事件监听方式,底层细节完全被遮蔽。
4. 实操部署与关键配置
4.1 web.config 必须改的三处配置
WebForm 环境下,大请求体默认被两重限制卡死,部署时有几处必须配置到位:
<configuration> <system.web> <!-- maxRequestLength 单位是KB,这里设成2GB --> <httpRuntime maxRequestLength="2097152" executionTimeout="3600" enableVersionHeader="false" /> </system.web> <system.webServer> <security> <requestFiltering> <!-- maxAllowedContentLength 单位是字节,这里设成2GB --> <requestLimits maxAllowedContentLength="2147483648" /> </requestFiltering> </security> </system.webServer> </configuration>注意 maxRequestLength 单位是 KB,maxAllowedContentLength 单位是字节,两者单位不同,经常有人写错。前者管 ASP.NET 运行时,后者管 IIS 请求筛选器,任何一个没放开都会拦请求。另外 executionTimeout 也要调大,不然长时间上传时工作进程直接给你掐断。
4.2 信创客户端的兼容性适配
在国产操作系统加国产浏览器环境下,重点验证三块内容。第一,浏览器内核是否为 Chromium,是的话 webkitdirectory、File.slice、Blob、XMLHttpRequest 这些特性都可用;第二,证书问题,内网环境自签名证书经常在国产浏览器上不受信,建议申请可信证书或提前把根证书导入系统信任库,否则 HTTPS 都握不上手;第三,控件的UI不要依赖特定微软字体,用通用字体族,避免国产操作系统下字体缺失导致布局错乱。
4.3 临时文件的存储与清理策略
分片上传会产生大量 .part 临时文件,如果进程中途崩溃或用户中途放弃,这些文件会永久残留,跑上一个月磁盘会被残片塞满。我的建议是:临时目录独立配置,不跟业务文件目录混放;上传会话初始化时记录一个过期时间,比如2小时;每次新的会话初始化时顺带清理过期目录,或者用一个定时任务统一清理;文件合并成功后立即清理对应part文件目录。这个细节不做,线上早晚出事,别问我怎么知道的。
5. 常见问题速查与排查经验
5.1 问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 上传大文件时请求直接404 | IIS请求筛选器拦截 | 检查 maxAllowedContentLength |
| 分片上传几百个后明显变慢 | 临时文件过多,目录索引压力大 | 按会话ID哈希散列到多个子目录 |
| 合并后文件校验失败 | 并发上传时合并没有按序号排序 | 合并前必须先排序,序号用固定位数格式化 |
| 国产浏览器控件不渲染 | JS资源未随程序集嵌入 | 检查资源是否为嵌入资源、命名空间是否正确 |
| SM4解密后文件损坏 | 加密模式不一致或IV处理错误 | 统一用CBC/GCM模式,IV需随密文一起传输 |
| 断点续传后文件不完整 | 前端已传分片记录未持久化 | 用localStorage或服务端progress接口记录 |
| 上传过程中页面无响应 | 并发上传数过高导致浏览器忙 | 降低并发数,上传操作改成异步队列 |
5.2 几个印象深刻的坑
第一个坑是分片序号命名。最早我用"index:"加序号这种无固定位数的命名,合并时按字符串排序,第10片就排到了第2片前面,文件内容直接错乱。换成 {index:D6} 这种固定位数格式化后彻底解决。
第二个坑是加密模式。SM4 用 ECB 模式虽然简单,但相同明文会得到相同密文,分片大小固定时很容易泄露文件内容规律。必须用 CBC 或 GCM 模式,GCM 还能顺带解决完整性校验,但旧内核浏览器不支持时就只能退到 CBC 加 SM3 摘要的方案。
第三个坑是并发上传数。一开始为了图快把并发开到8,结果服务端磁盘随机写压力太大,整体吞吐反而下降。后来压测了几组参数,3到4个并发是吞吐和稳定性的平衡点,超过4就会开始出现分片超时重传。这个数值跟服务器磁盘性能强相关,建议部署时用真实文件压测一轮来确定。
第四个坑是 IIS 应用程序池回收。默认的定期回收可能导致长时间上传过程中会话中断。要把应用程序池的"固定时间间隔回收"调大,或者确认回收动作不会发生在业务高峰期。否则用户传了半小时,一个回收操作把会话清掉,所有分片进度归零,这种反馈会让人当场崩溃。
6. 一点个人体会
做这类信创适配,最大的感受是方案设计阶段多想的每一分钟,都能省下实施阶段的一天。加密方式、分片大小、并发数、临时文件生命周期,这些决策在前期想清楚,后面就是按部就班写代码。真正让人崩溃的从来不是算法本身,而是环境差异——国产浏览器对H5特性的支持细节、IIS的隐性限制、嵌入式资源的命名空间、分片命名的排序规则,这些坑每个都踩得值。
最后再分享一个小技巧:这套控件的调试阶段,一定要在真实的信创终端上过一遍完整流程,别只在 Windows 的 Chrome 里测完就上线。我遇到过一次很诡异的情况,某款国产浏览器对同一个 XMLHttpRequest 请求在并发时表现和 Chrome 不一样,单看每个功能点都对,组合起来就是出问题。提前准备一台信创终端做回归,能省掉上线后大量的沟通成本。如果后续要把这套能力继续扩展,我建议重点关注两件事:一是把 SM4 替换成利用国产CPU密码指令集的硬件加速方案,加解密性能会有数量级提升;二是把上传 Handler 独立成可服务化部署的模块,跟 WebForm 站点解耦,为之后的跨平台迁移留好退路。这套思路在当下的信创项目里,应该能帮大家少走不少弯路。