ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Delphi构建Web数据库服务系统:架构、数据库访问与安全实践

用Delphi构建Web数据库服务系统:架构、数据库访问与安全实践

简介:这份文档资料面向希望用Delphi构建Web数据库服务系统的开发者,尤其适合具备一定Delphi基础、想从桌面应用转向Web端数据库开发的中级程序员。内容围绕Web浏览器、Web应用服务器、数据库服务器与DBMS的协作架构展开,讲解如何借助ISAPI框架、TWebModule组件以及TPageProducer、TQueryTableProducer等数据库组件,把HTTP请求转化为SQL操作并将结果以HTML返回浏览器,同时涉及TSession会话管理与通讯录发布实例。资源包共1个doc文件,约32KB,属于纯文档型资料,便于快速通读与查阅。目前已有93人学习,适合作为理解Delphi Web数据库开发流程的入门参考,帮助读者掌握组件化开发思路、请求响应处理机制与多用户会话管理要点,为搭建定制化Web数据库方案提供可借鉴的实践框架。

1. 用 DELPHI 制作 WEB 数据库服务系统:老工具在新需求里的真实位置

手里有一套 Delphi 写的桌面端业务系统,客户突然要求「能在浏览器里查数据、改数据」,预算又不允许推倒重来——这是我最近几年被问得最多的一类需求。标题里的「用 DELPHI 制作 WEB 数据库服务系统」,说的就是这件事:不换语言、不换团队,用 Delphi 把已有的数据库访问逻辑包成 HTTP 服务,前端用普通网页去调。它解决的不是「做一个互联网级高并发平台」,而是让存量 Delphi 资产快速获得 Web 访问能力,适合中小企业内部系统、设备管理后台、数据录入与查询这类场景。热搜里反复出现的「数据库增删改查」「web 服务器安全」「企业级 web 开发」,落到这里就是三件具体的事:接口怎么设计、连接怎么管、权限怎么卡。下面按我实际做过的路径拆开讲。

2. 先定架构:Delphi 做 Web 数据库服务系统的三种常见形态

2.1 三种形态的取舍:CGI/ISAPI、独立 HTTP 服务、REST 中间层

在动手写第一行代码前,必须先选形态,因为它决定了后面所有代码的组织方式。Delphi 做 Web 数据库服务,业内常见做法基本落在三条路上。

第一条是 CGI/ISAPI 路线。用 Delphi 编译出 dll 或 exe,挂到 IIS 或 Apache 下,由 Web 服务器负责 HTTP 解析,Delphi 只处理业务。优点是部署简单、和传统 Web 服务器安全体系天然兼容;缺点是 ISAPI 的 dll 一旦崩溃会拖垮整个 Web 服务器进程,调试也麻烦,现在新项目我基本不推荐。

第二条是独立 HTTP 服务。用 Delphi 自带的IdHTTPServer(Indy 组件)或TIdHTTPWebBrokerBridge起一个独立进程,自己监听端口、自己解析请求。这是目前最主流的做法,进程隔离好、调试方便、可以打包成 Windows 服务开机自启。热搜里的「web 服务器安全」在这个形态下要自己多操心一层,因为没有了 IIS 的请求过滤。

第三条是 REST 中间层。Delphi 只做纯 JSON 接口,前端用 Vue/React 或纯 HTML+fetch 调用,前后端彻底分离。这是我最推荐的形态,因为接口一旦稳定,前端换不换技术栈都无所谓,也方便以后把某个接口单独拆出去。

选型上给一个直接的判断标准:如果只是给内部几个人用、数据量小、要求一周上线,选第二条;如果前端团队独立、接口要被多个端复用,选第三条;如果公司已经有 IIS 运维体系且不允许开新端口,才考虑第一条。

2.2 最小可运行骨架:用 IdHTTPServer 起一个能返回 JSON 的服务

下面这段是我常用的最小骨架,能直接跑起来返回一段 JSON,验证环境是否通。用 Delphi 10.4 及以上版本,Indy 组件随 IDE 自带,不需要额外装包。

unit uMain; interface uses Winapi.Windows, System.SysUtils, System.Classes, System.JSON, IdContext, IdCustomHTTPServer, IdHTTPServer, IdSocketHandle; type TMainForm = class(TForm) procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); private FServer: TIdHTTPServer; procedure OnCommandGet(AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo); end; implementation procedure TMainForm.FormCreate(Sender: TObject); begin FServer := TIdHTTPServer.Create(Self); FServer.DefaultPort := 8080; // 监听端口,按需改 FServer.OnCommandGet := OnCommandGet; FServer.Active := True; // 启动服务 end; procedure TMainForm.OnCommandGet(AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo); var LJson: TJSONObject; begin // 只处理 /api/ping,其余返回 404 if ARequestInfo.Document = '/api/ping' then begin LJson := TJSONObject.Create; try LJson.AddPair('code', TJSONNumber.Create(0)); LJson.AddPair('msg', 'pong'); AResponseInfo.ContentType := 'application/json; charset=utf-8'; AResponseInfo.ContentText := LJson.ToJSON; finally LJson.Free; end; end else AResponseInfo.ResponseNo := 404; end; procedure TMainForm.FormDestroy(Sender: TObject); begin FServer.Active := False; end;

