AppEngine →  Авторизация через службу User Service в GWT приложениях

Данная статья адресована Java программистам начинающим знакомиться с возможностями облачной платформы Google Apps Engine. Так же, я немного напишу про прокол GWT-RPC. Дело в том, что в сфере веб-технологий меня в первую очередь интересуют RIA приложения, а в данный момент технология GWT и производные от нее фреймворки. Несмотря на это, в первой части статьи я постараюсь описать процесс авторизации для тех программистов, кто использует в качестве клиентской части отличные от GWT технологии. Для разработки GWT/GAE приложений я использую Google Eclipse плагин, дальнейший текст построен на предположении что читатель самостоятельно установил и настроил этот плагин, а так же разбирается в основах создания web-приложений на базе технологий Java Servlets & JSP.

GAE авторизация в классическом Java web-приложении.

Для иллюстрации принципа работы службы User Service в пакете с именем userservice.server создадим простой сервлет LoginInfoServlet. Обратите внимание, что имя верхнего каталога в структуре пакетов по умолчанию совпадает с именем проекта написанным в нижнем регистре, в моем случае проект соответственно называется UserService. Код сервлета:

package userservicesample.server;

import java.io.IOException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;

public class LoginInfoServlet extends HttpServlet {

	private static final long serialVersionUID = 1L;

	public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

		UserService userService = UserServiceFactory.getUserService();
        User user = userService.getCurrentUser();

        if (user != null) {
            resp.setContentType("text/html");
            resp.getWriter().println("Hello, " + user.getNickname() 		+ "<br/>");
            resp.getWriter().println("Email: " + user.getEmail()    		+ "<br/>");
            resp.getWriter().println("Admin: " + userService.isUserAdmin() 	+ "<br/>");
            resp.getWriter().println("<a href=\"" + userService.createLogoutURL(req.getRequestURI()) +
                    				 "\">sign out</a>");
        } else {
            resp.sendRedirect(userService.createLoginURL(req.getRequestURI()));
        }
	}
}
Затем, нам необходимо сконфигурировать "отображение" нашего сервлета. Для этого в файле web.xml, находящемся в структуре проекта по адресу war/WEB-INF/ перед комментарием "Default page to serve" добавим следующую разметку:

<servlet>
    <servlet-name>logininfopage</servlet-name>
    <servlet-class>userservicesample.server.LoginInfoServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>logininfopage</servlet-name>
    <url-pattern>/userservice/logininfopage</url-pattern>
  </servlet-mapping>
Собственно, после этого мы можем запустить сервер плагина и перейдя по адресу http://localhost:8888/userservice/logininfopage увидеть результат работы нашего сервлета. В режиме разработки используется заглушка с адресом test@example.com и с тестовой возможностью авторизоваться в роли администратора. В "боевых условиях" мы увидим форму авторизации, которую мы регулярно видим используя сервисы Google, а администраторы задаются в панели управления вашими приложениями. Впрочем, развертывание, создание и администрирование приложений, это тема отдельного разговора.

Что собственно происходит в нашем сервлете. Как можно видеть Apps Enigne инициализирует переменную user. Если мы не авторизованы в нашем сервисе, нам требуется перенаправить пользователя на форму авторизации с помощью userService.createLoginURL. В качестве параметра этот метод принимает путь к "месту назначения" пользователя, в случае успешной авторизации. В приведенном выше примере я просто перенаправлю пользователя на наш сервлет и мы можем получить о нем некоторую информацию. Имена методов и их назначение интуитивно понятны, поэтому опишу только последнюю строчку блока if. Метод createLogoutURL создает ссылку для выхода из нашего приложения, а в качестве параметра принимает путь куда пользователь будет перенаправлен после выхода.

GAE авторизация в GWT-приложении.

Ну а теперь, перейдем к более интересной части, а именно к авторизации в GWT приложениях. Тут кода будет немного больше, зато если читатель в первый раз знакомится с таким замечательным механизмом, как GWT-RPC, данное описание может снять некоторые дальнейшие вопросы.

Любое GWT приложение это, на самом деле, обычный html-документ который позволяет динамически менять свою структуру средствами JavaScript. Приятная особенность технологии заключается в том, что нам не требуется знать всех тонкостей JS, так как код мы пишем на языке Java, а дальнейшую его компиляцию в JavaScript берет на себя GWT SDK.

