Part3: 使用设备Actor
引言
在前面的主题中,我们解释了如何整体地查看Actor系统,也就是说,组件应该如何表示,Actor应该如何在层次结构中排列。在这一部分中,我们将通过实现Device Actor来详细介绍Actor。
如果我们使用对象,我们通常会将API设计为接口,即由实际类实现这些抽象方法的集合。在Actor的系统里,协议取代了接口。虽然无法用编程语言形式化通用协议,但我们可以编写它们最基本的元素,即消息。因此,我们将首先确定我们想要发送给设备Actor的消息。
通常情况下,消息分为类别或模式。通过识别这些模式,您会发现在它们之间进行选择和实现变得更加容易。第一个示例演示了请求-响应消息模式。
为设备定义消息
设备Actor的任务很简单:
- 采集温度测量值
- 当被访问时,报告最新测量的温度值
但是,设备可能会在没有温度测量值的情况下直接启动。 因此,我们需要考虑不存在温度的情况。 这也允许我们在没有写入部分的情况下测试Actor的查询部分,因为设备Actor可以上报一个空结果。
从设备Actor获取当前温度的协议很简单。 Actor:
- 等待获取当前温度的请求。
- 以回复的方式响应请求
- 包含当前的温度值,或者
- 表示温度尚不可用。
我们需要两条消息,一条用于请求,一条用于响应。 我们的第一次尝试可能如下所示:
package com.example;
import akka.actor.typed.ActorRef;
import java.util.Optional;
public class Device {
public interface Command {}
public static final class ReadTemperature implements Command {
final ActorRef<RespondTemperature> replyTo;
public ReadTemperature(ActorRef<RespondTemperature> replyTo) {
this.replyTo = replyTo;
}
}
public static final class RespondTemperature {
final Optional<Double> value;
public RespondTemperature(Optional<Double> value) {
this.value = value;
}
}
}
请注意ReadTemperature
消息中包含设备Actor在响应请求时用到的ActorRef<RespondTemperature>
。
这两条消息似乎涵盖了所需的功能。 但是,我们选择的方法必须考虑到应用程序的分布式特性。 虽然与本地 JVM 上的 Actor 通信的基本机制和与远程 Actor 通信的基本机制相同,但我们需要牢记以下几点:
- 因网络带宽和消息大小等因素影响,本地和远程消息之间的传递延迟会有明显的差异。
- 可靠性是一个问题,因为远程消息的发送涉及更多步骤,这意味着可能出现更多错误。
- 本地发送将在同一 JVM 中传递消息引用,因此对发送的底层对象大小没有任何限制,而远程传输将限制消息的大小。
此外,虽然在同一个JVM内发送消息要可靠得多,但如果Actor在处理消息时因程序员的错误而失败,其效果与远程网络请求在处理消息时因远程主机崩溃而失败的效果相同。尽管在这两种情况下,服务都会在一段时间后恢复(Actor由其监督者重新启动,主机由操作员或监控系统重新启动),但在崩溃期间,个别请求会丢失。因此,编写您的Actor以使每条消息都可能丢失是安全、悲观的赌注。
但是为了进一步理解协议对灵活性的需求,考虑 Akka 消息排序和消息传递保证会有所帮助。 Akka 为消息发送提供以下行为:
- 最多一次投递,即不保证必达。
- 每对送者/接收者都维护消息排序。
以下部分更详细地讨论了此行为:
消息传递
消息传递子系统提供的传递语义通常分为以下几类:
- **最多一次投递:**每一条信息都是零次或一次传递的;这意味着信息可能会丢失,但永远不会重复。
- **最少一次投递:**可能会多次尝试传递每条消息,直到至少有一次成功; 这意味着消息可能会重复但永远不会丢失。
- 一次性投递:每条消息只向接收方发送一次; 消息既不能丢失也不能重复。
Akka 使用的第一个行为是最便宜的,并且性能最高。 它具有最少的实现开销,因为它可以以即发即弃的方式完成,而无需将状态保留在发送端或传输机制中。 第二个,至少一次投递,需要重试以抵消传输损失。 这增加了在发送端保持状态并在接收端具有确认机制的开销。 一次性投递成本最高,性能最差:除了最少一次投递增加的开销之外,它还需要在接收端保留状态以过滤掉重复投递的消息。
在Actor系统中,我们需要确定保证的确切含义——系统何时认为投递已完成:
- 当消息被发送出网络?
- 当消息被目标Actor的主机接收到?
- 当消息被投递到目标Actor的邮箱中?
- 当消息目标Actor开始处理这些消息?
- 当目标Actor已经成功处理完这些消息?
大多数声称保证投递的框架和协议实际上提供了类似于第4点和第5点的东西。虽然这听起来很合理,但实际上有用吗?为了理解它的含义,考虑一个简单实用的例子:用户试图创建一个订单,我们只想声称它在订单数据库中,实际上是在磁盘上成功处理。
如果我们依赖消息的成功处理,一旦订单提交给负责验证、处理并放入数据库的内部 API,Actor就会报告成功。 不幸的是,在调用 API 之后,可能会立即发生以下任何情况:
- 主机宕机
- 反序列化失败
- 校验失败
- 数据库不可用
- 也可能发生程序错误
这说明交付保证并不能转化为域级别的保证。我们只想在订单实际得到完全处理和持久化后报告成功。唯一能够报告成功的实体是应用程序本身,因为只有它了解所需的域保证。没有一个通用的框架能够找出某个特定领域的细节,以及该领域的成功之处。
在这个特定的示例中,我们只希望在成功写入数据库后发出成功信号,其中数据库确认订单现在已安全存储。由于这些原因,Akka解除了应用程序本身的担保责任,也就是说,您必须使用Akka提供的工具自己实现担保。这使您能够完全控制想要提供的担保。现在,让我们考虑AKKA提供的消息排序,以便于对应用逻辑进行推理。
消息排序
在 Akka 中,对于给定的一对Actor,直接从第一个到第二个发送的消息不会被乱序接收。此保证仅适用于使用 tell 直接操作发送到最终目的地的情况,但不适用于使用调解员的情况。
假如:
- Actor
A1
发送消息M1
,M2
,M3
到ActorA2
。 - Actor
A3
发送消息M4
,M5
,M6
到ActorA2
。
对Akka中的消息来说,这意味着: - 如果
M1
投递,则必须在M2
和M3
之前投递。 - 如果
M2
投递,则必须在M3
之前投递。 - 如果
M4
投递,则必须在M5
和M6
之前投递。 - 如果
M5
投递,则必须在M6
之前投递。 A2
可以看到来自A1
的消息和来自A3
的消息交错。- 由于没有保证投递,任何消息都可能被丢弃,即不会到达
A2
。
这些保证达到了一个很好的平衡:让一个Actor的消息按顺序到达有利于构建易于推理的系统,而另一方面,允许不同Actor的消息交叉到达为Actor系统的高效实现提供了足够的自由度。
增加设备消息的灵活性
我们的第一个查询协议是正确的,但没有考虑分布式应用程序的执行。 如果我们想在查询设备Actor的Actor中实现重新发送(因为请求超时),或者如果我们想查询多个Actor,我们需要能够关联请求和响应。 因此,我们在消息中添加了一个字段,以便请求者可以提供一个 ID(我们将在后面的步骤中将此代码添加到我们的应用程序中):
public class Device extends AbstractBehavior<Device.Command> {
public interface Command {}
public static final class ReadTemperature implements Command {
final long requestId;
final ActorRef<RespondTemperature> replyTo;
public ReadTemperature(long requestId, ActorRef<RespondTemperature> replyTo) {
this.requestId = requestId;
this.replyTo = replyTo;
}
}
public static final class RespondTemperature {
final long requestId;
final Optional<Double> value;
public RespondTemperature(long requestId, Optional<Double> value) {
this.requestId = requestId;
this.value = value;
}
}
}
实现设备Actor及其读取协议
正如我们在 Hello World 示例中所了解的,每个Actor都定义了它将接受的消息类型。 我们的设备Actor有责任为给定查询的响应使用相同的 ID 参数,这将使其如下所示。
import akka.actor.typed.ActorRef;
import akka.actor.typed.Behavior;
import akka.actor.typed.PostStop;
import akka.actor.typed.javadsl.AbstractBehavior;
import akka.actor.typed.javadsl.ActorContext;
import akka.actor.typed.javadsl.Behaviors;
import akka.actor.typed.javadsl.Receive;
import java.util.Optional;
public class Device extends AbstractBehavior<Device.Command> {
public interface Command {}
public static final class ReadTemperature implements Command {
final long requestId;
final ActorRef<RespondTemperature> replyTo;
public ReadTemperature(long requestId, ActorRef<RespondTemperature> replyTo) {
this.requestId = requestId;
this.replyTo = replyTo;
}
}
public static final class RespondTemperature {
final long requestId;
final Optional<Double> value;
public RespondTemperature(long requestId, Optional<Double> value) {
this.requestId = requestId;
this.value = value;
}
}
public static Behavior<Command> create(String groupId, String deviceId) {
return Behaviors.setup(context -> new Device(context, groupId, deviceId));
}
private final String groupId;
private final String deviceId;
private Optional<Double> lastTemperatureReading = Optional.empty();
private Device(ActorContext<Command> context, String groupId, String deviceId) {
super(context);
this.groupId = groupId;
this.deviceId = deviceId;
context.getLog().info("Device actor {}-{} started", groupId, deviceId);
}
@Override
public Receive<Command> createReceive() {
return newReceiveBuilder()
.onMessage(ReadTemperature.class, this::onReadTemperature)
.onSignal(PostStop.class, signal -> onPostStop())
.build();
}
private Behavior<Command> onReadTemperature(ReadTemperature r) {
r.replyTo.tell(new RespondTemperature(r.requestId, lastTemperatureReading));
return this;
}
private Device onPostStop() {
getContext().getLog().info("Device actor {}-{} stopped", groupId, deviceId);
return this;
}
}
在代码中请注意:
- 静态方法
create
定义了如何为Device
Actor创建Behavior
;参数包括设备ID和它所属组ID,我们稍后会用到。 - 我们之前推理的消息是在前面显示的设备类中定义的。
- 在
Device
类中,lastTemperatureReading
被初始化设置成Optional.empty()
,如果被查询,Actor会返回它。
测试
基于上面的actor,我们可以编写一个测试。 在项目测试分支com.example
包中,将以下代码添加到 DeviceTest.java 文件中。
你可以通过mvn test
命令运行这个测试。
import akka.actor.testkit.typed.javadsl.TestKitJunitResource;
import akka.actor.testkit.typed.javadsl.TestProbe;
import akka.actor.typed.ActorRef;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
public class DeviceTest {
@ClassRule public static final TestKitJunitResource testKit = new TestKitJunitResource();
@Test
public void testReplyWithEmptyReadingIfNoTemperatureIsKnown() {
TestProbe<Device.RespondTemperature> probe =
testKit.createTestProbe(Device.RespondTemperature.class);
ActorRef<Device.Command> deviceActor = testKit.spawn(Device.create("group", "device"));
deviceActor.tell(new Device.ReadTemperature(42L, probe.getRef()));
Device.RespondTemperature response = probe.receiveMessage();
assertEquals(42L, response.requestId);
assertEquals(Optional.empty(), response.value);
}
}
现在,actor 在收到来自传感器的消息时需要一种方法来更改温度状态。
添加一个写协议
写协议的目的是当收到包含温度的消息时更新字段currentTemperature
。同样,很容易将写协议定义为一个非常简单的消息,如下所示:
public static final class RecordTemperature implements Command {
final double value;
public RecordTemperature(double value) {
this.value = value;
}
}
但是,这种方法没有考虑到记录温度消息的发送者永远无法确定消息是否被处理。 我们已经看到 Akka 不保证这些消息的传递,而是让应用程序提供成功通知。 在我们的例子中,一旦我们更新了我们最后的温度记录,我们想向发送者发送一个确认,例如。 回复 TemperatureRecorded
消息。 就像温度查询和响应的情况一样,包含一个 ID 字段以提供最大的灵活性也是一个好主意。
具有读写消息的Actor
将读取和写入协议放在一起,设备Actor类似于以下示例:
import java.util.Optional;
import akka.actor.typed.ActorRef;
import akka.actor.typed.Behavior;
import akka.actor.typed.PostStop;
import akka.actor.typed.javadsl.AbstractBehavior;
import akka.actor.typed.javadsl.ActorContext;
import akka.actor.typed.javadsl.Behaviors;
import akka.actor.typed.javadsl.Receive;
public class Device extends AbstractBehavior<Device.Command> {
public interface Command {}
public static final class RecordTemperature implements Command {
final long requestId;
final double value;
final ActorRef<TemperatureRecorded> replyTo;
public RecordTemperature(long requestId, double value, ActorRef<TemperatureRecorded> replyTo) {
this.requestId = requestId;
this.value = value;
this.replyTo = replyTo;
}
}
public static final class TemperatureRecorded {
final long requestId;
public TemperatureRecorded(long requestId) {
this.requestId = requestId;
}
}
public static final class ReadTemperature implements Command {
final long requestId;
final ActorRef<RespondTemperature> replyTo;
public ReadTemperature(long requestId, ActorRef<RespondTemperature> replyTo) {
this.requestId = requestId;
this.replyTo = replyTo;
}
}
public static final class RespondTemperature {
final long requestId;
final Optional<Double> value;
public RespondTemperature(long requestId, Optional<Double> value) {
this.requestId = requestId;
this.value = value;
}
}
public static Behavior<Command> create(String groupId, String deviceId) {
return Behaviors.setup(context -> new Device(context, groupId, deviceId));
}
private final String groupId;
private final String deviceId;
private Optional<Double> lastTemperatureReading = Optional.empty();
private Device(ActorContext<Command> context, String groupId, String deviceId) {
super(context);
this.groupId = groupId;
this.deviceId = deviceId;
context.getLog().info("Device actor {}-{} started", groupId, deviceId);
}
@Override
public Receive<Command> createReceive() {
return newReceiveBuilder()
.onMessage(RecordTemperature.class, this::onRecordTemperature)
.onMessage(ReadTemperature.class, this::onReadTemperature)
.onSignal(PostStop.class, signal -> onPostStop())
.build();
}
private Behavior<Command> onRecordTemperature(RecordTemperature r) {
getContext().getLog().info("Recorded temperature reading {} with {}", r.value, r.requestId);
lastTemperatureReading = Optional.of(r.value);
r.replyTo.tell(new TemperatureRecorded(r.requestId));
return this;
}
private Behavior<Command> onReadTemperature(ReadTemperature r) {
r.replyTo.tell(new RespondTemperature(r.requestId, lastTemperatureReading));
return this;
}
private Behavior<Command> onPostStop() {
getContext().getLog().info("Device actor {}-{} stopped", groupId, deviceId);
return Behaviors.stopped();
}
}
我们现在还应该编写一个新的测试用例,同时执行读/查询和写/记录功能:
@Test
public void testReplyWithLatestTemperatureReading() {
TestProbe<Device.TemperatureRecorded> recordProbe =
testKit.createTestProbe(Device.TemperatureRecorded.class);
TestProbe<Device.RespondTemperature> readProbe =
testKit.createTestProbe(Device.RespondTemperature.class);
ActorRef<Device.Command> deviceActor = testKit.spawn(Device.create("group", "device"));
deviceActor.tell(new Device.RecordTemperature(1L, 24.0, recordProbe.getRef()));
assertEquals(1L, recordProbe.receiveMessage().requestId);
deviceActor.tell(new Device.ReadTemperature(2L, readProbe.getRef()));
Device.RespondTemperature response1 = readProbe.receiveMessage();
assertEquals(2L, response1.requestId);
assertEquals(Optional.of(24.0), response1.value);
deviceActor.tell(new Device.RecordTemperature(3L, 55.0, recordProbe.getRef()));
assertEquals(3L, recordProbe.receiveMessage().requestId);
deviceActor.tell(new Device.ReadTemperature(4L, readProbe.getRef()));
Device.RespondTemperature response2 = readProbe.receiveMessage();
assertEquals(4L, response2.requestId);
assertEquals(Optional.of(55.0), response2.value);
}
接下来
至此,我们已经开始设计我们的整体架构,并且我们编写了第一个直接对应领域的actor。 我们现在必须创建负责维护设备组和设备Actor本身的组件。
Netxt:使用设备组。