逻辑说明:OnCommandGet是 Indy 处理 GET 请求的回调,所有路由判断都写在这里。ARequestInfo.Document是请求路径,AResponseInfo.ContentText是返回体。参数上,DefaultPort建议避开 80 和 443,用 8080 或 8090,避免和已有服务冲突;ContentType一定要带charset=utf-8,否则中文会乱码,这是新手最常见的翻车点。

2.3 路由与请求分发:别把业务逻辑全塞进 OnCommandGet

上面那段骨架只适合验证,真实项目里如果把所有接口都堆在OnCommandGet里,几百行之后就没法维护了。我一般会做一个极简的路由表,用TDictionary<string, TProc<...>>把路径映射到处理函数。

type TApiHandler = reference to procedure(ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo); // 注册路由 FRoutes := TDictionary<string, TApiHandler>.Create; FRoutes.Add('/api/user/list', HandleUserList); FRoutes.Add('/api/user/add', HandleUserAdd); // 分发 if FRoutes.TryGetValue(ARequestInfo.Document, LHandler) then LHandler(ARequestInfo, AResponseInfo) else AResponseInfo.ResponseNo := 404;

这样每个接口一个独立方法,数据库操作、参数校验、返回组装都在各自方法里,改一个接口不会影响其他接口。参数说明:TApiHandler用匿名方法引用,能直接捕获外部变量,但要注意别在闭包里持有数据库连接对象,否则连接释放时机不可控。

3. 数据库访问层:连接、参数化查询与增删改查落地

3.1 连接方式选型:ADO、FireDAC 与连接池

Delphi 访问数据库,常见的是 ADO(TADOConnection)和 FireDAC(TFDConnection)。ADO 依赖系统 OLE DB 驱动,部署省事但性能和跨平台性一般;FireDAC 是 Embarcadero 官方主推,支持 MySQL、SQL Server、SQLite、PostgreSQL 等,参数化查询和连接池都更完善。新项目我基本都用 FireDAC。

热搜里的「mysql 的数据库连接池」「托管数据库服务」在这里对应一个关键决策:Web 服务是多线程的,每个请求都可能并发访问数据库,如果每个请求都新建连接,几百并发就会把数据库打满。正确做法是用 FireDAC 的TFDManager配连接池,或者自己维护一个连接对象池。

// FireDAC 连接池配置,放在程序启动时 FDManager.Close; FDManager.AddConnectionDef('Pool_Main', 'MySQL', 'DriverID=MySQL;Server=127.0.0.1;Port=3306;' + 'Database=erp;User_Name=app;Password=***;' + 'Pooled=True;POOL_MaximumItems=20;POOL_ExpireTimeout=600');

参数说明:Pooled=True开启池化,POOL_MaximumItems是池内最大连接数,一般按「数据库最大连接数 ÷ 应用实例数」来估,20 到 50 之间比较稳;POOL_ExpireTimeout是空闲连接回收秒数,设太短会频繁重建连接,设太长会占着数据库连接不放。

3.2 参数化查询:把 SQL 注入挡在门外

「web 服务器安全」这个词落到数据库层,最实在的一条就是参数化查询。拼接 SQL 字符串是血泪教训的重灾区,一个' OR '1'='1就能把登录绕过。FireDAC 用TFDQuery的Params或Macros都能做参数化。

procedure HandleUserAdd(ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo); var LQuery: TFDQuery; LBody: TJSONObject; begin LBody := TJSONObject.ParseJSONValue(ARequestInfo.Content) as TJSONObject; LQuery := TFDQuery.Create(nil); try LQuery.ConnectionDefName := 'Pool_Main'; LQuery.SQL.Text := 'INSERT INTO t_user (user_name, mobile, create_time) ' + 'VALUES (:name, :mobile, NOW())'; LQuery.ParamByName('name').AsString := LBody.GetValue('name').Value; LQuery.ParamByName('mobile').AsString := LBody.GetValue('mobile').Value; LQuery.ExecSQL; // 执行增删改 AResponseInfo.ContentText := '{"code":0}'; finally LQuery.Free; LBody.Free; end; end;

逻辑说明::name和:mobile是占位符,FireDAC 会把值单独传给数据库驱动,不参与 SQL 文本拼接,从根本上杜绝注入。参数说明:AsString会做类型转换,如果字段是整型要用AsInteger,日期用AsDateTime,类型不匹配会抛异常。查询用Open,增删改用ExecSQL,这两个方法别用混,用错会报「数据集未打开」或「无法执行」。

3.3 增删改查的接口设计:统一返回结构

接口返回结构不统一,前端就要为每个接口写不同的解析逻辑,这是后期维护成本的大头。我一般定一个固定结构:code表示业务状态,msg表示提示信息,data放实际数据。查询接口返回数组,增删改返回受影响行数。

