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

в 11:02, , рубрики: AngularJS, java, javascript

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

Автор: Metalfire

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js