640?wx_fmt=gif

【CSDN 编者按】大家都知道Web和API服务器在互联网中的重要性,在计算机网络方面提供了最基本的界面。本文主要介绍了怎样利用Scala实现实时聊天网站和API服务器,通过本篇文章,你定将受益匪浅。

640?wx_fmt=png

作者 | Haoyi
译者 | 弯月,责编 | 刘静
出品 | CSDN(ID:CSDNnews)

 

以下为译文:

Web和API服务器是互联网系统的骨干,它们为计算机通过网络交互提供了基本的界面,特别是在不同公司和组织之间。这篇指南将向你介绍如何利用Scala简单的HTTP服务器,来提供Web内容和API。本文还会介绍一个完整的例子,告诉你如何构建简单的实时聊天网站,同时支持HTML网页和JSON API端点。

这篇文及章的目的是介绍怎样用Scala实现简单的HTTP服务器,从而提供网页服务,以响应API请求。我们会建立一个简单的聊天网站,可以让用户发表聊天信息,其他访问网站的用户都可以看见这些信息。为简单起见,我们将忽略认证、性能、用户挂历、数据库持久存储等问题。但是,这篇文章应该足够你开始用Scala构建网站和API服务器了,并为你学习并构建更多产品级项目打下基础。

 

我们将使用Cask web框架:

http://www.lihaoyi.com/cask/

Cask是一个Scala的HTTP为框架,可以用来架设简单的网站并迅速运行。

 

640?wx_fmt=png

开始

 

要开始使用Cask,只需下载并解压示例程序:

 

$ curl -L https://github.com/lihaoyi/cask/releases/download/0.3.0/minimalApplication-0.3.0.zip > cask.zip

$ unzip cask.zip

$ cd minimalApplication-0.3.0

运行find来看看有哪些文件:

 

$ find . -type f
./build.sc
./app/test/src/ExampleTests.scala
./app/src/MinimalApplication.scala
./mill

我们感兴趣的大部分代码都位于app/src/MinimalApplication.scala中。

 

package app
object MinimalApplication extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    "Hello World!"
  }

  @cask.post("/do-thing")
  def doThing(request: cask.Request) = {
    new String(request.readAllBytes()).reverse
  }

  initialize()
}

用build.sc进行构建:

 

import mill._, scalalib._

object app extends ScalaModule{
  def scalaVersion = "2.13.0"
  def ivyDeps = Agg(
    ivy"com.lihaoyi::cask:0.3.0"
  )
  object test extends Tests{
    def testFrameworks = Seq("utest.runner.Framework")

    def ivyDeps = Agg(
      ivy"com.lihaoyi::utest::0.7.1",
      ivy"com.lihaoyi::requests::0.2.0",
    )
  }
}

如果你使用Intellij,那么可以运行如下命令来设置Intellij项目配置:

 

$ ./mill mill.scalalib.GenIdea/idea

现在你可以在Intellij中打开minimalApplication-0.3.0/目录,查看项目的目录,也可以进行编辑。

640?wx_fmt=png

可以利用Mill构建工具运行该程序,只需执行./mill:

 

$ ./mill -w app.runBackground

该命令将在后台运行Cask Web服务器,同时监视文件系统,如果文件发生了变化,则重启服务器。然后我们可以使用浏览器浏览服务器,默认网址是localhost:8080:

640?wx_fmt=png

在/do-thing上还有个POST端点,可以在另一个终端上使用curl来访问:

 

$ curl -X POST --data hello http://localhost:8080/do-thing
olleh

可见,它接受数据hello,然后将反转的字符串返回给客户端。

然后可以运行app/test/src/ExampleTests.scala中的自动化测试:

 

$ ./mill clean app.runBackground # stop the webserver running in the background

$ ./mill app.test
[50/56] app.test.compile
[info] Compiling 1 Scala source to /Users/lihaoyi/test/minimalApplication-0.3.0/out/app/test/compile/dest/classes ...
[info] Done compiling.
[56/56] app.test.test
-------------------------------- Running Tests --------------------------------
+ app.ExampleTests.MinimalApplication 629ms

 

