В этой статье, я хочу поделиться своим подходом в организации
клиент-серверного взаимодействия, в одностраничных браузерных приложениях
с серверной частью на 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.
Автор: Metalfire