Простой вызов удалённых сервисных методов в одностраничных приложениях

от автора

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

Сокращённо, я называю этот подход «Json Remote Service Procedure Call» — JRSPC.
(Не очень благозвучно, конечно, но из песни слова не выкинешь.)

Применение jrspc — позволяет отказаться от использования слоёв
определений интерфейсов сервисов на клиенте и сервере, что сокращает количество кода,
упрощает его рефакторинг, и снижает вероятность появления ошибок.

Цена за это — замена набора параметров в сервисных методах,
на один параметр — объект Json, что немного усложняет код в сервисных методах.

Т.е, на сервере, вместо: int plus(int a, int, b){return a + b;};,
мы должны будем написать: int plus(JSONObject p){return p.optInt("a") + p.optInt("b", "4");};,

а на клиенте, вместо: PlusService.plus(1, 2, callbacks);,
должны будем написать: Server.call("plusService", "plus", {b: 2, a: 1}, callbacks);.

Однако, заплатив эту цену, мы получаем возможность исключить из процесса разработки
конфигурирование сервисов на сервере и подключение их на клиенте,
а также, сможем избежать ошибок, связанных с изменением мест параметров,
и сможем добавлять в параметры значения по умолчанию ( p.optInt(«b», «4») ).

Как это работает

На транспортном уровне, jrspc — использует json-rpc, с возможностью указывать
в вызове не только метод, но и сервис.
Поэтому, такой json-rpc можно было бы назвать json-rspc (s-service).

Если бы на него существовала спецификация, то она была бы похожа на
спецификацию json-rpc 2.0, за исключением того, что в объекте запроса
было бы добавлено поле «service», а поле «id» — было бы не обязательным, и в ответе — необязателен errorCode.

Для демонстрации, я написал простое демо-приложение, в котором реализуются
функциональности регистрации, логина, и изменения данных и прав пользователя.

Клиентская часть

Клиентская часть этого приложения — написана на фреймворке AngularJS.

(Считаю своим долгом — предупредить тех, кто ещё не пробовал писать на нём:
{{user.name}}, Ангуляр — тяжёлый наркотик!
Для попадения в зависимость от него — достатчно словить кайф всего один раз.)

Для оформления используется Bootstrap.

В серверной части — Spring

В качестве реализации объекта json, используется JSONObject
из библиотеки json-lib.

Клиентская часть состоит из трёх файлов:

ajax-connector.js

