2011년 1월 25일 화요일

Servlet3.0 - Annotation 및 Nonblocking 서블릿 구현

서블릿 스펙 3.0이 발표된지도 꽤 시간이 지났는데, 한참동안을 살펴보지 못했다.
요즘은 스프링 프레임워크를 이용하여 개발하다보니 Controller 가 웹 어플리케이션의 전부인것처럼 느껴질때도 있다.

그동안 관심을 가지고 있었던 Nonblocking 서블릿과 함께 어노테이션 기반의 서블릿 개발을 함께 살펴 보도록 하자.

서블릿 요청은 서블릿 스펙 2.5 까지만해도 thread for request 모델이었다. 모든 요청당 하나의 쓰레드가 할당되어 처리되도록 구현된 것이다.
Nonblocking 서블릿이란 처리 시간이 많이 소요되는 요청이라든지 메시지 큐를 이용하여 응답을 전송해야 하는 경우에 비동기적으로 요청을 처리하는 방식을 의미한다. HTTP1.1에서 연결을 지속적으로 유지할 수 있는 특징을 이용하여 서블릿 요청과 응답을 큐에 저장해두고 쓰레드는 반환 시킨 후 응답을 처리할 수 있을 때 다시 처리하는 것이다. 이렇게 하면 처리시간이 많이 소요되는 서블릿이라 하더라도 서블릿 요청이 쓰레드를 독점하지 않기 때문에 쓰레드를 고갈시키지 않는다.

Jetty나 Tomcat6.x 에서 자체적으로 Nonblocking 서블릿을 구현하기 위해 컨테이너에 의존적인 방법들을 소개하였지만 이는 이식성을 결여된 코드를 작성하는 것이기 때문에 추천할 만한 방법은 결코 아니다.

서블릿 3.0에서 Asyncronous processing 이라 하여 요청을 비동기적으로 처리할 수 있는 방법을 스펙에 포함시켰다.
Tomcat7.x에서 서블릿3.0을 지원하기 때문에 Tomcat7.x 에서 테스트 하도록 한다.
JDK는 1.6 이상의 버전을 사용한다.

Annotation을 이용한 웹 어플리케이션 개발


web.xml
<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                      http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
  version="3.0"
  metadata-complete="false">  

  <display-name>Servlet3.0 Web Application</display-name>
</web-app>




web.xml 파일은 web-app_3_0.xsd 스키마를 사용하도록 한다.
web-app 속성중에서 metadata-complete 를 false로 지정해야 서블릿 컨테이너가 로드되는 시점에 클래스 패스를 검사하여 Annotation이 설정된 클래스들을 서블릿으로 등록시킨다.

javax.servlet.annotation 패키지에 서블릿 개발 시 사용할 수 있는 annotation 클래스들이 있으면, WebServlet, WebFilter, WebListener등이 자주 사용된다.

살펴 볼 소스 코드는 glassfish 3.0x 버전의 샘플코드로 제공되는 소스다.
@WebServlet annotation의 asyncSupported 값을 true로 지정해야 한다.
doGet() 메서드를 살펴보면 AsyncContext를 생성한 후 queue에 추가시키고 요청을 끝낸다.
이 시점에 해당 서블릿에 할당된 쓰레드는 쓰레드 풀에 반환된다.
doPost() 메서드에는 doGet에서 생성된 asyncContext를 이용하여 각각의 명령에 따라서 결과를 클라이언트에게 전송해 준다.
아래의 소스코드에서는 Comet에서 즐겨 사용하는 방식으로 보이지 않는 iframe과 연결을 시켜두고 javascript로 iframe이 포함되어 있는 페이지의 javascript function을 호출하는 방식을 사용하였다.

package web.servlet.async_request_war;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet(urlPatterns = {"/chat"}, asyncSupported = true)
public class AjaxCometServlet extends HttpServlet {

    private static final Queue<AsyncContext> queue = new ConcurrentLinkedQueue<AsyncContext>();

    private static final BlockingQueue<String> messageQueue = new LinkedBlockingQueue<String>();

    private static final String BEGIN_SCRIPT_TAG = "<script type='text/javascript'>\n";

    private static final String END_SCRIPT_TAG = "</script>\n";

    private static final long serialVersionUID = -2919167206889576860L;

    private Thread notifierThread = null;

