跳到主要内容
基础构件

本页尚未翻译,当前显示英文版本。

RPC

**类型化的调用,函数体在别处执行。**把过程声明在它该在的地方——模块上或实体内部——每个绑定都会得到一个生成的、可 await 的方法。动词只有 invoke;单向调用是声明的一种模式,不是另一个动词。

何时使用

  • 调用方需要答复:请求/响应,返回值有类型。
  • 调用方只管上报、继续往下走:声明为单向的 RPC,不会有任何东西传回。
  • 工作比调用本身持续更久:声明为延迟的 RPC 会交回一个工作描述符(work descriptor),而不是超时。
  • 一个问题,多个回答者:群组(group)调用就是 N 次调用,每个答复到达时都绑定着发出它的成员(member)。
  • 动作属于某个对象:把它声明在实体(entity)内部,它只存在于那里(声明方式见 Entity)。
  • 无需使用:如果并没有要求谁去做什么。别人只需做出反应的事实,是一个事件。

分工

主体在本页中的职责
schema-author声明 RPC、它们的模式以及谁可以调用
任意主体在声明允许的范围内,发起有答复的调用或单向调用
群组成员答复扇出调用;每个成员传回一个答复

概览

声明一个有答复的 RPC 和一个单向 RPC;分别调用,再扇出到群组
[Rpc]                                        // answering, immediate, not overridable — the bare defaults
public static ScoreVerdict SubmitScore(ScoreReport report) => Scores.Judge(report);

[Rpc(OneWay = true)]                         // declared one-way: nothing travels back
public static void ReportPing(PingSample sample) => Metrics.Add(sample);

// invoking — generated, typed, awaitable
var verdict = await playserv.Rpc.Invoke.SubmitScore(report);
playserv.Rpc.Invoke.ReportPing(sample);      // one-way by declaration, not by call site

// group fan-out: N calls, one answer bound to each member
await foreach (var answer in squad.Invoke.ReadyCheck())
    Hud.Mark(answer.Member, answer.Ready);
export class MatchRpcs {
  @Rpc()                                     // answering, immediate, not overridable — the bare defaults
  static submitScore(report: ScoreReport): ScoreVerdict { return Scores.judge(report); }

  @Rpc({ oneWay: true })                     // declared one-way: nothing travels back
  static reportPing(sample: PingSample): void { Metrics.add(sample); }
}

// invoking — generated, typed, awaitable
const verdict = await playserv.rpc.invoke.submitScore(report);
playserv.rpc.invoke.reportPing(sample);      // one-way by declaration, not by call site

// group fan-out: N calls, one answer bound to each member
for await (const answer of squad.invoke.readyCheck())
  hud.mark(answer.member, answer.ready);
@rpc()                                       # answering, immediate, not overridable — the bare defaults
def submit_score(report: ScoreReport) -> ScoreVerdict:
    return scores.judge(report)

@rpc(one_way=True)                           # declared one-way: nothing travels back
def report_ping(sample: PingSample):
    metrics.add(sample)

# invoking — generated, typed, awaitable
verdict = await playserv.rpc.invoke.submit_score(report)
playserv.rpc.invoke.report_ping(sample)      # one-way by declaration, not by call site

# group fan-out: N calls, one answer bound to each member
async for answer in squad.invoke.ready_check():
    hud.mark(answer.member, answer.ready)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// invoking — generated, typed (the client surface; bodies live where routing sends them)
Client->Rpc->Call->SubmitScore(Report,
    TPSOnResult<FScoreVerdict>::CreateWeakLambda(this, [this](const TPSResult<FScoreVerdict>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowVerdict(Result.Value());
    }));
Client->Rpc->CallOneWay->ReportPing(Sample);   // one-way by declaration, not by call site