现在基本的东西已经运行起来了,我们来重新运行Web服务器:

 

$ ./mill -w app.runBackground

然后开始实现我们的聊天网站!

 

640?wx_fmt=png

提供HTML服务

 

第一件事就是将纯文本的"Hello, World!"转换成HTML网页。最简单的方式就是利用Scalatags这个HTML生成库。要在项目中使用Scalatags,只需将其作为依赖项加入到build.sc文件即可:

 

  def ivyDeps = Agg(
+    ivy"com.lihaoyi::scalatags:0.7.0",   
     ivy"com.lihaoyi::cask:0.3.0"
   )

如果使用Intellij,那么还需要重新运行./mill mill.scalalib.GenIdea/idea命令,来发现依赖项的变动,然后重新运行./mill -w app.runBackground让Web服务器重新监听改动。

然后,我们可以在MinimalApplication.scala中导入Scalatags:

 

package app
+import scalatags.Text.all._
 object MinimalApplication extends cask.MainRoutes{

然后用一段最简单的Scalatags HTML模板替换"Hello, World!"。

 

 def hello() = {
-    "Hello World!"
+    html(
+      head(),
+      body(
+        h1("Hello!"),
+        p("World")
+      )
+    ).render
   }

我们应该可以看到./mill -w app.runBackground命令重新编译了代码并重启了服务器。然后刷新网页额,就会看到纯文本已经被替换成HTML页面了。

640?wx_fmt=png

 

640?wx_fmt=png

Bootstrap

 

为了让页面更好看一些,我们使用Bootstrap这个CSS框架。只需按照它的指南,使用link标签引入bootstrap:

 

     head(
+        link(
+          rel := "stylesheet", 
+          href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
+        )
       ),

 

  body(
-        h1("Hello!"),
-        p("World")
+        div(cls := "container")(
+          h1("Hello!"),
+          p("World")
+        )
       )

现在字体不太一样了:

640?wx_fmt=png

虽然还不是最漂亮的网站,但现在已经足够了。

在本节的末尾,我们修改一下Scalatags的HTML模板,加上硬编码的聊天文本和假的输入框,让它看起来更像一个聊天应用程序。

 

 body(
         div(cls := "container")(
-          h1("Hello!"),
-          p("World")
+          h1("Scala Chat!"),
+          hr,
+          div(
+            p(b("alice"), " ", "Hello World!"),
+            p(b("bob"), " ", "I am cow, hear me moo"),
+            p(b("charlie"), " ", "I weigh twice as much as you")
+          ),
+          hr,
+          div(
+            input(`type` := "text", placeholder := "User name", width := "20%"),
+            input(`type` := "text", placeholder := "Please write a message!", width := "80%")
+          )
         )
       )

 

640?wx_fmt=png

现在我们有了一个简单的静态网站,其利用Cask web框架和Scalatags HTML库提供HTML网页服务。现在的服务器代码如下所示:

 

package app
import scalatags.Text.all._
object MinimalApplication extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    html(
      head(
        link(
          rel := "stylesheet",
          href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        )
      ),
      body(
        div(cls := "container")(
          h1("Scala Chat!"),
          hr,
          div(
            p(b("alice"), " ", "Hello World!"),
            p(b("bob"), " ", "I am cow, hear me moo"),
            p(b("charlie"), " ", "I weigh twice as much as you")
          ),
          hr,
          div(
            input(`type` := "text", placeholder := "User name", width := "20%"),
            input(`type` := "text", placeholder := "Please write a message!", width := "80%")
          )
        )
      )
    ).render
  }

  initialize()
}

接下来,我们来看看怎样让它支持交互!

 

640?wx_fmt=png

表单和数据

 

