408709 refactor test-webapp's chat application. Now there's only a single request for user login and initial chat message.

This commit is contained in:
Thomas Becker 2013-06-13 16:58:43 +02:00
parent b4913ef38c
commit df17ef8b3a
4 changed files with 225 additions and 133 deletions

View File

@ -19,13 +19,11 @@
package com.acme; package com.acme;
import java.io.IOException; import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.Map; import java.util.Map;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import javax.servlet.AsyncContext; import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent; import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener; import javax.servlet.AsyncListener;
@ -34,57 +32,70 @@ import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
// Simple asynchronous Chat room. // Simple asynchronous Chat room.
// This does not handle duplicate usernames or multiple frames/tabs from the same browser // This does not handle duplicate usernames or multiple frames/tabs from the same browser
// Some code is duplicated for clarity. // Some code is duplicated for clarity.
@SuppressWarnings("serial") @SuppressWarnings("serial")
public class ChatServlet extends HttpServlet public class ChatServlet extends HttpServlet
{ {
private static final Logger LOG = Log.getLogger(ChatServlet.class);
private long asyncTimeout = 10000;
public void init()
{
String parameter = getServletConfig().getInitParameter("asyncTimeout");
if (parameter != null)
asyncTimeout = Long.parseLong(parameter);
}
// inner class to hold message queue for each chat room member // inner class to hold message queue for each chat room member
class Member implements AsyncListener class Member implements AsyncListener
{ {
final String _name; final String _name;
final AtomicReference<AsyncContext> _async=new AtomicReference<>(); final AtomicReference<AsyncContext> _async = new AtomicReference<>();
final Queue<String> _queue = new LinkedList<String>(); final Queue<String> _queue = new LinkedList<>();
Member(String name) Member(String name)
{ {
_name=name; _name = name;
} }
@Override @Override
public void onTimeout(AsyncEvent event) throws IOException public void onTimeout(AsyncEvent event) throws IOException
{ {
LOG.debug("resume request");
AsyncContext async = _async.get(); AsyncContext async = _async.get();
if (async!=null && _async.compareAndSet(async,null)) if (async != null && _async.compareAndSet(async, null))
{ {
HttpServletResponse response = (HttpServletResponse)async.getResponse(); HttpServletResponse response = (HttpServletResponse)async.getResponse();
response.setContentType("text/json;charset=utf-8"); response.setContentType("text/json;charset=utf-8");
PrintWriter out=response.getWriter(); response.getOutputStream().write("{action:\"poll\"}".getBytes());
out.print("{action:\"poll\"}");
async.complete(); async.complete();
} }
} }
@Override @Override
public void onStartAsync(AsyncEvent event) throws IOException public void onStartAsync(AsyncEvent event) throws IOException
{ {
event.getAsyncContext().addListener(this); event.getAsyncContext().addListener(this);
} }
@Override @Override
public void onError(AsyncEvent event) throws IOException public void onError(AsyncEvent event) throws IOException
{ {
} }
@Override @Override
public void onComplete(AsyncEvent event) throws IOException public void onComplete(AsyncEvent event) throws IOException
{ {
} }
} }
Map<String,Map<String,Member>> _rooms = new HashMap<String,Map<String, Member>>(); Map<String, Map<String, Member>> _rooms = new HashMap<>();
// Handle Ajax calls from browser // Handle Ajax calls from browser
@ -92,113 +103,119 @@ public class ChatServlet extends HttpServlet
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
{ {
// Ajax calls are form encoded // Ajax calls are form encoded
String action = request.getParameter("action"); boolean join = Boolean.parseBoolean(request.getParameter("join"));
String message = request.getParameter("message"); String message = request.getParameter("message");
String username = request.getParameter("user"); String username = request.getParameter("user");
if (action.equals("join")) LOG.debug("doPost called. join={},message={},username={}", join, message, username);
join(request,response,username); if (username == null)
else if (action.equals("poll"))
poll(request,response,username);
else if (action.equals("chat"))
chat(request,response,username,message);
}
private synchronized void join(HttpServletRequest request,HttpServletResponse response,String username)
throws IOException
{
Member member = new Member(username);
Map<String,Member> room=_rooms.get(request.getPathInfo());
if (room==null)
{ {
room=new HashMap<String,Member>(); LOG.debug("no paramter user set, sending 503");
_rooms.put(request.getPathInfo(),room); response.sendError(503, "user==null");
}
room.put(username,member);
response.setContentType("text/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.print("{action:\"join\"}");
}
private synchronized void poll(HttpServletRequest request,HttpServletResponse response,String username)
throws IOException
{
Map<String,Member> room=_rooms.get(request.getPathInfo());
if (room==null)
{
response.sendError(503);
return;
}
final Member member = room.get(username);
if (member==null)
{
response.sendError(503);
return; return;
} }
synchronized(member) Map<String, Member> room = getRoom(request.getPathInfo());
Member member = getMember(username, room);
if (message != null)
{ {
if (member._queue.size()>0) sendMessageToAllMembers(message, username, room);
}
// If a message is set, we only want to enter poll mode if the user is a new user. This is necessary to avoid
// two parallel requests per user (one is already in async wait and the new one). Sending a message will
// dispatch to an existing poll request if necessary and the client will issue a new request to receive the
// next message or long poll again.
if (message == null || join)
{
synchronized (member)
{ {
// Send one chat message LOG.debug("Queue size: {}", member._queue.size());
response.setContentType("text/json;charset=utf-8"); if (member._queue.size() > 0)
StringBuilder buf=new StringBuilder();
buf.append("{\"action\":\"poll\",");
buf.append("\"from\":\"");
buf.append(member._queue.poll());
buf.append("\",");
String message = member._queue.poll();
int quote=message.indexOf('"');
while (quote>=0)
{ {
message=message.substring(0,quote)+'\\'+message.substring(quote); sendSingleMessage(response, member);
quote=message.indexOf('"',quote+2); }
else
{
LOG.debug("starting async");
AsyncContext async = request.startAsync();
async.setTimeout(asyncTimeout);
async.addListener(member);
if (!member._async.compareAndSet(null, async))
throw new IllegalStateException();
} }
buf.append("\"chat\":\"");
buf.append(message);
buf.append("\"}");
byte[] bytes = buf.toString().getBytes("utf-8");
response.setContentLength(bytes.length);
response.getOutputStream().write(bytes);
}
else
{
AsyncContext async = request.startAsync();
async.setTimeout(10000);
async.addListener(member);
if (!member._async.compareAndSet(null,async))
throw new IllegalStateException();
} }
} }
} }
private synchronized void chat(HttpServletRequest request,HttpServletResponse response,String username,String message) private Member getMember(String username, Map<String, Member> room)
throws IOException
{ {
Map<String,Member> room=_rooms.get(request.getPathInfo()); Member member = room.get(username);
if (room!=null) if (member == null)
{ {
// Post chat to all members LOG.debug("user: {} in room: {} doesn't exist. Creating new user.", username, room);
for (Member m:room.values()) member = new Member(username);
{ room.put(username, member);
synchronized (m) }
{ return member;
m._queue.add(username); // from }
m._queue.add(message); // chat
// wakeup member if polling private Map<String, Member> getRoom(String path)
AsyncContext async=m._async.get(); {
if (async!=null & m._async.compareAndSet(async,null)) Map<String, Member> room = _rooms.get(path);
async.dispatch(); if (room == null)
{
LOG.debug("room: {} doesn't exist. Creating new room.", path);
room = new HashMap<>();
_rooms.put(path, room);
}
return room;
}
private void sendSingleMessage(HttpServletResponse response, Member member) throws IOException
{
response.setContentType("text/json;charset=utf-8");
StringBuilder buf = new StringBuilder();
buf.append("{\"from\":\"");
buf.append(member._queue.poll());
buf.append("\",");
String returnMessage = member._queue.poll();
int quote = returnMessage.indexOf('"');
while (quote >= 0)
{
returnMessage = returnMessage.substring(0, quote) + '\\' + returnMessage.substring(quote);
quote = returnMessage.indexOf('"', quote + 2);
}
buf.append("\"chat\":\"");
buf.append(returnMessage);
buf.append("\"}");
byte[] bytes = buf.toString().getBytes("utf-8");
response.setContentLength(bytes.length);
response.getOutputStream().write(bytes);
}
private void sendMessageToAllMembers(String message, String username, Map<String, Member> room)
{
LOG.debug("Sending message: {} from: {}", message, username);
for (Member m : room.values())
{
synchronized (m)
{
m._queue.add(username); // from
m._queue.add(message); // chat
// wakeup member if polling
AsyncContext async = m._async.get();
LOG.debug("Async found: {}", async);
if (async != null & m._async.compareAndSet(async, null))
{
LOG.debug("dispatch");
async.dispatch();
} }
} }
} }
response.setContentType("text/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.print("{action:\"chat\"}");
} }
// Serve the HTML with embedded CSS and Javascript. // Serve the HTML with embedded CSS and Javascript.
@ -206,10 +223,10 @@ public class ChatServlet extends HttpServlet
@Override @Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
{ {
if (request.getParameter("action")!=null) if (request.getParameter("action") != null)
doPost(request,response); doPost(request, response);
else else
getServletContext().getNamedDispatcher("default").forward(request,response); getServletContext().getNamedDispatcher("default").forward(request, response);
} }
} }

View File

@ -0,0 +1,2 @@
org.eclipse.jetty.util.log.class=org.eclipse.jetty.util.log.StdErrLog
com.acme.LEVEL=DEBUG

View File

@ -30,38 +30,28 @@
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
req.send(body); req.send(body);
} }
; function send(user, message, handler, join)
function send(action, user, message, handler)
{ {
if (message) message = message.replace('%', '%25').replace('&', '%26').replace('=', '%3D'); if (message) message = message.replace('%', '%25').replace('&', '%26').replace('=', '%3D');
if (user) user = user.replace('%', '%25').replace('&', '%26').replace('=', '%3D'); if (user) user = user.replace('%', '%25').replace('&', '%26').replace('=', '%3D');
xhr('POST', 'chat', 'action=' + action + '&user=' + user + '&message=' + message, handler); var requestBody = 'user=' + user + (message ? '&message=' + message : '') + (join ? '&join=true' : '');
xhr('POST', 'chat', requestBody , handler);
} }
;
var room = { var room = {
join:function (name) join: function (name)
{ {
this._username = name; this._username = name;
$('join').className = 'hidden'; $('join').className = 'hidden';
$('joined').className = ''; $('joined').className = '';
$('phrase').focus(); $('phrase').focus();
send('join', room._username, null, room._joined); send(room._username, 'has joined!', room._poll, true);
}, },
_joined:function () chat: function (text)
{
send('chat', room._username, 'has joined!', room._startPolling);
},
_startPolling:function ()
{
send('poll', room._username, null, room._poll);
},
chat:function (text)
{ {
if (text != null && text.length > 0) if (text != null && text.length > 0)
send('chat', room._username, text); send(room._username, text, room._poll, false);
}, },
_poll:function (m) _poll: function (m)
{ {
//console.debug(m); //console.debug(m);
if (m.chat) if (m.chat)
@ -79,10 +69,9 @@
chat.appendChild(lineBreak); chat.appendChild(lineBreak);
chat.scrollTop = chat.scrollHeight - chat.clientHeight; chat.scrollTop = chat.scrollHeight - chat.clientHeight;
} }
if (m.action == 'poll') send(room._username, null, room._poll, false);
send('poll', room._username, null, room._poll);
}, },
_end:'' _end: ''
}; };
</script> </script>
<style type='text/css'> <style type='text/css'>
@ -106,7 +95,7 @@
padding: 4px; padding: 4px;
background-color: #e0e0e0; background-color: #e0e0e0;
border: 1px solid black; border: 1px solid black;
border-top: 0px border-top: 0
} }
input#phrase { input#phrase {
@ -122,14 +111,6 @@
div.hidden { div.hidden {
display: none; display: none;
} }
span.from {
font-weight: bold;
}
span.alert {
font-style: italic;
}
</style> </style>
</head> </head>
<body> <body>