// group fan-out: one call, one answer bound to each member
Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
    [this](const TPSResult<FReadyAnswer>& Answer)
    {
        if (!Answer.HasValue()) { return; }
        Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // the delegate fires once per member
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// Unity invokes; RPC bodies execute on the platform or a host — engines are not a handler runtime
var verdict = await playserv.Rpc.Invoke.SubmitScore(report);
playserv.Rpc.Invoke.ReportPing(sample);      // one-way by declaration, not by call site

await foreach (var answer in squad.Invoke.ReadyCheck())
    Hud.Mark(answer.Member, answer.Ready);

函数体在哪里执行——云函数、客户端、主客户端还是游戏服务器——属于路由,按方法声明;覆盖和中间件见 Extensibility。调用失败会抛出类型化的 Problem(Core)。

模型

RPC 声明哪些内容。

声明项含义
name取自动词词表
input调用方必须自己选定的参数
output恰好一个声明类型。精简版的答复要单独声明一个类型,而不是同一类型省掉几个字段;否则“没有请求”“不存在”和“被访问掩码隐藏”看起来会一模一样
答复模式with a reply:返回声明输出类型的值,或类型化的拒绝。one-way:没有答复,调用方只会得知本地发送失败
执行模式immediate:结果在调用内返回。deferred:调用返回工作描述符,结果之后读取或通过订阅送达。执行模式由声明决定,而不是运行时按负载选择,因为调用方依赖答复的形状
streaming输入和输出是否分段到达、边到边处理,而不是整体到达
idempotency单向 RPC 同样带有幂等键:没有答复不等于不会重复投递
overridability声明在方法本身上。没有声明的方法不能覆盖
context声明所在的位置。在实体内部声明的 RPC 是该实体的一部分,在实体之外不存在。在游戏服务器中声明 RPC,就等于在路由器中注册它——没有第二种添加方式
with a reply · immediatewith a reply · deferredone-wayinvokethe outcome, or a typed refusalinvokea work descriptor, at oncethe outcome, laterinvokeno reply within the callread it, or subscribeonly a local send failure
调用方得到什么:调用内直接拿到结果;先拿到工作描述符、之后拿到结果;或者没有答复

需要结果时不要用单向调用

单向调用只会把本地发送失败告诉调用方。如果你需要知道发生了什么,就声明答复:结果未知的代价比明确的拒绝更高。

一次调用携带什么。

携带项含义
arguments只有调用方必须自己选定的内容
隐式上下文接收方、调用方和环境上下文,在你写的第一个参数之前就已绑定——实体的方法永远不需要传入该实体的标识符
references作为参数传入的 SDK 对象以类型化的 Ref 传递:一个标识符或游标,而不是内容的副本
outcome声明输出类型的值,或类型化的 Problem

引用是地址,不是权限

接收方以自己的身份解析 Ref,受它自己的权限(permission)和谓词约束。传递引用并不会授予对所指内容的访问权。

延迟调用的描述符包含什么。

包含项含义
stateaccepted → running → completed 或 failed,后两者是终态
lifetime由声明给出;超过后结果不再可用,再去查询会得到拒绝,而不是空答复
cancel幂等。它只是一个请求:你观察到的终态,是工作实际到达的 completed 或 failed

所有 RPC 都满足的几点。

始终成立含义
一个处理器恰好一个逻辑处理器。RPC 请求执行一个动作,它的处理器必须存在;事件陈述一个事实,可以完全没有接收者。所以单向 RPC 不是事件,面向群组调用是 N 次调用而不是一次:Groups 提供地址,答复以流的形式返回,每个都绑定着发出它的成员
没有状态机声明和即时调用都没有状态机:调用要么返回了结果,要么没有,没有时就按超时规则处理。只有延迟调用有可观察的状态
流不是原子的。流式输出对整体不作任何保证,所以接收方必须处理中断,并区分“已完成”和“被中断”
写入不接受谓词任何写入都不接受谓词作为输入:“对所有满足该条件的人执行此操作”不是一种操作。批量动作通过枚举表达:先读出集合,再把列表交给一个声明了部分失败语义的批量操作
延迟 RPC:调用返回工作描述符,结果随后针对它送达
[Rpc(Execution = Execution.Deferred)]        // minutes of work — an answer inside the call would be a timeout
public static MatchReport BuildMatchReport(MatchId match) => Reports.Build(match);

var work = await playserv.Rpc.Invoke.BuildMatchReport(matchId);   // the descriptor, not the report
work.OnOutcome(report => Hud.ShowReport(report));                 // or read it later, by descriptor
await work.Cancel();                                              // a request, not a promise nothing ran
export class ReportRpcs {
  @Rpc({ execution: Execution.Deferred })    // minutes of work — an answer inside the call would be a timeout
  static buildMatchReport(match: MatchId): MatchReport { return Reports.build(match); }
}

const work = await playserv.rpc.invoke.buildMatchReport(matchId);  // the descriptor, not the report
work.onOutcome((report) => hud.showReport(report));                // or read it later, by descriptor
await work.cancel();                                               // a request, not a promise nothing ran
@rpc(execution=Execution.DEFERRED)           # minutes of work — an answer inside the call would be a timeout
def build_match_report(match: MatchId) -> MatchReport:
    return reports.build(match)

work = await playserv.rpc.invoke.build_match_report(match_id)   # the descriptor, not the report
work.on_outcome(lambda report: hud.show_report(report))         # or read it later, by descriptor
await work.cancel()                                             # a request, not a promise nothing ran
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// the client side of a deferred call: a descriptor now, the outcome against it later
Client->Rpc->Call->BuildMatchReport(MatchId,
    TPSOnResult<FPSDeferredHandle>::CreateWeakLambda(this, [this](const TPSResult<FPSDeferredHandle>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSDeferredHandle Work = Result.Value();
        TPSSubscription ReportWatch = Client->Rpc->Deferred->Subscribe(Work,
            [this](const FMatchReport& Report) { Hud->ShowReport(Report); });
        Client->Rpc->Deferred->Cancel(Work);   // a request, not a promise nothing ran
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the client side of a deferred call: a descriptor now, the outcome against it later
var work = await playserv.Rpc.Invoke.BuildMatchReport(matchId);
work.OnOutcome(report => Hud.ShowReport(report));
await work.Cancel();                         // a request, not a promise nothing ran

错误

  • 权限挂在具体 RPC 上,从不挂在原语上。没有笼统的“可以调用”:每个声明都写明调用方必须持有的原子权限。没有它的调用方会收到类型化的 forbidden 拒绝,而不是被悄悄丢弃。
  • 被隐藏的实例表现为 “not found”。对调用方的行谓词所隐藏的实例调用实体 RPC,答复与读取该实例完全相同,因此拒绝不会向调用方透露任何关于存在与否的信息。
  • 没有处理器时返回 “unavailable”,而不是 “not found”。RPC 已经声明,所以它存在;缺的是一条路由。值得重试,因为游戏服务器可能恢复;“not found” 则会告诉调用方就此停止。
  • hook 的拒绝带有 hook 自己的错误码和原因,所以“被游戏规则拒绝”永远不会看起来像“传输断了”。
  • 超时不是结果。延迟调用就去读描述符;即时调用则靠声明的幂等键(idempotency key)保证重试安全。

限制

具体数值会随平台限制一章给出;达到每个上限时会发生什么,现在已经确定。

  • 输入大小:调用在执行前就被拒绝。
  • 输出大小:直接拒绝而不截断,因为截断的答复无法和完整答复区分开。
  • 每个主体(actor)的调用频率:返回限流拒绝,并写明何时可以重试。
  • 每个主体的并发延迟调用数:拒绝新调用,进行中的调用照常完成。
  • 描述符生命周期:超过后结果不再可用,查询会被拒绝。
  • 调用链深度:超过时返回声明好的拒绝,而不是耗尽资源或悄然中断。