简介:DeWeb 是一套把既有 Delphi 工程直接搬上浏览器运行的工具包,面向熟悉 Object Pascal、希望绕开 HTML、JavaScript、PHP、ASP 与 C# 等语言、以最少改造成本交付 Web 应用的开发者。压缩包 24.59MB,共 2669 个文件,以 296 个 pas 源码、247 个 dcu 编译单元、165 个 dfm 窗体、126 个 dproj 工程为核心,配合 222 个 js、91 个 css 与 304 个 res 资源支撑页面呈现,并含 icsV858 控件相关文件,覆盖 Delphi 10.1、10.2、10.3 多个版本。包内示例可通过 demos.dw、mobile.dw、form4.dw 查看桌面与移动端效果,也演示了新建窗体后以 RegisterClass、UnRegisterClass 注册页面、重编译即生效的最小闭环。已有 1073 人学习,适合想验证 Delphi 转 Web 可行性的团队。
1. 一个 2020 年的 Delphi Web 示例包,为什么现在还有人翻出来
手上有一套跑了十几年的桌面程序,业务方突然要求「在浏览器里能打开」,这时候再回头翻 Delphi Web 方案的团队不少,uniGUI 基本是绕不开的一条路。这个deweb2020-05-23 beta2 new2.rar就是那个时间点留下来的示例工程合集:一堆能直接编译的窗体样例、ServerModule 与 MainModule 的骨架、以及把 VCL 界面往 Web 上搬的写法参考。它的价值不在于代码多漂亮,而在于省掉你从零搭脚手架、逐个控件试错的那两三天。适合两类人:正在评估 uniGUI 能不能承接存量桌面项目的人,以及已经决定用、但缺一份可编译参照物的开发者。包名带 beta,意味着它是过程产物,你得带着怀疑去读。
2. 从 rar 到第一个窗体:把包里的工程真正编译起来
2.1 三条硬门槛,先判断这套示例能不能在你机器上用
拿到一个来源年份较早的 Delphi 工程包,最省时间的做法是先做「能不能编」的判定,而不是急着读源码。这类包通常和三样东西强绑定:IDE 大版本、uniGUI 运行时版本、目标平台位数。年份在 2020 年附近的工程,用 10.x 系列打开的成功率最高;用更新的 IDE 打开时,.dproj会被自动升级,升级本身不可逆,所以务必先复制一份再打开,这条是血泪经验。
| 门槛 | 检查方法 | 不满足时的典型表现 |
|---|---|---|
| IDE 大版本 | 看.dproj里的<ProjectVersion>与<DCC_Namespace> | 打开时提示转换,转换后编译报找不到单元 |
| uniGUI 运行时 | 看工程引用的包名里带不带版本后缀 | 报Never-build package 'unigui...' must be recompiled |
| 目标平台 | .dproj里<Platform>是否为 Win32 | 编译通过但部署到 Web 服务器加载失败 |
位数这条要单独强调:多数人第一次部署会把 ISAPI 模块丢进 64 位应用池,然后得到一个 500 或者干脆空白页。示例包里若没有明确的 64 位工程配置,就按 32 位走,并在服务器上把「启用 32 位应用程序」打开。
2.2 目录怎么读:先找工程文件,不按文件夹名字猜
beta 包的目录结构往往和正式发布版对不上,靠名字猜会浪费时间。正确的顺序是:先全量列出工程文件,再反推哪些是主工程、哪些是被引用的库工程,最后才看窗体。下面这套流程我在很多老包里都用过,稳。
# 1) 解包到不含中文、不含空格的路径。西文短路径能省掉后面一半的怪问题 7z x deweb2020-05-23.beta2.new2.rar -o./deweb # 2) 列出所有工程文件,按路径深度排序,主工程一般藏在最外层或 Demo 层 find ./deweb -maxdepth 4 -name "*.dproj" | sort # 3) 看某个工程引用了哪些包,这一步直接决定你会不会卡在编译 grep -o "<DCC_UsePackage>[^<]*" ./deweb/Demo/WebApp.dproj | sort -u第 2 步用-maxdepth是为了避免某些包里塞了第三方组件源码,深挖会翻出一堆无关的.dproj。第 3 步的DCC_UsePackage是关键:它告诉你这个工程编译时依赖哪些设计期包,把它和你 IDE 里已安装的包列表对一遍,缺哪个补哪个,比盲目编译再逐个看报错快得多。
读完这三步,你会得到一张自己的目录用途表,大致长这样:
| 内容类型 | 常见位置 | 阅读优先级 |
|---|---|---|
| 主工程与部署配置 | 外层Demo/App目录 | 最高,先编它 |
| 服务器与主数据模块 | Server/Module一类目录 | 高,逻辑入口在这 |
| 窗体与 Frame 示例 | Forms/Frames一类目录 | 中,按需对照控件 |
| 静态资源与主题 | Res/ext一类目录 | 高,路径配错会白屏 |
2.3 用命令行编一次,比在 IDE 里点十次更能暴露问题
先把编译跑通,再谈改造。命令行构建的好处是参数全部显式,出错信息干净,不会被 IDE 的缓存干扰。
# 用 IDE 同款编译器命令行构建,Release + Win32 是最贴近部署的组合 msbuild ./deweb/Demo/WebApp.dproj \ /t:Build \ /p:Config=Release \ /p:Platform=Win32 \ /v:minimal # 构建成功后先别急着配服务器,用 standalone 模式跑起来看首屏 ./deweb/Bin/WebApp.exe参数逐个说清:/t:Build表示增量构建,第一次跑建议换成/t:Rebuild,避免残留的.dcu干扰;/p:Config=Release会切到发布配置,调试信息被裁掉,能提前暴露那些只在 Debug 下「碰巧能跑」的代码;/p:Platform=Win32前面提过,别省。/v:minimal只是压日志噪音,第一次失败时改成/v:detailed看完整调用链。
进程起来后浏览器访问http://127.0.0.1:端口,端口在 ServerModule 里配,默认值各版本不一样,看不出来就直接在服务端代码里写死一个高位端口测。看到第一个窗体渲染出来,说明编译器、运行时、静态资源三者的路径是对齐的;这一步不通,后面所有改造都是空谈。
3. uniGUI 的运行模型:桌面思维直接搬上浏览器必翻车
3.1 ServerModule、MainModule、Form 三层生命周期
uniGUI 把一次 Web 请求拆成了三层对象,搞不清这三层的存活范围,会写出「A 用户的操作改了 B 用户数据」这种极难排查的问题。ServerModule 是进程级单例,整个应用只有一份,适合放全局配置;MainModule 是会话级,每个浏览器会话一份,适合放连接、数据集、权限缓存;Form 是页面级,一个会话里可以有多个,但必须自己创建自己释放。
| 层 | 实例数量 | 生命周期 | 该放什么 | 不该放什么 |
|---|---|---|---|---|
| ServerModule | 进程内 1 份 | 随进程启动到进程退出 | 端口、主题、字符集、日志开关 | 数据库连接、用户状态 |
| MainModule | 每会话 1 份 | 会话建立到超时销毁 | 连接、Query、当前用户、权限 | 跨用户共享的缓存 |
| Form | 每会话 N 份 | 手动 Create 到手动 Free | 界面控件、临时状态 | 长事务、全局句柄 |
把上面这张表当纪律用,能省掉后面 80% 的排查时间。最典型的错误是把TFDConnection拖到 ServerModule 上,本地单人测试一切正常,一上并发就开始出现事务互相污染、提交了别人的数据。
procedure TUniMainModule.UniGUIMainModuleCreate(Sender: TObject); begin // 每个浏览器会话执行一次:连接、数据集、用户上下文都建在这里 FConn := TFDConnection.Create(Self); FConn.Params.Database := 'demo'; FConn.Params.UserName := 'app'; FConn.Params.Password := 'app'; FConn.LoginPrompt := False; FConn.Connected := True; FQuery := TFDQuery.Create(Self); FQuery.Connection := FConn; FQuery.FetchOptions.Mode := fmAll; // Web 端不留活游标,一次拉完最稳 end; procedure TUniMainModule.UniGUIMainModuleDestroy(Sender: TObject); begin // 会话超时或主动登出都会走到这里,漏了它连接会被慢慢吃干 FreeAndNil(FQuery); FreeAndNil(FConn); end;逻辑说明:Create事件对应会话建立,Destroy对应会话结束或超时,成对出现是硬要求。FetchOptions.Mode := fmAll这行值得单独讲——桌面程序习惯留活游标边翻边取,但 Web 请求是短连接,游标跨请求存活会拖住连接、放大锁范围,改成一次拉完,用内存换稳定,在这类场景下几乎总是划算的。事件名在不同小版本里可能显示为OnCreate或带框架前缀的写法,绑定的是同一个时机,别被名字绕晕。
3.2 无状态请求怎么拖住有状态窗体
HTTP 本身没有会话概念,uniGUI 靠会话标识 + 服务端对象表把「有状态窗体」桥接起来。你写的ShowModal在浏览器里其实是一次异步调用加一次前端遮罩,代码看起来是同步的,底下全是异步。理解这一点,才能解释为什么「在窗体构造函数里等一个慢查询」会把整个会话卡住。
procedure TUniMainModule.OpenOrderForm(const AOrderNo: string); var F: TUniFormOrders; begin // 每个会话独立实例,绝不能用单元级全局变量缓存窗体 F := TUniFormOrders.Create(UniApplication); try F.OrderNo := AOrderNo; F.ShowModal; finally F.Free; end; end;参数与逻辑:Create(UniApplication)把窗体挂到当前会话的所有者上,会话销毁时会兜底回收,但finally里的Free仍然要写,因为模态窗体关闭后不释放会一直占内存,用户开开关关十几次就能看到内存曲线往上爬。ShowModal之后主线程是被让出的,别在它后面直接假设用户已经操作完。
配套一条经验:所有耗时操作(导出、批量计算)都不要塞在窗体创建链路里,改成先渲染骨架、再用定时器或回调触发。这类改法在桌面上是可选项,在 Web 上是必选项。
3.3 VCL 控件往 TUniForm 上搬的对照关系
迁移的主体工作是换控件,换错了不是样式问题,是行为问题。
| 桌面控件 | Web 对应 | 注意点 |
|---|---|---|
TStringGrid | TUniStringGrid/TUniDBGrid | 大数据量必须分页,前端渲染行数是硬上限 |
TPageControl | TUniPageControl | 懒加载要显式打开,否则一次全渲染 |
TTimer | TUniTimer | 走的是浏览器轮询,间隔别小于 1 秒 |
ShowMessage | TUniMessageDlg或前端脚本 | Application.MessageBox在 Web 下不可用 |
TOpenDialog | TUniFileUpload | 上传是独立请求,不能同步等返回值 |
表格里最后一行最容易翻车:桌面代码里「弹出文件框 → 拿到路径 → 立刻读文件」是一条直线,Web 上必须拆成「用户选文件 → 上传完成事件 → 再读」,中间还夹着一次网络往返。所有依赖TOpenDialog.FileName的逻辑都要重写成事件驱动。
4. 参数与配置:把示例调成能扛住真实访问的样子
4.1 服务端参数:端口、会话超时、字符集
示例包的默认配置是给本地单人调试用的,直接拿去上线,第一天就会收到「点着点着就没反应了」的反馈。核心参数集中在 ServerModule 上,在初始化事件里显式赋值,比在设计器里点属性更可控,也便于做环境区分。
procedure TUniServerModule.UniGUIServerModuleCreate(Sender: TObject); begin Port := 8077; // standalone 模式监听端口;ISAPI 部署下这行无效 Title := 'Order Console'; CharSet := 'utf-8'; // 漏设的后果是中文全变问号,且很难一眼看出 Theme := 'triton'; // 决定首屏加载哪套前端主题资源 SessionTimeout := 60; // 单位通常是分钟,超时后会话对象整体销毁 ExtRoot := '/ext/'; // 静态资源前缀,改目录必须同步改这里 end;| 参数 | 建议值 | 作用 | 配错的典型症状 |
|---|---|---|---|
Port | 8000 以上高位端口 | standalone 监听端口 | 启动即报端口占用,或被防火墙拦掉 |
SessionTimeout | 30~60 分钟 | 会话存活上限 | 用户离开一会回来点击无响应 |
CharSet | utf-8 | 响应与页面编码 | 中文乱码、提交内容截断 |
Theme | 与资源目录一致 | 前端主题皮肤 | 首屏白屏或样式全丢 |
ExtRoot | 与实际目录一致 | 静态资源路径前缀 | 控制台一堆 404 |
SessionTimeout是最需要按业务调的一个。设太短,用户填了一半的表单被清空,投诉直接上门;设太长,会话对象长期不释放,内存缓慢上涨。折中做法是给到 60 分钟,同时在前端加一个到期前的提示,让用户知道要重新登录,而不是莫名其妙点不动。
4.2 静态资源与首屏:主题、缓存、压缩三件事
uniGUI 的首屏慢,九成不是服务端逻辑慢,是前端资源加载慢。示例包里带的资源目录如果没配压缩和缓存头,每次刷新都会重新拉一遍主题包。先用命令确认现状,再决定改不改。
# 看首屏响应有没有走压缩、缓存头是否合理 curl -s -I -H "Accept-Encoding: gzip" http://127.0.0.1:8077/ \ | grep -i -E "content-encoding|cache-control|content-length"判断标准很直接:content-encoding里没有gzip或br,说明压缩没开,静态资源体积会大出好几倍;cache-control是no-cache或者压根没有,说明每次访问都在重新下载。这两项在反向代理或应用服务器层面开,比在代码里折腾有效得多。
还有一个隐蔽的坑:ExtRoot指向的目录如果是相对路径,在 ISAPI 部署下会相对于应用池的工作目录解析,而不是站点根目录,结果就是本地跑得好好的,一上服务器全部 404。稳妥做法是部署时用绝对路径,或者干脆把资源目录放在站点根下用相对 URL。
4.3 数据访问:连接放哪一层、事务边界画在哪
前面说过连接必须放会话层,这里补事务。Web 场景下事务要短、要闭合、要跟会话绑定,任何一个跨请求的隐式事务都是灾难。
function TUniMainModule.SaveOrder(const AOrderNo: string; AQty: Integer): Boolean; begin Result := False; FConn.StartTransaction; // 事务开在会话自己的连接上 try FQuery.SQL.Text := 'update orders set qty = :q where order_no = :no'; FQuery.ParamByName('q').AsInteger := AQty; FQuery.ParamByName('no').AsString := AOrderNo; FQuery.ExecSQL; FConn.Commit; Result := True; except FConn.Rollback; // 任何异常都必须回滚,别指望上层兜 raise; end; end;逻辑说明:StartTransaction与Commit/Rollback必须成对,except里先回滚再raise,把异常继续往上抛给界面层做提示,而不是在这里吞掉。参数化查询用ParamByName而不是拼字符串,一方面是注入防护,另一方面是让数据库能复用执行计划——后者在并发上来之后对响应时间的影响比想象中大。
边界原则一句话:一次用户点击对应一个事务,事务里不做网络调用、不做文件 IO、不弹窗。违反任何一条,锁持有时间就会从毫秒级涨到秒级,然后你在监控上看到一堆超时,却找不到是哪行代码干的。
5. 避坑与排查:这份 beta 包里最容易翻车的五处
5.1 编译通过、浏览器白屏,控制台一堆 404
现象:进程正常启动,访问首页返回 200,但页面全白,开发者工具里能看到主题 JS 和 CSS 全是 404。
原因:ExtRoot或资源目录的实际位置与配置不一致,或者部署时只拷了 exe 没拷资源目录。beta 包的资源目录名很可能和正式版不同,直接沿用配置就会踩。
解决:先把ExtRoot改成绝对路径验证一次,能通就说明是路径解析问题;确认资源目录被完整拷贝到输出目录;ISAPI 部署下要特别检查应用池的工作目录,它和你想象的往往不是同一个。
5.2 两个人同时用,A 看到的是 B 的数据
现象:单人测试一切正常,两个人同时登录后,列表数据串了,偶尔还出现「提交了别人的单据」。
原因:把数据集、当前用户、权限缓存这类会话级状态放到了 ServerModule 或者单元级全局变量里。ServerModule 进程内只有一份,谁最后写谁生效。
解决:把所有跟「当前用户」相关的对象挪进 MainModule,全局变量一律改成 MainModule 的字段。排查手法是在赋值处打一行日志,把会话标识一起打出来,串数据的时候一眼就能看到两个会话写的是同一个对象。
5.3 窗体关掉再打开就报错,或者内存一路涨
现象:第一次打开正常,关掉再开报访问违规,或者开合十几次后进程内存明显上涨。
原因:两种可能,一是设计期把窗体设成了自动创建,代码里又Create了一次;二是Create了但关闭后没Free。
解决:在工程选项里把所有业务窗体从自动创建列表移除,统一走Create(UniApplication)加finally Free。检查方法很简单,在窗体的Destroy里打日志,开合几次看日志条数对不对得上。
5.4 中文乱码,存进库里是问号
现象:页面上的中文正常,但提交上来的内容进库变问号,或者反过来,库里读出来是乱码。
原因:链路有三段编码——页面响应、请求体、数据库连接,任何一段不是 UTF-8 都会断。示例包默认配置多半只覆盖了其中一段。
解决:CharSet设utf-8;数据库连接参数里显式指定字符集;如果中间有反向代理,确认它没有改写Content-Type。排查时不要猜,直接在保存函数入口把原始字符串的字节长度打出来,长度异常就说明在更早的环节已经坏了。
5.5 空闲一段时间后点击没反应,刷新才好
现象:用户填表单填了十分钟,点保存没有任何反馈,刷新页面后要求重新登录。
原因:SessionTimeout到期,会话对象被销毁,此时前端发出的请求打到了一个已经不存在的会话上,服务端通常不会给出友好提示。
解决:把SessionTimeout按业务节奏调到合理值;前端加一个到期前的提醒;对关键表单做本地暂存,会话失效后能恢复。这类问题不是纯技术问题,配置值要跟业务方对齐,我一般会把这个值写进部署文档而不是代码注释里,改的时候有人盯着。
6. 二次开发的最小骨架与一次冒烟验证
摸清一个来源年份较早的示例包之后,别急着在它上面改业务,先抽一个最小骨架出来,用最少的三件套验证整条链路:ServerModule 只留配置、MainModule 只留会话资源、一个TUniForm当壳。骨架跑通,再把业务窗体一个个搬进来,出问题时你能确定是新代码还是环境。
骨架里我会固定加一段冒烟脚本,每次改完配置跑一遍,比开浏览器点半天可靠:
# 冒烟三连:首页可达、会话建立、静态资源可取 curl -s -o /dev/null -w "home:%{http_code} t:%{time_starttransfer}\n" http://127.0.0.1:8077/ curl -s -c /tmp/uni.cookie -o /dev/null -w "session:%{http_code}\n" http://127.0.0.1:8077/ curl -s -b /tmp/uni.cookie -o /dev/null -w "ext:%{http_code}\n" http://127.0.0.1:8077/ext/三个数字对应三件事:第一个看服务是否活着、首字节时间是否正常;第二个看会话 cookie 有没有下发,这是后面所有交互的前提;第三个看静态资源前缀配得对不对。time_starttransfer这个指标特别有用,它在资源变多、主题变重的时候会先恶化,比等用户投诉早得多。
再往下走,二次开发有两个提效点。一是把窗体基类统一,所有业务窗体继承自同一个带权限校验、带异常兜底、带加载遮罩的基类,避免每个窗体各写一套。二是把「会话级资源」的初始化收敛到一个方法里,MainModule 的Create只调这一个方法,将来加连接池、加缓存、加埋点都只改一处。下面这张表是我判断骨架是否合格的清单:
| 验证项 | 做法 | 合格标准 |
|---|---|---|
| 会话隔离 | 两个浏览器各登录一次,交替操作 | 数据互不影响,日志里会话标识不同 |
| 资源释放 | 同一窗体开合 20 次 | 进程内存回到基线附近 |
| 超时行为 | 把超时改成 1 分钟,等 2 分钟后操作 | 有明确提示,不是静默失败 |
| 并发写入 | 两个会话同时保存同一单号 | 一方成功一方明确报错,无脏数据 |
最后说个习惯。早年我在一个内部系统上偷懒,把连接对象放在了全局单元里,单人测试一周都没事,上线第一个小时就出了数据串号,排查花了两天,因为日志里根本看不出会话边界。从那以后我每次拿到这类示例包,第一件事不是读业务代码,而是把 ServerModule、MainModule、Form 三层的对象归属列成一张表,逐个确认每个对象到底活在哪一层,确认完再动手。这个动作现在看来只花二十分钟,但省下来的都是半夜被叫起来的时间。希望帮到你。
本文还有配套的精品资源,点击获取