如何使用在编译时设置的动态数量的参数创建 java 方法(类似 lombok 的行为)

How to make a java method with a dynamic number of arguments which is set at compile-time (lombok-like behavior)

我想制作一个消息枚举,每条消息都在枚举类型上,以避免消息键中的拼写错误。我还想使用参数(如 #{0})来插入名称和更多信息。 为了使事情变得更简单,我想添加方法 get,它具有动态数量的(字符串类型)参数 - 每个我想要替换的参数都有一个。参数的确切数量应在编译时设置,并由该枚举值的字段定义。

考虑这个枚举:

public enum Message {
    // Written by hand, ignore typos or other errors which make it not compile.

    NO_PERMISSION("no_permission", 0),
    YOU_DIED("you_died", 1),
    PLAYER_LEFT("player_left", 2);

    private String key;
    private int argAmount;

    Message(String key, int argAmount) {
        this.key = key;
        this.argAmount = argAmount;
    }

    public String replace(String... args) {
        String message = get();
        for (int i = 0; i < args.length; i++) {
            message.replace("#{" + i + "}", args[i]);
        }

        return message;        
    }

    public String get() {
        return myConfigFileWrapper.getMessage(key);
    }
}

当我想取回一条消息时,我使用Message.YOU_DIED.replace(myInformation)。但是,我必须查找 YOU_DIED 消息需要多少个参数,如果有多个,我需要查看配置文件以查看哪个索引属于哪个参数类型。

为了阐明这一点,这里有一个例子: PLAYER_LEFT 消息被广播给所有玩家并告诉他们玩家 x 已经离开,得分为 y。在我的 .lang 文件中,可以找到 player_left= The player #{0} left with the score #{1}!。在源代码中,我将需要使用 Message.PLAYER_LEFT.replace(name, score)。现在扩展我的枚举时,我可能有 100 多条消息。 这意味着我根本不记得消息是 The player #{0} left with the score #{1}! 还是 The player #{1} just left!.

我的目标是当 get 方法没有给出它需要的确切参数数量时,编译器会自动抛出错误。这也意味着我的 IDE 自动完成功能会告诉我要传递多少个参数。

如您所见,目前我正在使用可变参数将变量信息注入到消息中。为什么我想更进一步,现在应该很清楚了。我知道这是一种奢侈的功能,但我只是在学习,没有人期望在某个时候得到某种结果。

一种方法是消息 class,其中包含大量子classes,使用一组参数 get(String name, String score) 覆盖原始 get 方法。然而,这会使数十亿个子 classes 变得一团糟——每条消息一个。我什至没有尝试创建这种消息 class(es)。此外,使用这种方式需要付出很多努力才能 'create' 所有消息,然后再添加新消息。

接下来,我查看了反射 API 以完成这项工作,但当我发现反射不适用于动态编译时方法时,我就继续了。据我所知,实际上创建新的动态方法(这基本上是我尝试做的)是不可能的,尤其是因为无法通过正常调用使用它们,因为该方法在编译时不存在。

到目前为止,我所知道的唯一执行此操作的应用程序是 Lombok。 Lombok 使用在编译时用字节代码替换的注释。我查看了源代码,但只是核心本身相当大,并且到处都有交叉依赖,这使得很难真正理解发生了什么。

使用在编译时设置的动态参数编号生成这些方法的最好和最简单的方法是什么?上述方法是如何工作的?

非常感谢代码片段和指向包含更多信息的页面的链接。

您可以通过为每个不同数量的参数创建一个通用子class 来限制子classes 的数量:

public class Message {
    public static final Message0Args NO_PERMISSION = new Message0Args("no_permission");
    public static final Message1Arg YOU_DIED = new Message1Arg("you_died");
    public static final Message2Args PLAYER_LEFT = new Message2Args("player_left");

    private String key;
    private int argAmount;

    protected Message(String key, int argAmount) {
        this.key = key;
        this.argAmount = argAmount;
    }

    // Same replace() method, but make it protected
}

子class例如:

public class Message2Args extends Message {
    public Message2Args(String key) {
        super(key, 2);
    }

    public String replace(String first, String second) {
        return super.replace(first, second);
    }   
}

请注意,Message 不再是 enum,但出于所有实际目的,它的工作方式相同(增加了一些灵活性,例如 subclassing),因为 enum 只是 class 的语法糖,它的唯一实例包含在它自己的 public static final 字段中。

问题是即使你知道参数的数量,你仍然不知道它们应该是什么。是 Message.PLAYER_LEFT.replace(name, score) 还是 Message.PLAYER_LEFT.replace(score, name)?或者是 Message.PLAYER_LEFT.replace(name, lastLocation)?

为了避免它,你可以更进一步,做这样的事情:

public abstract class Message<T> {

    public static final Message<Void> YOU_DIED = new Message<Void>("You died.") {
        @Override
        public String create(Void arguments) {
            return this.replace();
        }
    };

    public static final Message<Player> PLAYER_LEFT = new Message<Player>("Player %s left with score %d") {
        @Override
        public String create(Player arguments) {
            return this.replace( arguments.getName(), arguments.getScore());
        }
    };

    private Message(String template) {
        this.template = template;
    }

    private final String template;

    protected String replace( Object ... arguments) {
        return String.format( template, arguments );
    }

    public abstract String create(T arguments);
}

不可否认,这很冗长,但有一些事情可以解决:

  1. 所有消息都是类型安全的。
  2. 您可以(实际上您必须)使用更高级别的对象,希望它们具有更多意义。虽然很难弄清楚 Message.PLAYER_LEFT 的两个 String 参数应该放什么,但如果唯一的参数是 Player 类型的对象,答案就很明显了。
  3. 除此之外,如果中途您还想将消息更改为显示玩家的昵称或等级怎么办?您只需要修改实际的消息,调用者不必知道它。

它的最大缺点是,如果你有复杂的消息(例如 Message.PLAYER_HIT,它应该采用两个 Player 类型参数),你必须编写包装器 类 用于参数(在我们的示例中,一个封装了两个播放器)。这可能非常乏味。

就我个人而言,我会这样处理问题,因为我是一个强者

public interface Message
{

    public static final Message instance = loadInstance();

    String you_died(Player player);

    String player_left(Player player, int score); 

    // etc. hundreds of them
}

// usage
String x = Message.instance.player_left(player, 10);

// one subclass per language
public class Message_jp implements Message
{
    public String you_died(Player player){ return player.lastName + "君,你地死啦死啦"; }
                                           // or whatever way you like to create a String
    // etc.
}

在运行时,您需要加载 Message 的正确子 class。

static Message loadInstance()
{
    String lang = conf.get("language"); // e.g. "jp"
    Class clazz = Class.forName("Message_"+lang);  // Message_jp.class
    return clazz.newInstance();
}

这种方法将所有消息嵌入到 class 文件中,应该没问题。

经过许多小时的阅读和试​​验,我现在终于有了自己的注释处理器和源代码生成器。

感谢@biziclop、@bayou.io 和@Aasmund Eldhuset 对这个问题给出了 3 个非常不同且很好的答案,解释了智能方法。这个答案被接受,因为这是 OP(我)最终使用的方法。如果你不想像我一样在你的项目中投入那么多的工作,也可以考虑看看它们。

我遵循了@Radiodef 在他的评论中发布的指南,一切都很好,直到我解释了如何将注释处理器与 Maven 集成。在开始使用 maven 并遵循该指南遇到一些困难之后,事实证明,Apache Maven 曾经是并且现在仍然是用于此类注释处理的最佳依赖项和构建管理工具。因此,如果您还阅读了该指南并使用了 maven,我建议您跳过第 2 部分。

但是,现在,重要的不是发生了哪些问题,而是必须做些什么才能让它发挥作用: 所需的 Maven 依赖项:org.apache.velocity:velocity:1.7:jar.

项目设置略有变化,因为带有源的实际项目将包含在根容器项目中。这不是必需的,但它允许更清晰的项目结构和更具可读性的 POM。

有 4 个 POM:

  • 根项目
  • 实际项目
  • 注释
  • 注释处理器

如前所述,RootProject 不包含任何源代码或任何文件,但通常包含其他项目,因此它的 pom 很简单:

<modules>
    <module>ActualProject</module>
    <module>Annotations</module>
    <module>AnnotationProcessors</module>
</modules>

<!— Global dependencies can be configured here as well —>

ActualProject 显然依赖于 Annotations 工件和 AnnotationProcessors 工件。并且因为 AnnotationProcessors 工件依赖于 Annotation 项目,所以我们得到 Maven 反应器的以下顺序:

  1. 注释
  2. 注释处理器
  3. 实际项目

我们还需要配置哪些项目要执行注解处理器,哪些不要。注释处理器本身不应在其自身编译期间执行,因此添加编译器参数 -proc:none:

<plugin>
     <groupId>org.apache.maven.plugins</groupId>
     <artifactId>maven-compiler-plugin</artifactId>
     <version>3.3</version>
     <configuration>
         <compilerArgs>
             <arg>-proc:none</arg>
         </compilerArgs>
     </configuration>
</plugin>

对于实际项目,我们也会在正常编译时同样关闭注解处理,使用maven-processor-plugin连同 build-helper-maven-plugin:

<plugin>
    <groupId>org.bsc.maven</groupId>
    <artifactId>maven-processor-plugin</artifactId>
    <version>2.2.4</version>
    <executions>
        <!-- Run annotation processors on src/main/java sources -->
        <execution>
            <id>process</id>
            <goals>
                <goal>process</goal>
            </goals>
            <phase>generate-sources</phase>
            <configuration>
                <outputDirectory>target/generated-sources</outputDirectory>
                <processors>
                    <processor>my.annotations.processors.MessageListProcessor</processor>
                </processors>
            </configuration>
        </execution>
    </executions>
</plugin>

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <version>1.9.1</version>
    <executions>
        <execution>
            <id>add-source</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>add-source</goal>
            </goals>
            <configuration>
                <sources>
                    <source>target/generated-sources</source>
                </sources>
            </configuration>
        </execution>
    </executions>
</plugin>

Annotation 工件包含最重要的带有值字段的注释,该字段是 String 类型,以及注释 class 必须实现的接口。 枚举必须实现两个方法,显然是 String getKey()String[] getParams()。在此之后,问题(消息)中的枚举被扩展如下:

@MessageList("my.config.file.wrapper.type")
public enum Messages implements MessageInfo {

    NO_PERMISSION("no_permission"),
    YOU_DIED("you_died",                "score"),
    PLAYER_LEFT("player_left",          "player_name", "server_name");

    private String key;
    private String[] params;

    Messages(String key, String… params) {
        this.key = key;
        this.params = params;

    @Override
    public String getKey() { return key; }

    @Override
    public String[] getParams() { return params; }

}

接下来是我们的 AnnotationProcessor。当然,我们实现了 AbstractProcessor,因此 @Override 了 process 方法。 class 也使用注解 @SupportedAnnotationTypes("my.annotation.type") 注册自己。首先,我们对带注释的 class 执行一些检查。注意用注解标注的元素是在一个集合中递交的,这意味着会有一个foreach循环。但是,预计只会在一个项目中找到 one @MessageList 注释 - 永远。这显然是一个潜在的风险,尤其是当它用于非特定项目时。在这里,这并不重要,因为我们知道如何正确使用 Annotation。 (可以扩展此处理器以从多个枚举中收集消息,但根本不需要。)

for (Element e : roundEnv.getElementsAnnotatedWith(MessageList.class)) {
    if (!(e.getKind() == ElementKind.ENUM)) {
        raiseErrorAt(e, "Can only annotate enum types");
        continue;
    } ... }

接下来,我们必须检查带注释的 class 是否实际实现了接口。只有一个小问题:带注释的 class 尚未编译。 MessageInfo接口的class对象很容易获取:

Class<MessageInfo> messageInfoClass = (Class<MessageInfo>) Class.forName("my.annotations.MessageInfo");

是的,这确实是一个未经检查的转换,但我们使用常量字符串值,因此不会导致 ClassCastException。无论如何,让我们编译带注释的class。这意味着,带注释的 class 不得导入任何其他可能尚未编译的 classes。它不应该,因为它只是一个丰富的资源,技术上也可以是一个 .properties 文件。同样,这也是一个潜在的风险,同样,我们不在乎,因为我们不进口任何其他东西。

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);

// The convertToPath method just returns "src/main/java/<pathWithSlashes>.java"
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(
    new File("ActualProject/" + convertToPath(element.getQualifiedName().toString())));

// The boolean here defines whether the last separator char should be cut off.
// We need to expand the class path so we might as well leave it there.
String classpath = getCurrentClasspath(false) +
    new File("Annotations/target/Annotations-version.jar").getAbsolutePath();

File outputDir = new File("ActualProject/target/classes/");
Iterable<String> arguments = Arrays.asList("-proc:none",
    "-d", outputDir.getAbsolutePath(),
    "-classpath", classpath);

boolean success = compiler.getTask(null, fileManager, null, arguments, null, compilationUnits).call();

fileManager.close();

最后做的最后一件事是检查成功的值,return是否为假。 这是 getCurrentClassPath 方法:

private String getCurrentClasspath(boolean trim) {
    StringBuilder builder = new StringBuilder();
    for (URL url : ((URLClassLoader) Thread.currentThread().getContextClassLoader()).getURLs()) {
        builder.append(new File(url.getPath()));
        builder.append(System.getProperty("path.separator"));
    }
    String classpath = builder.toString();
    return trim ? classpath.substring(0, classpath.length() - 1) : classpath;
}

现在,带注释的 class 已编译,我们可以加载它:

URL classesURL = new URL("file://" + outputDir.getAbsolutePath() + "/");
// The current class loader serves as the parent class loader for the custom one.
// Obviously, it won’t find the compiled class.
URLClassLoader customCL = URLClassLoader.newInstance(new URL[]{classesURL}, classLoader);

Class<?> annotatedClass = customCL.loadClass(element.getQualifiedName().toString());

所以,这里是检查带注释的枚举是否实现了接口:

if (!Arrays.asList(annotatedClass.getInterfaces()).contains(messageInfoClass)) {
    raiseErrorAt(element, "Can only annotate subclasses of MessageInfo");
    continue;
}

现在,读取传递给源代码生成器的值:

MessageList annotation = element.getAnnotation(MessageList.class);
String locals = annotation.value();

// To get the package name, I used a while loop with an empty body. Does its job just fine.
Element enclosingElement = element;
while (!((enclosingElement = enclosingElement.getEnclosingElement()) instanceof PackageElement)) ;
String packageName = ((PackageElement) enclosingElement).getQualifiedName().toString();

ArrayList<Message> messages = new ArrayList<>();
for (Field field : annotatedClass.getDeclaredFields()) {
    if (!field.isEnumConstant()) continue;

    // Enum constants are static:
    Object value = field.get(null);
    MessageInfo messageInfo = messageInfoClass.cast(value);

    messages.add(new Message(field.getName(), messageInfo.getKey(), messageInfo.getParams()));
}

此处使用的消息 class 只是一个数据 class,具有私有最终字段和各自的 getter 方法。它在注释工件中找到,但我不确定将它放在哪里。 就是这样!现在可以实例化速度引擎和上下文并传递值。最后一块拼图是源模板。 首先,我创建了 3 个变量但特殊字符,因为我在将 velocity 的转义工具集成到我的项目中非常失败......

#set ($doubleq = '"')
#set ($opencb = "{")
#set ($closecb = "}“)
package $package;

class 主体几乎只是一个 foreach 循环:

/**
 * This class was generated by the Annotation Processor for the project ActualProject.
 */
public abstract class Message {

#foreach ($message in $messages)

#set ($args = "")
#set ($replaces = "")
#foreach ($param in $message.params)
#set ($args = "${args}String $param, ")
#set ($replaces = "${replaces}.replace($doubleq$opencb$param$closecb$doubleq, $param)")
#end
#set ($endIndex = $args.length() - 2)
#if ($endIndex < 0)
#set ($endIndex = 0)
#end
#set ($args = $args.substring(0, $endIndex))
    public static final String ${message.name}($args) {
        return locals.getMessage("$message.key")$replaces;
    }

#end

    private static final $locals locals = ${locals}.getInstance();
}

那套庞大的 Velocity 指令乍一看可能有点奇怪,但它确实很简单。没有空行,因为它们实际上会生成,使生成的文件非常混乱。那么做了什么?我们遍历所有消息。对于每条消息执行:

  1. 定义两个String类型的变量,args和replaces
  2. 对于消息采用的每个参数:
    • 将字符串 „String , „ 添加到 args 变量。
    • 将字符串“.replace(„{}“, )”附加到参数变量。
  3. 从 args 变量中删除最后一个逗号和 space。 (当消息没有参数时,endIndex 为负值。如果是这种情况,请将 endIndex 设置为 0。)
  4. 使用枚举常量的名称和在 2 和 3 中生成的参数字符串生成实际方法。
    • 方法 return 是通过 class 检索的消息,它处理不同语言并替换了占位符。

在文件末尾,我们定义了 Locals 的实例 class。我的第一个计划是使用一个接口,但效果不是很好,所以我只要求 class 是一个单例。第三次,这是另一个潜在的风险,第三次出于同样的原因被忽略了。

哦,您可能偶然发现的 raiseErrorAt(Element, String) 方法只是 processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, msg, element);

的超长调用的包装器

希望对您有所帮助。完整的项目是 public here. For the commit referenced to in this post, see here。 如果有任何问题或改进,请随时发表评论。