Для начала, в пакете *.client создадим класс LoginInfo:

package userservicesample.client;

import java.io.Serializable;

public class LoginInfo implements Serializable {

	private static final long serialVersionUID = 1L;

	public String nickName;
	public String email;
	public String loginUrl;
	public String logoutUrl;
	public Boolean isLogged;

	public LoginInfo() {
		isLogged = false;
	}

}
Это класс который мы будем передавать с сервера на клиентскую часть средствами gwt-rpc. Для классов участвующих в клиент-серверных вызовах по средствам протокола GWT-RPC существует требование в виде присутствия в них интерфейса-маркера Serializable. Так же, интересной особенностью GWT-RPC является то, что благодаря этому протоколу мы можем инициализировать класс, например на клиенте, отправить его на сервер и там воспользоваться им, верно и обратное. Это дает нам возможности единожды определить объекты бизнес-логики и использовать их в обоих звеньях, как в "браузере", так и на стороне сервлет-контейнера.

Создадим в пакете *.client два следующий интерфейса:

package userservicesample.client;

import com.google.gwt.user.client.rpc.RemoteService;
import com.google.gwt.user.client.rpc.RemoteServiceRelativePath;

@RemoteServiceRelativePath("login")
public interface LoginService extends RemoteService {
	public LoginInfo callService(String dummyVarStr, Integer dummyVarInt);
}

package userservicesample.client;

import com.google.gwt.user.client.rpc.AsyncCallback;

public interface LoginServiceAsync {
	public void callService(String dummyVarStr, Integer dummyVarInt, AsyncCallback<LoginInfo> callback);
}
Когда будете создавать LoginService, eclipse будет показывать ошибку, на которую не стоит обращать внимание, это такая багофича, потому что плагин ждет "корреспондирующий" интерфейс LoginServiceAsync, имеющий определенную структуру. Так же, обратите внимание на переменные с именами dummyVar*. В нашем конкретном случае они не несут никакой логической нагрузки и я их указал что бы просто проиллюстрировать как передавать параметры с клиента на сервер. То есть, если бы нам требовалось передать на сервер не два параметра, а например только один то сигнатура метода callService выглядел бы так:

public LoginInfo callService(String dummyVarStr) и
public void callService(String dummyVarStr, AsyncCallback<LoginInfo> callback); для Async интерфейса.

В нашем случае не нужен даже первый параметр, так что у нас сигнатура была бы такой:
public LoginInfo callService(); для public void callService(AsyncCallback<LoginInfo> callback);

Зато эти "лишние" параметры хорошо иллюстрируют структуру клиентских интерфейсов. Так же, следует помнить что второй интерфейс должен иметь имя, аналогичное первому но заканчиваться суффиксом Async, а его одноименный метод всегда последним параметром должен иметь интерфейс AsyncCallback, параметризованный типом возвращаемого значения - в нашем случае LoginInfo.

Нам остается только добавить серверный класс и приступить к созданию пользовательского интерфейса. В пакете *.server создадим следующий класс:

package userservicesample.server;

import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.gwt.user.server.rpc.RemoteServiceServlet;

import userservicesample.client.LoginInfo;
import userservicesample.client.LoginService;

@SuppressWarnings("serial")
public class LoginServiceImpl extends RemoteServiceServlet implements LoginService {

	@Override
	public LoginInfo callService(String dummyVarStr, Integer dummyVarInt) {
		UserService userService = UserServiceFactory.getUserService();
        User user = userService.getCurrentUser();

		LoginInfo	info = new LoginInfo();

		String redirectUrl = "";

		if (getThreadLocalRequest().getLocalPort() != 8888)
			redirectUrl = "yourprogectname.appspot.com";
		else
			redirectUrl = "http://localhost:8888/UserService.html?gwt.codesvr=192.168.1.2:9997";

		if (user != null) {
			info.isLogged   = true;
			info.nickName   = user.getNickname();
			info.email      = user.getEmail();
			info.logoutUrl  = userService.createLogoutURL(redirectUrl);
		}
		else {
			info.loginUrl   = userService.createLoginURL(redirectUrl);
		}

		return info;
	}

}
Несмотря на "новый" для нас родительский класс RemoteServiceServlet, LoginServiceImpl является потомком стандартного HttpServlet, что гарантирует возможность работы GWT приложений на большинстве существующих сервлет-контейнеров. Соответственно, как и в первой части статьи, нам требуется описать его в файле web.xml следующим образом:

  <servlet>
    <servlet-name>loginservice</servlet-name>
    <servlet-class>userservicesample.server.LoginServiceImpl</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>loginservice</servlet-name>
    <url-pattern>/userservice/login</url-pattern>
  </servlet-mapping>
