用Akka创建一个简单的聊天应用程序的教程实例

383 阅读10分钟

用Akka编写聊天app

你想知道更多关于WebSockets的信息吗?在这里你会找到更多关于它们的信息,并学习如何创建一个简单的聊天应用程序。

啊,写作聊天。如此简单却又如此复杂。是的,编写聊天记录--是指编码,而不是聊天(虽然这可能也会被证明是有问题的,但这是一个完全不同的问题)。如果您正在寻找一个关于实现基本多渠道聊天的后台的分步教程,请继续阅读。

那么,让我们深入了解一下技术问题。为了给你更多的细节,该服务将被实现为一个简单的REST API和一个Web Socket应用程序的混合体。为了使之更加有趣,我决定使用Akka相关的库,并在尽可能多的数字中键入演员。

请注意,本文中使用的所有代码也都可以在GitHub仓库中找到。

你可能还想阅读这篇关于用Go和WebSockets构建一个并发的聊天应用的文章。

好了,让我们开始吧,从一个简单的问题开始。

为什么是聊天?

为什么不呢?这将是最简单的答案,但由于我渴望成为一个严肃的作家,我将补充一些背景。

首先,就在视频流旁边,聊天对我来说是又一个非常有趣的话题。其次,编写聊天应用程序可能是熟悉WebSocket协议的最简单用例。此外,网络上的大多数聊天程序的工作方式与下文所述的相同。当然,规模和功能有很大不同,但基本思想是一样的。

什么是WebSocket,为什么它很重要?

简而言之,它是一种通信协议,通过使用单个TCP连接在服务器和浏览器之间提供双向通信。由于这一特性,我们不必不断地从服务器上获取新数据;相反,数据在有关各方之间以消息的形式进行 "实时 "交换。每个消息都是二进制数据或Unicode文本。

该协议于2011年由IETFRFC 6455的形式进行了标准化。WebSocket协议与HTTP不同,但两者都位于OSI模型的第7层,并依赖于第4层的TCP。另一方面,WebSocket的设计是在HTTP端口443和80上工作,并支持代理和中介等HTTP概念。更重要的是,WebSocket握手使用HTTP升级头,将协议从HTTP升级到WebSockets。

WebSocket作为一种协议的最大缺点是安全。WebSocket不受同源策略的限制,这可能使类似CSRF的攻击变得更加容易。

使用的工具

让我们从本文的技术部分开始,描述一下将进一步用于实现整个应用的工具:

  • Akka--来自Akka工具包的库和Actor模型将在整个应用的实现中发挥关键作用。因为类型安全很重要,而且编译时的类型检查往往能在运行代码之前解决很多问题,所以我决定尽可能多地使用类型化的角色。这就是为什么我添加了akka-stream-typedakka-actor-typed作为基础,而不是它们的经典版本。
  • akka-http--没有什么意外,因为我需要暴露具有WebSocket功能的REST API,并使用Akka actors。akka-http是最容易实现的方式,因为我不必担心不同库之间的集成和互操作性。此外,我正在使用 akka-http-circe.我打算用 circe库来解析传入的JSONs,我需要akka-http和circe之间的互操作性。
  • pureconfig - 用于加载配置文件,并将其解析为Scala对象,没有太多的模板。
  • logback-classic-- 日志记录

这就是我在Akka中实现WebSockets聊天应用所需要的一切。让我们开始实施吧

实施

我决定使用Scala2.13.8,因为在写这篇文章的时候(2022-05-30),并不是所有用来实现该应用的库都支持Scala 3。

1.我首先在项目的build.sbt文件中添加所有必要的依赖项:

Scala

libraryDependencies := Seq(
  "com.typesafe.akka" %% "akka-actor-typed" % "2.6.19",
  "com.typesafe.akka" %% "akka-stream-typed" % "2.6.19",
  "com.typesafe.akka" %% "akka-http" % "10.2.9",
  "de.heikoseeberger" %% "akka-http-circe" % "1.39.2",
  "io.circe" %% "circe-generic" % "0.14.1",
  "com.github.pureconfig" %% "pureconfig" % "0.17.1",
  "ch.qos.logback" % "logback-classic" % "1.2.11",
)

2.我正在为应用程序添加一个名为chatp-app.conf的配置文件,以及两个案例类,稍后将用来表示这些配置值。

.conf文件的内容示例:

Scala

http-config {
  port = 8070
  port = ${?PORT}
  host = "localhost"
  host = ${?HOST}
}

这样的符号允许读取一个名为PORT的环境变量,如果它不存在,那么将使用8070这个值。基本上,这个文件中的所有值都可以通过使用环境变量来重写。

案例类来模拟配置:

Scala

case class AppConfig(httpConfig: HttpConfig)
case class HttpConfig(port: Int, host: String) {
  val toPath = s"$host:$port"
}

3.我正在为应用程序实现一个runner类。

Scala

object ChatAppStarter {

  def main(args: Array[String]): Unit = {
    val appConfig = ConfigSource.resources("chat-app.conf").loadOrThrow[AppConfig]
    val rootBehavior = Behaviors.setup[Nothing] { context =>
      context.setLoggerName("ChatApiLogger")
      Behaviors.same
    }
    ActorSystem[Nothing](rootBehavior, "ChatApp")
  }
}

就目前而言,它只会读取我们的配置文件。我将在接下来的步骤中在这里添加更多的代码。

4.现在,我正在实现一个actor,代表通过应用程序创建的每个特定的Chat

Scala

object Chat {

  sealed trait ChatCommand
  final case class ProcessMessage(sender: String, content: String) extends ChatCommand
  final case class AddNewUser(ref: ActorRef[String]) extends ChatCommand

  def apply(): Behavior[ChatCommand] =
    Behaviors.setup { _ =>
      var participants = List.empty[ActorRef[String]]
      val messageQueue = mutable.Queue.empty[String]
      Behaviors.receiveMessage[ChatCommand] {
        case ProcessMessage(sender, content) =>
          participants.foreach(ref => ref ! s"$sender: $content")
          Behaviors.same
        case AddNewUser(ref) =>
          participants = participants.appended(ref)
          messageQueue.foreach(m => ref ! m)
          Behaviors.same
      }
    }
}

聊天是一个简单的角色,可以处理两种类型的消息。带有actorRef参数的AddNewUser和代表聊天内部用户间发送的每条消息的ProcessMessage

  • 收到AddNewUser消息后,聊天角色会将 actorRef添加到参与者列表中,并立即将聊天中已经交换的所有消息发送给新加入的用户。
  • 在收到ProcessMessage后,该角色会简单地将消息内容添加到消息队列中,并将内容广播给所有在聊天中出现的参与者。

5.我正在实现一个ChatsStore角色,负责存储应用程序中存在的所有聊天记录。

Scala

object ChatsStore {

  sealed trait StoreCommand
  final case class AddNewChat(sender: User, receiver: User, replyTo: ActorRef[Int]) extends StoreCommand
  final case class GetChatMeta(chatId: Int, userName: String, replyTo: ActorRef[Option[GetChatMetaResponse]]) extends StoreCommand

  final case class GetChatMetaResponse(userName: String, ref: ActorRef[ChatCommand])

  private var sequence = 0
  private val store = mutable.Map.empty[Int, ChatMetadata]

  private case class ChatMetadata(participants: Map[String, User], ref: ActorRef[ChatCommand]) {
    def containUserId(userId: String): Boolean =
      participants.contains(userId)
  }

  def apply(): Behavior[StoreCommand] =
    Behaviors.setup(context => {
      Behaviors.receiveMessage {
        case AddNewChat(sender, receiver, replyTo) =>
          sequence += 1
          val newChat: ActorRef[ChatCommand] = context.spawn(Chat(), s"Chat$sequence")
          val participants = Map(sender.id.toString -> sender, receiver.id.toString -> receiver)
          val metadata = ChatMetadata(participants, newChat)
          store.put(sequence, metadata)
          replyTo ! sequence
          Behaviors.same
        case GetChatMeta(chatId, userId, replyTo) =>
          val chatRef = store
            .get(chatId)
            .filter(_.containUserId(userId))
            .flatMap(meta =>
              meta.participants
                .get(userId)
                .map(user => GetChatMetaResponse(user.name, meta.ref))
            )
          replyTo ! chatRef
          Behaviors.same
      }
    })
}

它是另一个简单的角色,只支持两种类型的消息。AddNewChatGetChatMeta。在接收到AddNewChat消息后,Store将生成一个新的Chatactor实例,并将提供的id列表作为参与者,将下一个数字作为id。

另一方面,在收到GetChatMetada后,商店将尝试找到具有所提供iduserId聊天。如果特定组合的聊天存在,商店将返回其acrofRef和检索到的用户名。

作为它的内部状态行为者持有确切的存储,这里表示为从Integer到内部案例类的简单映射--ChatMatada与序列一起实现为一个简单的Integer,用于为新添加的聊天分配ID。

此外,我将在ChatAppStarter中添加这一行,以有效地生成ChatsStore行为体

val store = context.spawn(ChatsStore(), "Store")

6.我正在实现ChatService,它将扮演utils类的角色。

Scala

class ChatService(contextPath: List[String], httpConfig: HttpConfig) {

  private val apiPath = contextPath.mkString("/")

  val path: PathMatcher[Unit] = toPath(contextPath, PathMatcher(""))

  @tailrec
  private def toPath(l: List[String], pathMatcher: PathMatcher[Unit]): PathMatcher[Unit] = {
    l match {
      case x :: Nil => toPath(Nil, pathMatcher.append(x))
      case x :: tail => toPath(tail, pathMatcher.append(x / ""))
      case Nil => pathMatcher
    }
  }

  def generateChatLinks(chatId: Int, senderId: String, receiverId: String): (String, String) = {
    val chatPath = s"ws://${httpConfig.toPath}/$apiPath/chats/$chatId/messages"
    (s"$chatPath/$senderId", s"$chatPath/$receiverId")
  }

}

在这里你可以看到toPath方法,它从List[String]展开一个上下文路径到Akka Http兼容的PathMatcher,还有generateChatLinks方法,它生成了聊天的链接,在为特定的userIds和id组合创建聊天后将作为一个响应发送。

此外,我将在ChatAppStarter中添加这一行来实例化ChatService

val service = new ChatService(List("api", "v1"), appConfig.httpConfig)

7.我正在实现ChatApi类,该类负责暴露具有WebSocket功能的REST API以及Akka Stream Flow来处理WebSocket消息。

Scala

class ChatApi(service: ChatService, store: ActorRef[StoreCommand], logger: Logger)(implicit val system: ActorSystem[_]) {

  private implicit val timeout: Timeout = Timeout(2.seconds)
  private implicit val ec: ExecutionContextExecutor = system.executionContext

  val routes: Route = {
    pathPrefix(service.path / "chats") {
      concat(pathEnd {
        post {
          entity(as[StartChat]) { start =>
            val senderId = start.sender.id.toString
            val receiverId = start.receiver.id.toString
            logger.info(s"Starting new chat sender: $senderId, receiver: $receiverId")
            val eventualCreated =
              store
                .ask(ref => AddNewChat(start.sender, start.receiver, ref))
                .map(id => {
                  val chatLinks = service.generateChatLinks(id, senderId, receiverId)
                  ChatCreated(id, chatLinks._1, chatLinks._2)
                })
            onSuccess(eventualCreated) { c =>
              complete(StatusCodes.Created, c)
            }
          }
        }
      }, path(IntNumber / "messages" / Segment) { (id, userId) =>
        onSuccess(store.ask(ref => GetChatMeta(id, userId, ref))) {
          case Some(meta) => handleWebSocketMessages(websocketFlow(meta.userName, meta.ref))
          case None => complete(StatusCodes.NotFound)
        }
      })
    }
  }

  def websocketFlow(userName: String, chatActor: ActorRef[ChatCommand]): Flow[Message, Message, Any] = {
    val source: Source[TextMessage, Unit] =
      ActorSource.actorRef[String](PartialFunction.empty, PartialFunction.empty, 5, OverflowStrategy.fail)
        .map[TextMessage](TextMessage(_))
        .mapMaterializedValue(sourceRef => chatActor ! AddNewUser(sourceRef))

    val sink: Sink[Message, Future[Done]] = Sink
      .foreach[Message] {
        case tm: TextMessage =>
          chatActor ! ProcessMessage(userName, tm.getStrictText)
        case _ =>
          logger.warn(s"User with id: '{}', send unsupported message", userName)
      }

    Flow.fromSinkAndSource(sink, source)
  }
}

object ChatApi {

  case class StartChat(sender: User, receiver: User)
  case class User(id: UUID, name: String)
  case class ChatCreated(chatId: Int, senderChatLink: String, receiverChatLink: String)

  implicit val startChatDecoder: Decoder[StartChat] = deriveDecoder
  implicit val startChatEncoder: Encoder[StartChat] = deriveEncoder
  implicit val userDecoder: Decoder[User] = deriveDecoder
  implicit val userEncoder: Encoder[User] = deriveEncoder
  implicit val chatCreatedDecoder: Decoder[ChatCreated] = deriveDecoder
  implicit val chatCreatedEncoder: Encoder[ChatCreated] = deriveEncoder
}

