Akka作為一個天生用於構建分散式應用的工具,當然提供了用於分散式元件即Akka Remote,那麼我們就來看看如何用Akka Remote以及Akka Serialization來構建分散式應用。
背景
很多同學在程式的開發中都會遇到一個問題,當業務需求變得越來越複雜,單機伺服器已經不足以承載相應的請求的時候,我們都會考慮將服務部署到不同的伺服器上,但伺服器之間可能需要相互呼叫,那麼系統必須擁有相互通訊的介面,用於相應的資料互動,這時候一個好的遠端呼叫方案是一個絕對的利器,主流的遠端通訊有以下幾種選擇:
- RPC(Remote Procedure Call Protocol)
- Web Service
- JMS(Java Messaging Service)
這幾種方式都是被採用比較廣泛的通訊方案,有興趣的同學可以自己去了解一下,這裡我會講一下Java中的RPC即RMI (Remote Method Invocation)和JMS。
JAVA遠端呼叫
RMI和JMS相信很多寫過Java程式的同學都知道,是Java程式用來遠端通訊的主要方式,那麼RMI和JMS又有什麼區別呢?
1.RMI
i.特徵:
- 同步通訊:在使用RMI呼叫遠端方法時,執行緒會持續等待直到結果返回,所以它是一個同步阻塞操作;
- 強耦合:請求的系統中需要使用的RMI服務進行介面宣告,返回的資料型別有一定的約束;
ii.優點:
- 實現相對簡單,方法呼叫形式通俗易理解,介面宣告服務功能清晰。
iii.缺點:
- 只侷限支援JVM平臺;
- 對無法相容Java語言的其他語言也不適用;
2.JMS
i.特徵:
- 非同步通訊:JMS傳送訊息進行通訊,在通訊過程中,執行緒不會被阻塞,不必等待請求回應,所以是一個非同步操作;
- 鬆耦合:不需要介面宣告,返回的資料型別可以是各種各樣,比如JSON,XML等;
ii.通訊方式:
(1)點對點訊息傳送模型
顧名思義,點對點可以理解為兩個伺服器的定點通訊,傳送者和接收者都能明確知道對方是誰,大致模型如下:
(2)釋出/訂閱訊息傳遞模型
點對點模型有些場景並不是很適用,比如有一臺主伺服器,它產生一條訊息需要讓所有的從伺服器都能收到,若採用點對點模型的話,那主伺服器需要迴圈傳送訊息,後續若有新的從伺服器增加,還要改主伺服器的配置,這樣就會導致不必要的麻煩,那麼釋出/訂閱模型是怎麼樣的呢?其實這種模式跟設計模式中的觀察者模式很相似,相信很多同學都很熟悉,它最大的特點就是較鬆耦合,易擴充套件等特點,所以釋出/訂閱模型的大致結構如下:
iii.優點:
- 由於使用非同步通訊,不需要執行緒暫停等待,效能相對較高。
iiii.缺點:
- 技術實現相對複雜,並需要維護相關的訊息佇列;
更通俗的說:
RMI可以看成是用打電話的方式進行資訊交流,而JMS更像是發簡訊。
總的來說兩種方式沒有孰優孰劣,我們也不用比較到底哪種方式比較好,存在即合理,更重要的是哪種選擇可能更適合你的系統。
RMI Example
這裡我寫一個RMI的例子,一方面來看一下它的使用方式,另一方面用於和後續的Akka Remote做一些比較:
首先我們來編寫相應的傳輸物件和通訊介面:
1.JoinRmiEvt:
public class JoinRmiEvt implements Remote , Serializable{
private static final long serialVersionUID = 1L;
private Long id;
private String name;
public JoinRmiEvt(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}複製程式碼
2.RemoteRmi:
public interface RemoteRmi extends Remote {
public void sendNoReturn(String message) throws RemoteException, InterruptedException;
public String sendHasReturn(JoinRmiEvt joinRmiEvt) throws RemoteException;
}複製程式碼
然後在服務端對該介面進行實現:
3.RemoteRmiImpl:
public class RemoteRmiImpl extends UnicastRemoteObject implements RemoteRmi {
private static final long serialVersionUID = 1L;
public RemoteRmiImpl() throws RemoteException {};
@Override
public void sendNoReturn(String message) throws RemoteException, InterruptedException {
Thread.sleep(2000);
//throw new RemoteException();
}
@Override
public String sendHasReturn(JoinRmiEvt joinRmiEvt) throws RemoteException {
if (joinRmiEvt.getId() >= 0)
return new StringBuilder("the").append(joinRmiEvt.getName()).append("has join").toString();
else return null;
}
}複製程式碼
接著我們在Server端繫結相應埠併發布服務,然後啟動:
public class RemoteRMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException, InterruptedException {
System.out.println("the RemoteRMIServer is Starting ...");
RemoteRmiImpl remoteRmi = new RemoteRmiImpl();
System.out.println("Binding server implementation to registry");
LocateRegistry.createRegistry(2553);
Naming.bind("rmi://127.0.0.1:2553/remote_rmi",remoteRmi);
System.out.println("the RemoteRMIServer is Started");
Thread.sleep(10000000);
}
}複製程式碼
下面我們在Client端呼叫Server端的服務:
public class RemoteRmiClient {
public static void main(String[] args) throws RemoteException, NotBoundException, MalformedURLException, InterruptedException {
System.out.println("the client has started");
String url = "rmi://127.0.0.1:2553/remote_rmi";
RemoteRmi remoteRmi = (RemoteRmi) Naming.lookup(url);
System.out.println("the client has running");
remoteRmi.sendNoReturn("send no return");
System.out.println(remoteRmi.sendHasReturn(new JoinRmiEvt(1L,"godpan")));
System.out.println("the client has end");
}
}複製程式碼
執行結果:
從執行結果和程式碼上分析可得:
- Java Rmi呼叫是一個阻塞的過程,這會導致一個問題,假如服務端的服務奔潰了,會導致客戶端沒有反應;
- Java Rmi使用的是Java預設的序列化方式,效能並不是很好,而且並不提供支援使用其他序列化的介面,在一些效能要求高的系統會有一定的瓶頸;
- 在Rmi中使用的相應的介面和物件必須實現相應的介面,必須制定丟擲相應的Exception,導致程式碼看起來異常的繁瑣;
Akka Remote
上面講到JAVA中遠端通訊的方式,但我們之前說過Akka也是基於JVM平臺的,那麼它的通訊方式又有什麼不同呢?
在我看來,Akka的遠端通訊方式更像是RMI和JMS的結合,但更偏向於JMS的方式,為什麼這麼說呢,我們先來看一個示例:
我們先來建立一個遠端的Actor:
class RemoteActor extends Actor {
def receive = {
case msg: String =>
println(s"RemoteActor received message '$msg'")
sender ! "Hello from the RemoteActor"
}
}複製程式碼
現在我們在遠端伺服器上啟動這個Actor:
val system = ActorSystem("RemoteDemoSystem")
val remoteActor = system.actorOf(Props[RemoteActor], name = "RemoteActor")複製程式碼
那麼現在我們假如有一個系統需要向這個Actor傳送訊息應該怎麼做呢?
首先我們需要類似RMI釋出自己的服務一樣,我們需要為其他系統呼叫遠端Actor提供訊息通訊的介面,在Akka中,設定非常簡單,不需要程式碼侵入,只需簡單的在配置檔案裡配置即可:
akka {
actor {
provider = "akka.remote.RemoteActorRefProvider"
}
remote {
enabled-transports = ["akka.remote.netty.tcp"]
netty.tcp {
hostname = $localIp //比如127.0.0.1
port = $port //比如2552
}
log-sent-messages = on
log-received-messages = on
}
}複製程式碼
我們只需配置相應的驅動,傳輸方式,ip,埠等屬性就可簡單完成Akka Remote的配置。
當然本地伺服器也需要配置這些資訊,因為Akka之間是需要相互通訊的,當然配置除了hostname有一定的區別外,其他配置資訊可一致,本例子是在同一臺機器上,所以這裡hostname是相同的。
這時候我們就可以在本地的伺服器向這個Actor傳送訊息了,首先我們可以建立一個本地的Actor:
case object Init
case object SendNoReturn
class LocalActor extends Actor{
val path = ConfigFactory.defaultApplication().getString("remote.actor.name.test")
implicit val timeout = Timeout(4.seconds)
val remoteActor = context.actorSelection(path)
def receive: Receive = {
case Init => "init local actor"
case SendNoReturn => remoteActor ! "hello remote actor"
}
}複製程式碼
其中的remote.actor.name.test
的值為:“akka.tcp://RemoteDemoSystem@127.0.0.1:4444/user/RemoteActor”,另外我們可以看到我們使用了context.actorSelection(path)
來獲取的是一個ActorSelection物件,若是需要獲得ActorRef,我們可以呼叫它的resolveOne(),它返回的是是一個Future[ActorRef],這裡是不是很熟悉,因為它跟本地獲取Actor方式是一樣的,因為Akka中Actor是位置透明的,獲取本地Actor和遠端Actor是一樣的。
最後我們首先啟動遠端Actor的系統:
object RemoteDemo extends App {
val system = ActorSystem("RemoteDemoSystem")
val remoteActor = system.actorOf(Props[RemoteActor], name = "RemoteActor")
remoteActor ! "The RemoteActor is alive"
}複製程式碼
然後我們在本地系統中啟動這個LocalActor,並向它傳送訊息:
object LocalDemo extends App {
implicit val system = ActorSystem("LocalDemoSystem")
val localActor = system.actorOf(Props[LocalActor], name = "LocalActor")
localActor ! Init
localActor ! SendNoReturn
}複製程式碼
我們可以看到RemoteActor收到了一條訊息:
從以上的步驟和結果看出可以看出,Akka的遠端通訊跟JMS的點對點模式似乎更相似一點,但是它有不需要我們維護訊息佇列,而是使用Actor自身的郵箱,另外我們利用context.actorSelection獲取的ActorRef,可以看成遠端Actor的副本,這個又和RMI相關概念類似,所以說Akka遠端通訊的形式上像是RMI和JMS的結合,當然底層還是通過TCP、UDP等相關網路協議進行資料傳輸的,從配置檔案的相應內容便可以看出。
上述例子演示的是sendNoReturn的模式,那麼假如我們需要遠端Actor給我們一個回覆應該怎麼做呢?
首先我們建立一個訊息:
case object SendHasReturn
def receive: Receive = {
case SendHasReturn =>
for {
r <- remoteActor.ask("hello remote actor")
} yield r
}複製程式碼
我們重新執行LocalActor並像RemoteActor傳送一條訊息:
可以看到LocalActor在傳送訊息後並收到了RemoteActor返回來的訊息,另外我們這裡設定了超時時間,若在規定的時間內沒有得到反饋,程式就會報錯。
Akka Serialization
其實這一部分本可以單獨拿出來寫,但是相信序列化這塊大家都應該有所瞭解了,所以就不準備講太多序列化的知識了,怕班門弄斧,主要講講Akka中的序列化。
繼續上面的例子,假如我們這時向RemoteActor傳送一個自定義的物件,比如一個case class物件,但是我們這是是在網路中傳輸這個訊息,那麼怎麼保證這個物件型別和值呢,在同一個JVM系統中我們不需要擔心這個,因為物件就在堆中,我們只要傳遞相應的地址即可就行,但是在不同的環境中,我們並不能這麼做,我們在網路中只能傳輸位元組資料,所以我們必須將物件做特殊的處理,在傳輸的時候轉化成特定的由一連串位元組組成的資料,而且我們又可以根據這些資料恢復成一個相應的物件,這便是序列化。
我們先定義一個參與的case class, 並修改一下上面傳送訊息的語句:
case object SendSerialization
case class JoinEvt(
id: Long,
name: String
)
def receive: Receive = {
case SendSerialization =>
for {
r <- remoteActor.ask(JoinEvt(1L,"godpan"))
} yield println(r)
}複製程式碼
這時我們重新啟動RemoteActor和LocalActor所在的系統,傳送這條訊息:
有同學可能會覺得奇怪,我們明明沒有對JoinEvt進行過任何序列化的標識和處理,為什麼程式還能執行成功呢?
其實不然,只不過是有人替我們預設做了,不用說,肯定是貼心的Akka,它為我們提供了一個預設的序列化策略,那就是我們熟悉又糾結的java.io.Serializable,沉浸在它的易使用性上,又對它的效能深惡痛絕,尤其是當有大量物件需要傳輸的分散式系統,如果是小系統,當我沒說,畢竟存在即合理。
又有同學說,既然Akka是一個天生分散式元件,為什麼還用低效的java.io.Serializable,你問我我也不知道,可能當時的作者偷了偷懶,當然Akka現在可能覺醒了,首先它支援第三方的序列化工具,當然如果你有特殊需求,你也可以自己實現一個,而且在最新的文件中說明,在Akka 2.5x之後Akka核心訊息全面廢棄java.io.Serializable,使用者自定義的訊息暫時還是支援使用java.io.Serializable的,但是不推薦用,因為它是低效的,容易被攻擊,所以在這裡我也推薦大家再Akka中儘量不要在使用了java.io.Serializable。
那麼在Akka中我們如何使用第三方的序列化工具呢?
這裡我推薦一個在Java社群已經久負盛名的序列化工具:kryo,有興趣的同學可以去了解一下:kryo,而且它也提供Akka使用的相關包,這裡我們就使用它作為示例:
這裡我貼上整個專案的build.sbt, kryo的相關依賴也在裡面:
import sbt._
import sbt.Keys._
lazy val AllLibraryDependencies =
Seq(
"com.typesafe.akka" %% "akka-actor" % "2.5.3",
"com.typesafe.akka" %% "akka-remote" % "2.5.3",
"com.twitter" %% "chill-akka" % "0.8.4"
)
lazy val commonSettings = Seq(
name := "AkkaRemoting",
version := "1.0",
scalaVersion := "2.11.11",
libraryDependencies := AllLibraryDependencies
)
lazy val remote = (project in file("remote"))
.settings(commonSettings: _*)
.settings(
// other settings
)
lazy val local = (project in file("local"))
.settings(commonSettings: _*)
.settings(
// other settings
)複製程式碼
然後我們只需將application.conf中的actor配置替換成以下的內容:
actor {
provider = "akka.remote.RemoteActorRefProvider"
serializers {
kryo = "com.twitter.chill.akka.AkkaSerializer"
}
serialization-bindings {
"java.io.Serializable" = none
"scala.Product" = kryo
}
}複製程式碼
其實其中的"java.io.Serializable" = none可以省略,因為若是有其他序列化的策略則會替換掉預設的java.io.Serializable的策略,這裡只是為了更加仔細的說明。
至此我們就可以使用kryo了,整個過程是不是很easy,迫不及待開始寫demo了,那就快快開始吧。
從執行結果和程式碼上分析可得:
- Akka Remote使用內建的序列化工具,並支援配置指定的序列化方式,可以按需配置;
- Akka Remote使用的過程是一個非同步非阻塞的過程,客戶端能儘量減少對服務端的依賴;
- Akka Remote的程式碼實現相對Java Rmi實現來說簡單的多,非常簡潔;
整個例子的相關的原始碼已經上傳到akka-demo中:原始碼連結