    @Override
    public void init(ServletConfig config) throws ServletException {
        Runnable notifierRunnable = new Runnable() {
            public void run() {
                boolean done = false;
                while (!done) {
                    String cMessage = null;
                    try {
                        cMessage = messageQueue.take();
                        for (AsyncContext ac : queue) {
                            try {
                                PrintWriter acWriter = ac.getResponse().getWriter();
                                acWriter.println(cMessage);
                                acWriter.flush();
                            } catch(IOException ex) {
                                System.out.println(ex);
                                queue.remove(ac);
                            }
                        }
                    } catch(InterruptedException iex) {
                        done = true;
                        System.out.println(iex);
                    }
                }
            }
        };
        notifierThread = new Thread(notifierRunnable);
        notifierThread.start();
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        res.setContentType("text/html");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");
        
        PrintWriter writer = res.getWriter();
        // for IE
        writer.println("<!-- Comet is a programming technique that enables web servers to send data to the client without having any need for the client to request it. -->\n");
        writer.flush();

        final AsyncContext ac = req.startAsync();
//        ac.setTimeout(10  * 60 * 1000);
        ac.setTimeout(20 * 1000);
        ac.addListener(new AsyncListener() {
            public void onComplete(AsyncEvent event) throws IOException {
              System.out.println("## onComplete");
                queue.remove(ac);
            }

            public void onTimeout(AsyncEvent event) throws IOException {
              System.out.println("## onTimeout");
                queue.remove(ac);
            }

            public void onError(AsyncEvent event) throws IOException {
              System.out.println("## onError");
                queue.remove(ac);
            }

            public void onStartAsync(AsyncEvent event) throws IOException {
              System.out.println("## onStartAsync");
            }
        });
        queue.add(ac);
    }

    @Override
    @SuppressWarnings("unchecked")
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        res.setContentType("text/plain");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");

        req.setCharacterEncoding("UTF-8");
        String action = req.getParameter("action");
        String name = req.getParameter("name");

        if ("login".equals(action)) {
            String cMessage = BEGIN_SCRIPT_TAG + toJsonp("System Message", name + " has joined.") + END_SCRIPT_TAG;
            notify(cMessage);

            res.getWriter().println("success");
        } else if ("post".equals(action)) {
            String message = req.getParameter("message");
            String cMessage = BEGIN_SCRIPT_TAG + toJsonp(name, message) + END_SCRIPT_TAG;
            notify(cMessage);

            res.getWriter().println("success");
        } else {
            res.sendError(422, "Unprocessable Entity");
        }
    }

    @Override
    public void destroy() {
        queue.clear();
        notifierThread.interrupt();
    }

    private void notify(String cMessage) throws IOException {
        try {
            messageQueue.put(cMessage);
        } catch(Exception ex) {
            IOException t = new IOException();
            t.initCause(ex);
            throw t;
        }
    }

    private String escape(String orig) {
        StringBuffer buffer = new StringBuffer(orig.length());

        for (int i = 0; i < orig.length(); i++) {
            char c = orig.charAt(i);
            switch (c) {
            case '\b':
                buffer.append("\\b");
                break;
            case '\f':
                buffer.append("\\f");
                break;
            case '\n':
                buffer.append("<br />");
                break;
            case '\r':
                // ignore
                break;
            case '\t':
                buffer.append("\\t");
                break;
            case '\'':
                buffer.append("\\'");
                break;
            case '\"':
                buffer.append("\\\"");
                break;
            case '\\':
                buffer.append("\\\\");
                break;
            case '<':
                buffer.append("<");
                break;
            case '>':
                buffer.append(">");
                break;
            case '&':
                buffer.append("&");
                break;
            default:
                buffer.append(c);
            }
        }

        return buffer.toString();
    }

    private String toJsonp(String name, String message) {
        return "window.parent.app.update({ name: \"" + escape(name) + "\", message: \"" + escape(message) + "\" });\n";
    }
}


chat.html
원래 소스코드는 prototype을 이용하여 작성되었으나 jquery로 변경하여 테스트 하였다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <title>Glassfish Chat</title>
        <link rel="stylesheet" href="stylesheets/default.css" type="text/css" />
        <script type="text/javascript" src="javascripts/jquery.js"></script>
    <script type="text/javascript">
