接手一个跑了七八年的老系统客户提的需求往往简单得让人放松警惕把每天的提交记录落到一个 TXT 文件里管理员后台能翻看也能下载一份归档。听起来是十分钟的活真动手才发现用 ASP 读写 TXT 文本文件这件事表面简单坑却密得很——编码不对中文全变问号目录权限没配直接甩一个 800A0046路径拼错了报文件找不到大文件一次性 ReadAll 把内存吃光多个请求同时追加还会写出半行乱码。这篇就把这些年用 ASP 读写 TXT 文本文件攒下来的东西一次讲透从 FileSystemObject 的对象模型到 UTF-8 编码的正确处理再到权限配置、并发规避和安全防护全程配能直接抄的代码。不管你是刚接手老项目的维护人员还是想彻底搞清楚 FSO 到底怎么回事的开发看完都能自己写出稳定可用的读写逻辑。1. 先搞清楚ASP 读写文本到底靠什么1.1 FileSystemObject 和 TextStream 的分工关系经典 ASP 本身没有内置的文件操作能力它靠的是 COM 组件。具体说是Scripting.FileSystemObject业内都简称 FSO这个对象它负责“文件系统层面”的事情判断文件在不在、删除文件、创建目录、返回文件大小和修改时间。真正跟文件内容打交道的是 FSO 派生出来的另一个对象TextStream它才拥有ReadAll、ReadLine、WriteLine这些方法。这个分工关系必须理清楚不然写代码的时候很容易乱。你可以把 FSO 想成图书馆的管理员它知道哪本书在哪个架子上、书还在不在、什么时候还回来而 TextStream 是那本被借到你手上的书本身你翻页、做笔记都是对它操作。所以要读一个文件标准流程是先CreateObject拿到管理员FSO用管理员打开那本书OpenTextFile拿回一个 TextStream 句柄然后对这个句柄做读写用完必须Close还回去。用代码表达就是这几行建议直接背下来% Dim fso, ts Set fso Server.CreateObject(Scripting.FileSystemObject) Set ts fso.OpenTextFile(Server.MapPath(/data/demo.txt), 1, False, -2) Dim content content ts.ReadAll ts.Close Set ts Nothing Set fso Nothing %这里有两个细节值得停下来想一想。第一为什么用Server.CreateObject而不是CreateObject在 ASP 里两者都能跑但Server.CreateObject创建的对象会参与页面生命周期管理页面结束时会自动释放CreateObject创建的则要你手动Set ... Nothing。老代码里混用的情况特别多我个人的习惯是统一用Server.CreateObject然后仍然显式关闭和释放双保险。第二为什么路径要套一层Server.MapPath因为 FSO 只认物理路径也就是D:\sites\app\data\demo.txt这种形式它不认识站点里的/data/demo.txt这种相对路径。Server.MapPath干的就是把虚拟路径翻译成物理路径的活。这一步漏了报错一定是“文件未找到”而且你会盯着一个明明存在的文件怀疑人生。1.2 三个必须记牢的参数含义OpenTextFile这个方法有四个参数签名是OpenTextFile(文件名, 打开模式, 是否允许新建, 格式)。这三个非路径参数是最容易记混的地方我按实际使用频率讲。参数位置名称常用值含义第 2 个打开模式 iomode1 / 2 / 81 只读2 覆盖写8 追加写第 3 个是否允许新建 createTrue / False仅对写模式有意义读模式传 False第 4 个格式 format-2 / -1 / 0-2 系统默认-1 Unicode0 ANSI打开模式这三个值对应的常量名分别是ForReading、ForWriting、ForAppending。注意ForWriting是覆盖文件原有内容会被清空不是追加。这一点坑过太多人本来想往日志里加一行结果用了 2历史记录全没了。要追加必须用 8。格式参数更微妙。-2表示用系统默认编码在中文 Windows 上通常就是 ANSIGBK 系-1是 UTF-16 小端带 BOM 的 Unicode0是纯 ASCII实际表现接近 ANSI。这里有个关键事实必须说清楚经典 ASP 的 FSO 根本不支持 UTF-8。你翻遍文档也找不到 UTF-8 的选项。这是后面编码章节要重点解决的问题。第三个参数“是否允许新建”只在写模式下需要认真考虑。读一个不存在的文件无论这个参数传什么都会报错所以读之前应该先用FileExists判断。写的时候传 True文件不存在就自动创建省掉一次判断很实用。1.3 为什么老项目还在用 TXT 而不是数据库这个问题我被问过不止一次。新入行的朋友看到用 TXT 存数据的第一反应是“这不对劲”但我劝你先别急着下结论很多场景下 TXT 反而是更合理的选择。最典型的是轻量配置。一个后台系统可能就三五个开关参数比如“是否开启审核”“每页显示多少条”“公告内容是什么”。为这个建一张表、写一套增删改查、再配一个管理界面成本明显过高。直接一个config.txt用keyvalue的格式每行一条读的时候按行解析写的时候整篇覆盖代码量不到五十行出了问题用记事本一打开就能看到全部内容排查成本极低。其次是日志和审计留痕。这类数据的特点是只追加、不修改、不查询写进去就是为了某天出事的时候能翻出来看。用 TXT 追加写天然就是顺序结构运维同事不需要任何工具就能直接打开看比让人连数据库跑 SQL 友好太多。还有一种情况是跟外部系统交换数据。对方可能只支持“往一个目录里丢文件”这种最原始的对接方式比如某些老式打印系统、某些数据采集设备。这时候 TFS 读写就是唯一的通道你没得选。不过话说回来TXT 的边界也很清楚并发写入不可靠、没有事务、没有索引、数据量大了读取会卡。超过几千行、或者需要多用户同时写、或者需要按条件查询就该老老实实上数据库。我个人的判断标准是单文件小于 500KB、单写入点、不需要按条件查询满足这三条用 TXT破一条就换方案。2. 读文件整篇读、逐行读、按长度读2.1 三种读取方式的适用场景TextStream 提供三个读方法用错了不是报错而是悄悄地慢或者爆内存所以得分开说。ReadAll最简单一次性把整个文件内容读成一个字符串。文件小的时候最省事读出来直接做字符串处理就行。它的代价是全量加载进内存一个 10MB 的文件读进来加上字符串处理过程中的临时副本内存占用可能翻两三倍。经典 ASP 跑在 32 位进程里的情况并不少见这时候一次 ReadAll 就可能把整个站点拖垮。ReadLine一次读一行返回的字符串不含换行符。配合AtEndOfStream属性可以遍历整个文件。这是处理日志类文件的标准手法因为你可以读一行处理一行处理完就丢掉内存占用恒定。缺点是需要自己写循环且每行都要判空处理代码稍长。Read(n)按字符数读返回指定长度的字符串。这个用得最少一般只在处理定长格式文件或者做分块读取比如每次读 4096 个字符的时候才用。要注意它读的是字符数不是字节数中文也算一个字符跟文件大小不是一比一对应。我的经验是配置文件用ReadAll反正就几 KB日志和未知大小的文件一律用ReadLine循环。拿不准文件多大就先判断一下再决定判断方法下一节说。2.2 逐行读取的标准写法直接上代码这是我在多个项目里反复用的逐行读取骨架% Function ReadLinesToArray(filePath) Dim fso, ts, arr, line Dim i : i 0 ReDim arr(0) Set fso Server.CreateObject(Scripting.FileSystemObject) If Not fso.FileExists(filePath) Then ReadLinesToArray Array() Set fso Nothing Exit Function End If Set ts fso.OpenTextFile(filePath, 1, False, 0) Do While Not ts.AtEndOfStream line ts.ReadLine If Len(Trim(line)) 0 Then ReDim Preserve arr(i) arr(i) line i i 1 End If Loop ts.Close Set ts Nothing Set fso Nothing If i 0 Then ReadLinesToArray Array() Else ReDim Preserve arr(i - 1) ReadLinesToArray arr End If End Function %这里有几个点值得展开讲。ReDim Preserve每次扩容都会重新分配整个数组并复制效率并不高但对于几千行以内的文件完全够用。如果文件上万行更好的做法是先按 1000 的步长预分配一个数组超出时一次性扩容 1000能减少大量复制开销。If Len(Trim(line)) 0 Then这行是过滤空行。为什么需要因为人工编辑过的 TXT 文件里经常混着空行和只有空格的行直接解析会把后面的逻辑搞乱。但也不是所有场景都该过滤——如果你要处理的是“把原文件原样转存”这类需求空行是有意义的那就得保留。这个细节看起来不起眼我见过不止一个项目的编号错乱问题最后查出源头就是多过滤了一个空行。ts.AtEndOfStream这个判断也有讲究。空文件时它第一次判断就是 True循环一次都不进去i保持 0最后返回空数组不会报错。这比很多新手写的“先读第一行再循环”的写法安全得多——那种写法遇到空文件直接ReadLine就抛异常了。2.3 中文乱码的真正原因与验证方法乱码是 ASP 读写 TXT 出现频率最高的问题而且它的表现很有迷惑性同样是中文有时候正常有时候变问号有时候变一堆像是乱码的东西让人摸不着规律。先讲清楚原理。文本文件本身不知道自己是“什么编码”它只是字节序列。打开它的时候你必须告诉程序“按哪种编码解释这些字节”。解释错了中文就会出现经典的两种症状变成满屏问号或者变成鍟嗗搧这种看不懂的字符组合。FSO 的问题在于它的格式参数只有三个选项系统默认、Unicode、ASCII没有 UTF-8。而现在的编辑器VS Code、Notepad、新版记事本默认保存的常常就是 UTF-8。用 FSO 去读一个 UTF-8 文件它按 ANSI 解释中文必然出错。更麻烦的是 UTF-8 文件如果带 BOM文件开头那三个隐藏字节EF BB BFFSO 读出来还会在内容开头多一个奇怪的字符前端显示的时候会看到一个小方块。验证方法很简单用记事本打开目标文件另存为的时候看“编码”显不显示 UTF-8。或者用十六进制工具看文件头是不是EF BB BF。判断清楚了方案就明确了——要么把文件另存为 ANSI治标要么改用支持 UTF-8 的组件来读治本。生产环境显然要选后者。另外还有一种容易忽略的情况读出内容是正常的但输出到页面上变乱码了。这种问题不在读取而在页面编码。ASP 页面顶部要声明%LANGUAGEVBSCRIPT CODEPAGE65001%同时Response.Charset utf-8两者缺一不可。我有一次排查了半小时文件编码最后发现是页面没声明 CODEPAGE白白绕了一大圈。2.4 用 ADODB.Stream 读写 UTF-8 的正确方式FSO 不支持 UTF-8但 Windows 上的另一个 COM 组件支持——ADODB.Stream。这是早年微软数据访问组件自带的东西几乎所有服务器都有不需要额外部署。用它读 UTF-8 文件核心就三行设置% Function ReadUtf8File(filePath) Dim stm, content content If Not CreateObject(Scripting.FileSystemObject).FileExists(filePath) Then ReadUtf8File Exit Function End If Set stm Server.CreateObject(ADODB.Stream) stm.Type 2 2 表示文本流1 表示二进制流 stm.Charset utf-8 关键指定按 UTF-8 解释 stm.Open stm.LoadFromFile filePath content stm.ReadText 一次性读出全部文本 stm.Close Set stm Nothing ReadUtf8File content End Function %Type属性必须设成 2表示这是文本模式如果保持默认或者设成 1读出来的是二进制数组不是字符串。Charset必须在Open之前或者紧随其后设置设晚了不生效。LoadFromFile负责把磁盘文件加载进流里然后ReadText一次性取出。这套流程我用了很多年只要服务器上有那个组件稳定得很。ReadText也可以传参数指定读多少字符跟 FSO 的Read类似。同样不建议对大文件用无参版本。如果文件特别大可以用ADODB.Stream配合ReadText(4096)做分块读取再逐步拼接但这个用法比较少见真遇到大文件场景我更倾向直接换用数据库。需要提醒的是ADODB.Stream在服务器上偶尔会因为安全策略被限制报“无法创建对象”。这种情况下的排查方向是确认数据访问组件是否正常注册而不是去改代码。我遇到过一台机器重装后没装完整的数据访问组件所有 Stream 相关功能全挂装完之后一切恢复正常代码一行没改。3. 写文件新建、覆盖、追加三条路径3.1 CreateTextFile 与 OpenTextFile 的选择写文件有两个入口容易让人犹豫用哪个。CreateTextFile(路径, 是否覆盖, 是否Unicode)顾名思义主要用于新建。第二个参数传 True 表示如果文件已存在就覆盖传 False 则会在文件存在时报错。这个方法适合“整篇重写”的场景比如配置文件保存、报表导出。OpenTextFile(路径, 2 或 8, True, 格式)更灵活既能覆盖写模式 2也能追加写模式 8。它的优势在于可以直接复用同一套路径处理逻辑跟读文件的写法保持对称。我的选择习惯是需要追加的一律用OpenTextFile加模式 8需要整篇覆盖的用CreateTextFile加覆盖参数语义更清楚别人看代码一眼就知道“这里是要重写”。有个细节要注意CreateTextFile的第三个参数是否Unicode传 True写出来的是 UTF-16 带 BOM 的文件大小是 ANSI 的两倍用记事本打开正常但很多系统对接时会不认。传 False 写 ANSI中文在中文系统上正常在别的环境可能乱码。所以除非明确需要 UTF-16我一般把这个参数设成 False然后靠ADODB.Stream处理需要 UTF-8 的场合。3.2 追加写入的完整实现追加是最高频的写入场景日志、流水、留言记录都是这个模式。基础写法不复杂% Sub AppendLine(filePath, lineText) Dim fso, ts, folderPath Set fso Server.CreateObject(Scripting.FileSystemObject) 先确保目录存在否则开文件会失败 folderPath fso.GetParentFolderName(filePath) If Not fso.FolderExists(folderPath) Then fso.CreateFolder folderPath End If Set ts fso.OpenTextFile(filePath, 8, True, 0) ts.WriteLine lineText ts.Close Set ts Nothing Set fso Nothing End Sub %这段代码里有两处是“经验补丁”文档上不一定强调但实际不加就会踩坑。第一处是GetParentFolderName加CreateFolder的判断。如果你传的路径里中间某一级目录不存在OpenTextFile会直接报“路径未找到”而不是帮你建目录。很多项目按日期分目录存日志比如/logs/2024-06-01/access.txt到了新的一天目录不存在第一次写入就炸。提前判一下一行代码解决一个未来的故障。第二处是WriteLine而不是Write。WriteLine会自动在末尾补换行符Windows 下是\r\n保证每条记录独立成行。用Write的话所有内容会挤在一行里后续解析全部失效。这个错误我在别人的代码里见过好几次表现是日志文件打开后一整行几千个字符看着特别诡异。还有个更隐蔽的问题追加模式下如果文件最后一行没有换行符比如上次写入用的Write你再WriteLine就会把新内容接在旧内容后面。所以规范的做法是全程只用WriteLine或者在追加前检查文件末尾。后面讲的并发章节还会再提到这个坑。3.3 用 ADODB.Stream 写出无 BOM 的 UTF-8这里必须讲一个很实际的问题。用ADODB.Stream的文本模式写 UTF-8保存出来的文件默认带 BOM。在你自己系统里看没问题可一旦这个文件要交给别的系统解析对方很可能把 BOM 当成内容的一部分导致第一个字段永远多出几个看不见的字节解析全错。解决办法是把文本流转换成二进制流跳过前三个字节再保存。完整实现如下% Sub WriteUtf8NoBom(filePath, content) Dim stm, bstm, binData, fso Set stm Server.CreateObject(ADODB.Stream) stm.Type 2 stm.Charset utf-8 stm.Open stm.WriteText content 切回开头准备转换 stm.Position 0 stm.Type 1 切成二进制模式 If stm.Size 3 Then stm.Position 3 跳过 EF BB BF 三个字节 End If binData stm.Read stm.Close Set stm Nothing Set bstm Server.CreateObject(ADODB.Stream) bstm.Type 1 bstm.Open bstm.Write binData Set fso Server.CreateObject(Scripting.FileSystemObject) If Not fso.FolderExists(fso.GetParentFolderName(filePath)) Then fso.CreateFolder fso.GetParentFolderName(filePath) End If Set fso Nothing bstm.SaveToFile filePath, 2 2 表示覆盖写入 bstm.Close Set bstm Nothing End Sub %几个关键点解释一下。stm.Position 0必须在改Type之前执行因为流打开后如果位置不在开头是不允许切换类型的会抛“操作在此上下文中无效”。SaveToFile的第二个参数传 2 表示覆盖传 1 表示只在文件不存在时创建实际用覆盖的情况居多。If stm.Size 3这个判断是防止空内容时报错——如果内容为空Position 3会越界。这种边界判断看起来啰嗦但在生产环境就是靠它挡住一类偶发异常。我曾经因为漏了这个判断在用户提交空内容时收到一堆错误告警加完之后再没出现过。3.4 换行符与写入的坑WriteLine补的换行是\r\n这是 Windows 的约定。如果文件要被第三方系统读取而你没法控制对方怎么解析最好确认一下对方能接受哪种换行符。跨平台交换的场景下\n更通用一些这时候就不用WriteLine改为Write content vbLf手动控制。vbCrLf、vbLf、vbCr这三个常量要分清楚。vbCrLf就是\r\nvbLf是\nvbCr是\r。Classic ASP 里WriteLine用的是vbCrLf。我见过有同事为了“统一换行符”在写入前把内容里的vbLf全替换成vbCrLf结果处理一段已经带\r\n的内容时变成了\r\r\n多出一个看不见的字符解析端疯狂报错。这类替换操作要特别注意不要重复处理。还有一个容易被忽视的点WriteBlankLines(n)可以写 n 个空行用它来做分节比循环WriteLine 优雅。导出报表格式的文本时我用得挺多。4. 落地实战封装一套可复用的读写工具4.1 为什么要封装上面讲的都是原子操作真写进业务代码里如果每个地方都CreateObject、OpenTextFile、Close走一遍会产生三个问题代码重复度高出错时很难定位是哪一段漏了Close编码处理散落各处导致行为不一致。我的做法是在站点根目录建一个lib/textfile.asp把读写、判断、删除、追加全部封成函数业务页面只要!--#include filelib/textfile.asp--然后调一行就行。封装的核心原则有三条路径统一收口。所有对外暴露的函数都接收虚拟路径比如/data/log.txt内部统一用Server.MapPath转换。这样业务代码里永远不出现物理路径迁移服务器时不用改业务代码。编码统一收口。函数参数里带一个charset值和行为一一对应传ansi走 FSO传utf-8走 ADODB.Stream传unicode走 FSO 的 -1。判断逻辑只写一次以后要改只改一处。异常统一收口。所有函数内部用On Error Resume Next包一层出错时记录Err.Number和Err.Description返回空值或者 False不让异常直接冒到页面上。经典 ASP 的错误页面直接暴露物理路径和代码行安全性很差能兜住就兜住。4.2 完整工具函数库把前面的代码整合成一套可以直接拿去用% 基础判断 Function TF_Exists(filePath) Dim fso Set fso Server.CreateObject(Scripting.FileSystemObject) TF_Exists fso.FileExists(Server.MapPath(filePath)) Set fso Nothing End Function Function TF_Size(filePath) Dim fso Set fso Server.CreateObject(Scripting.FileSystemObject) On Error Resume Next TF_Size fso.GetFile(Server.MapPath(filePath)).Size If Err.Number 0 Then TF_Size 0 On Error GoTo 0 Set fso Nothing End Function 读文件 Function TF_ReadAll(filePath, charset) Dim fso, ts, stm, realPath, fmt TF_ReadAll realPath Server.MapPath(filePath) charset LCase(charset ) Set fso Server.CreateObject(Scripting.FileSystemObject) If Not fso.FileExists(realPath) Then Set fso Nothing Exit Function End If On Error Resume Next If charset utf-8 Then Set stm Server.CreateObject(ADODB.Stream) stm.Type 2 stm.Charset utf-8 stm.Open stm.LoadFromFile realPath TF_ReadAll stm.ReadText stm.Close Set stm Nothing Else If charset unicode Then fmt -1 Else fmt 0 End If Set ts fso.OpenTextFile(realPath, 1, False, fmt) TF_ReadAll ts.ReadAll ts.Close Set ts Nothing End If If Err.Number 0 Then TF_ReadAll End If On Error GoTo 0 Set fso Nothing End Function 写文件覆盖 Function TF_WriteAll(filePath, content, charset) Dim fso, ts, stm, bstm, binData, realPath TF_WriteAll False realPath Server.MapPath(filePath) charset LCase(charset ) Set fso Server.CreateObject(Scripting.FileSystemObject) If Not fso.FolderExists(fso.GetParentFolderName(realPath)) Then On Error Resume Next fso.CreateFolder fso.GetParentFolderName(realPath) On Error GoTo 0 End If On Error Resume Next If charset utf-8 Then Set stm Server.CreateObject(ADODB.Stream) stm.Type 2 stm.Charset utf-8 stm.Open stm.WriteText content stm.Position 0 stm.Type 1 If stm.Size 3 Then stm.Position 3 binData stm.Read stm.Close Set stm Nothing Set bstm Server.CreateObject(ADODB.Stream) bstm.Type 1 bstm.Open bstm.Write binData bstm.SaveToFile realPath, 2 bstm.Close Set bstm Nothing Else Set ts fso.OpenTextFile(realPath, 2, True, 0) ts.Write content ts.Close Set ts Nothing End If If Err.Number 0 Then TF_WriteAll True On Error GoTo 0 Set fso Nothing End Function 追加一行 Function TF_AppendLine(filePath, lineText, charset) Dim oldContent, ok TF_AppendLine False If LCase(charset ) utf-8 Then UTF-8 无法真正追加先读再整体重写 oldContent TF_ReadAll(filePath, utf-8) If Len(oldContent) 0 And Right(oldContent, 1) vbLf Then oldContent oldContent vbCrLf End If ok TF_WriteAll(filePath, oldContent lineText vbCrLf, utf-8) TF_AppendLine ok Else Dim fso, ts, realPath realPath Server.MapPath(filePath) Set fso Server.CreateObject(Scripting.FileSystemObject) On Error Resume Next If Not fso.FolderExists(fso.GetParentFolderName(realPath)) Then fso.CreateFolder fso.GetParentFolderName(realPath) End If Set ts fso.OpenTextFile(realPath, 8, True, 0) ts.WriteLine lineText ts.Close Set ts Nothing If Err.Number 0 Then TF_AppendLine True On Error GoTo 0 Set fso Nothing End If End Function %这里有个设计取舍要说明UTF-8 的追加被实现成了“读全文 重写”。这不是偷懒是因为ADODB.Stream本身没有追加保存的能力SaveToFile的参数只有覆盖和不覆盖两种。所以 UTF-8 文件做追加只能整体重写。这就带出一个重要结论UTF-8 编码不适合写高频日志。假设日志文件涨到 2MB每次追加都要读 2MB 再写 2MB一天几百条记录就是几百 MB 的磁盘 IO服务器扛不住。所以我的实际策略是日志类文件一律用 ANSI 编码加 FSO 追加只在内容需要跨系统交换时才用 UTF-8而且那种场景一般也不需要高频追加。这个取舍想清楚了架构上就不会出问题。4.3 实战场景一简易访问记录拿一个真实的需求练手记录管理员每次登录的时间和 IP写到一个按月份滚动的文本文件里后台能查看最近 100 条。% Sub LogAdminLogin(adminName, ip) Dim lineText, logPath, nowTime nowTime Now() logPath /logs/admin_ Year(nowTime) Right(0 Month(nowTime), 2) .txt lineText FormatDateTime(nowTime, 0) vbTab adminName vbTab ip TF_AppendLine logPath, lineText, ansi End Sub %几个设计点。按月滚动文件单个文件不会无限增长同时也便于按月份归档删除。用vbTab而不是逗号做分隔是因为管理员名称里可能带逗号而 Tab 几乎不会出现在这类输入里解析起来更安全。真要用逗号分隔那就得对所有字段做转义处理或者干脆用固定格式。读取最近 100 条的时候要小心别直接ReadAll然后Split——文件大了会慢。更好的做法是逐行读用一个长度 100 的数组做环形缓冲每次写入位置是i Mod 100最后按正确顺序拼出来。这样内存占用恒定不管文件多大都是常数量级。这个方法我在处理访问日志时用得很多代码不复杂但效果立竿见影。4.4 实战场景二把 TXT 当轻量配置配置文件的特点是“读多写少”所以读的性能比写重要。格式上用keyvalue每行一对#开头当注释。读取时解析成一个字典结构后续直接查。% Function LoadConfig(configPath) Dim dict, lines, arr, i, line, pos Set dict Server.CreateObject(Scripting.Dictionary) lines Split(TF_ReadAll(configPath, ansi), vbCrLf) For i 0 To UBound(lines) line Trim(lines(i)) If Len(line) 0 And Left(line, 1) # Then pos InStr(line, ) If pos 0 Then dict(Trim(Left(line, pos - 1))) Trim(Mid(line, pos 1)) End If End If Next Set LoadConfig dict End Function %Scripting.Dictionary这个对象用来做键值存储非常顺手Exists判断是否存在取值直接用键名当索引。用它装配置比用二维数组清晰得多。写回配置的时候要注意顺序问题。字典是无序的遍历写回去会打乱原有顺序人工看配置文件的时候会很难受。我的做法是另外维护一个有序的键名数组写回时按这个数组的顺序遍历。多写十几行代码换来一个永远整洁的配置文件很值。另一个经验是配置读取要加缓存。如果每个页面都去读一遍配置文件IO 开销累积起来很可观。用Application对象缓存起来配置文件写入成功后主动更新缓存能把读操作降到几乎为零。缓存失效策略也简单写入成功就刷一次写入失败就不动保证内存里的和磁盘上的一致。5. 权限、并发与安全三个最容易翻车的地方5.1 写不进去的真正原因在权限80A0046 这个错误码翻译过来是“权限被拒绝”意思是代码没错是操作系统不让写。这个错误在本地开发时通常不会出现一部署到服务器就冒出来所以特别容易被误判成“代码在服务器上不兼容”。原理是这样的ASP 页面运行时是以某个特定账户的身份执行代码的。这个账户对站点目录通常只给了读和执行权限没有写权限。你的代码要往/data/目录写文件操作系统一看这个账户没有写权限直接拒绝异常就冒上来了。解决思路有两种优先级不同。第一种是给专门的目录授予写权限。这是推荐做法。在站点目录下建一个独立的data目录只对这个目录授予修改权限站点其他目录保持只读。这样即使代码有漏洞被利用攻击者也只能在这个数据目录里折腾影响范围可控。授予权限的账户要选对通常是站点的匿名访问账户和应用程序池账户两个都要给因为不同配置下实际执行身份可能不一样。第二种是给整个站点目录写权限。这是绝对不推荐的做法非常危险。一旦站点根目录可写攻击者上传一个恶意脚本文件就有了立足点。我见过有项目图省事直接这么干后来出了安全事故排查下来根因就是权限开得太宽。这个坑千万别踩。还有一个隐蔽情况目录权限已经给了还是写不进去。这时候要看两个地方。一是文件本身可能带只读属性用Attrib命令看一眼。二是路径指向的可能是网络映射盘而 Web 服务账户没有网络访问权限。第二种情况在虚拟机和多机部署里比较常见处理方法是改用本地磁盘路径或者让服务以有网络权限的账户运行。5.2 并发写入的坑与缓解方案FSO 有没有文件锁严格来说有一种“共享模式”但经典 ASP 的OpenTextFile已经把参数简化了你没法直接控制。实际表现是两个请求几乎同时打开同一个文件追加大部分时候没事偶尔会出现内容互相覆盖或者写进半截。这个概率有多高单机、低并发的情况下一个月可能碰上一次。但只要碰上了日志就是脏的而且很难复现。所以做重要日志的时候不能只靠运气。我的缓解方案分三个层次按可靠性递增。最轻量的做法是错开文件。日志按小时甚至按分钟切分文件名access_2024060114.txt这样。同一时刻写同一个文件的概率大幅下降冲突几乎消失。代价是文件变多清理的时候要按时间批量删。这个方法成本最低我一直用它做访问日志。中等强度的做法是用Application对象加锁。Application.Lock和Application.Unlock之间的代码在同一个应用内是串行的把它包在写入操作外面能保证同一时刻只有一个请求在写。% Application.Lock TF_AppendLine /logs/order.txt, orderLine, ansi Application.Unlock %要注意这个锁是应用级的会影响整个站点的并发性能。所以包裹的代码要尽可能短绝对不能把耗时的操作放进去。另外它只在单进程内有效如果站点配置了多个工作进程锁就不管用了。这一点必须清楚别以为加了锁就万无一失。最可靠的做法是换存储。真正重要的、写入频繁的数据老老实实用数据库。数据库有事务、有行锁、有并发控制这些是文本文件天生不具备的能力。判断标准很简单如果这份数据丢了或者坏了会造成业务损失就不该放在 TXT 里。5.3 路径安全与内容输出安全文件操作类功能有两个安全问题必须处理而且都是那种“不出事则已一出事就很严重”的类型。第一个是路径穿越。如果文件路径里包含了用户输入攻击者构造一个../../web.config这样的路径就能读到本不该读的文件。防护方法是两条能不用用户输入拼路径就不用改用 ID 映射必须用时用正则严格限制文件名只能包含字母数字下划线和点并且用Server.MapPath转换后再校验结果路径是否以预期的数据目录开头不在就拒绝。第二条容易被忽略读出来的内容输出到页面时要做 HTML 转义。TXT 里的内容可能包含script标签直接Response.Write出去就变成了 XSS 漏洞。用Server.HTMLEncode处理一遍成本几乎为零效果立竿见影。这一点我在做“后台查看日志”功能时特别强调因为日志内容恰恰是用户可控的攻击者只要提交一条带脚本的记录管理员一打开日志页面就中招了。还有一条经验永远不要相信扩展名。有些功能允许用户上传 TXT 然后读取如果只校验扩展名攻击者可以传一个改名的可执行文件读取时触发奇怪的行为。更好的做法是校验文件头的内容或者干脆不允许上传文件直接读改成上传后转存到受控目录再读。6. 常见报错与排查速查表6.1 高频错误码对照这张表是我这些年攒下来的遇到问题先对着查能省掉大量盲目搜索的时间。错误码典型提示最常见原因排查动作800A0035文件未找到物理路径拼错、MapPath漏写、目录不存在输出真实路径逐级确认目录存在800A0046权限被拒绝数据目录未授予写权限、文件带只读属性检查目录权限和文件属性800A003A文件已存在CreateTextFile第二参数传了 False改为 True 或先删除800A0005无效的过程调用或参数模式参数传错、Position越界检查 iomode 是否用了 1/2/8800A01AD无法创建对象组件未注册、服务器禁用确认 FSO 或数据访问组件可用800A000D类型不匹配把对象当字符串用了、忘了 Set检查Set关键字800A01AD这个错误值得单独说。如果CreateObject(Scripting.FileSystemObject)直接失败说明服务器上根本没有这个组件或者被安全策略禁用了。这种情况不是代码能解决的得从服务器环境入手。我遇到过客户为了“加固”把脚本运行时组件整个禁用导致所有文件操作功能全挂最后只能改服务器配置。6.2 一套固定的排查顺序错误码能定位方向但真正高效的做法是养成固定的排查顺序从最外层往内层走避免来回跳跃。第一步先确认路径。把Server.MapPath的结果Response.Write出来记得只在测试环境做生产环境泄露物理路径有风险然后去服务器上按这个路径找一下文件/目录到底在不在。这一步能解决掉一半的问题因为路径拼接错误实在太常见了。第二步确认权限。文件在但还是报错那就是权限问题了。用一个最简单的测试在数据目录里手动新建一个文本文档如果能建说明你的账户有权限如果建不了那 Web 账户多半也没权限。第三步确认编码。读出来了但内容是乱码就回到编码章节用十六进制工具看文件头判断是 UTF-8 还是 ANSI。第四步确认代码路径。前三步都正常还是报错那就是逻辑问题。这时候在关键位置加Response.Write打点看代码究竟走到了哪一行、变量的实际值是什么。经典 ASP 没有断点调试Response.Write加Response.End就是最有效的调试手段配合On Error Resume Next加Err.Description打出具体错误描述定位效率能提高一大截。6.3 几个踩过的坑第一个坑OpenTextFile的创建参数用了 True结果在只读模式下不生效读文件还是报“文件未找到”。这个参数只对写模式有意义读的时候传 True 是浪费表情。我早期写代码习惯性传 True被这个坑教育过一次。第二个坑ReadLine循环里改文件。有人一边读一边在循环里追加写同一个文件结果陷入死循环或者读到一半内容消失。原因是写入改变了流的内部状态。正确做法是先读完全部关闭流再另开一个流写入。这个错误在小文件上可能很晚才被发现因为数据量小的时候表现正常。第三个坑忘记Close。经典 ASP 页面执行完会回收对象短期看不出问题但在高并发下没关闭的文件句柄会累积最终报“打开文件过多”。我现在的习惯是把Close和Set ... Nothing写在打开操作的下方不远处写完立刻关绝不拖到函数末尾。第四个坑以为Server.MapPath(/)返回的是站点根目录。实际它返回的是站点根目录但如果你在虚拟目录里部署行为会不一样。稳妥的写法是相对当前文件用Server.MapPath(./data/xxx.txt)或者用Server.MapPath(Request.ServerVariables(APPL_PHYSICAL_PATH))拿到应用根。这个问题在把站点从根目录搬到子目录时暴露得特别明显。7. 如果你用的是 ASP.NET同样的需求换个写法7.1 一行代码完成的读写标题里的“ASP”有两种理解。如果你维护的是较新的项目实际用的是 ASP.NET那方案完全不同而且简单得多。读整个文件File.ReadAllText(Server.MapPath(~/data/demo.txt), Encoding.UTF8)。第二个参数指定编码UTF-8 直接写Encoding.UTF8ANSI 用Encoding.Default。不需要任何对象创建和关闭操作静态方法内部全帮你处理了。写整个文件File.WriteAllText(path, content, Encoding.UTF8)。这个方法默认覆盖文件不存在自动创建父目录不存在会抛异常所以还是要自己确保目录在。追加一行File.AppendAllText(path, line Environment.NewLine, Encoding.UTF8)。这个方法是真正的追加底层会自动处理编码不会像经典 ASP 那样遇到 UTF-8 就得整体重写。这是两套技术栈差距最明显的地方。按行读取用File.ReadLines(path)它返回的是惰性枚举配合foreach使用的时候是边读边处理内存占用恒定等于把经典 ASP 里手写循环的逻辑内置了。7.2 并发场景下的额外处理ASP.NET 里多线程并发写同一个文件同样会冲突。默认的File.WriteAllText会以独占方式打开文件另一个线程同时写就会抛IOException。处理方式是显式指定共享模式用FileStream包一层using (var fs new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) using (var sw new StreamWriter(fs, Encoding.UTF8)) { sw.WriteLine(line); }FileShare.ReadWrite表示允许其他进程同时读写。但要注意允许共享不代表不会冲突多个线程同时往一个文件追加写入顺序仍然是不确定的。所以真正高并发写日志的场景更稳妥的是用现成的日志框架它们内部做了队列和批量落盘性能和可靠性都比手写强得多。7.3 两套技术栈的选型建议维护老项目只能在经典 ASP 上改那就把前面几章的封装用起来重点盯住编码和权限两个点。新项目如果还能选毫无疑问用 ASP.NET 或者更新的技术栈文件 API 的易用性和安全性都高出一截编码处理也不用手工拼字节。有一种中间情况值得提一句老项目一时半会迁移不了但新增的功能模块可以用更现代的方式实现通过接口和老代码通信。这种做法能逐步降低技术债又不会因为一次性重构引入风险。我在几个项目里用过这个策略效果不错。最后分享一个我个人长期坚持的做法不管用哪套技术栈文件路径一律配置化不要在代码里写死。路径变了只需要改配置不用全站搜索替换。这个小习惯在服务器迁移、目录结构调整的时候能省下大量时间也避免了改漏一处导致的诡异 bug。
阅读完成 · 觉得有帮助?