function BuildResp(ACode: Integer; const AMsg: string; AData: TJSONValue): string; var LRoot: TJSONObject; begin LRoot := TJSONObject.Create; LRoot.AddPair('code', TJSONNumber.Create(ACode)); LRoot.AddPair('msg', AMsg); if AData <> nil then LRoot.AddPair('data', AData); Result := LRoot.ToJSON; LRoot.Free; end;

参数说明:AData允许为 nil,这样增删改接口不用硬塞一个空对象。code建议 0 表示成功,非 0 表示各类错误,具体错误码在文档里列清楚,前端按码处理,不要靠解析msg文本判断。

4. 避坑与排查:Delphi Web 数据库服务最容易翻车的五件事

4.1 中文乱码:请求和响应两头都要管

现象:前端提交的中文存进数据库变成问号,或者查询返回的中文在浏览器里显示乱码。原因:HTTP 请求体默认按 ISO-8859-1 解析,响应头没声明 UTF-8。解决:请求侧用ARequestInfo.PostStream读原始字节再按 UTF-8 解码,响应侧固定设置ContentType := 'application/json; charset=utf-8',数据库连接串里也要加CharacterSet=utf8mb4。三处缺一处都会乱。

4.2 多线程访问数据库崩溃

现象:单机测试正常,一上并发就报「连接已关闭」或直接进程崩溃。原因:Indy 的OnCommandGet是在工作线程里执行的,多个线程共用一个TFDQuery或TFDConnection实例会冲突。解决:每个请求内部自己创建TFDQuery,连接用连接池按名获取,绝不把连接对象放到全局变量里跨线程共享。这是最隐蔽也最致命的一条。

4.3 端口被占用或防火墙拦截

现象:本机http://127.0.0.1:8080能访问,局域网其他机器访问不了。原因:Windows 防火墙默认拦截入站连接,或者端口已被其他进程占用。解决:用netstat -ano | findstr 8080查占用,在防火墙入站规则里放行对应端口。部署到服务器时,这一步不做,前端永远连不上,很多人会误以为是代码问题。

4.4 大结果集把内存吃满

现象:查询一张几十万行的表,服务内存飙升甚至 OOM。原因:一次性把全部结果读进TJSONArray再序列化。解决:接口强制分页,page和pageSize作为必传参数,pageSize上限卡在 200 到 500 之间;SQL 用LIMIT加OFFSET,同时返回总数供前端分页控件使用。别指望前端自己截断,数据已经传过去了。

4.5 服务异常退出没有日志

现象:服务跑几天突然没了,重启后也不知道发生了什么。原因:没有全局异常捕获,未处理异常直接终止进程。解决:在OnCommandGet外层包try...except,把异常堆栈写进日志文件;同时用Application.OnException兜底。日志按天切分,保留最近 30 天。没有日志的线上服务,排查全靠猜,这是我最不愿意看到的局面。

5. 进阶技巧:把接口文档、鉴权和部署一次做对

5.1 用接口签名做轻量鉴权

内部系统不一定要上 OAuth,但裸奔的接口绝对不行。我常用的做法是:登录接口返回一个 token,后续请求在 Header 里带X-Token,服务端校验 token 有效性并绑定用户身份。token 用 GUID 加时间戳生成,存数据库或内存缓存,设过期时间。校验逻辑放在路由分发之前,未通过直接返回 401,业务代码里就不用每个接口都写一遍。

function CheckToken(ARequestInfo: TIdHTTPRequestInfo): Boolean; var LToken: string; begin LToken := ARequestInfo.HeaderValues['X-Token']; Result := (LToken <> '') and TokenStore.Exists(LToken); end;

参数说明:HeaderValues大小写不敏感,前端传x-token也能取到。TokenStore可以用TDictionary加定时清理,量大了再换 Redis。

5.2 打包成 Windows 服务并配置自启

独立 exe 双击运行,一关窗口服务就停了,这不是生产做法。用TService把服务逻辑包起来,或者用sc create把 exe 注册成系统服务。注册命令:

sc create DelphiWebDB binPath= "D:\app\WebDBService.exe" start= auto sc description DelphiWebDB "Delphi Web 数据库服务" sc start DelphiWebDB

参数说明:binPath后面等号前要有空格,这是sc命令的坑,少空格会报参数错误。start= auto表示开机自启。注册后可以在「服务」管理器里看到,日志建议写到固定目录,方便运维查看。

5.3 上线前必做的三项验证

第一项,用curl或 Postman 把所有接口跑一遍,确认正常返回和异常返回都符合预期,特别是参数缺失、类型错误、越权访问这三种情况。第二项,用ab或wrk做一轮并发压测,观察连接池是否被打满、响应时间是否稳定。第三项,检查日志目录、数据库备份、端口放行三件事是否都到位。这三项做完,一个 Delphi Web 数据库服务系统才算真正能交付。

我自己踩过最深的一次坑,是早期图省事把数据库连接放在全局变量里,单机测了一周都没事,上线第二天并发一上来整个服务直接崩,查了一整晚才定位到线程冲突。从那以后,凡是 Web 服务,连接一律走池、对象一律请求内创建,这个习惯帮我省了无数次后悔药。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进