var count = 0;    
var app = {
  url: '/async-request-war/chat',
  initialize: function() {
    $('#login-name').focus();
    app.listen();
  },
  listen: function() {
    $('#comet-frame').attr('src', app.url + '?' + count);
    count++;
  },
  login: function() {
    alert("login");
    var name = $('#login-name').val();
    if(! name.length > 0) {
      $('#system-message').css('color', 'red');
      $('#login-name').focus();
      return;
    }
    $('#system-message').css('color','#2d2b2d');
    $('#system-message').html(name + ":");
    $('#login-button').attr('disabled', 'true');
    $('#login-form').hide();
    $('#message-form').show();

    var query = 'action=login' + '&name=' + encodeURI(name);

    app.callAjaxPost(query, 
      function(data){
        $('#message').focus();
      }, function() {
      });
  },
  callAjaxPost: function(query, successFunc, completeFunc) {
    jQuery.ajax({  
      type: "post",
      async: false,
      url: app.url,
        data: query,
      error:?function(){
        alert('Occur Error');
      },
      success:?successFunc,
      complete: completeFunc
    });   
  },
  post: function() {
    var name = $('#login-name').val();
    var message = $('#message').val();
    if(!message.length > 0) {
      return;
    }
    $('#message').attr('disabled', 'true');
    $('post-button').attr('disabled', 'true');
    var query = 'action=post' + '&name=' + encodeURI(name) + '&message=' + encodeURI(message);
    app.callAjaxPost(query, 
        function() {},
        function() {
          $('#message').removeAttr('disabled');
          $('#post-button').removeAttr('disabled');
          $('#message').focus();
          $('#message').val('');
        });
  },
  update: function(data) {
    var name = data.name;
    var message = data.message;
    var p = $('<p>' + name + ':<br/>' + message + '</p>');
    $('#display').append(p);

    $('#display').attr('scrollTop', $('#display').attr('scrollHeight'));
  } 
};

    
$(document).ready(function() {
  $('#login-name').keydown(function(event) {
    if(event.keyCode == 13) {
      $('#login-button').focus();
    }
  });
  $('#message').keydown(function(event) {
    if(event.shiftKey && event.keyCode == 13) {
      $('#post-button').focus();
    }
  });
  $('#login-button').click(app.login);
  $('#post-button').click(app.post);

  app.initialize();
  
});

    </script>

    </head>
    <body>
        <div id="container">
            <div id="container-inner">
                <div id="header">
                    <h1>Glassfish Chat</h1>
                </div>
                <div id="main">
                    <div id="display">
                    </div>
                    <div id="form">
                        <div id="system-message">Please input your name:</div>
                        <div id="login-form">
                            <input id="login-name" type="text" />
                            <br />
                            <input id="login-button" type="button" value="Login" />
                        </div>
                        <div id="message-form" style="display: none;">
                            <div>
                                <textarea id="message" name="message" rows="2" cols="40"></textarea>
                                <br />
                                <input id="post-button" type="button" value="Post Message" />
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <iframe id="comet-frame" name='cometFrame' style="display: none;"></iframe>
    </body>
</html>


브라우저에서 위의 소스코드를 테스트하고자 한다면 http://localhost:8080/async-request-war/chat 라고 URL을 입력하면 테스트 할 수 있다.

CSS파일과 jquery.js 파일은 생략하도록 한다.

댓글 2개:

  1. 안녕하세요? 질문을 해도 되는건지 ^^;

    님의 예제를 참조하여,
    단순한 aysnc servlet을 만들었습니다.

    doGet에서 요청을 받아,
    내부에서 runnable을 implementation 하여,
    executor에 execute 하는 간단 예제 입니다.

    여기서 문제인데요,
    동시에 브라우저에서 요청을 날리면,
    첫번째 요청이 끝날때 까지 다음 요청이
    대기 하는 현상이 발생합니다.

    제 생각엔 concurrent하게 실행이 되어야 하는데 말이죠.

    이에 염치불구 질문 드립니다.

    감사합니다.

    답글삭제
  2. 자문 자답입니다;;
    구글 크롭 2개 띄워놓고 했는데,
    리퀘스트 하나만 지원 되네요 ;;;
    혹시나 하고 IE, 파폭 띄우고 하니
    잘 됩니다.
    감사합니다.

    답글삭제