大部分的代码都致力于以两个端点的形式暴露我们的API。第一个是纯REST端点,路径是http://{url}/chats ,负责POST,以暴露创建聊天的方式。

第二个是路径http::/{url}/chats/{chatId}/messages/{userId} 下的混合端点,它启动WebSocket通道,同时处理特定聊天的消息。

ChatApi同伴对象的所有代码都致力于沿着一些半自动推导的圈子对请求和响应进行建模,我决定使用半自动推导。

这一步中最重要的代码是负责处理WebSocket消息的部分。特别是websocketFlow方法。它有经典的akka-http签名来处理websocket,但实现起来却不那么简单。

首先,我正在创建一个基于ActorRef的源,负责接收来自代表聊天用户的角色的消息。在每个源被具体化后,它将把它的actor ref发送到被请求的聊天actor上。第二件事是Sink,所有来自websocket端点的消息都会被送到它那里,它将把所有消息转发给感兴趣的聊天演员。我使用Flow.fromSinkAndSource将它们合并。

此外,我将在 ChatAppStarter中添加这一行,以创建一个新的ChatApi实例。

val api = new ChatApi(service, store, context.log)(context.system)

8.在这一步,我将添加一个负责启动HTTP服务器的对象

Scala

object Server {

  def start(routes: Route, config: HttpConfig, logger: Logger)(implicit system: ActorSystem[_]): Unit = {
    import system.executionContext

    val bindingFuture = Http()
      .newServerAt(config.host, config.port)
      .bind(routes)
    bindingFuture.onComplete {
      case Success(binding) =>
        val address = binding.localAddress
        logger.info("Server online at http://{}:{}/", address.getHostString, address.getPort)
      case Failure(ex) =>
        logger.error("Failed to bind HTTP endpoint, terminating system", ex)
        system.terminate()
    }
  }
}

这里没有什么复杂的东西,只是一个简单的Akka Http服务器,以一个消息开始。

还有ChatAppStarter中的最后一行,将它们全部合并:

Server.start(api.routes, appConfig.httpConfig, context.log)(context.system)

9.我再添加一个配置文件,负责加载Akka配置。加载后,它将导致自动向我们的聊天的websockets连接发送一个keep-alive ping。

这个文件是一个简单的单行文件:

akka.http.server.websocket.periodic-keep-alive-max-idle = 1 second

我在ChatAppStarter中加入两行:

Scala

val akkaConfig = ConfigSource.resources("akka.conf").config().right.get -  for loading the config file 

ActorSystem[Nothing](rootBehavior, "ChatApp", akkaConfig)

中加入两行,以便用加载的配置启动整个行为体系统。

就这样,我们实现了整个聊天系统。让我们来测试它吧

测试

对于测试,我正在使用 驿站简单的Web Socket客户端.

1.我正在使用Postman为两个用户创建一个新的聊天。

在回复正文中,我得到了一个聊天Id和两个个性化的链接,供用户加入和使用准超媒体风格的聊天。

2.现在是使用它们并检查用户是否能相互交流的时候了。简单的网络套接字客户端在这里开始发挥作用。

到了这里,一切都正常了,用户能够相互交流了。

还有最后一件事要做。让我们花点时间来看看可以做得更好的地方。

什么可以做得更好

由于我刚刚建立的是最基本的聊天应用程序,有一些(或事实上是相当多的)事情可以做得更好。下面,我列出了我认为值得改进的事情:

  • 更好地支持用户离开和事后重新加入--现在,它没有以最理想的方式实现,这里有一个地方需要大幅改进。
  • 发送附件 - 目前,聊天只支持简单的文本信息。虽然发短信是聊天的基本功能,但用户也喜欢交换图片和音频文件。
  • 消息模型 - 重新思考和考虑你在消息中到底需要什么,也许还需要对API模型进行一些修改。
  • 持久的消息存储--使消息在应用程序重新启动之间持久存在。此外,出于安全考虑,还需要某种程度的加密。
  • 支持群组聊天 - 现在,应用程序只支持一对一的聊天,所以群组聊天是第一个要添加到应用程序中的合理的下一个功能。
  • 测试 - 现在还没有测试,但为什么要让它这样呢?测试总是一个好主意。