未验证 提交 ee179a3f 编写于 作者: M Miguel Ángel Martín 提交者: GitHub

Fixes Make EL Resolvers reusable and generic #3671 (#3675)

* Refactor Activiti EL module  #3671

* Added extra tests and refactor

* Fixed minor issues - Unused imports

* Minor improvements after Codacy review

* Fixes after Peer Review

* Fix invalid test

* Make ExpressionResolver Generic
上级 27b0c684
......@@ -10,6 +10,9 @@
<artifactId>activiti-core-common-dependencies</artifactId>
<packaging>pom</packaging>
<name>Activiti :: Core :: Common :: Dependencies BOM (Bill Of Materials)</name>
<properties>
<juel.version>2.2.7</juel.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
......@@ -72,6 +75,26 @@
<artifactId>activiti-common-util</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.activiti</groupId>
<artifactId>activiti-expression-language</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-api</artifactId>
<version>${juel.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-impl</artifactId>
<version>${juel.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-spi</artifactId>
<version>${juel.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.activiti</groupId>
<artifactId>activiti-core-common-dependencies</artifactId>
<version>7.1.0-SNAPSHOT</version>
<relativePath>../activiti-core-common-dependencies</relativePath>
</parent>
<artifactId>activiti-expression-language</artifactId>
<name>Activiti :: Expression Language Support</name>
<dependencies>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-api</artifactId>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-impl</artifactId>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-spi</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
......@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.activiti.engine.impl.el;
package org.activiti.core.el;
import javax.el.ELContext;
import javax.el.ELResolver;
......
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import javax.el.ArrayELResolver;
import javax.el.BeanELResolver;
import javax.el.ELContext;
import javax.el.ELResolver;
import javax.el.ListELResolver;
import javax.el.MapELResolver;
/**
* Builder of {@link ELContext} instances.
*/
public final class CommonELResolversUtil {
private CommonELResolversUtil() {
// Not intended to be instantiated
}
public static ELResolver arrayResolver() {
return new ArrayELResolver();
}
public static ELResolver listResolver() {
return new ListELResolver();
}
public static ELResolver mapResolver() {
return new MapELResolver();
}
public static ELResolver jsonNodeResolver() {
return new JsonNodeELResolver();
}
public static ELResolver beanResolver() {
return new ELResolverReflectionBlockerDecorator(new BeanELResolver());
}
}
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import javax.el.CompositeELResolver;
import javax.el.ELContext;
import javax.el.ELResolver;
/**
* Builder of {@link javax.el.ELContext} instances.
*/
public class ELContextBuilder {
private List<ELResolver> resolvers;
private Map<String, Object> variables;
public ELContextBuilder withResolvers(ELResolver... resolvers) {
this.resolvers = List.of(resolvers);
return this;
}
public ELContextBuilder withVariables(Map<String, Object> variables) {
this.variables = variables;
return this;
}
public ELContext build() {
CompositeELResolver elResolver = new CompositeELResolver();
elResolver.add(new ReadOnlyMapELResolver(new HashMap<>(variables)));
addResolvers(elResolver);
return new ActivitiElContext(elResolver);
}
private void addResolvers(CompositeELResolver compositeResolver) {
Stream.ofNullable(resolvers)
.flatMap(Collection::stream)
.forEach(compositeResolver::add);
}
}
......@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.engine.impl.el;
package org.activiti.core.el;
import java.beans.FeatureDescriptor;
import java.util.Iterator;
......
......@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.engine.impl.el;
package org.activiti.core.el;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
......
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import java.util.Map;
public interface ExpressionResolver {
<T> T resolveExpression(String expression, Map<String, Object> variables, Class<T> type);
}
......@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.engine.impl.el;
package org.activiti.core.el;
import java.beans.FeatureDescriptor;
import java.math.BigDecimal;
......@@ -26,7 +26,7 @@ import javax.el.ELException;
import javax.el.ELResolver;
import javax.el.PropertyNotWritableException;
import org.activiti.engine.impl.context.Context;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
......@@ -40,6 +40,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
public class JsonNodeELResolver extends ELResolver {
private final boolean readOnly;
private final ObjectMapper defaultObjectMapper = new ObjectMapper();
/**
* Creates a new read/write BeanELResolver.
......@@ -230,8 +231,7 @@ public class JsonNodeELResolver extends ELResolver {
} else {
if (resultNode.isArray()) {
result = Context.getProcessEngineConfiguration().getObjectMapper().convertValue(resultNode,
List.class);
result = getObjectMapper().convertValue(resultNode, List.class);
} else {
result = resultNode;
}
......@@ -241,6 +241,14 @@ public class JsonNodeELResolver extends ELResolver {
return result;
}
/**
* Returns the {@link ObjectMapper} used internally to convert {@link List}
* properties. Subclasses may override this method to provide a specific one
*/
protected ObjectMapper getObjectMapper() {
return defaultObjectMapper;
}
/**
* If the base object is a map, returns whether a call to
* {@link #setValue(ELContext, Object, Object, Object)} will always fail. If
......@@ -360,13 +368,6 @@ public class JsonNodeELResolver extends ELResolver {
/**
* Test whether the given base should be resolved by this ELResolver.
*
* @param base
* The bean to analyze.
* @param property
* The name of the property to analyze. Will be coerced to a
* String.
* @return base != null
*/
private final boolean isResolvable(Object base) {
return base instanceof JsonNode;
......
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import static org.activiti.core.el.CommonELResolversUtil.arrayResolver;
import static org.activiti.core.el.CommonELResolversUtil.beanResolver;
import static org.activiti.core.el.CommonELResolversUtil.jsonNodeResolver;
import static org.activiti.core.el.CommonELResolversUtil.listResolver;
import static org.activiti.core.el.CommonELResolversUtil.mapResolver;
import java.util.Map;
import javax.el.ELContext;
import javax.el.ExpressionFactory;
import javax.el.ValueExpression;
import de.odysseus.el.ExpressionFactoryImpl;
public class JuelExpressionResolver implements ExpressionResolver {
private final ExpressionFactory expressionFactory;
public JuelExpressionResolver() {
this(new ExpressionFactoryImpl());
}
public JuelExpressionResolver(ExpressionFactory expressionFactory) {
this.expressionFactory = expressionFactory;
}
@Override
public <T> T resolveExpression(String expression, Map<String, Object> variables, Class<T> type) {
if(expression == null) {
return null;
}
final ELContext context = buildContext(variables);
final ValueExpression valueExpression = expressionFactory.createValueExpression(context, expression, type);
return (T)valueExpression.getValue(context);
}
protected ELContext buildContext (Map<String, Object> variables) {
return new ELContextBuilder()
.withResolvers(
arrayResolver(),
listResolver(),
mapResolver(),
jsonNodeResolver(),
beanResolver()
)
.withVariables(variables)
.build();
}
}
......@@ -15,7 +15,7 @@
*/
package org.activiti.engine.impl.el;
package org.activiti.core.el;
import java.beans.FeatureDescriptor;
import java.util.Iterator;
......@@ -24,8 +24,6 @@ import java.util.Map;
import javax.el.ELContext;
import javax.el.ELResolver;
import org.activiti.engine.ActivitiException;
/**
* An {@link ELResolver} that exposed object values in the map, under the name of the entry's key. The values in the map are only returned when requested property has no 'base', meaning it's a
* root-object.
......@@ -57,7 +55,7 @@ public class ReadOnlyMapELResolver extends ELResolver {
public void setValue(ELContext context, Object base, Object property, Object value) {
if (base == null) {
if (wrappedMap.containsKey(property)) {
throw new ActivitiException("Cannot set value of '" + property + "', it's readonly!");
throw new IllegalArgumentException("Cannot set value of '" + property + "', it's readonly!");
}
}
}
......
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.util.Collections;
import java.util.Map;
import org.junit.Test;
public class ELResolverReflectionBlockerDecoratorTest {
@Test
public void should_resolveExpressionCorrectly_when_noReflectionOrNativeMethodsAreUsed() {
//given
Map<String, Object> availableVariables = Collections.singletonMap("name", "jon doe");
String expressionString = "${name.toString()}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//when
String value = expressionResolver.resolveExpression(expressionString, availableVariables, String.class);
//then
assertThat(value).isEqualTo("jon doe");
}
@Test
public void should_throwException_when_nativeMethodIsUsed() {
//given
Map<String, Object> availableVariables = Collections.singletonMap("name", "jon doe");
String expressionString = "${name.getClass().getName()}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//then
assertThatExceptionOfType(IllegalArgumentException.class)
.as("Using Native Method: getClass in an expression")
.isThrownBy(() -> expressionResolver.resolveExpression(expressionString, availableVariables, Object.class))
.withMessage("Illegal use of Native Method in a JUEL Expression");
}
@Test
public void should_throwException_when_reflectionIsUsed() {
//given
Map<String, Object> availableVariables = Collections.singletonMap("class", String.class);
String expressionString = "${class.forName(\"java.lang.Runtime\").getMethods()[6].invoke()}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//then
assertThatExceptionOfType(IllegalArgumentException.class)
.as("Using Reflection in an expression")
.isThrownBy(() -> expressionResolver.resolveExpression(expressionString, availableVariables, Object.class))
.withMessage("Illegal use of Reflection in a JUEL Expression");
}
}
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.core.el;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.util.Collections;
import java.util.Map;
import javax.el.PropertyNotFoundException;
import org.junit.Test;
public class JuelResolverTest {
@Test
public void should_returnNull_when_nullExpressionIsPassed() {
//given
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//when
Object value = expressionResolver.resolveExpression(null, Collections.emptyMap(), Object.class);
//then
assertThat(value).isNull();
}
@Test
public void should_returnSameValue_when_StringWithoutJuelExpressionIsPassed() {
//given
String expressionString = "string with no JUEL expression";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//when
String value = expressionResolver.resolveExpression(expressionString, Collections.emptyMap(), String.class);
//then
assertThat(value).isEqualTo(expressionString);
}
@Test
public void should_returnStringVariable_when_knownVariableIsReferenced() {
//given
Map<String, Object> availableVariables = Collections.singletonMap("name", "jon doe");
String expressionString = "${name.toString()}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//when
String value = expressionResolver.resolveExpression(expressionString, availableVariables, String.class);
//then
assertThat(value).isEqualTo("jon doe");
}
@Test
public void should_returnBoolean_when_expressionIsAPredicate() {
//given
String expressionString = "${1 > 0}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//when
boolean value = expressionResolver.resolveExpression(expressionString, Collections.emptyMap(), Boolean.class);
//then
assertThat(value).isTrue();
}
@Test
public void should_throwException_when_unknownVariableIsReferenced() {
//given
Map<String, Object> availableVariables = Collections.singletonMap("name", "jon doe");
String expressionString = "${nameeee}";
ExpressionResolver expressionResolver = new JuelExpressionResolver();
//then
assertThatExceptionOfType(PropertyNotFoundException.class)
.as("Referencing an unknown variable")
.isThrownBy(() -> expressionResolver.resolveExpression(expressionString, availableVariables, Object.class))
.withMessage("Cannot resolve identifier 'nameeee'");
}
}
......@@ -43,5 +43,6 @@
<module>activiti-project-model</module>
<module>activiti-spring-project</module>
<module>activiti-common-util</module>
<module>activiti-expression-language</module>
</modules>
</project>
......@@ -34,6 +34,10 @@
<groupId>org.activiti</groupId>
<artifactId>activiti-process-validation</artifactId>
</dependency>
<dependency>
<groupId>org.activiti</groupId>
<artifactId>activiti-expression-language</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
......
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.engine.impl.el;
import javax.el.CompositeELResolver;
import javax.el.ELResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.activiti.core.el.JsonNodeELResolver;
import org.activiti.engine.impl.context.Context;
/**
* Defines property resolution behavior on JsonNodes.
*
* @see CompositeELResolver
* @see ELResolver
*/
public class CustomMapperJsonNodeELResolver extends JsonNodeELResolver {
@Override
protected ObjectMapper getObjectMapper() {
return Context.getProcessEngineConfiguration().getObjectMapper();
}
}
......@@ -29,6 +29,9 @@ import javax.el.ExpressionFactory;
import javax.el.ListELResolver;
import javax.el.MapELResolver;
import javax.el.ValueExpression;
import org.activiti.core.el.ActivitiElContext;
import org.activiti.core.el.ELResolverReflectionBlockerDecorator;
import org.activiti.core.el.ReadOnlyMapELResolver;
import org.activiti.engine.delegate.Expression;
import org.activiti.engine.delegate.VariableScope;
import org.activiti.engine.impl.bpmn.data.ItemInstance;
......@@ -131,7 +134,7 @@ public class ExpressionManager {
elResolver.add(new ArrayELResolver());
elResolver.add(new ListELResolver());
elResolver.add(new MapELResolver());
elResolver.add(new JsonNodeELResolver());
elResolver.add(new CustomMapperJsonNodeELResolver());
elResolver.add(new DynamicBeanPropertyELResolver(ItemInstance.class,
"getFieldValue",
"setFieldValue")); // TODO: needs verification
......
......@@ -45,11 +45,12 @@ import javax.script.ScriptEngineFactory;
import javax.script.ScriptException;
import javax.script.SimpleBindings;
import org.activiti.core.el.ELResolverReflectionBlockerDecorator;
import org.activiti.engine.ActivitiException;
import org.activiti.engine.impl.bpmn.data.ItemInstance;
import org.activiti.engine.impl.el.CustomMapperJsonNodeELResolver;
import org.activiti.engine.impl.el.DynamicBeanPropertyELResolver;
import org.activiti.engine.impl.el.ExpressionFactoryResolver;
import org.activiti.engine.impl.el.JsonNodeELResolver;
import org.activiti.engine.impl.util.ReflectUtil;
import de.odysseus.el.util.SimpleResolver;
......@@ -121,10 +122,10 @@ public class JuelScriptEngine extends AbstractScriptEngine implements Compilable
compositeResolver.add(new ArrayELResolver());
compositeResolver.add(new ListELResolver());
compositeResolver.add(new MapELResolver());
compositeResolver.add(new JsonNodeELResolver());
compositeResolver.add(new CustomMapperJsonNodeELResolver());
compositeResolver.add(new ResourceBundleELResolver());
compositeResolver.add(new DynamicBeanPropertyELResolver(ItemInstance.class, "getFieldValue", "setFieldValue"));
compositeResolver.add(new BeanELResolver());
compositeResolver.add(new ELResolverReflectionBlockerDecorator(new BeanELResolver()));
return new SimpleResolver(compositeResolver);
}
......
......@@ -24,8 +24,8 @@ import javax.el.ELResolver;
import javax.el.ListELResolver;
import javax.el.MapELResolver;
import org.activiti.core.el.ELResolverReflectionBlockerDecorator;
import org.activiti.engine.delegate.VariableScope;
import org.activiti.engine.impl.el.ELResolverReflectionBlockerDecorator;
import org.activiti.engine.impl.el.ExpressionManager;
import org.activiti.engine.impl.el.VariableScopeElResolver;
......
......@@ -57,7 +57,7 @@ public class ELResolverReflectionBlockerDecoratorTest {
assertThatExceptionOfType(ActivitiException.class)
.as("Using Native Method: getClass in an expression")
.isThrownBy(() -> expression.getValue(expressionManager, new DefaultDelegateInterceptor(), availableVariables))
.as("Illegal use of Native Method in a JUEL Expression");
.withCauseInstanceOf(IllegalArgumentException.class);
}
@Test
......@@ -75,7 +75,7 @@ public class ELResolverReflectionBlockerDecoratorTest {
assertThatExceptionOfType(ActivitiException.class)
.as("Using Reflection in an expression")
.isThrownBy(() -> expression.getValue(expressionManager, new DefaultDelegateInterceptor(), availableVariables))
.as("Illegal use of Reflection in a JUEL Expression");
.withCauseInstanceOf(IllegalArgumentException.class);
}
}
......@@ -20,7 +20,7 @@ package org.activiti.spring;
import java.util.Map;
import javax.el.CompositeELResolver;
import org.activiti.engine.impl.el.ExpressionManager;
import org.activiti.engine.impl.el.ReadOnlyMapELResolver;
import org.activiti.core.el.ReadOnlyMapELResolver;
import org.springframework.context.ApplicationContext;
/**
......
......@@ -21,7 +21,6 @@
<jgraphx.version>4.2.2</jgraphx.version>
<joda-time.version>2.10.10</joda-time.version>
<json-unit.version>2.25.0</json-unit.version>
<juel.version>2.2.7</juel.version>
<mybatis.version>3.5.7</mybatis.version>
<subethasmtp-wiser.version>1.2</subethasmtp-wiser.version>
<xmlgraphics-commons.version>2.6</xmlgraphics-commons.version>
......@@ -47,21 +46,6 @@
<artifactId>jakarta.enterprise.concurrent-api</artifactId>
<version>${jakarta-enterprise-concurrent.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-api</artifactId>
<version>${juel.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-impl</artifactId>
<version>${juel.version}</version>
</dependency>
<dependency>
<groupId>de.odysseus.juel</groupId>
<artifactId>juel-spi</artifactId>
<version>${juel.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册