клиент-серверного взаимодействия, в одностраничных браузерных приложениях
с серверной частью на 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.
Клиентская часть состоит из трёх файлов:
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.)
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.
<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 кб.,
и состоит из шести классов, два из которых — уже знакомые нам аннотации
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 {}
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(); }
а также
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; } }
абстрактный класс, от которого должны наследоваться все сервисы, и
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.
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;} }
для хранения данных о пользователе, и
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 (тестовая реализация с эмуляцией персистентности).
Бизнес-логика реализована в двух сервисах:
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; } }
сервис с методами для регистрации, логина, и редактирования данных, и
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/
Добавить комментарий