简介:Absolute Database v7.93 源代码包面向 Delphi 4 至 11(含 Alexandria)开发者,适合需要研读数据库引擎实现、进行二次开发或做企业级定制的中高级程序员。压缩包共 756 个文件、约 18.9MB,以 pas 单元与 hpp 头文件、dcu 编译单元、dpk 与 bdsproj 包工程、sql 脚本、dfm 窗体及 res 资源为主,另含 c/cpp 与 asm 底层代码、chm/hlp 帮助文档和 bpl 运行库,完整覆盖引擎、组件库与安装包三部分,已有 446 人学习下载。读者可据此获得源码级参考:事务处理、内存与缓存策略、用户权限与数据加密、备份恢复、多用户并发控制等模块均以 Delphi 源码形式开放,便于研究实现细节并按业务需求裁剪;面向 DBF、Paradox、Access、SQL Server 的迁移接口与 VCL 组件设计时支持,也可用于跨平台部署和旧项目升级。
1. 单文件数据库的源码包到底值不值得拆:先看它能替你省掉什么
某公司的老生产管理系统还在 Delphi 7 上跑,数据全塞在 BDE 的别名里,客户机换成 64 位系统之后连别名都建不起来,车间一次意外断电就丢半张工单表——这是嵌入式数据库每次被重新翻出来的典型场面。这份 Absolute_Database_v7.93_sources_for_D4-11 属于同一类东西:把数据库引擎整个做进进程内,不装服务端、不配 BDE、一个库就是一个文件,而且给的是源码,引擎层想改就能改。它面向的是还在维护 Delphi 4 到 Delphi 11 老工程、又不想把架构推倒重来的人。下面按「能不能编、编完怎么用、用起来哪里疼」的顺序拆。
2. 从压缩包到可编译工程:v7.93 源码在 D4-11 上的落地路径
2.1 先看目录再动手:源码包的四个组成部分
拿到压缩包第一件事不是解到 IDE 安装目录里,那个位置一旦被覆盖,回滚只能重装。我一般解到D:\SDK\ADB793\这种独立路径,工程里通过搜索路径引用,出问题直接删目录,不留痕迹。
解压后的结构各家打包习惯不同,但按职责基本能分成四块,对着下表核对一遍再动手:
| 目录/文件 | 职责 | 编译目标 | 部署时是否需要 |
|---|---|---|---|
| 运行期源码目录(若干 .pas) | 引擎、页管理、SQL 解析、索引 | 运行期包 / 直接编进 exe | 编译期需要,运行期不需要 |
| 设计期注册单元(含 Register 过程) | 往 IDE 组件面板注册组件 | 设计期包(dcl 前缀) | 不需要,只装在开发机 |
| 帮助文档与 .inc 条件编译头 | 属性说明、版本分支开关 | 无 | 不需要 |
| Demo 与示例库 | 建库、查询、事务的最小样例 | 独立工程 | 不需要 |
源码目录里最容易被忽略的是条件编译。为了同时兼容 D4 到 D11,单元里会有大量{$IFDEF VER140}、{$IFDEF VER200}这类版本判断——Delphi 4 到 11 跨了十几个编译器版本,字符串从AnsiString切到UnicodeString的分界点在 Delphi 2009(VER200),这套条件编译基本是围着这个分界点写的。改源码之前先确认自己落在分界点哪一侧,否则一个Char等于 1 字节还是 2 字节的假设,就能让 BLOB 字段读出来全是乱码。
还有一点:sources包通常不带预编译 DCU,意味着第一次编译是全量编译,慢是正常的。如果你拿到的是带 DCU 的版本,先确认 DCU 的编译版本和你本地编译器一致,混用会报「Unit was compiled with a different version of…」,这个错看着像源码问题,实际是二进制不匹配。
提示:解压后先对整个目录做一次只读备份,改源码前留一份原始副本。这套代码里条件编译分支多,改错一个
IFDEF后面很难定位。
2.2 用 msbuild 命令行把运行期包编出来
IDE 里点 Install 最省事,但只要涉及多平台(Win32/Win64)和多版本(D7 与 D11 并存),命令行更可控。Delphi 的包工程(.dpk)可以直接交给 msbuild,先加载环境变量:
:: 加载 Delphi 编译环境变量,路径换成你本机实际安装位置 call "<Delphi安装目录>\bin\rsvars.bat" :: 编译运行期包,Win32 Release;包文件名以你包内实际为准,这里用 ADBRuntime.dpk 代指 msbuild ADBRuntime.dpk ^ /t:Build ^ /p:Config=Release ^ /p:Platform=Win32 ^ /p:DCC_UnitSearchPath="..\src;..\src\common" ^ /p:DCC_Define="ADB_DEBUG_OFF" ^ /v:minimal/t:Build是全量重建,和/t:Clean;Build的区别在于前者不清理中间产物,源码改动大时建议先 Clean。/p:Platform必须显式给,缺省会落到 Win32 但部分老工程会误判成 Win64,编出来的 DCU 与后续链接的 BPL 位数不一致,症状是链接期报一堆「Unsatisfied forward or external declaration」。DCC_UnitSearchPath里一定要把源码目录和它的子目录都列上,这套源码的内部单元引用有时候是跨子目录的,IDE 里加的搜索路径不会自动传给 msbuild。
DCC_Define用来关掉调试分支。很多引擎源码里带{$IFDEF DEBUG}的断言和日志写盘,Release 下如果还留着,跑批量插入时磁盘 IO 会被日志吃掉一大截,性能差距能有几倍。编完看输出目录,应该有.bpl、.dcp和一堆.dcu,.dcp是给别的包引用用的,.dcu是给普通工程用的,两者都要留着。
Win64 再走一遍同样的命令,只把Platform换成Win64。这里有个血泪经验:这套引擎的指针运算在 32 位和 64 位下行为不同,源码里如果有把Integer当指针用的地方,32 位能跑、64 位直接访问违例。真要在 Win64 上跑,先把源码里所有Integer(...)强转指针的位置过一遍,换成NativeInt。
2.3 设计期包注册:让组件出现在面板上
运行期包编通了,接着编设计期包。设计期包本身不含逻辑,只负责在 IDE 启动时调用注册过程。注册单元的核心就这么几行:
unit ADBReg; interface procedure Register; implementation uses Classes, ADBUnit; // 运行期主单元,名字以包内实际为准 procedure Register; begin // 第一个参数是组件类数组,第二个是 IDE 组件面板上的页签名 RegisterComponents('ADB', [TADDatabase, TADSession, TADTransaction, TADTable, TADQuery]); end; end.RegisterComponents的第二个参数决定组件挂在面板哪一页,起个自己好找的名字就行,重复了 IDE 会把两组并到同一页。设计期包的requires里必须包含rtl、vcl(如果组件有可视祖先)以及刚编好的运行期包,缺一个都会在安装时报「Cannot load package」。
安装走 IDE 的 Component → Install Packages → Add,选中编出来的dcl*.bpl。装完如果面板上是空的,八成是设计期包引用了另一个路径下的同名运行期包,两个 BPL 版本对不上;把 IDE 的 Library Path 里的旧路径删掉,只留当前这份,再重启 IDE。
2.4 编完先跑冒烟测试,别急着往老工程里塞
包能装上不代表引擎能用。我习惯先写一个控制台小程序,把建库、插入、查询、事务回滚四条路径走一遍,通过了再往生产工程里引:
program ADBSmoke; {$APPTYPE CONSOLE} uses System.SysUtils, ADBUnit; // 代指,实际单元名以包内为准 var Db: TADDatabase; Qry: TADQuery; begin Db := TADDatabase.Create(nil); try Db.DatabaseName := 'D:\temp\smoke.adb'; Db.LoginPrompt := False; // 控制台程序没有登录框,必须关掉 Db.Open; Qry := TADQuery.Create(nil); try Qry.Database := Db; Qry.SQL.Text := 'CREATE TABLE T1 (ID INTEGER, NAME VARCHAR(50))'; Qry.ExecSQL; // 事务内插入,验证提交与回滚两条路径 Db.StartTransaction; Qry.SQL.Text := 'INSERT INTO T1 (ID, NAME) VALUES (:ID, :NAME)'; Qry.ParamByName('ID').AsInteger := 1; Qry.ParamByName('NAME').AsString := 'smoke'; Qry.ExecSQL; Db.Commit; Qry.SQL.Text := 'SELECT COUNT(*) FROM T1'; Qry.Open; Writeln('rows=', Qry.Fields[0].AsInteger); // 期望 1 Qry.Close; finally Qry.Free; end; finally Db.Close; Db.Free; end; end.这段代码的验证点有三个:LoginPrompt := False能不能在无界面环境打开库、StartTransaction/Commit是否真的落盘、参数化查询的:ID占位符是否被正确识别。三个都过,说明引擎主体是好的,剩下的都是配置问题。
跑完去D:\temp\下看,除了smoke.adb还应该有同名的临时日志文件(提交后一般会被回收)。如果日志文件一直不消失,说明事务没正常提交,回到try...finally里检查是不是中途抛异常跳过了 Commit。第一次跑建议开着 Process Monitor 看文件句柄,能提前发现引擎是不是在偷偷往系统临时目录写东西——有些版本会把排序临时文件放到%TEMP%,在磁盘配额紧张的环境里会出问题。
3. 存储引擎层:页式文件、字段编码与索引维护的实现细节
3.1 一个 .adb 文件里到底装了什么
单文件数据库听起来像个黑匣子,拆开看其实是固定页大小的分页文件。文件头第一页存元信息(页大小、版本号、加密标志、页分配位图起点),后面是数据页、索引页、BLOB 页和系统表页混排。页大小在建库那一刻定下来,之后不能改——这一点和某些服务端数据库可以动态调整不同,是选型时必须先想清楚的。
页大小直接影响两件事:单条记录的上限,和一次 IO 读多少。页越小,BLOB 越容易被拆成链式页,读一个大字段要多次寻道;页越大,小记录的浪费越明显。我一般的取法是:表里以短记录为主、并发读多,用较小页(1KB~2KB);有大量文本或二进制字段,用 4KB~8KB。改页大小只能重建库,所以建库脚本里这个参数要写死并加注释,别等到上线三个月才发现不合适。
页分配位图是判断文件健康度的入口。源码里维护位图的单元通常有一个「空闲页计数」,正常关闭时这个值应该和文件实际大小对得上。如果进程被强杀,位图可能停在中间态,下次打开时引擎会走一遍修复流程,速度明显变慢。这也是为什么这套引擎不适合放在会被突然断电的工控机上裸跑——要么配 UPS,要么把提交频率调高,用性能换一致性。
3.2 字段类型到物理字节的映射
选字段类型不能只看语义,要看它怎么落盘。定长类型在页内是原地更新,变长类型改长度可能触发记录搬迁,进而影响索引指针。下面这张表是我在迁移老表结构时对照用的:
| 字段类型 | 定长/变长 | 索引友好度 | 迁移时要注意的点 |
|---|---|---|---|
| 短整型 / 整型 | 定长 | 高,比较是数值比较 | 注意 64 位平台下的宽度,别用整型存指针 |
| 浮点 / 定点 | 定长 | 高,但等值比较不可靠 | 金额类务必用定点或整数分,浮点做等值查询会漏行 |
| 定长字符串 | 定长 | 高 | 长度按字符还是字节算,Unicode 下差一倍 |
| 变长字符串 | 变长 | 中,超长值不进索引 | 长度阈值以上会被排除在索引外,查询要走全表 |
| 日期时间 | 定长 | 高 | 老库可能存的是本地时间,跨时区要统一 |
| BLOB / 大文本 | 变长,链式页 | 不可索引 | 只存引用,正文单独放,避免主表页被撑爆 |
变长字符串那一条是踩过坑的。很多引擎对超长值做「前缀索引」,超出阈值的部分不参与索引比较,于是WHERE NAME = '很长很长的一串'会走索引但查不到,只能扫全表。迁移时如果把原来VARCHAR(255)改成VARCHAR(4000),索引行为可能悄悄变了,测试用例里必须有一条超长值的等值查询。
日期时间字段的坑更隐蔽。老系统里常见「存字符串、应用层格式化」的做法,换到有原生日期类型的引擎之后,排序和范围查询才对得上。迁移脚本里要做一次显式转换,别指望引擎自动识别'2024-01-01'这种格式,不同区域设置下解析结果可能差一天。
3.3 索引维护:B+ 树分裂与回滚日志
索引在这套引擎里是独立的 B+ 树,主键和普通索引各占一棵。插入导致页满时触发分裂,分裂本身是原子的,但分裂过程中锁的粒度比普通插入大,所以高并发写入的表上,自增主键比随机主键的吞吐明显更好——随机插入会到处触发分裂,页缓存命中率直线下降。
这一点在批量导入时体现得最明显。同样十万条记录,按主键递增顺序插入可能几十秒,打乱顺序插入能慢好几倍。我的做法是导入前先按主键排序,或者干脆导入时先删掉二级索引、导完再重建,代价是重建期间表不可写。
事务回滚靠的是同目录下的日志文件。提交前所有修改先写日志,提交时刷盘并标记,回滚时按日志反向撤销。这里有个容易误判的现象:事务开着的时候,.adb文件大小可能一直不涨,因为数据还在日志里;提交瞬间文件才膨胀。有人看到文件不涨就以为没写进去,重复提交,结果插了两遍。判断有没有写进去,看日志文件的修改时间比看主文件大小靠谱。
崩溃恢复的边界要说清楚:引擎能保证「已提交的不丢」,但对「未提交的」只做到尽量回滚,极端情况下(日志文件本身损坏)会退化成修复模式,可能丢最后一批未提交数据。所以关键业务里,别把「事务」当成「备份」,定期把.adb文件冷拷贝一份出去,仍然是最便宜的安全网。
3.4 TADTable 还是 TADQuery:两套访问方式的取舍
这套组件同时提供表式访问和 SQL 访问两套接口。表式接口(对应 TADTable)走的是记录缓冲,适合逐条编辑、带界面的数据维护;SQL 接口(对应 TADQuery)走解析执行,适合批量操作和复杂条件。
混用要小心。同一张表上,一个 TADTable 开着记录缓冲、另一个 TADQuery 在改同一行,两边的缓存不会自动同步,界面上看到的还是旧值。老工程里这种「表格控件绑一个、代码里再开一个查询」的写法很常见,症状是改完刷新才看得到。解决办法是用同一个数据集控件,或者改完主动调一次刷新。
批量写入优先用 SQL 接口加参数化,表式接口逐条 Append/Post 的开销在万级数据上非常明显。我做过对比,同样一万条插入,表式接口在事务里跑和参数化 SQL 在事务里跑,后者通常快一个量级,差就差在每次 Post 的缓冲管理和索引维护上。
4. SQL 执行链路:解析、参数绑定与并发边界的实测记录
4.1 一条 SELECT 从文本到结果集经过了什么
这套引擎的 SQL 支持是子集式的,不是完整标准。一条语句进来,大致走四步:词法语法分析生成语法树、语义检查(表存不存在、字段类型对不对)、计划选择(走哪个索引、要不要排序)、执行取数。
计划选择这块比服务端数据库朴素得多,基本是规则式的:WHERE 里有等值条件且字段有索引就走索引,有范围条件走索引区间扫描,LIKE '%x%'这种前导通配直接全表扫。没有基于统计信息的代价模型,所以「同一个查询昨天快今天慢」在这套引擎上很少见,好处是可预测,坏处是复杂查询得自己 rewrite。
理解这一点很关键:不要指望它像服务端数据库那样自动帮你选最优计划。写法上要主动配合,把选择性最高的条件放前面只是习惯问题,真正影响的是「有没有可用索引」和「能不能避免排序」。ORDER BY如果没有匹配索引,会走内存排序,数据量大时溢出到临时文件,这时候性能断崖式下跌。
执行层有记录缓冲,Open之后不是一次性把结果全读进来,而是按需取。这意味着Open很快但遍历慢,如果只取第一行就Close,开销很小;反过来,如果用一个查询遍历十万行再逐行处理,总耗时里取数占大头。批量处理时用Fetch循环或者干脆用 SQL 做聚合,比拉全量到应用层快得多。
4.2 参数化查询与 LIKE 的真实代价
参数化不只是防注入,在这套引擎上它还是性能问题。SQL 文本不变时,解析结果可以复用;每次拼接字符串,解析都要重来一遍。批量操作里这个差距被放大得很明显:
// 反例:循环里拼 SQL,每次都要重新解析 for I := 0 to List.Count - 1 do begin Qry.SQL.Text := Format('UPDATE T1 SET V=%d WHERE ID=%d', [List[I].Value, List[I].ID]); Qry.ExecSQL; end; // 正例:SQL 只设一次,参数在循环里改 Qry.SQL.Text := 'UPDATE T1 SET V=:V WHERE ID=:ID'; Qry.Prepare; // 显式预编译,部分版本不调也可,调了更稳 Qry.Params.ParamByName('V').DataType := ftInteger; for I := 0 to List.Count - 1 do begin Qry.ParamByName('V').AsInteger := List[I].Value; Qry.ParamByName('ID').AsInteger := List[I].ID; Qry.ExecSQL; end;Prepare的作用是把解析和计划选择提前做掉,循环里只做参数绑定和执行。参数必须显式指定DataType,否则第一次绑定的类型会被沿用,后面传不同类型的值会报类型转换错,或者更糟——被静默截断。AsInteger这类属性赋值也依赖DataType已确定,顺序别搞反。
前导通配的LIKE是另一个高频翻车点。LIKE 'abc%'能走索引区间扫描,LIKE '%abc'和LIKE '%abc%'一律全表扫。业务上如果真有「包含」查询的需求,做法是建一张倒排的辅助表(把关键词拆出来存一行),或者把数据同步到专门做检索的组件里,别指望在这套引擎上硬扛。十万行以上的表,一个前导通配 LIKE 就能让界面卡住十几秒,用户以为死机。
还有一个参数化的边界:IN列表不能参数化。WHERE ID IN (:A, :B)这种写法在部分版本里只认第一个参数。要做动态IN,只能拼字符串,这时候必须自己保证列表元素是整数或做过转义,别把用户输入直接拼进去。
4.3 多线程与多进程访问的边界
这套引擎的进程内并发模型是「一个连接一个会话」。TADSession是隔离单位,同一个 Session 下的组件共享事务上下文,跨 Session 就是两个独立连接。多线程访问时最容易犯的错是所有线程共用一个TADQuery,症状是偶发的「数据集未打开」或者数据串行,非常难复现。
正确做法是每个线程自己 Create 一套 Session 和 Query,用完释放。连接本身是线程内使用的,不要跨线程传递。引擎内部对文件有页级锁,多线程写入同一张表时会互相等待,所以写入线程数量不宜多,两三个就够,再多收益递减还增加死锁概率。
多进程是另一回事。同一台机器上两个进程打开同一个.adb文件,引擎靠文件锁协调,能跑但性能差,而且进程被强杀时锁可能残留,表现为「另一个程序正在使用此文件」。我的处理方式是写一个.lock标记文件,启动时检查,异常退出后人工确认再删,比引擎自己的锁更可控。
最要命的是把.adb放在网络共享目录里。网络文件系统的锁语义和本地不同,两个客户端同时写会出各种奇怪的数据损坏,而且这种损坏往往是延迟暴露的——今天写完看着没事,明天打开发现索引对不上。这条是硬红线:单文件数据库只能放本地盘或者块设备,别放共享盘。
5. 避坑与排查:编译、加密、并发与升级的四类翻车现场
5.1 编译期报找不到单元,或者报「compiled with a different version」
现象:命令行编译报F1026 File not found: 'System.SysUtils.dcu',或者在 IDE 里编过了、换 msbuild 就报Unit 'ADBUnit' was compiled with a different version of 'System.Classes'。
原因:前者是搜索路径没带全,rsvars.bat只设了环境变量,工程里引用的第三方源码目录还得靠DCC_UnitSearchPath显式给;后者是 DCU 版本串味——你的搜索路径里同时存在两套不同编译器版本编出来的 DCU,或者当前目录下的 DCU 比源码旧、msbuild 判断依赖时没重编。
解决:第一步清空输出目录重编,很多时候就是残留 DCU 的问题;第二步在 msbuild 命令里加-verbosity:detailed看它实际用了哪个路径的单元,路径列表一眼就能看出多出来的那条;第三步把DCC_UnitSearchPath里所有绝对路径检查一遍,别用相对路径混搭。如果是从老工程迁移过来,检查工程文件里有没有硬编码的旧路径,那东西不会因为改环境变量而失效。
5.2 库文件换台机器就打不开,提示密码错误
现象:开发机上一切正常,拷到客户机上打开报密码错误或者「不是有效的数据库文件」,密码确定没输错。
原因:三个可能。第一,加密算法依赖的运行时库在客户机上缺,引擎走了降级路径;第二,密码里带非 ASCII 字符,开发机和客户机的默认代码页不同,字符串编码在传给引擎之前就变了;第三,客户机上装的是另一套同名组件,注册表或系统路径里的 BPL 优先被加载。
解决:密码策略统一用 ASCII 可见字符,别用中文和特殊符号,这一条能省掉八成的跨机器问题。部署包里的 BPL 和 DCU 版本要锁死,安装时检查目标机是否已存在同名 BPL,存在就先备份。加密库如果带独立的 DLL,把它和 exe 放同一目录,别放系统目录——放系统目录之后升级会互相覆盖,症状是「以前能开现在开不了」,查起来极费时间。
5.3 多线程下偶发数据错乱或「session busy」
现象:单线程测试全部通过,上了多线程之后隔几个小时出现一次数据不对,或者抛出会话忙的异常,重启进程就恢复。
原因:共享了同一个数据集组件,或者共享了同一个事务对象。事务上下文绑定在 Session 上,两个线程在一个 Session 里各自 StartTransaction,后面 Commit 的那个会把另一个的未提交数据一起提交掉,回滚同理,直接造成数据不一致。
解决:线程内自建 Session,用完释放,绝不复用。给每个线程的 Session 起个可辨识的名字(比如带线程 ID),出问题时日志里能对上。事务里做的事尽量短,别在事务中间去调外部接口或者弹框等用户输入。写入线程数控制在两三个以内,实测再往上加收益很小,死锁概率反而上升。日志里出现「会话忙」的时候,先看有没有长事务没提交,八成是某个分支抛异常跳过了 Commit 或 Rollback。
5.4 用新版本组件打开老库之后,表结构看着不对
现象:从旧版本升级上来,用新组件打开原来的.adb,能连上,但某些字段显示为空、长度变了,或者索引失效导致查询变慢。
原因:字段类型的物理宽度在新版本里调整过(典型是字符串长度和日期精度),老库的元信息按老规则解析;另外索引的页格式如果有变化,旧索引要么被标记失效,要么读出来顺序不对。
解决:升级前先做一次「导出—重建—导入」,别直接在老库上开新组件写数据。导出用老版本的组件,导成文本或中间表;重建库时把页大小、字段类型、索引一次性定好;导入完逐表对比行数和关键字段校验和。升级脚本要可重复执行,出问题时能回到上一步。这一步看着笨,但比事后从备份里捞数据便宜太多。
6. 加密与交付前自检:把 .adb 交出去之前的最后一道关
带加密的单文件数据库有个特性:文件本身就是全部。谁能拿到这个文件,加上密码,谁就拿到全部数据。所以交付前的检查不能只看程序能不能跑,得看这份文件被拷走之后会发生什么。
我一般按这张表过一遍:
| 检查项 | 判断标准 | 不通过的后果 |
|---|---|---|
| 加密是否真的生效 | 用十六进制工具看文件头,明文标识应该不可见 | 加密属性设了但没生效,等于裸奔 |
| 密钥是否硬编码在 exe 里 | 字符串搜索能搜出密码片段就是没做 | 反编译即可拿到全库 |
| 是否有明文临时文件 | 检查%TEMP%和程序目录 | 排序、导出过程落明文 |
| 版本号与库文件是否匹配 | 旧程序打开新库应明确报错 | 静默写坏数据 |
| 干净机器验证 | 没装过开发环境的机器上跑一次全流程 | 缺运行库,现场打不开 |
密钥不要写死在代码里是常识,但落地时很多人图省事还是硬编码。折中做法是把密钥拆成几段,一段在配置文件、一段由机器特征算出来,缺任何一段都打不开,成本不高但能挡住顺手拷贝。真要更严,把库文件本身再做一层应用层字段级加密,敏感字段在入库前就加密,引擎里存的是密文——代价是这些字段不能索引也不能做范围查询,取舍要提前想清楚。
干净机器验证这一步千万别省。我见过太多次「开发机好好的,客户现场打不开」,原因全是运行库缺失或者 BPL 版本不对。准备一台只装操作系统的虚拟机,把安装包从头装一遍,建库、写数据、导出、备份恢复四条路径全走通,才算交付合格。
从那以后我每次交付带.adb的安装包,都强制走一遍上面那张表,尤其是拿一台干净机器从头跑一次——这一步花二十分钟,能省掉现场两天。希望帮到你。
本文还有配套的精品资源,点击获取