- add long polling support

- broadcast a message when a new user connects
- utilize the long polling to deliver the message to existing clients
This commit is contained in:
Andy Janata 2011-12-22 14:19:59 -08:00
parent 727f1cf3c0
commit 5aacc0ac32
7 changed files with 277 additions and 14 deletions

View File

@ -32,3 +32,7 @@
span.error {
color: red;
}
span.debug {
color: blue;
}

View File

@ -3,6 +3,9 @@ cah.ajax = {};
cah.ajax.ErrorHandlers = {};
cah.ajax.SuccessHandlers = {};
cah.DEBUG = true;
cah.LONG_POLL_TIMEOUT = 2 * 60 * 1000;
cah.ajax.ErrorHandlers.register = ajax_register_error;
cah.ajax.ErrorHandlers.firstload = ajax_firstload_error;
@ -20,6 +23,8 @@ $(document).ready(function() {
cache : false,
error : ajaxError,
success : ajaxDone,
timeout : cah.DEBUG ? undefined : 10 * 1000, // 10 second timeout for normal requests
// timeout : 1, // 10 second timeout for normal requests
type : 'POST',
url : '/cah/AjaxServlet'
});
@ -51,47 +56,77 @@ function addLog(text) {
$("#log").prop("scrollTop", $("#log").prop("scrollHeight"));
}
function addLogError(text) {
addLog("<span class='error'>Error: " + text + "</span>");
}
function addLogDebug(text) {
if (cah.DEBUG) {
addLog("<span class='debug'>DEBUG: " + text + "</span>");
}
}
function addLogDebugObject(text, obj) {
if (cah.DEBUG) {
if (JSON && JSON.stringify) {
addLogDebug(text + ": " + JSON.stringify(obj));
} else {
addLogDebug(text + ": TODO: debug log without JSON.stringify()");
}
}
}
/**
* Send an ajax request to the server, and store that the request was sent so we know when it gets
* responded to.
*
* @param op
* {string} Operation code for the request.
* @param data
* {object} Parameter map to send for the request.
* This should be used for data sent to the server, not long-polling.
*
* @param {string}
* op Operation code for the request.
* @param {object}
* data Parameter map to send for the request.
* @param {?function(jqXHR,textStatus,errorThrown)}
* [opt_errback] Optional error callback.
*/
function ajaxRequest(op, data) {
function ajaxRequest(op, data, opt_errback) {
data.op = op;
data.serial = serial++;
$.ajax({
var jqXHR = $.ajax({
data : data
});
pendingRequests[data.serial] = data;
addLogDebugObject("ajax req", data);
if (opt_errback) {
jqXHR.fail(opt_errback);
}
}
function ajaxError(jqXHR, textStatus, errorThrown) {
// TODO deal with this somehow
// and figure out which request it was so we can remove it from pending
addLog("<span class='error'>Error: " + textStatus + "</span>");
debugger;
addLogError(textStatus);
}
function ajaxDone(data) {
addLogDebugObject("ajax done", data);
if (data['error']) {
// TODO cancel any timers or whatever we may have, and disable interface
var req = pendingRequests[data.serial];
if (req && cah.ajax.ErrorHandlers[req.op]) {
cah.ajax.ErrorHandlers[req.op](data);
} else {
addLog("<span class='error'>Error: " + data.error_message + "</span>");
addLogError(data.error_message);
}
} else {
var req = pendingRequests[data.serial];
if (req && cah.ajax.SuccessHandlers[req.op]) {
cah.ajax.SuccessHandlers[req.op](data);
} else if (req) {
addLog("<span class='error'>Error: Unhandled response for op " + req.op + "</span>");
addLogError("Unhandled response for op " + req.op);
} else {
addLog("<span class='error'>Error: Unknown response for serial " + data.serial + "</span>");
addLogError("Unknown response for serial " + data.serial);
}
}
@ -105,6 +140,8 @@ function ajax_register_success(data) {
addLog("You are connected as " + nickname);
$("#nickbox").hide();
$("#canvass").show();
after_registered();
}
function ajax_register_error(data) {
@ -120,9 +157,40 @@ function ajax_firstload_success(data) {
addLog("You have reconnected as " + nickname);
$("#nickbox").hide();
$("#canvass").show();
after_registered();
}
}
function ajax_firstload_error(data) {
// TODO dunno what to do here
}
/**
* This should only be called after we have a valid registration with the server, as we start doing
* long polling here.
*/
function after_registered() {
addLogDebug("done registering");
long_poll();
}
function long_poll() {
addLogDebug("starting long poll");
$.ajax({
complete : long_poll,
error : long_poll_error,
success : long_poll_done,
timeout : cah.LONG_POLL_TIMEOUT,
url : '/cah/LongPollServlet',
});
}
function long_poll_done(data) {
addLogDebugObject("long poll done", data);
}
function long_poll_error(jqXHR, textStatus, errorThrown) {
// TODO deal with this somehow
debugger;
addLogError(textStatus);
}

View File

@ -0,0 +1,69 @@
package net.socialgamer.cah;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import net.socialgamer.cah.data.QueuedMessage;
import net.socialgamer.cah.data.User;
/**
* Servlet implementation class LongPollServlet
*/
@WebServlet("/LongPollServlet")
public class LongPollServlet extends CahServlet {
private static final long serialVersionUID = 1L;
private static final long TIMEOUT = 60 * 1000;
/**
* @see HttpServlet#HttpServlet()
*/
public LongPollServlet() {
super();
// TODO Auto-generated constructor stub
}
/**
* @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response)
*/
@Override
protected void handleRequest(final HttpServletRequest request,
final HttpServletResponse response, final HttpSession hSession) throws ServletException,
IOException {
final PrintWriter out = response.getWriter();
final long start = System.currentTimeMillis();
final User user = (User) hSession.getAttribute("user");
// TODO we might have to synchronize on the user object?
while (!(user.hasQueuedMessages()) && start + TIMEOUT > System.currentTimeMillis()) {
try {
user.waitForNewMessageNotification(start + TIMEOUT - System.currentTimeMillis());
} catch (final InterruptedException ie) {
// I don't think we care?
}
}
if (user.hasQueuedMessages()) {
final QueuedMessage qm = user.getNextQueuedMessage();
// just in case...
if (qm != null) {
returnData(out, qm.getData());
return;
}
}
// otherwise, return that there is no new data
final Map<String, Object> data = new HashMap<String, Object>();
data.put("event", "noop");
data.put("timestamp", System.currentTimeMillis());
returnData(out, data);
}
}

View File

@ -3,6 +3,8 @@ package net.socialgamer.cah.data;
import java.util.HashMap;
import java.util.Map;
import net.socialgamer.cah.data.QueuedMessage.Type;
public class ConnectedUsers {
@ -14,10 +16,23 @@ public class ConnectedUsers {
public void newUser(final User user) {
// TODO fire an event for a new user to interested parties
users.put(user.getNickname(), user);
synchronized (users) {
users.put(user.getNickname(), user);
for (final User u : users.values()) {
final Map<String, Object> data = new HashMap<String, Object>();
data.put("event", "newplayer");
data.put("nickname", user.getNickname());
data.put("timestamp", System.currentTimeMillis());
final QueuedMessage qm = new QueuedMessage(Type.NEW_PLAYER, data);
u.enqueueMessage(qm);
}
}
}
public void removeUser(final User user, final User.DisconnectReason reason) {
// TODO fire an event for a disconnected user to interested parties
synchronized (users) {
}
}
}

View File

@ -0,0 +1,54 @@
package net.socialgamer.cah.data;
import java.util.Map;
public class QueuedMessage implements Comparable<QueuedMessage> {
private final Type messageType;
private final Map<String, Object> data;
public QueuedMessage(final Type messageType, final Map<String, Object> data) {
this.messageType = messageType;
this.data = data;
}
public Type getMessageType() {
return messageType;
}
public Map<String, Object> getData() {
return data;
}
/**
* This is not guaranteed to be consistent with .equals() since we do not care about the data for
* ordering.
*/
@Override
public int compareTo(final QueuedMessage qm) {
return this.messageType.getWeight() - qm.messageType.getWeight();
}
/**
* Types of messages that can be queued. The numerical value is the priority that this message
* should be delivered (lower = more important) compared to other queued messages.
*
* @author ajanata
*
*/
public enum Type {
PING(0), NEW_PLAYER(5);
private final int weight;
Type(final int weight) {
this.weight = weight;
}
public int getWeight() {
return weight;
}
}
}

View File

@ -1,5 +1,8 @@
package net.socialgamer.cah.data;
import java.util.concurrent.PriorityBlockingQueue;
public class User {
enum DisconnectReason {
@ -8,8 +11,52 @@ public class User {
private final String nickname;
private final PriorityBlockingQueue<QueuedMessage> queuedMessages;
private final Object queuedMessageSynchronization = new Object();
public User(final String nickname) {
this.nickname = nickname;
queuedMessages = new PriorityBlockingQueue<QueuedMessage>();
}
public void enqueueMessage(final QueuedMessage message) {
synchronized (queuedMessageSynchronization) {
queuedMessages.add(message);
queuedMessageSynchronization.notifyAll();
}
}
public boolean hasQueuedMessages() {
return !queuedMessages.isEmpty();
}
/**
* Wait for a new message to be queued.
*
* @see java.lang.Object#wait(long timeout)
* @param timeout
* Maximum time to wait in milliseconds.
* @throws InterruptedException
*/
public void waitForNewMessageNotification(final long timeout) throws InterruptedException {
synchronized (queuedMessageSynchronization) {
queuedMessageSynchronization.wait(timeout);
}
}
/**
* This method blocks if there are no messages to return, or perhaps if the queue is being
* modified by another thread.
*
* @return The next message in the queue, or null if interrupted.
*/
public QueuedMessage getNextQueuedMessage() {
try {
return queuedMessages.take();
} catch (final InterruptedException ie) {
return null;
}
}
public String getNickname() {

View File

@ -5,6 +5,7 @@ import java.util.Map;
import javax.servlet.http.HttpSession;
import net.socialgamer.cah.Server;
import net.socialgamer.cah.data.ConnectedUsers;
import net.socialgamer.cah.data.User;
@ -17,9 +18,14 @@ public class RegisterHandler implements Handler {
private final ConnectedUsers users;
// @Inject
// public RegisterHandler(final ConnectedUsers users) {
// this.users = users;
// }
@Inject
public RegisterHandler(final ConnectedUsers users) {
this.users = users;
public RegisterHandler(final Server server) {
this.users = server.getConnectedUsers();
}
@Override