ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Delphi uniGUI示例包编译部署与VCL迁移避坑指南

Delphi uniGUI示例包编译部署与VCL迁移避坑指南

简介: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 对应注意点
TStringGridTUniStringGrid/TUniDBGrid大数据量必须分页,前端渲染行数是硬上限
TPageControlTUniPageControl懒加载要显式打开,否则一次全渲染
TTimerTUniTimer走的是浏览器轮询,间隔别小于 1 秒
ShowMessageTUniMessageDlg或前端脚本Application.MessageBox在 Web 下不可用
TOpenDialogTUniFileUpload上传是独立请求,不能同步等返回值

表格里最后一行最容易翻车:桌面代码里「弹出文件框 → 拿到路径 → 立刻读文件」是一条直线,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;
参数建议值作用配错的典型症状
Port8000 以上高位端口standalone 监听端口启动即报端口占用,或被防火墙拦掉
SessionTimeout30~60 分钟会话存活上限用户离开一会回来点击无响应
CharSetutf-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 三层的对象归属列成一张表,逐个确认每个对象到底活在哪一层,确认完再动手。这个动作现在看来只花二十分钟,但省下来的都是半夜被叫起来的时间。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表