ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Delphi与FreePascal中的RESTful ORM SOA MVC架构实践

Delphi与FreePascal中的RESTful ORM SOA MVC架构实践 简介面向Delphi与FreePascal开发者的mORMot2开源框架源码包整合RESTful、ORM、SOA与MVC四大现代架构适用于快速构建高并发Web服务、REST API及企业级业务系统。包内共573个文件压缩后约5.29MB以pas源文件、h头文件、sh构建脚本及md文档为主辅以dpr工程文件、lpi项目索引、dfm窗体定义、inc配置和批量编译脚本目录覆盖完整项目框架、示例与跨平台编译配置便于二次开发与学习底层实现。已有177人次学习下载。通过该资源可获得mORMot核心源码、官方示例、跨平台编译脚本及框架文档既能直接编译运行也能深入研究其ORM对象持久化、SOA服务发布和MVC模式实现细节适合中高级Delphi开发者作为现代架构改造与快速交付的一站式参考。1. 从 Win32 老项目到 Web APIRESTful ORM SOA MVC 框架在 Delphi 和 FreePascal 里的真正作用老 Delphi 项目最难受的是被要求“开接口”。业务逻辑还留在 Form 事件里时客户端直接握着数据库连接临时加一个 HTTP 处理函数能应付单个接口但接口一多、路径一变、字段一改重复劳动立刻失控。标题里的这套发布包实际是把 RESTful 通信、ORM 持久化、SOA 服务边界、MVC 控制器收进了同一个开源组合ORM 负责表与类互相翻译SOA 接口负责把业务方法变成稳定契约RESTful 负责对外资源路径MVC 负责把代码按职责拆分。用 mORMot、DelphiMVCFramework 这类开源项目的成熟思路Delphi 和 FreePascal 都能编同一份服务端代码。适合两类人一是正给老客户端补 JSON 接口的维护者二是下载了这样一个 zip 却不知道从哪个目录开始搭最小服务的新手。下面按一条最小可复现路径往下走。2. 架构分层RESTful 资源、ORM 映射、SOA 契约与 MVC 控制器怎么串起来先分清边界RESTful 管的是“外部怎么看”ORM 管的是“数据怎么存”SOA 管的是“什么能调”MVC 管的是“代码往哪放”。一个请求的完整路径是路由根据 URL 找到控制器控制器做参数校验后调用 SOA 服务服务里操作 ORM 模型ORM 把结果转回对象最后由序列化层输出 JSON。哪一层坏掉都不至于推翻整座楼这是四个词放在同一个框架里的真正价值。2.1 为什么四个词要出现在同一个框架里这四个词经常被等价对待但它们的职责差异很大。RESTful 是协议风格决定/api/users/1这样的资源路径怎么设计ORM 是存储方案决定TSQLRecord子类如何映射到表SOA 是服务边界决定调用方拿到的是一个业务方法而不是一张表MVC 是代码组织决定控制器和服务层谁该写什么。Delphi 生态没有 Spring 那样的大一统容器一个打包发的开源框架把这些约定收拢等于给团队发了一套标准骨架。有人觉得项目里只用 RESTful 加一个数据库驱动就够了那是只看到了路径没看到改动会被扩散到每个调用点的风险。2.2 类型与 ORM 映射从 Delphi 属性到表字段再到 JSON 的统一用 ORM 的第一步是把表和类对齐。下面这个模型类在 mORMot 这类框架里很常见type TSQLUser class(TSQLRecord) private FUserName: RawUTF8; FNickName: RawUTF8; FEmail: RawUTF8; FAge: Integer; FCreatedAt: TDateTime; FIsActive: Boolean; published property UserName: RawUTF8 read FUserName write FUserName; property NickName: RawUTF8 read FNickName write FNickName; property Email: RawUTF8 read FEmail write FEmail; property Age: Integer read FAge write FAge; property CreatedAt: TDateTime read FCreatedAt write FCreatedAt; property IsActive: Boolean read FIsActive write FIsActive; end;这里有几个值得说明的参数选择。继承TSQLRecord是为了让框架通过 RTTI 拿到 published 属性自动生成字段和 JSON字符串统一用RawUTF8避免AnsiString和UnicodeString转换带来的乱码TDateTime在序列化时默认按 ISO-8601 输出。数据库方言不写死在模型里换 SQLite 与 PostgreSQL 时只改连接层。常见类型映射关系如下Delphi/FPC 类型SQLite / PostgreSQL 映射JSON 序列化结果Integer / Int64INTEGER / BIGINT数字RawUTF8TEXT / VARCHAR字符串TDateTimeDATETIME / TIMESTAMP2025-03-01T12:30:00BooleanINTEGER0/1 / BOOLEANtrue/falseTNullableIntegerINTEGERnull或数字这张表要记住的不是“SQLite 里布尔值是 0/1”而是类型选择决定兼容面。项目一旦要跑多个数据库TDateTime和Boolean最容易出问题所以在模型层尽量使用跨方言的类型。2.3 SOA 接口如何避免把数据库泄露给调用方最危险的设计是把 ORM 对象直接当接口返回调用方甚至能猜出表结构。SOA 层要做的是把业务方法定义成契约实现细节不外泄type IUserService interface(IInvokable) [{3D4D7BBD-0A23-4621-A7F0-78D7A2C4B821}] function GetUserByID(const aID: Integer): TSQLUser; function GetUserList(const aPage, aSize: Integer): TSQLUserList; procedure AddUser(const aUser: TSQLUser); end;接口里的参数和返回值就是契约。调用方是 HTTP 客户端可以用 Delphi、FPC、Java 或 Node 实现服务端把GetUserByID映射成一个可寻址的 RESTful 资源具体是/api/users/1还是/api/UserService/GetUserByID/1由路由层决定。SOA 的关键收益是把“你调我哪个方法”和“你访问我哪张表”彻底分开。2.4 MVC 路由如何把 /api/users/1 变成控制器调用MVC 层做的是最后一公里。控制器类只负责三件事从 URL 和查询串里取参数、校验参数、调用服务并返回结果。控制器里面不应该出现 SQL更不应该出现ADQuery1.SQL.Texttype TUserController class(TBaseController) public procedure GetOne; // GET /api/users/5 procedure GetPage; // GET /api/users?page1size20 procedure Create; // POST /api/users end;路由的绑定方式在不同开源包里不一样有的是方法名约定有的是特性标签但职责是一样的把路径参数id变成方法入参把服务返回的对象变成 JSON。控制器薄、服务层厚、ORM 只管存取这三条是 MVC 设计模式在服务端项目里最实用的落地标准。3. 最小可运行 RESTful 服务从模型建表到 curl 调用 /api/users/1 的装配步骤这一章给一条能直接跑通的路径。目标是一个单文件服务端提供GET /api/users/{id}、分页列表和新增用户三个入口数据库第一次启动时自动创建。3.1 最小工程目录把 models、services、controllers 分开一个能维护的项目目录骨架比代码本身更重要。建议从解压出的 zip 里保留这些位置src/models // TSQLRecord 子类比如 user.pas src/services // IUserService 接口与实现类 src/controllers // 控制器负责路由参数 src/app.lpr // 入口装配模型、数据库、服务、HTTP 监听 db/app.db3 // 第一次运行自动生成依赖的框架源码不要散落在 src 里单独放到 lib 目录工程文件用相对路径引用。这样你升级框架时只需要替换 lib不用碰业务代码。3.2 模型定义与自动建表TSQLRecord 的声明方式在models里放一个user.pas内容即上一章那个TSQLUser。随后在入口里把它注册到模型并让框架建表var Model: TSQLModel; Server: TSQLRestServerDB; begin Model : TSQLModel.Create([TSQLUser]); Server : TSQLRestServerDB.Create(Model, app.db3, True); Server.CreateMissingTables; end.TSQLRestServerDB.Create的第三个参数传True表示允许写库CreateMissingTables会扫描模型里注册的类把缺失的表自动建出来。生产环境不要把这个流程放在每次启动都执行的初始化里第一次建表后可以改成False避免误判表结构。3.3 SOA 服务实现业务方法写在服务类里接口定义放services实现类也放这里。实现类要拿到数据库上下文常见做法是构造函数注入type TUserService class(TInjectableObject, IUserService) private fDatabase: TSQLRest; public constructor Create(aDatabase: TSQLRest); override; function GetUserByID(const aID: Integer): TSQLUser; function GetUserList(const aPage, aSize: Integer): TSQLUserList; procedure AddUser(const aUser: TSQLUser); end; function TUserService.GetUserByID(const aID: Integer): TSQLUser; begin Result : TSQLUser.Create(fDatabase, aID); if not Result.IsValid then raise EUserNotFound.Create(user not found); end;解释几个点。TSQLUser.Create(fDatabase, aID)是按主键加载的常规写法IsValid判断记录是否存在。客户端的GET /api/users/404如果查不到应抛一个能被框架识别为 404 的异常不能让 200 响应里带着空对象。这个语义在 RESTful API 接口规范里属于必查项。3.4 控制器与主程序装配从端口监听到处理链控制器层在这一步可以很薄只做参数转换procedure TUserController.GetOne; var uid: Integer; begin uid : RouteParamAsInteger(id); if uid 0 then raise EBadRequest.Create(invalid id); Render(Service.GetUserByID(uid)); end;RouteParamAsInteger是从路径里取参数的方法名不同框架叫法略有差别但逻辑一致先校验再调用业务最后交给渲染器。不要让控制器直接创建数据库连接连接统一由服务层持有。主程序里把服务和服务器装配起来Server.ServiceRegister(TUserService, IUserService); HttpServer : TSQLHttpServer.Create(8888, Server, ); HttpServer.AccessControlAllowOrigin : *; writeln(listening on http://127.0.0.1:8888);ServiceRegister把接口实现绑到服务端这样 HTTP 请求才能命中IUserService表示监听所有网卡AccessControlAllowOrigin : *是给浏览器跨域调试用的上线前按域名收紧。3.5 用 curl 验证第一个 RESTful 接口启动后先测新增再测查询curl -X POST -H Content-Type: application/json \ -d {UserName:neo,Age:30,IsActive:true} \ http://127.0.0.1:8888/api/users curl -i http://127.0.0.1:8888/api/users/1第一条应当返回新记录的主键第二条能看到 JSON 形式的用户信息。如果第二条404优先检查路由注册前缀如果400多半是 JSON 里字段名和 published 属性对不上。FreePascal 编译时注意目标平台位数Windows 下默认 32 位编译容易遇到内存限制建议直接编 64 位。4. 服务端配置与边界处理端口、序列化、CORS、软删除和 SOA 对接参数框架默认值能跑通最小示例但接真实前端和第三方系统时一定要把下面几组参数显式配置出来否则会在对接阶段反复改代码。4.1 服务器启动参数端口、线程池、超时和 CORS 配置配置项虽然不是每个框架同名但语义一致。常用的一组如下配置项典型值说明Port8080监听端口生产环境不要用 8888 这类调试习惯端口ThreadPoolSize16并发请求线程数结合数据库连接池一起调KeepAliveTimeOut30连接保活秒数单位是秒AccessControlAllowOriginhttps://admin.example.com浏览器跨域白名单生产环境不要用通配符MaxRequestBodySize10 * 1024 * 1024请求体上限防止大报文拖垮线程池在入口代码里设置时可以这样组织HttpServer.ThreadPoolSize : 16; HttpServer.KeepAliveTimeOut : 30; HttpServer.AccessControlAllowOrigin : https://admin.example.com; HttpServer.MaxRequestBodySize : 10 * 1024 * 1024;线程池不是越大越好。每个请求都会占用一个数据库连接资源线程池开到 64 而数据库连接池只有 20高并发下大部分线程会阻塞在等连接上。一般是从 16 起步用压测结果反推。4.2 ORM 行为开关时间戳、软删除和主键类型ORM 层最常用的三个开关是自动时间戳、软删除和主键生成方式。自动时间戳让框架在写入时填充CreatedAt和UpdatedAt软删除把DELETE变成UPDATE IsActive 0主键则要注意 SQLite 自增字段在服务端并发插入时返回主键的可靠性。框架里一般都有如下配置Model.Properties[TSQLUser].AutoCreateProperties : [cCreatedAt, cUpdatedAt]; Model.Properties[TSQLUser].SoftDeleteField : IsActive;AutoCreateProperties指定哪些字段由框架自动赋值SoftDeleteField让删除操作走到更新路径。做 RESTful 接口对接时软删除特别适合“列表页默认不显示已删除用户但保留审计记录”的需求。需要注意软删除开启后唯一索引要谨慎建在UserName上否则删除后无法重建同名用户。4.3 JSON 序列化与 RESTful 接口对接的参数选择序列化决定前端拿到的字段大小写、日期格式和 null 行为。最容易引发扯皮的是 null 字段默认序列化会把没有赋值的字符串属性输出为或null前端两种都得处理。建议统一成三种约定字段名统一小写开头和 published 属性名一致日期只输出 ISO-8601 字符串不输出时间戳数字未赋值对象输出null空数组输出[]不要混用。示例输出如下{ id: 1, userName: neo, email: null, createdAt: 2025-03-01T12:30:00 }这里的email: null和[]是配对出现的客户端可以用同一套空值判断逻辑。如果框架默认序列化不带时区服务端部署在 UTC 而客户端在东八区日期会差 8 小时这个问题放在第 5 章详细讲。4.4 SOA 与 RESTful 并存的错误码和缓存策略对外暴露 RESTful 接口时统一错误响应结构比“返回一个字符串”更重要。常见做法是定义一个轻量错误体{ error: { code: USER_NOT_FOUND, message: user not found } }服务实现里抛业务异常由框架统一转成 HTTP 状态码参数错误返回 400未登录返回 401无权限返回 403资源不存在返回 404唯一键冲突返回 409剩下未捕获异常返回 500。SOA 接口的调用方只看error.code不解析message这样多语言提示就不会影响客户端逻辑。缓存建议放在控制器或服务层而不是 ORM 层。针对GET /api/users/{id}可以按主键做短缓存配合Last-Modified响应头列表接口不要缓存否则新增数据后列表不刷新排查时会怀疑 ORM 缓存了自己改不了的 SQL。5. 上线前的接口验证与三个高频坑状态码断言、Unicode、时区、ID 自增5.1 把 curl 转化成可回归的断言脚本手动测一遍接口不等于验证过。把 curl 命令写成一个脚本用 HTTP 状态码做断言上线前跑一遍改模型后跑一遍这比任何代码评审都有效#!/usr/bin/env bash code$(curl -s -o /dev/null -w %{http_code} \ http://127.0.0.1:8888/api/users/999999) if [ $code ! 404 ]; then echo expected 404 but got $code exit 1 fi code$(curl -s -o /dev/null -w %{http_code} \ -X POST -H Content-Type: application/json \ -d {UserName:neo} http://127.0.0.1:8888/api/users) if [ $code ! 201 ] [ $code ! 200 ]; then echo expected create success but got $code exit 1 fi断言脚本不要只检查 200重点检查错误路径不存在资源返回 404非法参数返回 400重复数据返回 409。把这些用例留在 CI 里数据库换成内存模式每次提交都能跑。5.2 三个高频坑Unicode、时区和自增 ID第一个是 Unicode 乱码。Delphi 7 老代码里大量使用AnsiStringORM 层一旦接错中文名入库后就变成乱码也就是常说的 “delphi sqlite 亂碼”。规避方法是模型字段全部用RawUTF8JSON 输出统一 UTF-8只在界面层做显示转换FreePascal 下注意源码文件保存为 UTF-8字符串常量前用显式类型转换。第二个是时区。TDateTime不带时区信息服务端在东八区写入2025-03-01 12:30:00客户端在 UTC 会读成同一天同一时刻而不是换算后的时间。建议服务端统一存 UTCDTO 层转成带时区的 ISO-8601 字符串再输出比如2025-03-01T04:30:00Z。在 ORM 里不要直接序列化TDateTime宁可多写一个只读的CreatedAtIso属性。第三个是自增 ID。SQLite 的LastInsertRowID只能拿到当前连接最后一次插入的主键服务端线程池并发时两条插入之间如果读取时机不对会拿到别的主键。正确做法是使用框架 INSERT 方法的返回值或在事务完成后立刻从结果对象里取 ID而不是在另一个查询里读。5.3 最后一个验证动作盯着 ORM 日志看 SQL如果只做一项性能验证建议把 ORM 日志打开输出每条 RESTful 请求对应的 SQL。这个动作能立刻暴露两类问题N1 查询和全表扫描。日志大致长这样GET /api/users?page1size20 [1 ms] SELECT * FROM t_user LIMIT 20 OFFSET 0; GET /api/users/1 [0 ms] SELECT * FROM t_user WHERE id1;看到一条控制器请求背后跟了 20 条SELECT * FROM t_user WHERE id?就是典型 N1。此时应该改成 JOIN 或批量加载而不是急着加数据库缓存。日志确认查询条数稳定后再关掉 ORM 日志把KeepAliveTimeOut和线程池数值写进部署文档这组参数就是你下一次扩容时的基准线。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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