This is now the "Getting started" page on Lift's website
I have the honor to represent the demo that David Pollak gives all the time, it's the ubiquitous Lift chat app.
In this article I'll show how you can create a comet-enabled chat application using Lift. I will show all the code you need to get it working and walk through the lines step by step to give you an understanding of what's happening. In the end I'll show how to enhance the application with some extra functionality and a few effects.
Before we begin I want to say a quick word about comet in case it's the first you've heard of it. Comet describes a model where the client sends a request to the server. The request is hanging till the server has something interesting to response. As soon as the server responses another request is made. The idea is to give the impression that the server is notifying the client of changes on the server.
To get started install the Simple Build Tool (aka sbt) and download the TAR or Zip of the default Lift project and un-tar or un-zip the file.
Now cd into the new folder and type sbt update to grab the dependencies.
Next, spark up your editor of choice and create the src/main/scala/code/comet/Chat.scala file. Put the following code into Chat.scala:
package code.comet
import net.liftweb._
import http._
import actor._
object ChatServer extends LiftActor with ListenerManager {
private var messages = List("Welcome")
def createUpdate = messages
override def lowPriority = {
case s: String => messages ::= s ; updateListeners()
}
}
class Chat extends CometActor with CometListener {
private var msgs: List[String] = Nil
def registerWith = ChatServer
override def lowPriority = {
case m: List[String] => msgs = m; reRender(false)
}
def render = {
<div>
<ul>
{
msgs.reverse.map(m => <li>{m}</li>)
}
</ul>
<lift:form>
{
SHtml.text("", s => ChatServer ! s)
}
<input type="submit" value="Chat"/>
</lift:form>
</div>
}
}
In your src/main/webapp/index.html file, put the tag: <lift:comet type="Chat"/> and run the app by typing the following in your console:
sbt ~jetty-run
Now browse to http://localhost:8080 with multiple browsers and you have your chat app. Pretty cool huh? Lets walk through the code to figure out how it all fits together.
object ChatServer extends LiftActor with ListenerManager {
private var messages = List("Welcome")
def createUpdate = messages
override def lowPriority = {
case s: String => messages ::= s ; updateListeners()
}
}
We're doing a couple of things here. In the first line we're defining a chat server as an object (singleton) that's a LiftActor and that can manage listeners by mixing in the ListenerManager trait.
In the implementation of our ChatServer we're creating a private list of strings that we'll use to store the messages posted by the clients. The createUpdate method is called when the updateListeners method needs a message to send to the subscribed Actors. Here's it's simply returning all the messages posted to the server.
Lastly we're overriding the lowPriority method where we're pattern matching against the messages sent to us. If the message is a string we're simply adding it to the list of messages and telling all the listeners that something happened by invoking the updateListeners method which we got by mixing in the ListenerManager trait.
lowPriority is just one of three methods (lowPriority, mediumPriority, hightPriority) you can override to process the messages. As the names may suggest the three methods lets you prioritize your messages.
class Chat extends CometActor with CometListener {
Here we're defining our chat component that knows how to push updates to the browser and interact with the ChatServer.
private var msgs: List[String] = Nil
This is where we'll store our local state.
def registerWith = ChatServer
Here the component is registrering itself with the ChatSever so it will get notified of any changes.
override def lowPriority = {
case m: List[String] => msgs = m; reRender(false)
}
This is where we implement how our component will handle the messages from our ChatServer. We're simply updating our local state (msgs) and invoking reRender(false). false tells Lift that we don't want to rerender the entire page but just the comet component.
def render = {
<div>
<ul>
{
msgs.reverse.map(m => <li>{m}</li>)
}
</ul>
<lift:form>
{
// a <lift:form> is an Ajax form. Define our
// input box that sends the message to the chat server
SHtml.text("", s => ChatServer ! s)
}
<input type="submit" value="Chat"/>
</lift:form>
</div>
}
Here we're telling the component how to render itself. But, OMG... we've mixed view logic with our Scala code, gaaakkk... sputter... barf. Yes, Lift allows you to mix view into your business logic, but it's a choice. Here's how we can break the code out to separate the view from the logic. The critical thing to keep in mind is that we did not change our logic at all in order to achieve the separation.
First, let's update our view (src/main/webapp/index.html) to:
<lift:comet type="Chat">
<ul>
<chat:line>
<li><chat:msg/></li>
</chat:line>
</ul>
<lift:form>
<chat:input/>
<input type="submit" value="chat"/>
</lift:form>
</lift:comet>
The view now contains the definition of the layout with "bind points" where dynamic content will be inserted.
Next, let's update our Chat component's render method. Replace the def render = line through the end of the file (except the closing brace) with:
// import NodeSeq... yes, this can be done inside any scope in Scala
import scala.xml.NodeSeq
def render =
bind("chat", // the namespace for binding
"line" -> lines _, // bind the function lines
"input" -> SHtml.text("", s => ChatServer ! s))
private def lines(xml: NodeSeq): NodeSeq =
msgs.reverse.flatMap(m => bind("chat", xml, "msg" -> m))
Instead of using inline xhtml we're using the bind method. You'll find the bind method in the BindHelpers trait and it is used to bind real content to the binding points of your templates.
In this code we're binding content to tags with the namespace chat. The last argument of bind is repeatable and accepts BindParam. So the second and third line of the bind statement are really instances of BindParam and should be read: "replace the tag <chat:line> with the return value of invoking lines" and "replace the tag <chat:input> with the return value of invoking the text method on the SHtml object".
You might notice that we aren't specifying which xml to bind to in our bind statements like we've done previously. This is because the markup passed to the comet component when it is instantiated (i.e. every child node of <lift:comet type="Chat">) is kept around and that's what we're binding against.
the method lines simply takes a NodeSeq and returns a NodeSeq. It reverses the list of messages so the newest message will be at the end of the list and flatMaps the list of messages into the proper xhtml by using the bind method. Note that we have to use flatMap instead of map, because map would result in a Seq[NodeSeq] instead of the expeced NodeSeq (Seq[Node]).
The text method of the SHtml object returns an input field. The method takes two normal arguments and a repeatable one. The first is the initial value of the input field and the second is a function that takes a string and returns Any ((String) => Any). This function will be invoked when the form is submitted. Our function takes a string s and sends that string to our ChatServer using the bang (!) method.
I hope this has helped you gain a better understanding of what the code does. Now, it would be quite dull if all I did was recite the demo that David has done numerous times - Lets see if I can't spice the demo application up a bit to provide some extra functionality.
Lets add the following to our application
- It should be possible to delete messages
- When a message is deleted/added it should fade out/in
To achieve this lets start editing the view (src/main/webapp/index.html) so it looks like this:
<lift:comet type="Chat">
<ul id="ul_dude">
<chat:line>
<li><chat:msg/> <chat:btn/></li>
</chat:line>
</ul>
<lift:form>
<chat:input/>
<input type="submit" value="chat"/>
</lift:form>
</lift:comet>
Not a whole lot change in the view, we simply added a new tag <chat:btn/> and added an id attribute to the <ul> tag. So lets fast forward to the exciting part. Changing the ChatServer and Chat.
Start by adding the following imports to your src/main/scala/code/comet/Chat.scala file:
import js._
import JsCmds._
import js.jquery.JqJsCmds.{AppendHtml, FadeOut, Hide, FadeIn}
import java.util.Date
import scala.xml._
Now that we've got the right classes imported lets start looking at the actual code, again in your src/main/scala/code/comet/Chat.scala file add the following:
sealed trait ChatCmd
object ChatCmd {
implicit def strToMsg(msg: String): ChatCmd =
new AddMessage(Helpers.nextFuncName, msg, new Date)
}
final case class AddMessage(guid: String, msg: String, date: Date) extends ChatCmd
final case class RemoveMessage(guid: String) extends ChatCmd
In the two last lines we're creating two case classes (the final keyword means you can't subclass them). We're going to send instances of these classes between the client and server instead of strings as we did earlier. We're also creating an object named ChatCmd which has an implicit conversion from string to AddMessage as this will simplify the code in Chat as we'll be able to send the string the user entered in the input field and let the implicit conversion do the work of instantiating an instance of AddMessage.
The Helpers.nextFuncName simply creates a unique string based on the current time and a random String generation. We use it here as a unique id for both AddMessage and RemoveMessage.
Now lets take a look how we need to change the ChatServer:
object ChatServer extends LiftActor with ListenerManager {
private var messages: List[ChatCmd] = List("Welcome")
def createUpdate = messages
override def lowPriority = {
case s: String => messages ::= s ; updateListeners()
case d: RemoveMessage => messages ::= d ; updateListeners()
}
}
Instead of using a list of strings to store our messages we're using a list of ChatCmd (both AddMessage and RemoveMessage are subclasses of ChatCmd). The lowPriority message has also changed a bit. We're pattern matching against the message and if it's a string we simply add it to the list of ChatCmd ... but wait ... String isn't a subclass of ChatCmd so surely this doesn't compile. Oh, but it does, this is where our implicit conversion from String to AddMessage comes in handy. The compiler does notice that String isn't a subclass of ChatCmd but before it starts complaining it checks if there is any implicit conversion in scope that might be able to solve the type problem and in this case there is.
Finally lets take a look at the Chat. Replace the following implementation of Chat with the one currently in your file:
class Chat extends CometActor with CometListener {
private var msgs: List[ChatCmd] = Nil
private var bindLine: NodeSeq = Nil
def registerWith = ChatServer
override def lowPriority = {
case m: List[ChatCmd] => {
val delta = m diff msgs
msgs = m
updateDeltas(delta)
}
}
def updateDeltas(what: List[ChatCmd]) {
partialUpdate(what.foldRight(Noop) {
case (m: AddMessage , x) =>
x & CmdPair(AppendHtml("ul_dude", doLine(m)),
CmdPair(Hide(m.guid), FadeIn(m.guid, TimeSpan(0),TimeSpan(500))))
case (RemoveMessage(guid), x) =>
x & CmdPair(FadeOut(guid,TimeSpan(0),TimeSpan(500)),
After(TimeSpan(500),Replace(guid, NodeSeq.Empty)))
})
}
def render =
bind("chat", // the namespace for binding
"line" -> lines _, // bind the function lines
"input" -> SHtml.text("", s => ChatServer ! s)) // the input
private def lines(xml: NodeSeq): NodeSeq = {
bindLine = xml
val deleted = Set((for {
RemoveMessage(guid) <- msgs
} yield guid) :_*)
for {
m @ AddMessage(guid, msg, date) <- msgs.reverse if !deleted.contains(guid)
node <- doLine(m)
} yield node
}
private def doLine(m: AddMessage): NodeSeq =
bind("chat", addId(bindLine, m.guid),
"msg" -> m.msg,
"btn" -> SHtml.ajaxButton("delete",
() => {
ChatServer !
RemoveMessage(m.guid)
Noop}))
private def addId(in: NodeSeq, id: String): NodeSeq = in map {
case e: Elem => e % ("id" -> id)
case x => x
}
}
Bam, If you restart the jetty server and browse to http://localhost:8080 after pasting in the above code you should have a chat application with fancy fading messages and the ability to delete old messages. I hope this is enough to keep your motivated as we walk through the code. Lets take it from the top:
private var msgs: List[ChatCmd] = Nil
private var bindLine: NodeSeq = Nil
def registerWith = ChatServer
We're declaring a list of ChatCmd which we'll use as our local state (ChatCmd instead of String) and a NodeSeq called bindLine which I'll talk about later when we're using it. We're still registering with ChatServer.
override def lowPriority = {
case m: List[ChatCmd] => {
val delta = m diff msgs
msgs = m
updateDeltas(delta)
}
}
Again, the lowPriority method is the one that deals with the messages sent from the ChatServer. We're pattern matching against the messages and if it's a list of ChatCmd we're calculating the difference between the new list and our local state using the diff method on List and store the result in the variable delta. Then we're replacing our local state with the new list and finally we call updateDeltas with delta. Now lets take a look at what updateDeltas actually does:
def updateDeltas(what: List[ChatCmd]) {
partialUpdate(what.foldRight(Noop) {
case (m: AddMessage , x) =>
x & CmdPair(AppendHtml("ul_dude", doLine(m)),
CmdPair(Hide(m.guid), FadeIn(m.guid, TimeSpan(0),TimeSpan(500))))
case (RemoveMessage(guid), x) =>
x & CmdPair(FadeOut(guid,TimeSpan(0),TimeSpan(500)),
After(TimeSpan(500),Replace(guid, NodeSeq.Empty)))
})
}
We're calling partialUpdate which is declared in CometActor and takes a JsCmd as it's only argument. As the name may suggest partialUpdate is used to do partial updates of your comet component. The exciting part of updateDeltas is how we convert a List[ChatCmd] into a JsCmd. Lets take a look.
We declared what as an argument of updateDeltas. We're calling foldRight on what which is a method on List that has the following method signature foldRight [B](z : B)(f : (A, B) => B) : B. Unless you're used to reading Scala code this doesn't help you much, so here's the explanation of foldRight from the Scala Library Documentation: Combines the elements of this list together using the binary function f, from right to left, and starting with the value z
.
In our case we're currying it with Noop which extends JsCmd and basically is an empty javascript statement. In the binary function we're pattern matching against the arguments which is the current element from the list starting from the right-most element and the cumulated value of foldRight so far.
If the element is an instance of AddMessage we're doing a couple of things, first off we're chaining javascripts calls using CmdPair which takes two arguments of type JsCmd. The left-most argument well be invoked before the right-most one. We're also using the AppendHtml object declared in JqJsCmds which has an apply method def apply(uid: String, content: NodeSeq): JsCmd that takes the id of the node to append html to, in this case it's our UL tag with the id ul_dude. The second argument is the NodeSeq to append. In this case we're calling the doLine method with our instance of AddMessage. Well go through that method in due time don't worry. For the second argument of the first CmdPair we're chaining together another CmdPair where we're hiding the html we've just created using the Hide class and then we're using the FadeIn object to fade in the message. Had this not been a demo I would probably have added a css class to the newly created messages with the display property set to none and then simply fade in the messages ones added.
In the second match statement we're using Scala Extractors to fetch the value guid of RemoveMessage. For more information about extractors read this. If the unapply method on RemoveMessage was successful (i.e. returned Some) we're using CmdPair once more. First we're fading out the message using FadeOut and then we're creating an instance of After which allows us to invoke a JsCmd after waiting for the amount of time specified in the instantiation of After. The JsCmd we're handing to After is an instance of Replace which we use to replace the node with id guid with NodeSeq.Empty (i.e. nothing).
In each case we're using the & method on JsCmd to combine the new JsCmd with the cumulated JsCmd which means that the entire foldRight method wil results in a JsCmd that will remove all unwanted messages and add all the new ones on the fly with Javascript. Neat!
The render method hasn't changed so we can skip that. The lines method however has, so lets take a look at it:
private def lines(xml: NodeSeq): NodeSeq = {
bindLine = xml
val deleted = Set((for {
RemoveMessage(guid) <- msgs
} yield guid) :_*)
for {
m @ AddMessage(guid, msg, date) <- msgs.reverse if !deleted.contains(guid)
node <- doLine(m)
} yield node
}
The first thing we're doing is storing the xml in the private variable bindLine (the one I mentioned earlier). We're storing it because we need to use it in the doLine method that I'll explain next. Next we're creating a local variable deleted that we'll use to store the guid of all the messages that should be deleted. We find all the deleted messages by using a for comprehension. We're yielding the guild of all the objects in msgs (our local list of messages) by using the RemoveMessage extractor (which I explained earlier) in our for-comprehension. The for-comprehension returns a List so if we pass it to Set(..) we'll get a Set with Lists instead of a Set of String. To avoid this we're using :_* which tells scala to pass each element of the list to Set as a separate argument.
Next we're using yet another for-comprehension. This time we want to do something with all the instances in msgs (reversed) that isn't part of set of deleted messages. In the second line we're storing the result of invoking doLine with the message. Again we're yielding the result of the for-comprehension so the result of the for-comprehension will be NodeSeq.
Finally it's time to take a look at doLine:
private def doLine(m: AddMessage): NodeSeq =
bind("chat", addId(bindLine, m.guid),
"msg" -> m.msg,
"btn" -> SHtml.ajaxButton("delete",
() => {
ChatServer !
RemoveMessage(m.guid)
Noop}))
We're using the bind message to bind content to nodes with the prefix chat in the NodeSeq returned by calling addId with bindLine and the guid of the AddMessage passed to doLine. We'll look at addId next. The new thing in this bind statement is the invocation of SHtml.ajaxButton(...). The AjaxButton of the SHtml object takes two arguments, the text of the button and a function that takes zero arguments and returns a JsCmd that will get invoked when the button is clicked. In this case we're setting the value of the button to delete and the function sends a RemoveMessage with the guid of the current message to the ChatServer followed by Noop.
Now lets take a look at the very last method:
private def addId(in: NodeSeq, id: String): NodeSeq = in map {
case e: Elem => e % ("id" -> id)
case x => x
}
It takes a NodeSeq and a String. It simply matches against the NodeSeq: If it's an Elem (scala.xml.Elem) it simply adds the attribute id with the value of the argument id. If it's anything else (well it has to be NodeSeq or a subclass of NodeSeq or else the compiler would have complained) it just returns that.
And thats it! I hope this have given you some taste of what Lift is able to do. If you have any feedback please don't hesitate to communicate it to the community.
No comments:
Post a Comment