Обратите внимание на тэг url-pattern, заключительная его часть должна совпадать с параметром аннотации @RemoteServiceRelativePath указанным в интерфейсе LoginService. Так же, дам небольшое пояснение вот этой части сервлета: if (getThreadLocalRequest().getLocalPort() != 8888). Последние версии GAE/GWT SDK обычно работают на порте 8888, так что мы сразу указываем куда будет идти редирект, как в случае режима разработки, так и в случае работы на сервере apps engine;

Итак, в данный момент у нас почти все готово для нашего приложения. В завершении нам останется только описать вызов процедуры GWT-RPC, однако перед этим будет еще немного рутины. Для начала нам надо очистить проект от кода "привет солнце", сгенерированного плагином. Для этого в папке war откроем html файл проекта и удалим все то, что находится между тэгами body. Затем откроем в пакете *.client класс, носящий имя проекта и сделаем что бы он выглядел следующим образом:

package userservicesample.client;

import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.DialogBox;
import com.google.gwt.user.client.ui.HTML;

/**
 * Entry point classes define <code>onModuleLoad()</code>.
 */
public class UserService implements EntryPoint {

	LoginInfo	info;
	 native void redirect(String url)
	  /*-{
	          $wnd.location = url;
	  }-*/;
	public void onModuleLoad() {

		LoginServiceAsync service = (LoginServiceAsync) GWT.create(LoginService.class);

		AsyncCallback<LoginInfo> callback = new AsyncCallback<LoginInfo>() {

		@Override
		public void onSuccess(LoginInfo result) {

			  		if (result.isLogged) {
						info = result;
						showLoginInfo();
					}
					else {
						redirect(result.loginUrl);
					}
				}
			    public void onFailure(Throwable caught) {

			    	DialogBox dialogBox = new DialogBox();
					dialogBox.setText("Server Error");
					dialogBox.add(new HTML(caught.toString()));
					dialogBox.show();
			    }

			  };
			service.callService(null, null, callback);
	}

	public void showLoginInfo() {

		DialogBox dialogBox = new DialogBox();
		dialogBox.setText("Login info");

		HTML  html = new HTML();

		html.setHTML("Hello, " + info.nickName 	 + "<br/>" +
					 "Email: " + info.email    	 + "<br/>" +
					 "<a href=\"" + info.logoutUrl + "\">sign out</a>");

		dialogBox.add(html);

		dialogBox.show();
	}
}
Когда мы запускаем наше приложение то вызывается метод onModuleLoad(). В нем мы вызываем наш GWT-RPC сервис, в случае если у нас вызов прошел нормально, выполнится метод onSucces, в котором мы как раз и сможем прочитать переданный с сервера класс LoginInfo. Далее мы смотрим "залогинен" ли пользователь и если да, то показываем информацию о логине, если нет, то направляем его в форму авторизации. Можно обратить внимание, что метод redirect имеет модификатор native и очень специфичное оформление тела метода. В данном случае с помощью подобной конструкции можно из GWT вызывать JavaScript функции.

Заключение.

Если программист впервые знакомится с протоколом gwt-rpc, то с первого взгляда может показаться, что для, казалось бы, стандартных вещей как клиент-серверный обмен требуется написать довольно много рутинного кода: два интерфейса, сервлет, да еще и конфигурирование сервлета. Меня по началу тоже смущало такое "многословие", но после некоторого понимания базовых механизмов все потихоньку становится на свои места.

Допустим, у нас есть форма со следующим функционалом: показать данные, добавить данные, удалить данные из БД. Однако, нам никто не мешает оформить наш gwt-rpc сервис для всех трех операций единожды, создав для этих целей структуру, в которой удобно помимо самой информации, так же передавать в контексте какой команды должен отработать наш сервис. Грамотная композиция позволит избежать "свалки интерфейсов" в структуре проекта, а так же банально сэкономит время, потраченное на написание рутинного кода.