为网站添加交互的第一次尝试是使用HTML表单。首先我们要删掉硬编码的消息列表,转而根据数据来输出HTML网页:

 

 object MinimalApplication extends cask.MainRoutes{
+  var messages = Vector(
+    ("alice", "Hello World!"),
+    ("bob", "I am cow, hear me moo"),
+    ("charlie", "I weigh twice as much as you"),
+  )
  @cask.get("/")

 

 

 div(
-            p(b("alice"), " ", "Hello World!"),
-            p(b("bob"), " ", "I am cow, hear me moo"),
-            p(b("charlie"), " ", "I weight twice as much as you")
+            for((name, msg) <- messages)
+            yield p(b(name), " ", msg)
           ),

这里我们简单地使用了内存上的mssages存储。关于如何将消息持久存储到数据库中,我将在以后的文章中介绍。

接下来,我们需要让页面底部的两个input支持交互。为实现这一点,我们需要将它们包裹在form元素中:

 

    hr,
-          div(
-            input(`type` := "text", placeholder := "User name", width := "20%"),
-            input(`type` := "text", placeholder := "Please write a message!", width := "80%")
+          form(action := "/", method := "post")(
+            input(`type` := "text", name := "name", placeholder := "User name", width := "20%"),
+            input(`type` := "text", name := "msg", placeholder := "Please write a message!", width := "60%"),
+            input(`type` := "submit", width := "20%")
          )

这样我们就有了一个可以交互的表单,外观跟之前的差不多。但是,提交表单会导致Error 404: Not Found错误。这是因为我们还没有将表单与服务器连接起来,来处理表单提交并获取新的聊天信息。我们可以这样做:

 

   -  )
+
+  @cask.postForm("/")
+  def postHello(name: String, msg: String) = {
+    messages = messages :+ (name -> msg)
+    hello()
+  }
+
   @cask.get("/")

@cast.postForm定义为根URL(即 / )添加了另一个处理函数,但该处理函数处理POST请求,而不处理GET请求。Cask文档(http://www.lihaoyi.com/cask/)中还有关于@cask.*注释的其他例子,你可以利用它们来定义处理函数。

640?wx_fmt=png

 

640?wx_fmt=png

 

640?wx_fmt=png

验证

 

现在,用户能够以任何名字提交任何评论。但是,并非所有的评论和名字都是有效的:最低限度,我们希望保证评论和名字字段非空,同时我们还需要限制最大长度。

实现这一点很简单:

 

  @cask.postForm("/")
   def postHello(name: String, msg: String) = {
-    messages = messages :+ (name -> msg)
+    if (name != "" && name.length < 10 && msg != "" && msg.length < 160){
+      messages = messages :+ (name -> msg)
+    }
     hello()
   }

这样就可以阻止用户输入非法的name和msg,但出现了另一个问题:用户输入了非法的名字或信息并提交,那么这些信息就会消失,而且不会为错误产生任何反馈。解决方法是,给hello()页面渲染一个可选的错误信息,用它来告诉用户出现了什么问题:

 

 @cask.postForm("/")
   def postHello(name: String, msg: String) = {
-    if (name != "" && name.length < 10 && msg != "" && msg.length < 160){
-      messages = messages :+ (name -> msg)
-    }
-     hello()
+    if (name == "") hello(Some("Name cannot be empty"))
+    else if (name.length >= 10) hello(Some("Name cannot be longer than 10 characters"))
+    else if (msg == "") hello(Some("Message cannot be empty"))
+    else if (msg.length >= 160) hello(Some("Message cannot be longer than 160 characters"))
+    else {
+      messages = messages :+ (name -> msg)
+      hello()
+    }
   }

 

  @cask.get("/")
-  def hello() = {
+  def hello(errorOpt: Option[String] = None) = {
     html(

 

  hr,
+          for(error <- errorOpt) 
+          yield i(color.red)(error),
           form(action := "/", method := "post")(

现在,当名字或信息非法时,就可以正确地显示出错误信息了。

640?wx_fmt=png

 

下一次提交时错误信息就会消失。

 

640?wx_fmt=png

记住名字和消息

 

现在比较烦人的是,每次向聊天室中输入消息时,都要重新输入用户名。此外,如果用户名或信息非法,那消息就会被清除,只能重新输入并提交。可以让hello页面处理函数来填充这些字段,这样就可以解决:

 

 @cask.get("/")
-  def hello(errorOpt: Option[String] = None) = {
+  def hello(errorOpt: Option[String] = None, 
+            userName: Option[String] = None,
+            msg: Option[String] = None) = {
     html(

 

  form(action := "/", method := "post")(
-            input(`type` := "text", name := "name", placeholder := "User name", width := "20%", userName.map(value := _)),
-            input(`type` := "text", name := "msg", placeholder := "Please write a message!", width := "60%"),
+            input(
+              `type` := "text", 
+              name := "name", 
+              placeholder := "User name", 
+              width := "20%", 
+              userName.map(value := _)
+            ),
+            input(
+              `type` := "text",
+              name := "msg",
+              placeholder := "Please write a message!", 
+              width := "60%",
+              msg.map(value := _)
+            ),
             input(`type` := "submit", width := "20%")

这里我们使用了可选的userName和msg查询参数,如果它们存在,则将其作为HTML input标签的value的默认值。

接下来在postHello的处理函数中渲染页面时,填充userName和msg,再发送给用户:

 

  def postHello(name: String, msg: String) = {
-    if (name == "") hello(Some("Name cannot be empty"))
-    else if (name.length >= 10) hello(Some("Name cannot be longer than 10 characters"))
-    else if (msg == "") hello(Some("Message cannot be empty"))
-    else if (msg.length >= 160) hello(Some("Message cannot be longer than 160 characters"))
+    if (name == "") hello(Some("Name cannot be empty"), Some(name), Some(msg))
+    else if (name.length >= 10) hello(Some("Name cannot be longer than 10 characters"), Some(name), Some(msg))
+    else if (msg == "") hello(Some("Message cannot be empty"), Some(name), Some(msg))
+    else if (msg.length >= 160) hello(Some("Message cannot be longer than 160 characters"), Some(name), Some(msg))
     else {
       messages = messages :+ (name -> msg)
-      hello()
+      hello(None, Some(name), None)
     }

注意任何情况下我们都保留name,但只有错误的情况才保留msg。这样做是正确的,因为我们只希望用户在出错时才进行编辑并重新提交。

完整的代码MinimalApplication.scala如下所示:

 

  package app
import scalatags.Text.all._
object MinimalApplication extends cask.MainRoutes{
  var messages = Vector(
    ("alice", "Hello World!"),
    ("bob", "I am cow, hear me moo"),
    ("charlie", "I weigh twice as you"),
  )

  @cask.postForm("/")
  def postHello(name: String, msg: String) = {
    if (name == "") hello(Some("Name cannot be empty"), Some(name), Some(msg))
    else if (name.length >= 10) hello(Some("Name cannot be longer than 10 characters"), Some(name), Some(msg))
    else if (msg == "") hello(Some("Message cannot be empty"), Some(name), Some(msg))
    else if (msg.length >= 160) hello(Some("Message cannot be longer than 160 characters"), Some(name), Some(msg))
    else {
      messages = messages :+ (name -> msg)
      hello(None, Some(name), None)
    }
  }

  @cask.get("/")
  def hello(errorOpt: Option[String] = None,
            userName: Option[String] = None,
            msg: Option[String] = None) = {
    html(
      head(
        link(
          rel := "stylesheet",
          href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        )
      ),
      body(
        div(cls := "container")(
          h1("Scala Chat!"),
          hr,
          div(
            for((name, msg) <- messages)
            yield p(b(name), " ", msg)
          ),
          hr,
          for(error <- errorOpt)
          yield i(color.red)(error),
          form(action := "/", method := "post")(
            input(
              `type` := "text",
              name := "name",
              placeholder := "User name",
              width := "20%",
              userName.map(value := _)
            ),
            input(
              `type` := "text",
              name := "msg",
              placeholder := "Please write a message!",
              width := "60%",
              msg.map(value := _)
            ),
            input(`type` := "submit", width := "20%")
          )
        )
      )
    ).render
  }

  initialize()
}

 

640?wx_fmt=png

利用Ajax实现动态页面更新

 

现在有了一个简单的、基于表单的聊天网站,用户可以发表消息,其他用户加载页面即可看到已发表的消息。下一步就是让网站变成动态的,这样用户不需要刷新页面就能发表消息了。

为实现这一点,我们需要做两件事情:

允许HTTP服务器发送网页的一部分,例如接收消息并渲染消息列表,而不是渲染整个页面

添加一些Javascript来手动提交表单数据。

 

640?wx_fmt=png

渲染页面的一部分

 

要想只渲染需要更新的那部分页面,我们可以重构下代码,从hello页面处理函数中提取出messageList()辅助函数:

 

   )
+  
+  def messageList() = {
+    frag(
+      for((name, msg) <- messages)
+      yield p(b(name), " ", msg)
+    )
+  }
+
   @cask.postForm("/")

 

   hr,
-          div(
-            for((name, msg) <- messages)
-            yield p(b(name), " ", msg)
+          div(id := "messageList")(
+            messageList()
           ),

接下来,我们可以修改postHello处理函数,从而仅渲染可能发生了变化的messageList,而不是渲染整个页面:

 

 

-  @cask.postForm("/")
-   def postHello(name: String, msg: String) = {
-    if (name == "") hello(Some("Name cannot be empty"), Some(name), Some(msg))
-    else if (name.length >= 10) hello(Some("Name cannot be longer than 10 characters"), Some(name), Some(msg))
-    else if (msg == "") hello(Some("Message cannot be empty"), Some(name), Some(msg))
-    else if (msg.length >= 160) hello(Some("Message cannot be longer than 160 characters"), Some(name), Some(msg))
-    else {
-      messages = messages :+ (name -> msg)
-      hello(None, Some(name), None)
+  @cask.postJson("/")
+  def postHello(name: String, msg: String) = {
+    if (name == "") ujson.Obj("success" -> false, "txt" -> "Name cannot be empty")
+    else if (name.length >= 10) ujson.Obj("success" -> false, "txt" -> "Name cannot be longer than 10 characters")
+    else if (msg == "") ujson.Obj("success" -> false, "txt" -> "Message cannot be empty")
+    else if (msg.length >= 160) ujson.Obj("success" -> false, "txt" -> "Message cannot be longer than 160 characters")
+    else {
+      messages = messages :+ (name -> msg)
+      ujson.Obj("success" -> true, "txt" -> messageList().render)
     }
   }

注意我们这里用@cask.postJson替换了@cask.postForm,此外不再调用hello()来重新渲染整个页面,而是仅返回一个很小的JSON结构ujson.Obj,这样浏览器可以利用它更新HTML页面。ujson.Obj数据类型由uJson库提供。

 

640?wx_fmt=png

利用Javascript更新页面

 

现在我们写好了服务器端代码,接下来我们编写相关的客户端代码,从服务器接收JSON响应,并利用它来更新HTML界面

要处理客户端逻辑,我们需要给一些关键的HTML元素添加ID,这样才能在Javascript中访问它们:

 

   hr,
-          for(error <- errorOpt)
-          yield i(color.red)(error),
+          div(id := "errorDiv", color.red),
           form(action := "/", method := "post")(

 

  input(
               `type` := "text",
-              name := "name",
+              id := "nameInput",
               placeholder := "User name",
               width := "20%"
             ),
             input(
               `type` := "text",
-              name := "msg",
+              id := "msgInput",
               placeholder := "Please write a message!",
               width := "60%"
             ),

接下来,在页面头部引入一系列Javascript:

 

  head(
         link(
           rel := "stylesheet",
           href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
-        ),
+        )
+        script(raw("""
+          function submitForm(){
+            fetch(
+              "/",
+              {
+                method: "POST",
+                body: JSON.stringify({name: nameInput.value, msg: msgInput.value})
+              }
+            ).then(response => response.json())
+             .then(json => {
+              if (json.success) {
+                messageList.innerHTML = json.txt
+                msgInput.value = ""
+                errorDiv.innerText = ""
+              } else {
+                errorDiv.innerText = json.txt
+              }
+            })
+          }
+        """))
       ),

从表单的onsubmit处理函数中调用该Javascript函数:

 

-          form(action := "/", method := "post")(
+          form(onsubmit := "submitForm(); return false")(

这样就可以了。现在向网站添加聊天文本,文本就会立即出现在网页上,之后加载页面的其他人也能看见。

最终的代码如下:

 

package app
import scalatags.Text.all._
object MinimalApplication extends cask.MainRoutes{
  var messages = Vector(
    ("alice", "Hello World!"),
    ("bob", "I am cow, hear me moo"),
    ("charlie", "I weigh twice as you"),
  )

  def messageList() = {
    frag(
      for((name, msg) <- messages)
      yield p(b(name), " ", msg)
    )
  }

  @cask.postJson("/")
  def postHello(name: String, msg: String) = {
    if (name == "") ujson.Obj("success" -> false, "txt" -> "Name cannot be empty")
    else if (name.length >= 10) ujson.Obj("success" -> false, "txt" -> "Name cannot be longer than 10 characters")
    else if (msg == "") ujson.Obj("success" -> false, "txt" -> "Message cannot be empty")
    else if (msg.length >= 160) ujson.Obj("success" -> false, "txt" -> "Message cannot be longer than 160 characters")
    else {
      messages = messages :+ (name -> msg)
      ujson.Obj("success" -> true, "txt" -> messageList().render)
    }
  }

  @cask.get("/")
  def hello() = {
    html(
      head(
        link(
          rel := "stylesheet",
          href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        ),
        script(raw("""
          function submitForm(){
            fetch(
              "/",
              {
                method: "POST",
                body: JSON.stringify({name: nameInput.value, msg: msgInput.value})
              }
            ).then(response => response.json())
             .then(json => {
              if (json.success) {
                messageList.innerHTML = json.txt
                msgInput.value = ""
                errorDiv.innerText = ""
              } else {
                errorDiv.innerText = json.txt
              }
            })
          }
        """))
      ),
      body(
        div(cls := "container")(
          h1("Scala Chat!"),
          hr,
          div(id := "messageList")(
            messageList()
          ),
          hr,
          div(id := "errorDiv", color.red),
          form(onsubmit := "submitForm(); return false")(
            input(
              `type` := "text",
              id := "nameInput",
              placeholder := "User name",
              width := "20%"
            ),
            input(
              `type` := "text",
              id := "msgInput",
              placeholder := "Please write a message!",
              width := "60%"
            ),
            input(`type` := "submit", width := "20%")
          )
        )
      )
    ).render
  }

  initialize()
}

注意尽管你输入的消息你自己可以立即看到,但其他人只有刷新页面,或者输入自己的消息迫使messageList重新加载,才能看到你的消息。本文的最后一节将介绍怎样让所有人立即看到你的消息,而不需要手动刷新。

 

640?wx_fmt=png

利用Websockets实时更新页面

 

推送更新的概念和简单:每次提交新消息后,就将消息”推送"到所有监听中的浏览器上,而不是等待浏览器刷新并“拉取”更新后的数据。实现这一目的有多种方法。本文我们使用Websockets。

Websockets可以让浏览器和服务器在正常的HTTP请求-响应流之外互相发送消息。连接一旦建立,任何一方都可以在任何时间发送消息,消息可以包含任意字符串或任意字节。

我们要实现的流程如下:

  1. 网站加载后,浏览器建立到服务器的websocket连接

  2. 连接建立后,浏览器将发送消息"0"到服务器,表明它已准备好接收更新

  3. 服务器将响应初始的txt,其中包含所有已经渲染的消息,以及一个index,表示当前的消息计数

  4. 每当收到消息时,浏览器就会将最后看到的index发送给服务器,然后等待新消息出现,再按照步骤3进行响应

在服务器上实现这一点的关键就是保持所有已打开的连接的集合:

 

var openConnections = Set.empty[cask.WsChannelActor]

该集合包含当前等待更新的浏览器的列表。每当新消息出现时,我们就会向这个列表进行广播。

接下来,定义@cask.websocket处理函数,接收进入的websocket连接并处理:

 

 @cask.websocket("/subscribe")
  def subscribe() = {
    cask.WsHandler { connection =>
      cask.WsActor {
        case cask.Ws.Text(msg) =>
          if (msg.toInt < messages.length){
            connection.send(
              cask.Ws.Text(
                ujson.Obj("index" -> messages.length, "txt" -> messageList().render).render()
              )
            )
          }else{
            openConnections += connection
          }
        case cask.Ws.Close(_, _) => openConnections -= connection
      }
    }
  }

该处理函数接收来自浏览器的msg,检查其内容是否应该立即响应,还是应该利用openConnections注册一个连接再稍后响应。

我们需要在postHello处理函数中做类似的修改:

 

messages = messages :+ (name -> msg)
+      val notification = cask.Ws.Text(
+        ujson.Obj("index" -> messages.length, "txt" -> messageList().render).render()
+      )
+      for(conn <- openConnections) conn.send(notification)
+      openConnections = Set.empty
       ujson.Obj("success" -> true, "txt" -> messageList().render)

这样,每当新的聊天消息提交时,就会将它发送给所有打开的连接,以通知它们。

最后,我们需要在浏览器的script标签中添加一点Javascript代码,来打开Websocket连接,并处理消息的交换:

 

var socket = new WebSocket("ws://" + location.host + "/subscribe");
var eventIndex = 0
socket.onopen = function(ev){ socket.send("" + eventIndex) }
socket.onmessage = function(ev){
  var json = JSON.parse(ev.data)
  eventIndex = json.index
  socket.send("" + eventIndex)
  messageList.innerHTML = json.txt
}

这里,我们首先打开一个连接,发送第一条"0"消息来启动整个流程,然后每次收到更新后,就将json.txt渲染到messageList中,然后将json.index发送回服务器,来订阅下一次更新。

现在,同时打开两个浏览器,就会看到一个窗口中发送的聊天消息立即出现在另一个窗口中:

本节的完整代码如下:

640?wx_fmt=gif
 

package app

import scalatags.Text.all._

object MinimalApplication extends cask.MainRoutes{
  var messages = Vector(
    ("alice", "Hello World!"),
    ("bob", "I am cow, hear me moo"),
    ("charlie", "I weigh twice as you"),
  )

  var openConnections = Set.empty[cask.WsChannelActor]

  def messageList() = {
    frag(
      for((name, msg) <- messages)
      yield p(b(name), " ", msg)
    )
  }

  @cask.postJson("/")
  def postHello(name: String, msg: String) = {
    if (name == "") ujson.Obj("success" -> false, "txt" -> "Name cannot be empty")
    else if (name.length >= 10) ujson.Obj("success" -> false, "txt" -> "Name cannot be longer than 10 characters")
    else if (msg == "") ujson.Obj("success" -> false, "txt" -> "Message cannot be empty")
    else if (msg.length >= 160) ujson.Obj("success" -> false, "txt" -> "Message cannot be longer than 160 characters")
    else {
      messages = messages :+ (name -> msg)
      val notification = cask.Ws.Text(
        ujson.Obj("index" -> messages.length, "txt" -> messageList().render).render()
      )
      for(conn <- openConnections) conn.send(notification)
      openConnections = Set.empty
      ujson.Obj("success" -> true, "txt" -> messageList().render)
    }
  }

  @cask.get("/")
  def hello() = {
    html(
      head(
        link(
          rel := "stylesheet",
          href := "https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        ),
        script(raw("""
          function submitForm(){
            fetch(
              "/",
              {
                method: "POST",
                body: JSON.stringify({name: nameInput.value, msg: msgInput.value})
              }
            ).then(response => response.json())
             .then(json => {
              if (json.success) {
                messageList.innerHTML = json.txt
                msgInput.value = ""
                errorDiv.innerText = ""
              } else {
                errorDiv.innerText = json.txt
              }
            })
          }

          var socket = new WebSocket("ws://" + location.host + "/subscribe");
          socket.onopen = function(ev){ socket.send("0") }
          socket.onmessage = function(ev){
            var json = JSON.parse(ev.data)
            messageList.innerHTML = json.txt
            socket.send("" + json.index)
          }
        """))
      ),
      body(
        div(cls := "container")(
          h1("Scala Chat!"),
          hr,
          div(id := "messageList")(
            messageList()
          ),
          hr,
          div(id := "errorDiv", color.red),
          form(onsubmit := "submitForm(); return false")(
            input(
              `type` := "text",
              id := "nameInput",
              placeholder := "User name",
              width := "20%"
            ),
            input(
              `type` := "text",
              id := "msgInput",
              placeholder := "Please write a message!",
              width := "60%"
            ),
            input(`type` := "submit", width := "20%")
          )
        )
      )
    ).render
  }

  @cask.websocket("/subscribe")
  def subscribe() = {
    cask.WsHandler { connection =>
      cask.WsActor {
        case cask.Ws.Text(msg) =>
          if (msg.toInt < messages.length){
            connection.send(
              cask.Ws.Text(
                ujson.Obj("index" -> messages.length, "txt" -> messageList().render).render()
              )
            )
          }else{
            openConnections += connection
          }
        case cask.Ws.Close(_, _) => openConnections -= connection
      }
    }
  }

  initialize()
}

 

640?wx_fmt=png

总结

 

本文我们介绍了怎样利用Scala实现实时聊天网站和API服务器。我们从静态网站开始,添加基于表单的交互,再利用Ajax访问JSON API实现动态页面,最后利用websocket实现推送通知。我们使用了Cask web框架,Scalatags HTML库,以及uJson序列化库,代码大约125行。

这里展示的聊天网站非常简单。我们故意忽略了将消息保存到持久数据库、认证、用户账号、多聊天室、使用量限制以及许多其他的功能。这里仅使用了内存上的messages列表和openConnections集合,从并发更新的角度来看,它们并非线程安全的。但无论如何,希望这篇文章能够让你体会到怎样使用Scala实现简单的网站和API服务器,进而用它构建更大、更宏伟的应用程序。

英文:Simple Web and Api Servers with Scala

原文链接:http://www.lihaoyi.com/post/SimpleWebandApiServerswithScala.html

【END】

一个好的Python工程师有哪些能力要求?

https://edu.csdn.net/topic/python115?utm_source=csdn_bw

CSDN 博客诚邀入驻啦!

本着共享、协作、开源、技术之路我们共同进步的准则

只要你技术够干货,内容够扎实,分享够积极

欢迎加入 CSDN 大家庭!

扫描下方二维码,即刻加入吧!

640?wx_fmt=jpeg

 热 文 推 荐 

 
 
 
 
 
 
 
 
640?wx_fmt=gif 点击阅读原文,即刻阅读《程序员大本营》最新期刊。
640?wx_fmt=png
你点的每个“在看”,我都认真当成了喜欢
Logo

20年前,《新程序员》创刊时,我们的心愿是全面关注程序员成长,中国将拥有新一代世界级的程序员。20年后的今天,我们有了新的使命:助力中国IT技术人成长,成就一亿技术人!

更多推荐