View File

@ -0,0 +1,92 @@
//
// ========================================================================
// Copyright (c) 1995-2013 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty;
import com.acme.ChatServlet;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.servlet.ServletTester;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
@RunWith(JUnit4.class)
public class ChatServletTest
{
private final ServletTester tester = new ServletTester();
@Before
public void setUp() throws Exception
{
tester.setContextPath("/");
ServletHolder dispatch = tester.addServlet(ChatServlet.class, "/chat/*");
dispatch.setInitParameter("asyncTimeout","500");
tester.start();
}
@After
public void tearDown() throws Exception
{
tester.stop();
}
@Test
public void testLogin() throws Exception
{
assertResponse("user=test&join=true&message=has%20joined!", "{\"from\":\"test\",\"chat\":\"has joined!\"}");
}
@Test
public void testChat() throws Exception
{
assertResponse("user=test&message=has%20joined!", "{\"from\":\"test\",\"chat\":\"has joined!\"}");
assertResponse("user=test&message=message", "");
}
@Test
public void testPoll() throws Exception
{
assertResponse("user=test", "{action:\"poll\"}");
}
private void assertResponse(String requestBody, String expectedResponse) throws Exception
{
String response = tester.getResponses(createRequestString(requestBody));
assertThat(response.contains(expectedResponse), is(true));
}
private String createRequestString(String body)
{
StringBuilder req1 = new StringBuilder();
req1.append("POST /chat/ HTTP/1.1\r\n");
req1.append("Host: tester\r\n");
req1.append("Content-length: " + body.length() + "\r\n");
req1.append("Content-type: application/x-www-form-urlencoded\r\n");
req1.append("Connection: close\r\n");
req1.append("\r\n");
req1.append(body);
return req1.toString();
}
}