Хотелось бы упомянуть еще один плюс использования gwt-rpc. Работая в Eclipse плагине, мы прямо во время выполнения программы имеем возможность расставить точки остановки, как на клиентской, так и на серверной частях, что позволяет получить такой уровень отладки веб-приложений, который еще вчера казался невозможным. Например, прям "в рантайме" мы можем менять значения переменных и эти значения становятся немедленно доступны в нашем работающем приложении.

На этом я, пожалуй, закругляюсь. Спасибо за внимание и удачных экспериментов!

p.s. Я не в коей мере не являюсь гуру Java-программирования или тем более GWT/GAE, таким образом, если я допустил где ошибки, или у вас есть замечания/дополнения, я с удовольствием их выслушаю.

комментарии:

+1Enetri 18.05.2010 13:19
Отличная статья!
Поздравляем с торжественным перерезанием "красной ленточки" :)
+3OZKA 18.05.2010 14:11
Спасибо :)
Enetri 18.05.2010 15:12
Спасибо вам, что не боитесь быть первым :) У вас здорово получилось, так держать!
+1Vitaly Gashock 24.05.2010 19:35
Интересно, спасибо.
Такой вопрос:

if (getThreadLocalRequest().getLocalPort() != 8888)

это что получается, что приложение на АппСпоте постоянно работает на 8888 порту? Если мне не изменяет память то даже в Еклипсо-плагине можно указать на каком порту локально запускать приложение, поэтому делать такую жесткую привязку к порту считаю немного некорректной ;)
Скажите, а можно ли при использовании google-авторизации использовать свою форму ввода логин-пароля, отличную от той, которая предоставляется сервисом, скажем, в общем дизайне всего приложения?
Спасибо
OZKA 26.05.2010 12:38
Привязка возможно слишком "хардкодная", тут основной смысл был в том, что бы код можно было использовать как на appspot'e, так и при локальной отладке. В данном случае у меня плагин работал на 8888 порту, на GAE, насколько я понял, запустить приложение на порту отличном от 80 вряд ли получится. Во всяком случае в документации я не встречал упоминания о чем либо подобном.

По поводу собственной формы авторизации, так же не встречал никаких упоминаний что это возможно. Ну и честно говоря, меня бы лично, насторожило бы что я ввожу свой гугловый логин/пароль в какую-то непонятную новую форму, стандартное приглашение выглядит более надежно :)
Vitaly Gashock 27.05.2010 19:04
По поводу потра для запуска приложения локально:
1. он может быть вручную указан (по-дефолту 8888)
2. может быть выбран плагином автоматически из свободных

См. скриншот
Piccy.info - Free Image Hosting
+1bystrov1984 27.05.2010 16:55
Статья супер. Спасибо.

redirectUrl = "http://localhost:8888/UserService.html?gwt.codesvr=192.168.1.2:9997"; тут видимо нужно свой ip ?
Vitaly Gashock 27.05.2010 18:54
Да нет, этот параметр URLа доавляеляется, когда GWT-приложение запускаеттся в dev-mode, т.е. режиме разрработки. При первом "заходе" на этот адрес браузер предложит установить GWT Developer Plugin, который позволит выполнять пере-кросс-компиляцию на лету, по рефрешу страницы. Это избавляет разработчика от необходимости выполнения перекомпиляции каждый раз, когда вносится изменение.
Из моих наблюдений: перекомпиляция на лету работает для всех случаев изменения кода приложения (изменения в файлах исходников, изменение самой структуры пакетов, изменения в ui.xml-файлах и т.д.). Однако, если Вы внесете изменения в дескриптор модуля приложения (.gwt.xml-файл) -- нужно будет остановить dev-режим и запустить его заново.
Вроде так
+1ShVolodya 16.08.2010 14:40
Спасибо за статью!

Единственное, вместо кода:
if (getThreadLocalRequest().getLocalPort() != 8888)
redirectUrl = "yourprogectname.appspot.com";
else
redirectUrl = "http://localhost:8888/UserService.html?gwt.codesvr=192.168.1.2:9997";

Предлагаю писать: redirectUrl = GWT.getHostPageBaseURL().

добавить комментарий: