REST 服务器:用户、多租户与指标 | eSeGeCe 博客

REST 服务器:用户、多租户与指标

· 组件
sgcWebSockets REST 服务器的用户、租户与指标

第一篇文章介绍了 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 之后,存储会定期自行落盘;否则就在你合适的时候调用 SaveUsersSaveToFile。如果用户数据已经存放在你自己的数据库中,可以把 StorageType 设为 ustCustom,改为响应各个事件,此时 OnValidateCredentials 就成为权威判定:

procedure TForm1.UsersValidateCredentials(Sender: TObject;
  const aUsername, aPassword: string; var Valid: Boolean);
begin
  Valid := MyDatabase.CheckLogin(aUsername, aPassword);
end;

如果你要做一个管理界面,有一个细节很重要:GetUserByIndex 在把记录交回之前会清空 PasswordHashSalt,因此一个枚举路由即便出于疏忽也不会泄露凭据。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
trJWTClaimbearer 令牌中的某个声明ClaimName

当使用 trHostHostSuffix 设为 .example.com 时,发往 acme.example.com 的请求会解析为 acme。当使用 trPathPathSegmentIndex 为 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;

TenantOnBeforeCommandOnCommandGetOnCommandOther 中都是有效的,而且它是线程局部的,因此一台同时服务众多租户的繁忙服务器绝不会把它们弄混。

关于 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 文档。它的 statusok,或者当挂有熔断器且存在处于打开状态的断路器时为 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 下载页面下载最新构建版本。