第一篇文章介绍了 TsgcHTTPRESTServer 及其请求处理方式。本篇则讲述三个配套组件,它们把一个可用的端点变成真正能拿到客户面前的东西:用户存储、多租户,以及运维团队上线第一天就会索要的指标。
这三者都是独立的组件。你只创建需要的那些并把它们赋给服务器,而任何没有赋值的组件,每个请求只多花一次指针判断的代价。
用用户存储替代 TStringList
TsgcHTTPServer_Users 保存的账户带有加盐并多次迭代的密码哈希。重要的是它所需的接线极少:把它赋给服务器的认证选项,HTTP Basic 关卡就会自行用它来校验凭据。
uses
sgcHTTP_REST_Server, sgcHTTP_REST_Server_Users;
FUsers := TsgcHTTPServer_Users.Create(self);
FServer := TsgcHTTPRESTServer.Create(self);
FServer.Authentication.Users := FUsers;
就这么多。要让 Basic 认证工作,你不需要 OnAuthentication 处理器,关卡会自己完成查找和哈希比对。
账户通过 AddUser 添加,它返回生成的用户 Id;当用户名为空或已被占用时返回空字符串:
FUsers.AddUser('alice', 'secret123', 'admin,reader');
FUsers.AddUser('bob', 'secret456', 'reader');
其余的接口正如你所预期,而且每一次查找都是线程安全的:
if FUsers.ValidateCredentials('alice', 'secret123') then
...
FUsers.SetPassword('bob', 'newsecret');
FUsers.EnableUser('bob', False);
FUsers.DeleteUser('bob');
角色
角色是账户上一个以逗号分隔的列表,并配有读取和检测它们的辅助方法。在处理器中做一次角色校验只需一次调用:
FUsers.AddRole('bob', 'admin');
if not FUsers.UserHasRole(vUser, 'admin') then
begin
AResponseInfo.ResponseNo := 403;
AResponseInfo.ContentType := 'application/json';
AResponseInfo.ContentText := '{"error":"forbidden"}';
Exit;
end;
哈希
密码默认使用 SHA-512 迭代 10000 次进行哈希,每个账户都带有自己的随机盐。正是这个按账户区分的盐,使两个相同的密码也会得到不同的哈希,所以你很少需要去改动它:
FUsers.Hashing.Algorithm := uhaSHA512;
FUsers.Hashing.Iterations := 10000;
Salt 属性则是另一回事,值得仔细读一读。它是一个可选的胡椒(pepper),会在每个账户自带的盐之上再混入每一次哈希;它默认为空是有意为之:在构造时随手生成的值在下一次运行时就会不同,于是每一个正确的密码都会被拒绝,而且没有任何错误信息来解释原因。只有当这个值能来自用户存储本身之外的地方时才去设置它,例如环境变量或密钥保险库。把胡椒和它所保护的哈希放在一起,等于什么也没做。修改它会让已经存储的所有密码全部失效。
持久化
该存储默认位于内存中。把它指向一个文件,它就能在重启后继续存在:
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'sgcRESTUsers.dat';
FUsers.Storage.EncryptAtRest := True;
FUsers.Storage.EncryptionKey := GetKeyFromEnvironment;
FUsers.Storage.AutoSaveSeconds := 30;
FUsers.LoadUsers;
设置了 AutoSaveSeconds 之后,存储会定期自行落盘;否则就在你合适的时候调用 SaveUsers 或 SaveToFile。如果用户数据已经存放在你自己的数据库中,可以把 StorageType 设为 ustCustom,改为响应各个事件,此时 OnValidateCredentials 就成为权威判定:
procedure TForm1.UsersValidateCredentials(Sender: TObject;
const aUsername, aPassword: string; var Valid: Boolean);
begin
Valid := MyDatabase.CheckLogin(aUsername, aPassword);
end;
如果你要做一个管理界面,有一个细节很重要:GetUserByIndex 在把记录交回之前会清空 PasswordHash 和 Salt,因此一个枚举路由即便出于疏忽也不会泄露凭据。FindUser 并不会这样做。它原样返回存储中的记录,凭据字段也包含在内,因为身份验证关卡本身用的就是这个查找。请从中读取你需要的字段,绝不要把整条记录序列化进响应体。
多租户
TsgcHTTPServer_Tenancy 为每个请求回答一个问题:这是给哪个客户的?它会在你的处理器运行之前解析出一个租户字符串,服务器则通过只读的 Tenant 属性把它暴露出来。
FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FTenancy.HeaderName := 'X-Tenant-Id';
FTenancy.DefaultTenant := 'public';
FServer.Tenancy := FTenancy;
共有五种解析模式:
| Resolution | 来源 | 配置属性 |
|---|---|---|
trNone | 关闭,Tenant 始终为空 | — |
trHost | 主机名 | HostSuffix |
trPath | 请求路径中的某一段 | PathSegmentIndex |
trHeader | 某个请求头 | HeaderName |
trJWTClaim | bearer 令牌中的某个声明 | ClaimName |
当使用 trHost 且 HostSuffix 设为 .example.com 时,发往 acme.example.com 的请求会解析为 acme。当使用 trPath 且 PathSegmentIndex 为 0 时,/acme/api/orders 也解析出同样的结果。当配置的来源得不到任何值时,就会使用 DefaultTenant。
在处理器中读取它就只是一个属性:
procedure TForm1.ServerCommandGet(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
begin
AResponseInfo.ResponseNo := 200;
AResponseInfo.ContentType := 'application/json';
AResponseInfo.ContentText := '{"tenant":"' + FServer.Tenant + '"}';
end;
Tenant 在 OnBeforeCommand、OnCommandGet 和 OnCommandOther 中都是有效的,而且它是线程局部的,因此一台同时服务众多租户的繁忙服务器绝不会把它们弄混。
关于 trJWTClaim 有一点必须说清楚。该声明是不校验签名就直接从令牌载荷中读出的,因为租户只是一个路由提示。当你启用 JWT 认证时,签名仍然会被检查。不要单凭租户就把它当作身份的证明。
如果这五种模式都不合适,OnResolveTenant 会把所有来源一次性交给你,由你自己决定:
procedure TForm1.TenancyResolveTenant(Sender: TObject;
const aHost, aPath, aHeaderValue, aJWTPayload: string; var aTenant: string);
begin
aTenant := LookupTenantForHost(aHost);
end;
指标与健康检查
TsgcHTTPServerStats 统计服务器做了些什么,并可以把结果发布在两个端点上。在你逐个启用之前两者都是关闭的,所以不会仅仅因为你把组件放到窗体上,就让某些东西变得可访问。
FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Metrics.Enabled := True;
FStats.Endpoints.Health.Enabled := True;
FServer.ServerStats := FStats;
这些计数器也可以从代码中读取,用来做一个内部状态界面很方便:
lblRequests.Caption := IntToStr(FStats.TotalRequests);
lblErrors.Caption := IntToStr(FStats.Status5xx);
lblLatency.Caption := IntToStr(FStats.LatencyAvgMs) + ' ms';
lblUptime.Caption := IntToStr(FStats.UptimeSeconds) + ' s';
/metrics 直接返回 Prometheus 文本展示格式,中间不需要 exporter。输出包含按端点统计的计数器,其基数上限为 256 个不同路径,因此一个路径里带 id 的路由不会让序列数量爆炸。超出上限的部分统一归入 other。
# HELP sgc_server_endpoint_requests_total Requests per endpoint
# TYPE sgc_server_endpoint_requests_total counter
sgc_server_endpoint_requests_total{endpoint="/api/orders"} 3120
sgc_server_endpoint_requests_total{endpoint="/api/users"} 845
如果挂上了防火墙、限流器、熔断器或 API 密钥管理组件,它们各自的指标也会被加入同一份输出:
FStats.RateLimiter := FRateLimiter;
FStats.Firewall := FFirewall;
/health 返回一份紧凑的 JSON 文档。它的 status 为 ok,或者当挂有熔断器且存在处于打开状态的断路器时为 degraded,这使它可以直接用作负载均衡器的探针。
最后,如果你更愿意自己把这些数字推送到别处,OnStats 会带着整个统计对象触发:
procedure TForm1.StatsEvent(Sender: TObject;
const aStats: TsgcHTTPServerStats);
begin
MyTelemetry.Send(aStats.TotalRequests, aStats.LatencyAvgMs);
end;
组合起来
把三者全部挂上的服务器大约十来行代码,而且每个组件都独立于其他组件:
FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Health.Enabled := True;
FStats.Endpoints.Metrics.Enabled := True;
FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FUsers := TsgcHTTPServer_Users.Create(self);
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'users.dat';
FUsers.LoadUsers;
FServer := TsgcHTTPRESTServer.Create(self);
FServer.ServerStats := FStats;
FServer.Tenancy := FTenancy;
FServer.Authentication.Users := FUsers;
FServer.Authentication.Enabled := True;
FServer.Port := 5876;
FServer.Active := True;
下一篇文章会在这台服务器前面放上一份 OpenAPI 契约,让路由、校验和文档全都来自规范文件。
请从 sgcWebSockets 下载页面下载最新构建版本。
