Skip to content

Commit 1e322c6

Browse files
MakarovSdmitry-timofeev
authored andcommitted
Add transaction methods extraction and invocation [ECR-3923] (#1274)
Add transaction methods extraction and invocation classes. They will be used by ServiceWrapper to invoke transaction methods by an id. Transaction methods are Service methods annotated with @TransactionMethod, and will replace Transaction-interface, TransactionConverter and explicit deserialization of arguments.
1 parent ce2174e commit 1e322c6

5 files changed

Lines changed: 551 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2019 The Exonum Team
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.exonum.binding.core.runtime;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
21+
import com.exonum.binding.core.service.Service;
22+
import com.exonum.binding.core.transaction.TransactionContext;
23+
import com.exonum.binding.core.transaction.TransactionExecutionException;
24+
import java.lang.invoke.MethodHandle;
25+
import java.util.Map;
26+
27+
/**
28+
* Stores ids of transaction methods and their method handles of a corresponding service.
29+
*/
30+
final class TransactionInvoker {
31+
private final Service service;
32+
private final Map<Integer, MethodHandle> transactionMethods;
33+
34+
TransactionInvoker(Service service) {
35+
this.service = service;
36+
this.transactionMethods =
37+
TransactionMethodExtractor.extractTransactionMethods(service.getClass());
38+
}
39+
40+
/**
41+
* Invoke the transaction method with a given transaction identifier.
42+
*
43+
* @param transactionId a transaction method identifier
44+
* @param context a transaction execution context
45+
* @param arguments the serialized transaction arguments
46+
*
47+
* @throws IllegalArgumentException if there is no transaction method with given id in a
48+
* corresponding service
49+
* @throws TransactionExecutionException if {@link TransactionExecutionException} was thrown by
50+
* the transaction method, it is propagated
51+
* @throws RuntimeException any other error is wrapped into a {@link RuntimeException}
52+
*/
53+
void invokeTransaction(int transactionId, byte[] arguments, TransactionContext context)
54+
throws TransactionExecutionException {
55+
checkArgument(transactionMethods.containsKey(transactionId),
56+
"No method with transaction id (%s)", transactionId);
57+
try {
58+
MethodHandle methodHandle = transactionMethods.get(transactionId);
59+
methodHandle.invoke(service, arguments, context);
60+
} catch (Throwable throwable) {
61+
if (throwable instanceof TransactionExecutionException) {
62+
throw (TransactionExecutionException) throwable;
63+
} else {
64+
throw new RuntimeException(throwable);
65+
}
66+
}
67+
}
68+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright 2019 The Exonum Team
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.exonum.binding.core.runtime;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static java.util.stream.Collectors.toMap;
21+
22+
import com.exonum.binding.core.transaction.TransactionContext;
23+
import com.exonum.binding.core.transaction.TransactionMethod;
24+
import com.google.common.annotations.VisibleForTesting;
25+
26+
import java.lang.invoke.MethodHandle;
27+
import java.lang.invoke.MethodHandles;
28+
import java.lang.invoke.MethodHandles.Lookup;
29+
import java.lang.reflect.Method;
30+
import java.util.HashMap;
31+
import java.util.Map;
32+
33+
/**
34+
* Finds and validates transaction methods in a service.
35+
*/
36+
final class TransactionMethodExtractor {
37+
38+
/**
39+
* Returns a map of transaction ids to transaction methods found in a service class.
40+
*
41+
* @see TransactionMethod
42+
*/
43+
static Map<Integer, MethodHandle> extractTransactionMethods(Class<?> serviceClass) {
44+
Map<Integer, Method> transactionMethods = findTransactionMethods(serviceClass);
45+
Lookup lookup = MethodHandles.publicLookup()
46+
.in(serviceClass);
47+
return transactionMethods.entrySet().stream()
48+
.peek(tx -> validateTransactionMethod(tx.getValue(), serviceClass))
49+
.collect(toMap(Map.Entry::getKey,
50+
(e) -> toMethodHandle(e.getValue(), lookup)));
51+
}
52+
53+
@VisibleForTesting
54+
static Map<Integer, Method> findTransactionMethods(Class<?> serviceClass) {
55+
Map<Integer, Method> transactionMethods = new HashMap<>();
56+
while (serviceClass != Object.class) {
57+
Method[] classMethods = serviceClass.getDeclaredMethods();
58+
for (Method method : classMethods) {
59+
if (method.isAnnotationPresent(TransactionMethod.class)) {
60+
TransactionMethod annotation = method.getAnnotation(TransactionMethod.class);
61+
int transactionId = annotation.value();
62+
checkDuplicates(transactionMethods, transactionId, serviceClass, method);
63+
transactionMethods.put(transactionId, method);
64+
}
65+
}
66+
serviceClass = serviceClass.getSuperclass();
67+
}
68+
return transactionMethods;
69+
}
70+
71+
private static void checkDuplicates(Map<Integer, Method> transactionMethods, int transactionId,
72+
Class<?> serviceClass, Method method) {
73+
if (transactionMethods.containsKey(transactionId)) {
74+
String firstMethodName = transactionMethods.get(transactionId).getName();
75+
String errorMessage = String.format("Service %s has more than one transaction with the same"
76+
+ " id (%s): first: %s; second: %s",
77+
serviceClass.getName(), transactionId, firstMethodName, method.getName());
78+
throw new IllegalArgumentException(errorMessage);
79+
}
80+
}
81+
82+
/**
83+
* Checks that the given transaction method signature is correct.
84+
*/
85+
private static void validateTransactionMethod(Method transaction, Class<?> serviceClass) {
86+
String errorMessage = String.format("Method %s in a service class %s annotated with"
87+
+ " @TransactionMethod should have precisely two parameters of the following types:"
88+
+ " 'byte[]' and 'com.exonum.binding.core.transaction.TransactionContext'",
89+
transaction.getName(), serviceClass.getName());
90+
checkArgument(transaction.getParameterCount() == 2, errorMessage);
91+
Class<?> firstParameter = transaction.getParameterTypes()[0];
92+
Class<?> secondParameter = transaction.getParameterTypes()[1];
93+
checkArgument(firstParameter == byte[].class,
94+
String.format(errorMessage
95+
+ ". But first parameter type was: %s", firstParameter.getName()));
96+
checkArgument(TransactionContext.class.isAssignableFrom(secondParameter),
97+
String.format(errorMessage
98+
+ ". But second parameter type was: %s", secondParameter.getName()));
99+
}
100+
101+
private static MethodHandle toMethodHandle(Method method, Lookup lookup) {
102+
try {
103+
return lookup.unreflect(method);
104+
} catch (IllegalAccessException e) {
105+
throw new IllegalArgumentException(
106+
String.format("Couldn't access method %s", method.getName()), e);
107+
}
108+
}
109+
110+
private TransactionMethodExtractor() {}
111+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2019 The Exonum Team
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.exonum.binding.core.transaction;
18+
19+
import com.exonum.binding.common.message.TransactionMessage;
20+
import com.exonum.core.messages.Runtime.ErrorKind;
21+
import com.exonum.core.messages.Runtime.ExecutionError;
22+
import java.lang.annotation.ElementType;
23+
import java.lang.annotation.Retention;
24+
import java.lang.annotation.RetentionPolicy;
25+
import java.lang.annotation.Target;
26+
27+
/**
28+
* Indicates that a method is a transaction method. The annotated method should execute the
29+
* transaction, possibly modifying the blockchain state. The method should:
30+
* <ul>
31+
* <li>be public
32+
* <li>have exactly two parameters - the
33+
* {@linkplain TransactionMessage#getPayload() serialized transaction arguments} of type
34+
* 'byte[]' and a transaction execution context, which allows to access the information about
35+
* this transaction and modify the blockchain state through the included database fork of
36+
* type '{@link TransactionContext}' in this particular order
37+
* </ul>
38+
*
39+
* <p>The annotated method might throw {@linkplain TransactionExecutionException} if the
40+
* transaction cannot be executed normally and has to be rolled back. The transaction will be
41+
* committed as failed (error kind {@linkplain ErrorKind#SERVICE SERVICE}), the
42+
* {@linkplain ExecutionError#getCode() error code} with the optional description will be saved
43+
* into the storage. The client can request the error code to know the reason of the failure.
44+
*
45+
* <p>The annotated method might also throw {@linkplain RuntimeException} if an unexpected error
46+
* occurs. A correct transaction implementation must not throw such exceptions. The transaction
47+
* will be committed as failed (status "panic").
48+
*
49+
* @see <a href="https://exonum.com/doc/version/0.13-rc.2/architecture/transactions">Exonum Transactions</a>
50+
* @see <a href="https://exonum.com/doc/version/0.13-rc.2/architecture/services">Exonum Services</a>
51+
*/
52+
@Target(ElementType.METHOD)
53+
@Retention(RetentionPolicy.RUNTIME)
54+
// TODO: rename to Transaction after migration
55+
public @interface TransactionMethod {
56+
57+
/**
58+
* Returns the transaction type identifier which is unique within the service.
59+
*/
60+
int value();
61+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright 2019 The Exonum Team
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.exonum.binding.core.runtime;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
import static org.mockito.Mockito.spy;
22+
import static org.mockito.Mockito.verify;
23+
24+
import com.exonum.binding.common.hash.HashCode;
25+
import com.exonum.binding.core.service.Node;
26+
import com.exonum.binding.core.service.Service;
27+
import com.exonum.binding.core.storage.database.Snapshot;
28+
import com.exonum.binding.core.transaction.TransactionContext;
29+
import com.exonum.binding.core.transaction.TransactionExecutionException;
30+
import com.exonum.binding.core.transaction.TransactionMethod;
31+
import io.vertx.ext.web.Router;
32+
import java.util.Collections;
33+
import java.util.List;
34+
import org.junit.jupiter.api.Test;
35+
import org.mockito.Mock;
36+
37+
class TransactionInvokerTest {
38+
39+
private static final byte[] ARGUMENTS = new byte[0];
40+
@Mock
41+
private TransactionContext context;
42+
43+
@Test
44+
void invokeValidServiceTransaction() throws Exception {
45+
ValidService service = spy(new ValidService());
46+
TransactionInvoker invoker = new TransactionInvoker(service);
47+
invoker.invokeTransaction(ValidService.TRANSACTION_ID, ARGUMENTS, context);
48+
invoker.invokeTransaction(ValidService.TRANSACTION_ID_2, ARGUMENTS, context);
49+
50+
verify(service).transactionMethod(ARGUMENTS, context);
51+
verify(service).transactionMethod2(ARGUMENTS, context);
52+
}
53+
54+
@Test
55+
void invokeInvalidTransactionId() {
56+
TransactionInvoker invoker = new TransactionInvoker(new ValidService());
57+
int invalidTransactionId = Integer.MAX_VALUE;
58+
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
59+
() -> invoker.invokeTransaction(invalidTransactionId, ARGUMENTS, context));
60+
assertThat(e.getMessage())
61+
.contains(String.format("No method with transaction id (%s)", invalidTransactionId));
62+
}
63+
64+
@Test
65+
void invokeThrowingTransactionExecutionException() {
66+
TransactionInvoker invoker = new TransactionInvoker(new ThrowingService());
67+
TransactionExecutionException e = assertThrows(TransactionExecutionException.class,
68+
() -> invoker.invokeTransaction(ThrowingService.TRANSACTION_ID, ARGUMENTS, context));
69+
assertThat(e.getErrorCode()).isEqualTo(ThrowingService.ERROR_CODE);
70+
}
71+
72+
@Test
73+
void invokeThrowingServiceException() {
74+
TransactionInvoker invoker = new TransactionInvoker(new ThrowingService());
75+
RuntimeException e = assertThrows(RuntimeException.class,
76+
() -> invoker.invokeTransaction(ThrowingService.TRANSACTION_ID_2, ARGUMENTS, context));
77+
assertThat(e.getCause().getClass()).isEqualTo(IllegalArgumentException.class);
78+
}
79+
80+
static class BasicService implements Service {
81+
82+
static final int TRANSACTION_ID = 1;
83+
static final int TRANSACTION_ID_2 = 2;
84+
85+
@Override
86+
public List<HashCode> getStateHashes(Snapshot snapshot) {
87+
return Collections.emptyList();
88+
}
89+
90+
@Override
91+
public void createPublicApiHandlers(Node node, Router router) {
92+
// no-op
93+
}
94+
}
95+
96+
public static class ValidService extends BasicService {
97+
98+
@TransactionMethod(TRANSACTION_ID)
99+
@SuppressWarnings("WeakerAccess") // Should be accessible
100+
public void transactionMethod(byte[] arguments, TransactionContext context) {
101+
}
102+
103+
@TransactionMethod(TRANSACTION_ID_2)
104+
@SuppressWarnings("WeakerAccess") // Should be accessible
105+
public void transactionMethod2(byte[] arguments, TransactionContext context) {
106+
}
107+
}
108+
109+
public static class ThrowingService extends BasicService {
110+
111+
static final byte ERROR_CODE = 18;
112+
static final String ERROR_MESSAGE = "Service originated exception";
113+
114+
@TransactionMethod(TRANSACTION_ID)
115+
public void transactionMethod(byte[] arguments, TransactionContext context)
116+
throws TransactionExecutionException {
117+
throw new TransactionExecutionException(ERROR_CODE);
118+
}
119+
120+
@TransactionMethod(TRANSACTION_ID_2)
121+
public void transactionMethod2(byte[] arguments, TransactionContext context)
122+
throws TransactionExecutionException {
123+
throw new IllegalArgumentException(ERROR_MESSAGE);
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)