makes it easier to handle network messages. These delegators can introspect a delegate type to find message-type specific handler methods. This mapping can be done automatically or performed manually.experimental
parent
1fec72605f
commit
c9eaeeea12
@ -0,0 +1,313 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2015 jMonkeyEngine |
||||||
|
* All rights reserved. |
||||||
|
* |
||||||
|
* Redistribution and use in source and binary forms, with or without |
||||||
|
* modification, are permitted provided that the following conditions are |
||||||
|
* met: |
||||||
|
* |
||||||
|
* * Redistributions of source code must retain the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer. |
||||||
|
* |
||||||
|
* * Redistributions in binary form must reproduce the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer in the |
||||||
|
* documentation and/or other materials provided with the distribution. |
||||||
|
* |
||||||
|
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors |
||||||
|
* may be used to endorse or promote products derived from this software |
||||||
|
* without specific prior written permission. |
||||||
|
* |
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
||||||
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED |
||||||
|
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
||||||
|
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
||||||
|
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
||||||
|
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
||||||
|
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
||||||
|
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
||||||
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
||||||
|
*/ |
||||||
|
|
||||||
|
package com.jme3.network.util; |
||||||
|
|
||||||
|
import com.jme3.network.Message; |
||||||
|
import com.jme3.network.MessageConnection; |
||||||
|
import com.jme3.network.MessageListener; |
||||||
|
import java.lang.reflect.InvocationTargetException; |
||||||
|
import java.lang.reflect.Method; |
||||||
|
import java.util.Arrays; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.HashSet; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.logging.Level; |
||||||
|
import java.util.logging.Logger; |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* A MessageListener implementation that will forward messages to methods |
||||||
|
* of a delegate object. These methods can be automapped or manually |
||||||
|
* specified. Subclasses provide specific implementations for how to |
||||||
|
* find the actual delegate object. |
||||||
|
* |
||||||
|
* @author Paul Speed |
||||||
|
*/ |
||||||
|
public abstract class AbstractMessageDelegator<S extends MessageConnection> |
||||||
|
implements MessageListener<S> { |
||||||
|
|
||||||
|
static final Logger log = Logger.getLogger(AbstractMessageDelegator.class.getName()); |
||||||
|
|
||||||
|
private Class delegateType; |
||||||
|
private Map<Class, Method> methods = new HashMap<Class, Method>(); |
||||||
|
private Class[] messageTypes; |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates an AbstractMessageDelegator that will forward received |
||||||
|
* messages to methods of the specified delegate type. If automap |
||||||
|
* is true then reflection is used to lookup probably message handling |
||||||
|
* methods. |
||||||
|
*/ |
||||||
|
protected AbstractMessageDelegator( Class delegateType, boolean automap ) { |
||||||
|
this.delegateType = delegateType; |
||||||
|
if( automap ) { |
||||||
|
automap(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the array of messages known to be handled by this message |
||||||
|
* delegator. |
||||||
|
*/ |
||||||
|
public Class[] getMessageTypes() { |
||||||
|
if( messageTypes == null ) { |
||||||
|
messageTypes = methods.keySet().toArray(new Class[methods.size()]); |
||||||
|
} |
||||||
|
return messageTypes; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the specified method is valid for the specified |
||||||
|
* message type. This is used internally during automapping to |
||||||
|
* provide implementation specific filting of methods. |
||||||
|
* This implementation checks for methods that take either no |
||||||
|
* arguments, the connection and message type arguments (in that order), |
||||||
|
* or just the message type or connection argument. |
||||||
|
*/ |
||||||
|
protected boolean isValidMethod( Method m, Class messageType ) { |
||||||
|
|
||||||
|
if( log.isLoggable(Level.FINEST) ) { |
||||||
|
log.log(Level.FINEST, "isValidMethod({0}, {1})", new Object[]{m, messageType}); |
||||||
|
} |
||||||
|
|
||||||
|
// Parameters must be S and message type or just message type
|
||||||
|
Class<?>[] parms = m.getParameterTypes(); |
||||||
|
if( parms.length != 2 && parms.length != 1 ) { |
||||||
|
log.finest("Parameter count is not 1 or 2"); |
||||||
|
return false; |
||||||
|
} |
||||||
|
int connectionIndex = parms.length > 1 ? 0 : -1; |
||||||
|
int messageIndex = parms.length > 1 ? 1 : 0; |
||||||
|
|
||||||
|
if( connectionIndex > 0 && !MessageConnection.class.isAssignableFrom(parms[connectionIndex]) ) { |
||||||
|
log.finest("First paramter is not a MessageConnection or subclass."); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if( messageType == null && !Message.class.isAssignableFrom(parms[messageIndex]) ) { |
||||||
|
log.finest("Second paramter is not a Message or subclass."); |
||||||
|
return false; |
||||||
|
} |
||||||
|
if( messageType != null && !parms[messageIndex].isAssignableFrom(messageType) ) { |
||||||
|
log.log(Level.FINEST, "Second paramter is not a {0}", messageType); |
||||||
|
return false; |
||||||
|
} |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convenience method that returns the message type as |
||||||
|
* reflecively determined for a particular method. This |
||||||
|
* only works with methods that actually have arguments. |
||||||
|
* This implementation returns the last element of the method's |
||||||
|
* getParameterTypes() array, thus supporting both |
||||||
|
* method(connection, messageType) as well as just method(messageType) |
||||||
|
* calling forms. |
||||||
|
*/ |
||||||
|
protected Class getMessageType( Method m ) { |
||||||
|
Class<?>[] parms = m.getParameterTypes(); |
||||||
|
return parms[parms.length-1]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Goes through all of the delegate type's methods to find |
||||||
|
* a method of the specified name that may take the specified |
||||||
|
* message type. |
||||||
|
*/ |
||||||
|
protected Method findDelegate( String name, Class messageType ) { |
||||||
|
// We do an exhaustive search because it's easier to
|
||||||
|
// check for a variety of parameter types and it's all
|
||||||
|
// that Class would be doing in getMethod() anyway.
|
||||||
|
for( Method m : delegateType.getDeclaredMethods() ) { |
||||||
|
|
||||||
|
if( !m.getName().equals(name) ) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if( isValidMethod(m, messageType) ) { |
||||||
|
return m; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the specified method name is allowed. |
||||||
|
* This is used by automapping to determine if a method |
||||||
|
* should be rejected purely on name. Default implemention |
||||||
|
* always returns true. |
||||||
|
*/ |
||||||
|
protected boolean allowName( String name ) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calls the map(Set) method with a null argument causing |
||||||
|
* all available matching methods to mapped to message types. |
||||||
|
*/ |
||||||
|
protected final void automap() { |
||||||
|
map((Set<String>)null); |
||||||
|
if( methods.isEmpty() ) { |
||||||
|
throw new RuntimeException("No message handling methods found for class:" + delegateType); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Specifically maps the specified methods names, autowiring |
||||||
|
* the parameters. |
||||||
|
*/ |
||||||
|
public AbstractMessageDelegator<S> map( String... methodNames ) { |
||||||
|
Set<String> names = new HashSet<String>( Arrays.asList(methodNames) ); |
||||||
|
map(names); |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Goes through all of the delegate type's declared methods |
||||||
|
* mapping methods that match the current constraints. |
||||||
|
* If the constraints set is null then allowName() is |
||||||
|
* checked for names otherwise only names in the constraints |
||||||
|
* set are allowed. |
||||||
|
* For each candidate method that passes the above checks, |
||||||
|
* isValidMethod() is called with a null message type argument. |
||||||
|
* All methods are made accessible thus supporting non-public |
||||||
|
* methods as well as public methods. |
||||||
|
*/ |
||||||
|
protected void map( Set<String> constraints ) { |
||||||
|
|
||||||
|
if( log.isLoggable(Level.FINEST) ) { |
||||||
|
log.log(Level.FINEST, "map({0})", constraints); |
||||||
|
} |
||||||
|
for( Method m : delegateType.getDeclaredMethods() ) { |
||||||
|
if( log.isLoggable(Level.FINEST) ) { |
||||||
|
log.log(Level.FINEST, "Checking method:{0}", m); |
||||||
|
} |
||||||
|
|
||||||
|
if( constraints == null && !allowName(m.getName()) ) { |
||||||
|
log.finest("Name is not allowed."); |
||||||
|
continue; |
||||||
|
} |
||||||
|
if( constraints != null && !constraints.contains(m.getName()) ) { |
||||||
|
log.finest("Name is not in constraints set."); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if( isValidMethod(m, null) ) { |
||||||
|
if( log.isLoggable(Level.FINEST) ) { |
||||||
|
log.log(Level.FINEST, "Adding method mapping:{0} = {1}", new Object[]{getMessageType(m), m}); |
||||||
|
} |
||||||
|
// Make sure we can access the method even if it's not public or
|
||||||
|
// is in a non-public inner class.
|
||||||
|
m.setAccessible(true); |
||||||
|
methods.put(getMessageType(m), m); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
messageTypes = null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Manually maps a specified method to the specified message type. |
||||||
|
*/ |
||||||
|
public AbstractMessageDelegator<S> map( Class messageType, String methodName ) { |
||||||
|
// Lookup the method
|
||||||
|
Method m = findDelegate( methodName, messageType ); |
||||||
|
if( m == null ) { |
||||||
|
throw new RuntimeException( "Method:" + methodName |
||||||
|
+ " not found matching signature (MessageConnection, " |
||||||
|
+ messageType.getName() + ")" ); |
||||||
|
} |
||||||
|
|
||||||
|
if( log.isLoggable(Level.FINEST) ) { |
||||||
|
log.log(Level.FINEST, "Adding method mapping:{0} = {1}", new Object[]{messageType, m}); |
||||||
|
} |
||||||
|
methods.put( messageType, m ); |
||||||
|
messageTypes = null; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the mapped method for the specified message type. |
||||||
|
*/ |
||||||
|
protected Method getMethod( Class c ) { |
||||||
|
Method m = methods.get(c); |
||||||
|
return m; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Implemented by subclasses to provide the actual delegate object |
||||||
|
* against which the mapped message type methods will be called. |
||||||
|
*/ |
||||||
|
protected abstract Object getSourceDelegate( S source ); |
||||||
|
|
||||||
|
/** |
||||||
|
* Implementation of the MessageListener's messageReceived() |
||||||
|
* method that will use the current message type mapping to |
||||||
|
* find an appropriate message handling method and call it |
||||||
|
* on the delegate returned by getSourceDelegate(). |
||||||
|
*/ |
||||||
|
@Override |
||||||
|
public void messageReceived( S source, Message msg ) { |
||||||
|
if( msg == null ) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Object delegate = getSourceDelegate(source); |
||||||
|
if( delegate == null ) { |
||||||
|
// Means ignore this message/source
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Method m = getMethod(msg.getClass()); |
||||||
|
if( m == null ) { |
||||||
|
throw new RuntimeException("Delegate method not found for message class:" |
||||||
|
+ msg.getClass()); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if( m.getParameterTypes().length > 1 ) { |
||||||
|
m.invoke( delegate, source, msg ); |
||||||
|
} else { |
||||||
|
m.invoke( delegate, msg ); |
||||||
|
} |
||||||
|
} catch( IllegalAccessException e ) { |
||||||
|
throw new RuntimeException("Error executing:" + m, e); |
||||||
|
} catch( InvocationTargetException e ) { |
||||||
|
throw new RuntimeException("Error executing:" + m, e.getCause()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
@ -0,0 +1,72 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2015 jMonkeyEngine |
||||||
|
* All rights reserved. |
||||||
|
* |
||||||
|
* Redistribution and use in source and binary forms, with or without |
||||||
|
* modification, are permitted provided that the following conditions are |
||||||
|
* met: |
||||||
|
* |
||||||
|
* * Redistributions of source code must retain the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer. |
||||||
|
* |
||||||
|
* * Redistributions in binary form must reproduce the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer in the |
||||||
|
* documentation and/or other materials provided with the distribution. |
||||||
|
* |
||||||
|
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors |
||||||
|
* may be used to endorse or promote products derived from this software |
||||||
|
* without specific prior written permission. |
||||||
|
* |
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
||||||
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED |
||||||
|
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
||||||
|
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
||||||
|
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
||||||
|
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
||||||
|
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
||||||
|
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
||||||
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
||||||
|
*/ |
||||||
|
|
||||||
|
package com.jme3.network.util; |
||||||
|
|
||||||
|
import com.jme3.network.MessageConnection; |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* A MessageListener implementation that will forward messages to methods |
||||||
|
* of a specified delegate object. These methods can be automapped or manually |
||||||
|
* specified. |
||||||
|
* |
||||||
|
* @author Paul Speed |
||||||
|
*/ |
||||||
|
public class ObjectMessageDelegator<S extends MessageConnection> extends AbstractMessageDelegator<S> { |
||||||
|
|
||||||
|
private Object delegate; |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a MessageListener that will forward mapped message types |
||||||
|
* to methods of the specified object. |
||||||
|
* If automap is true then all methods with the proper signature will |
||||||
|
* be mapped. |
||||||
|
* <p>Methods of the following signatures are allowed: |
||||||
|
* <ul> |
||||||
|
* <li>void someName(S conn, SomeMessage msg) |
||||||
|
* <li>void someName(Message msg) |
||||||
|
* </ul> |
||||||
|
* Where S is the type of MessageConnection and SomeMessage is some |
||||||
|
* specific concreate Message subclass. |
||||||
|
*/ |
||||||
|
public ObjectMessageDelegator( Object delegate, boolean automap ) { |
||||||
|
super(delegate.getClass(), automap); |
||||||
|
this.delegate = delegate; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
protected Object getSourceDelegate( MessageConnection source ) { |
||||||
|
return delegate; |
||||||
|
} |
||||||
|
} |
||||||
|
|
@ -0,0 +1,103 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2015 jMonkeyEngine |
||||||
|
* All rights reserved. |
||||||
|
* |
||||||
|
* Redistribution and use in source and binary forms, with or without |
||||||
|
* modification, are permitted provided that the following conditions are |
||||||
|
* met: |
||||||
|
* |
||||||
|
* * Redistributions of source code must retain the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer. |
||||||
|
* |
||||||
|
* * Redistributions in binary form must reproduce the above copyright |
||||||
|
* notice, this list of conditions and the following disclaimer in the |
||||||
|
* documentation and/or other materials provided with the distribution. |
||||||
|
* |
||||||
|
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors |
||||||
|
* may be used to endorse or promote products derived from this software |
||||||
|
* without specific prior written permission. |
||||||
|
* |
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
||||||
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED |
||||||
|
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
||||||
|
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
||||||
|
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
||||||
|
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
||||||
|
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
||||||
|
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
||||||
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
||||||
|
*/ |
||||||
|
|
||||||
|
package com.jme3.network.util; |
||||||
|
|
||||||
|
import com.jme3.network.HostedConnection; |
||||||
|
import java.util.logging.Level; |
||||||
|
import java.util.logging.Logger; |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* A MessageListener implementation that will forward messages to methods |
||||||
|
* of a delegate specified as a HostedConnection session attribute. This is |
||||||
|
* useful for handling connection-specific messages from clients that must |
||||||
|
* delegate to client-specific data objects. |
||||||
|
* The delegate methods can be automapped or manually specified. |
||||||
|
* |
||||||
|
* @author Paul Speed |
||||||
|
*/ |
||||||
|
public class SessionDataDelegator extends AbstractMessageDelegator<HostedConnection> { |
||||||
|
|
||||||
|
static final Logger log = Logger.getLogger(SessionDataDelegator.class.getName()); |
||||||
|
|
||||||
|
private String attributeName; |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a MessageListener that will forward mapped message types |
||||||
|
* to methods of an object specified as a HostedConnection attribute. |
||||||
|
* If automap is true then all methods with the proper signature will |
||||||
|
* be mapped. |
||||||
|
* <p>Methods of the following signatures are allowed: |
||||||
|
* <ul> |
||||||
|
* <li>void someName(S conn, SomeMessage msg) |
||||||
|
* <li>void someName(Message msg) |
||||||
|
* </ul> |
||||||
|
* Where S is the type of MessageConnection and SomeMessage is some |
||||||
|
* specific concreate Message subclass. |
||||||
|
*/ |
||||||
|
public SessionDataDelegator( Class delegateType, String attributeName, boolean automap ) { |
||||||
|
super(delegateType, automap); |
||||||
|
this.attributeName = attributeName; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the attribute name that will be used to look up the |
||||||
|
* delegate object. |
||||||
|
*/ |
||||||
|
public String getAttributeName() { |
||||||
|
return attributeName; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Called internally when there is no session object |
||||||
|
* for the current attribute name attached to the passed source |
||||||
|
* HostConnection. Default implementation logs a warning. |
||||||
|
*/ |
||||||
|
protected void miss( HostedConnection source ) { |
||||||
|
log.log(Level.WARNING, "Session data is null for:{0} on connection:{1}", new Object[]{attributeName, source}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the attributeName attribute of the supplied source |
||||||
|
* HostConnection. If there is no value at that attribute then |
||||||
|
* the miss() method is called. |
||||||
|
*/ |
||||||
|
protected Object getSourceDelegate( HostedConnection source ) { |
||||||
|
Object result = source.getAttribute(attributeName); |
||||||
|
if( result == null ) { |
||||||
|
miss(source); |
||||||
|
} |
||||||
|
return result; |
||||||
|
} |
||||||
|
} |
||||||
|
|
Loading…
Reference in new issue