Akka 系列(九):Akka 分散式之 Akka Remote

ScalaCool發表於2017-08-28

本文由 GodPan 發表在 ScalaCool 團隊部落格。

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)點對點訊息傳送模型

顧名思義,點對點可以理解為兩個伺服器的定點通訊,傳送者和接收者都能明確知道對方是誰,大致模型如下:

jms-point-to-point
jms-point-to-point

(2)釋出/訂閱訊息傳遞模型

點對點模型有些場景並不是很適用,比如有一臺主伺服器,它產生一條訊息需要讓所有的從伺服器都能收到,若採用點對點模型的話,那主伺服器需要迴圈傳送訊息,後續若有新的從伺服器增加,還要改主伺服器的配置,這樣就會導致不必要的麻煩,那麼釋出/訂閱模型是怎麼樣的呢?其實這種模式跟設計模式中的觀察者模式很相似,相信很多同學都很熟悉,它最大的特點就是較鬆耦合,易擴充套件等特點,所以釋出/訂閱模型的大致結構如下:

jms-point-to-point
jms-point-to-point

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-result
java-rmi-result

從執行結果和程式碼上分析可得:

  • 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收到了一條訊息:

send-no-return
send-no-return

從以上的步驟和結果看出可以看出,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傳送一條訊息:

send-has-return
send-has-return

可以看到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所在的系統,傳送這條訊息:

send-serialization
send-serialization

有同學可能會覺得奇怪,我們明明沒有對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實現來說簡單的多,非常簡潔;

1.Akka Remote

整個例子的相關的原始碼已經上傳到akka-demo中:原始碼連結

相關文章