var Server = {url: "http://"+ document.location.host +"/jrspc/ajax-request"};  (function() { 	 	function getXMLHttpRequest() { 		if (window.XMLHttpRequest) { 			return new XMLHttpRequest(); 		} else if (window.ActiveXObject) { 			return new ActiveXObject("Microsoft.XMLHTTP"); 		} 		if(confirm("This browser not support AJAX!\nDownload modern browser?")){ 			document.location = "http://www.mozilla.org/ru/firefox/new/"; 		}else{ 			alert("Download modern browser for work with this application!"); 		    throw "This browser not suport Ajax!"; 		} 	} 	 	Server.call = function(service, method, params, successCallback, errorCallback, control) { 		var data = { 			service : service, 			method : method, 			params : params ? params : {} 		}; 		if (control) {control.disabled = true;} 		var requestData = JSON.stringify(data); 		var request = getXMLHttpRequest();  		request.onreadystatechange = function() {			 			//log("request.status="+request.status+", request.readyState="+request.readyState); 			if ((request.readyState == 4 && request.status != 200)) { 				processError("network error!", errorCallback); 				if (control) {control.disabled = false;} 				return; 			}	 			if (!(request.readyState == 4 && request.status == 200)) {return;}			 			//log("request.responseText="+request.responseText); 			try { 				var response = JSON.parse(request.responseText); 				if (response.error) { 					processError(response.error, errorCallback); 				} else { 					if (successCallback) { 						try { 							//log("response="+JSON.stringify(response));							 							successCallback(response.result); 						} catch (ex) { 							error("in ajax successCallback: " + ex + ", data=" + data); 						} 					} 				} 			} catch (conectionError) { 				error("in process ajax request: " + conectionError); 			}			 			if (control) {control.disabled = false;} 		} 		request.open("POST", Server.url, true); 		request.send(requestData); 	} 	 	function processError(error, errorCallback){ 		if (errorCallback) { 			try { 				errorCallback(error); 			} catch (ex) { 				error("in ajax errorCallback: " + ex); 			} 		} else { 			alert(error); 		} 		 	}	 })();  function error(s){if(window.console){console.error(s);}}; function log(s){if(window.console){console.log(s);}}; 

Реализация механизма запросов к серверу, инкапсулированная в объекте Server.
(Префикс ajax — используется, чтобы отличать его от вебсокетного ws-connector.js,
которым он может быть заменён, без изменения кода user-controller.js.)

user-controller.js

function userController($scope){ 		 	var self = $scope;	  	self.user = {login: "", password: ""}; 	 	self.error = ""; 	self.result = "Для входа или регистрации - введите логин и пароль."; 	self.loged = false;  	 	/** This method will called at application initialization (see last string in this file). */ 	 	self.trySetSessionUser = function(control){ 		Server.call("testUserService", "getSessionUser", null,  		   function(user){ 			log("checkUser: user="+JSON.stringify(user)); 			if(!user.id){return;} 			self.user = user; 			self.loged = true; 			self.$digest();			 		}, self.onError, control);		 	}	 	 	 	/** common user methods */ 	 	self.registerUser = function(control){ 		Server.call("testUserService", "registerUser", self.user,  		   function(id){ 			self.user.id = id;			 			self.onSuccess("you registered with id: "+id);		 			setTimeout(function(){control.disabled = true;}, 20); 		}, self.onError, control);		 	} 	 	self.logIn = function(control){ 		self.loginControl = control; 		Server.call("testUserService", "logIn", self.user, function(user){ 			self.user = user; 			self.loged = true; 			self.onSuccess("you loged in with role: "+user.role);	 			setTimeout(function(){control.disabled = true;}, 20); 		}, self.onError, control);		 	} 	 	 	self.logOut = function(control){		 		Server.call("testUserService", "logOut", {}, function(){ 			self.user.role = ""; 			self.user.city = ""; 			self.loged = false; 			self.onSuccess("you loged out"); 			setTimeout(function(){ 				control.disabled = true; 				if(self.loginControl){self.loginControl.disabled = false;} 			}, 20); 		}, self.onError, control);	 	}		 	 	self.getUsersCount = function(control){ 		Server.call("testAdminService", "getUsersCount", null, function(count){ 			self.onSuccess("users count: "+count);			 		}, self.onError, control);			 	}	 	 	self.changeCity = function(control){ 		Server.call("testUserService", "changeCity", {city: self.user.city}, function(){ 			self.onSuccess("users city changed to: "+self.user.city);			 		}, self.onError, control);			 	}		 	 	 	/** admin methods */ 	 	self.grantRole = function(control){		 		Server.call("testAdminService", "grantRole", {role: self.role, userId: self.userId}, function(result){      		self.onSuccess(result);		 		}, self.onError, control);		 	}	 		 	self.removeUser = function(control){ 		Server.call("testAdminService", "removeUser", {userId: self.userId}, self.onSuccess, self.onError, control);		 	}		 	 	/** common callbacks */ 	 	self.onError = function(error){ 		self.error = error;		 		self.$digest();		 	} 	 	self.onSuccess = function(result){	 		self.result = result; 		self.error = ""; 		self.$digest();		 	}		 	 	/** initialization */ 	self.trySetSessionUser(); } 

Здесь находится бизнес-логика приложения, инкапсулированная в функции userController.

application.html

<html x-ng-app><head> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <title>JRSPC Demo application</title> <link href="http://getbootstrap.com/dist/css/bootstrap.css" rel="stylesheet"> <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.9/angular.min.js"></script> <script src="ajax-connector.js"></script> <script src="user-controller.js"></script> </head><body style="padding-left: 42px; padding-top: 12px; padding-right: 12px;"               x-ng-app="jrspcTest"> <table><tr><td> <h1 title="JSON Remote Service Procedure Call" style="cursor: help;"> JRSPC Demo application </h1></td><td style="padding-left: 120px;" valign="middle">      <a href="aga">related article on habrahabr.ru</a></td></tr></table> <div  x-ng-controller="userPanelController"> <pre>   User:         id: {{user.id}}               login: <input type="text" x-ng-model="user.login" x-ng-disabled="loged"/>                  password: <input type="password" x-ng-model="user.password" x-ng-disabled="loged"/>     from city: <input type="text" x-ng-model="user.city"/>  <input value="save"                     x-ng-disabled="!loged" type="button"                     x-ng-click="changeCity($event.target)" class="btn btn-success"/>                          role: {{user.role}}                            <input value="register" x-ng-disabled="loged || user.id > 0  || user.login=='' || user.password==''"              type="button" x-ng-click="registerUser($event.target)" class="btn btn-primary"/> <input                                          value="log in" x-ng-disabled="loged || user.login=='' || user.password==''"              type="button" x-ng-click="logIn($event.target)" class="btn btn-success "/> <input                             value="log out" x-ng-disabled="!loged"              type="button" x-ng-click="logOut($event.target)" class="btn btn-warning"/>                   If you are is admin, you also can:             <input value="grant role:" type="button" x-ng-click="grantRole($event.target)"              class="btn btn-success"/> <input type="text" style="width: 50px;"              x-ng-model="role"/> to user: <input type="text" x-ng-model="userId"               style="width: 40px;"/> or <input value="remove this user"               type="button" x-ng-click="removeUser($event.target)" class="btn btn-warning"/>                                           </pre> <div class="alert alert-{{error == '' ? 'info':'warning'}}">{{error == '' ? result : error}}</div>                     <input value="get users count"               type="button" x-ng-click="getUsersCount($event.target)" class="btn"/>    </div> </body></html> 

Графический интерфейс приложения с логикой блокировки элементов.

Как видим, в представлении скриптового кода, удалённый сервер — выглядит как
объект Server, который должен быть проинициализирован url’ом.

Через этот объект, мы можем обращаться к любому компоненту на сервере
и вызывать любые его методы, таким способом:

Server.call(serviceName, mathodName, params, successCallBack, errorCallback, control);

Ответы или ошибки — приходят в соответствующие коллбэки.

Добавление нового сервиса или метода на сервере — никак не затрагивает клиентский код,
и мы можем вызывать эти сервисы и методы сразу, после того как они появились в серверном коде.

Естественно, сказав «любому и любые» — я немного отошёл от истины.
На самом деле, как удалённые сервисы, вызываться могут только классы, производные от
AbstractService, а вызываемые удалённо методы, должны быть аннотированы @Remote.

Для ограничения прав доступа к методам — используется аннотация @Secured(roleName).
Так, например, метод, аннотированный @Secured("Admin") — не может быть вызван пользователем
с ролью «User».

Cерверная часть

Весь серверный «фреймворк», если можно так выразиться, занимает меньше 9 кб.,
и состоит из шести классов, два из которых — уже знакомые нам аннотации

Remote

package habr.metalfire.jrspc;  import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;  /** If method NOT annotated as Remote MethodInvoker throw exception,   *  when user try to call this method from browser  **/  @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Remote {} 

Secured

package habr.metalfire.jrspc;  import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;  /** If method annotated as Secured  MethodInvoker throw exception,  *  if User not in declared role.  **/  @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Secured {     String[] value();     } 

а также

AbstractService

package habr.metalfire.jrspc;  import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory;  /** parent class for all services */  public abstract class AbstractService {      protected  Log log = LogFactory.getLog(this.getClass());          private User user;                      public void setUser(User user) {                    this.user = user;     }            public User getUser() {                   return user;     }        }

абстрактный класс, от которого должны наследоваться все сервисы, и

CommonServiceController(controller)

.

package habr.metalfire.jrspc;  import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays;  import javax.servlet.http.HttpSession;  import net.sf.json.JSONObject;  import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Controller; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody;  @Controller public class CommonServiceController {      final static Log log = LogFactory.getLog(CommonServiceController.class);      @Autowired     private ApplicationContext applicationContext;          @Autowired     private HttpSession session;          @RequestMapping(value = "/ajax-request", method = RequestMethod.POST)     @ResponseBody     private String processAjaxRequest(@RequestBody String requestJson) {         //log.debug("requestJson="+requestJson);             JSONObject request = JSONObject.fromObject(requestJson);         String serviceName = request.optString("service");         String methodName = request.optString("method");         JSONObject params = request.optJSONObject("params");           log.debug("request ="+request);         JSONObject response = callServiceMethod(serviceName, methodName, params);         log.debug("response="+response);         return response.toString();            }          private JSONObject callServiceMethod(String serviceName, String methodName, JSONObject params) {         JSONObject response =  new JSONObject();          try {             Object serviceObject = applicationContext.getBean(serviceName);             if (serviceObject == null) {                 throw new RuntimeException("AbstractService bean with name " + serviceName + " not found!");             }             if (!(serviceObject instanceof AbstractService)) {                 throw new RuntimeException("Collable service \""+serviceName+"\" MUST be instance of AbstractService, but not of: "                         + serviceObject.getClass().getName());             }             AbstractService service = (AbstractService) serviceObject;                          User user = (User) session.getAttribute("user");             service.setUser(user);             Object result = invokeMethod(service, methodName, params);                       if(result != null){                 response.put("result", result);             } else{                 response.put("result", new JSONObject());             }                                     } catch (Throwable th) {                    response.put("error", th.getMessage());                }         return response;     }        private Object invokeMethod(AbstractService service, String methodName, JSONObject methodParams) throws Throwable {         try {                                             User user = service.getUser();             log.debug("user="+ JSONObject.fromObject(user));                         Class<?> ownerClass = service.getClass();             Class<?>[] parameterTypes = new Class[] { JSONObject.class };             Object[] arguments = new Object[] { methodParams };             Method actionMethod = ownerClass.getMethod(methodName, parameterTypes);             checkAccess(actionMethod, methodParams, user);                                Object result = actionMethod.invoke(service, arguments);                       return result == null ? new Object() : result;                     } catch (Throwable th) {             if (th instanceof InvocationTargetException) {                 th = ((InvocationTargetException) th).getTargetException();             }              if (th instanceof NoSuchMethodException) {                 th = new RuntimeException("Method \""+methodName+"\" not found on class \""+service.getClass().getName()+"\"!");             }                      throw th;         }     }      private void checkAccess(Method method, Object methodParams, User user) {         if (!method.isAnnotationPresent(Remote.class)) {             throw new RuntimeException("Remotely invoked method MUST be annotated as Remote!");         }                         if (method.isAnnotationPresent(Secured.class)) {             String[] roles = method.getAnnotation(Secured.class).value();                         if ( user == null || ( !Arrays.asList(roles).contains(user.getRole()) && !"Admin".equals(user.getRole()) ) ) {                 String message = "User not in role: "                             + StringUtils.arrayToDelimitedString(roles, " or ")                                                  + ", required for invocation of \""                             + method.getName() + "\" method !";                                throw new RuntimeException(message);             }         }              }       }

В его метод processAjaxRequest приходят запросы из скриптового объекта Service.
Далее, запрос преобразуются в JSONObject, находится компонент, по имени сервиса,
и на нём, после проверки прав доступа, рефлективно, вызвается указанный метод.
В вызываемом удалённо методе — всегда должен быть только один параметр, типа JSONObject.

User (entity)

package habr.metalfire.jrspc;  public class User{                  public static enum Role { User, Admin, Supervisor }          private Long id;     private String login;         private String password;     private String city;           private String role;          public User() { }          public Long getId() {return id;}     public void setId(Long id) {this.id = id;}          public String getLogin() {return login;}     public void setLogin(String login) {this.login = login;}          public String getPassword() { return password;}     public void setPassword(String password) {this.password = password; }          public String getRole() {return role;}     public void setRole(String role) {this.role = role;}      public String getCity() { return city;}     public void setCity(String city) {this.city = city;}         }

для хранения данных о пользователе, и

UserManager(component)

package habr.metalfire.jrspc;  import java.util.HashMap; import java.util.concurrent.atomic.AtomicLong;  import org.springframework.stereotype.Component;   @Component public class UserManager {              private static HashMap<Long, User> idUsersMap = new HashMap<Long, User>();          private static HashMap<String, Long> loginIdMap = new HashMap<String, Long>();            private AtomicLong nextId = new AtomicLong(0);              public User findById(Long id) {                return idUsersMap.get(id);     }       public User findByLogin(String login) {         Long id = loginIdMap.get(login);         if(id == null){return null;}         return  findById(id);     }            public boolean saveUser(User user) {         user.setId(nextId.addAndGet(1));         idUsersMap.put(user.getId(), user);         loginIdMap.put(user.getLogin(), user.getId());         return false;     }      public void updateUser(User user) {        idUsersMap.put(user.getId(), user);            }      public void deleteUser(User user) {        idUsersMap.remove(user.getId());               loginIdMap.remove(user.getLogin());                }              public Integer getUsersCount() {        return idUsersMap.size();       }          }

для операций с объектом User (тестовая реализация с эмуляцией персистентности).

Бизнес-логика реализована в двух сервисах:

TestUserService(component)

package habr.metalfire.jrspc;  import javax.servlet.http.HttpSession;  import net.sf.json.JSONObject;  import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component;  @Component @Scope("session") public class TestUserService extends AbstractService{      @Autowired     UserManager userManager;              @Autowired     private HttpSession session;                     @Remote     public Long registerUser(JSONObject userJson){                 User user = (User) JSONObject.toBean(userJson, User.class);                 if(userManager.findByLogin(user.getLogin()) != null){           throw new RuntimeException("User with login "+user.getLogin()+" already registered!");         }                    if(userManager.getUsersCount() == 0){           user.setRole(User.Role.Admin.name());         }else{           user.setRole(User.Role.User.name());         }          userManager.saveUser(user);          return user.getId();     }              @Remote     public User logIn(JSONObject params){                String error = "Unknown combination of login and password!";          User user = userManager.findByLogin(params.optString("login"));          if(user == null){ throw new RuntimeException(error);}          if(!user.getPassword().equals(params.optString("password"))){ throw new RuntimeException(error);}           session.setAttribute("user", user);          return user;     }               @Secured("User")      @Remote     public void logOut(JSONObject params){                 session.removeAttribute("user");     }                     @Secured("User")        @Remote     public void changeCity(JSONObject params){            String city = params.optString("city");                         User user = getUser();         user.setCity(city);                         userManager.updateUser(user);     }                  @Remote     public User getSessionUser(JSONObject params){                    try{            return (User) session.getAttribute("user");         }catch(Throwable th){log.debug("in checkUser: "+th);}         return null;     }          }

сервис с методами для регистрации, логина, и редактирования данных, и

TestAdminService(component)

package habr.metalfire.jrspc;  import net.sf.json.JSONObject;  import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component;  @Component @Scope("session") public class TestAdminService extends AbstractService{      @Autowired     UserManager userManager;                         private User checkUser(Long userId){         User user = userManager.findById(userId);         if(user == null){throw new RuntimeException("User with id "+userId+" not found!");}         return user;             }         @Secured("Admin")        @Remote     public String grantRole(JSONObject params){             Long userId = params.optLong("userId");           User user = userManager.findById(userId);         String role = params.optString("role");                      if(user.getId().equals(getUser().getId())){throw new RuntimeException("Admin role cannot be revoked!");}         user.setRole(role);          userManager.updateUser(user);         return "role "+role+" granted to user "+userId;             }               @Secured("Admin")        @Remote     public String removeUser(JSONObject params){          User user = checkUser(params.optLong("userId"));         if("Admin".equals(user.getRole())){throw new RuntimeException("Admin cannot be removed!");}         userManager.deleteUser(user);         return "User "+user.getId()+" removed.";             }               @Remote     public Integer getUsersCount(JSONObject params){                 return userManager.getUsersCount();     }         }

сервис с методами для удаления юзера, и изменения его роли.

Код написан максимально self-explanatory, поэтому надеюсь, что разобраться в нём будет легко.

Код демо-приложения на Гитхабе

Что дальше?

В следующей статье, я планирую написать, как, на базе данного подхода,
можно организовать клиент-серверное взаимодействие через вебсокеты,
и как, на сервере, из вебсокетного контекста, достать сессию http.

ссылка на оригинал статьи http://habrahabr.ru/post/211937/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *