# NukkitLearn章外篇之二-插件中的多语言解决方案

参与编写者: [innc11](https://github.com/innc11)

##### 知识点:  配置文件、HashMap、枚举、文本替换、可变参数、正则表达式

本章外篇教程innc11制作，目的尽可能地简化插件开发过程中对多语言的处理。不得不说对多语言的支持是插件的一大加分项，特别是将插件发布到Nukkit的国际论坛上更是如此，如果有任何的疑问欢迎随时提issue。

## 0.HashMap

所谓HashMap就是保存着一个个键值对的类，一个存储数据的类，形象的描述HashMap就相当于中药铺的药材柜子，柜子上有着数不清的小屉子，每个小屉子里都放着不同的药材，比如胖大海、金银花、荷叶等等（值），每个小屉子上都有一个标签，写着这个屉子里放着什么药材（键），这样就形成了一个关系，一个标签对应一个药材，这就是键值对的关系，一对一的关系，也是HashMap的存储结构，但也有一些限制，比如不能出现两个一模一样的标签，不然HashMap就无法判断到底需要哪一个小屉子的药材；但可以允许两个屉子里的东西一模一样；屉子可以是空的，但标签绝对不能为空。

HashMap能够处理一些数组无法处理的数据，比如一个统计玩家击杀数的插件，当某个玩家干掉10个怪物以后就给其发放奖励，这时候就需要对每个玩家进行记录，把玩家名作为标签，击杀数作为屉子里的东西，每次只需要根据玩家名把对应屉子打开，把里面的数据拿出来+1然后放回去关上屉子即可。这时候便出现了一个关系，每一个玩家名对应一个击杀数。
```
zhangsan = 5
lisi = 3
wangwu = 8
```
玩家名就是键，击杀数就是值，大致就是这个结构，可以随时根据玩家名获取到对应击杀数，这是HashMap的工作方式。

## 1. 原理

插件首先从配置文件加载所有语言到一个HashMap里，在需要时从这个HashMap里读出来，再进行相应变量替换后，显示给玩家。

## 2. 创建项目

1. 首先创建一个项目，在这里我使用一个当玩家破坏方块时，给玩家发送一个消息，告诉玩家破坏的方块的ID，这里插件名字就叫 Tips，配置好依赖后，首先是```plugin.yml```

```yaml
name: Tips
main: exam.miner.TipsPlugin
version: "1.0"
api: ["1.0.0"]
```

## 3. 使用语言类

3. 首先我们声明一个语言类，这个类非常简单，仅仅包含一个HashMap、构造方法、获取语言的方法

```lang```负责存储所有的语言文本，```String getLang()```负责从```lang```里面获取对应的文本并做参数替换，在构造方法里我们往```lang```里面添加2个语言，其中```BROKE_MESSAGE```和```PLACE_MESSAGE```是关键字，我们通过传递给```getLang()```一个关键字来获取对应的文本，```getLang()```会在```lang```里面用关键字去进行查找，并返回对应的文本

```java
public class MyLang
{
    HashMap<String, String> lang = new HashMap<String, String>();
    
    public MyLang()
    {
        lang.put("BROKE_MESSAGE", "你破坏了一个方块");
        lang.put("PLACE_MESSAGE", "你放置了一个方块");
    }
    
    public String getLang(String key)
    {
        return lang.get(key);
    }
}
```

接下来是主类：

在主类中使用刚才的```MyLang```类，并注册监听器，当玩家在破坏或者放置一个方块时，去获取对应的文本，然后发送给玩家

```java
public class TipsPlugin extends PluginBase implements Listener
{
    MyLang lang;
    
    @Override
    public void onEnable() 
    {
        lang = new MyLang();
        
        getServer().getPluginManager().registerEvents(this, this);
    }
    
    @EventHandler
    public void onPlayerBrokeBlock(BlockBreakEvent e)
    {
        String message = lang.getLang("BROKE_MESSAGE"); // 你放置了一个方块
        e.getPlayer().sendMessage(message);
    }
    
    @EventHandler
    public void onPlayerPlaceBlock(BlockPlaceEvent e)
    {
        String message = lang.getLang("PLACE_MESSAGE"); // 你破坏了一个方块
        e.getPlayer().sendMessage(message);
    }

}
```

这就是最简单的方式，但实际开发中语言往往是从配置文件进行加载的，而不是写死在代码里，接下来就是如何从yml文件进行读取加载

## 3. 从yaml配置文件加载语言

1. 我们使用language.yml文件用来保存语言文本

```yaml
# language.yml
PLACE_MESSAGE: "你放置了一个方块"
BROKE_MESSAGE: "你破坏了一个方块"
```

1. 接着我们需要修改我们的语言文件，使其从配置文件进行加载，首先需要在```MyLang```类里额外添加一个```Config config```变量，和一个```void reload()```方法，我们手动调用```reload()```方法来从配置文件加载语言文本。

2. 构造方法我们需要添加一个参数，用来告诉```MyLang```类应该读取哪一个yml文件，不建议在构造方法中立即调用```reload()```，因为当对象构造的时候```language.yml```可能根本就不存在。非常建议在插件主类中保存默认配置文件后手动调用```reload()```

3. 在新添加的```void reload()```方法中，首先是命令```config```(重新)加载一下，然后把```lang```中已经存在的数据全部删除掉，接着就是使用```getKeys(false)```来获取```config```中所有的key，就是上面yml中的```PLACE_MESSAGE```和```BROKE_MESSAGE```，这个方法会以```Set<String>```的形式返回，我们使用foreach进行遍历即可，需要说明的是参数中的```false```指```boolean child```，我们只需要根节点上的key不需要子节点上的key，传```false```即可
4. 在foreach中我们定义一个变量value来放置获取到的"key对应的值"也就是```你放置了一个方块```和```你破坏了一个方块```接下来我们需要进行一个判断，如果这个值是```String```类型的，我们就把它添加到```lang```里面，如果不是，比如```int```,```bool```,或者```list```类型，则跳过。

```java
public class MyLang
{
    Config config;
    HashMap<String, String> lang = new HashMap<String, String>();
    
    public MyLang(String languageFileName)
    {
        config = new Config(new File(getDataFolder(), languageFileName), Config.YAML);
    }
    
    public void reload()
    {
        config.reload();
        lang.clear();
        
        for(String key : config.getKeys(false))
        {
            Object value = config.get(key.name());

            if(value instanceof String)
            {
                lang.put(key, (String) value);
            }
        }
    }
    
    public String getLang(String key)
    {
        return lang.get(key);
    }
}
```

5. 主类需要在```new MyLang()```时传递文件名。当然也要把```language.yml```以前打包进插件里。在onEnable()里要调用```saveResource("language.yml", false)```把```language.yml```写入到插件**DataFolder**里

```java
public class TipsPlugin extends PluginBase implements Listener
{
    MyLang lang;
    
    @Override
    public void onEnable() 
    {
        saveResource("language.yml", false);
        
        lang = new MyLang("language.yml");
        lang.reload();
        
        getServer().getPluginManager().registerEvents(this, this);
    }
    
    @EventHandler
    public void onPlayerBrokeBlock(BlockBreakEvent e)
    {
        String message = lang.getLang("BROKE_MESSAGE"); // 你放置了一个方块
        e.getPlayer().sendMessage(message);
    }
    
    @EventHandler
    public void onPlayerPlaceBlock(BlockPlaceEvent e)
    {
        String message = lang.getLang("PLACE_MESSAGE"); // 你破坏了一个方块
        e.getPlayer().sendMessage(message);
    }

}
```

7. 在实际使用中，我们只需要修改```language.yml```中文字，然后使用指令调用```MyLang.reload()```重新加载即可，但在复杂的插件中，只有这些功能时远远不够的，语言文件不能一成不变，有时候需要将文字中的一部分字符替换成各种实际数据，比如商店插件在交易完成时会显示这笔交易花费了多少多少钱，玩家死亡时会显示被谁谁谁干掉了，其中的"钱"和"击杀者"就是实际的数据，需要根据实际情景来决定具体应该是什么。这就涉及到参数化的问题，将文本中一部分文字使用实际数据进行替换。

## 4. 参数化

1. 参数化必然会涉及到**占位符**这个概念，拿一个例子来说

```yaml
PLACE_MESSAGE: "你放置了ID为 ${BLOCK_ID} 的方块"
```

其中的**${BLOCK_ID}**就是占位符，他只是给实际的数据占个位置而已，并不会被显示出来。当然风格可以自己定义，在这个例子中，我们使用```${占位符名字}```这种风格。

2. 我们修改我们的```MyLang```类的```getLang()```方法，使其可以动态替换占位符，具体的调用方式为```MyLang.getLang("PLACE_MESSAGE", "{BLOCK_ID}", String.valueOf(block.getId()));```
3. 多个参数的调用方式
```
MyLang.getLang("PLACE_MESSAGE", 
    "{BLOCK_ID}", String.valueOf(block.getId(),
    "{PLAYER_NAME}", player.getName(),
    ));
```
4. 无参数的调用方式
```
MyLang.getLang("PLACE_MESSAGE"));
```
5. 后面的占位符和实际数据总是成双成对的出现，这可以大幅加快开发效率，当然这需要```MyLang.getLang()```具有对应的支持，具体看下面的示例代码。

```java
public class MyLang
{
    Config config;
    HashMap<String, String> lang = new HashMap<String, String>();
    
    public MyLang(String languageFileName)
    {
        config = new Config(new File(getDataFolder(), languageFileName), Config.YAML);
    }
    
    public void reload()
    {
        config.reload();
        lang.clear();
        
        for(String key : config.getKeys(false))
        {
            Object value = config.get(key.name());

            if(value instanceof String)
            {
                lang.put(key, (String) value);
            }
        }
    }
    
    public String getLang(String key, String... argsPair) // 这里使用可变参数，当做数组一样处理即可
    {
        String rawStr = lang.get(key);
        
        int argCount = argsPair.length / 2; // 计算出有多少"对"参数，末尾的孤立参数会被舍弃
        
        for(int i=0;i<argCount;i++)
        {
            String reg = argsPair[i*2]; // 占位符
            String replacement = argsPair[i*2+1]; // 具体数值
            
            // 风格检查，检查是否以{开头，以}结尾
            if(reg.startsWith("{") && reg.endsWith("}"))
            {
                reg = reg.replaceAll("\\{", "\\\\{"); // 构建正则表达式，把{替换成\{
                reg = reg.replaceAll("\\}", "\\\\}"); // 构建正则表达式，把}替换成\}
                rawStr = rawStr.replaceAll("\\$"+reg, replacement); // 执行替换
                // 最终reg 等于  \$\{占位符名字\}   并在rawStr中执行替换
            }
            
        }
        
        return rawStr;
    }
}
```

6. 这样我们就可以随意组合占位符和参数了
7. 我们修改```language.yml```

```yaml
# language.yml
PLACE_MESSAGE: "你放置了一个ID ${ID} 的方块"
BROKE_MESSAGE: "${PLAYER} 破坏了一个ID ${ID} 的方块"
```

8. 再修改主类以添加支持

```java
public class TipsPlugin extends PluginBase implements Listener
{
    MyLang lang;
    
    @Override
    public void onEnable() 
    {
        saveResource("language.yml", false);
        
        lang = new MyLang("language.yml");
        lang.reload();
        
        
        getServer().getPluginManager().registerEvents(this, this);
    }
    
    @EventHandler
    public void onPlayerBrokeBlock(BlockBreakEvent e)
    {
        String playerName = e.getPlayer().getName(); // 这里把int 转换为 String 类型
        String id = String.valueOf(e.getBlock().getId());
        
        String message = lang.getLang("BROKE_MESSAGE", "{PLAYER}", playerName, "{ID}", id); 
        
        e.getPlayer().sendMessage(message);// xxx破坏了一个ID 137 的方块
    }
    
    @EventHandler
    public void onPlayerPlaceBlock(BlockPlaceEvent e)
    {
        String id = String.valueOf(e.getBlock().getId());
        String message = lang.getLang("PLACE_MESSAGE", "{ID}", id); 
        
        // 你放置了一个ID 137 的方块
        e.getPlayer().sendMessage(message);
    }
    
}
```

9. 到这里还没有结束，在一个大型插件项目中往往有着数量极多语言文本，经常上百行是很常见的问题，如果每次都手动去输入关键字，难免会出现差错，而且效率也会大打折扣，这个时候我们就需要引入一个新的内容，**枚举**，通过把关键字定义成枚举，可以由IDE快速补齐，也会大大减少因拼写错误的BUG。

## 5. 枚举常量

1. 我们需要在MyLang中额外定义一个enum
```java
public enum L // L : Language
{
    PLACE_MESSAGE,
    BROKE_MESSAGE,
    
    public String getDefaultLangText()
    {
        return name();
    }
}
```

2. 在L中有两个成员常量，和```getDefaultLangText()```方法，字如其意，用于获取默认的文本，这里直接返回字段名。也就是"```PLACE_MESSAGE```"、"```BROKE_MESSAGE```"
3. 由于enum的引入，```reload()```也要发生变化，```lang```的泛型也发生了变化，```getLang()```的参数也发生了变化，看代码！

```java
public class MyLang
{
    Config config;
    // 开始使用 HashMap<L, String> 将MyLang.L作为键(key)以提高效率
    HashMap<L, String> lang = new HashMap<L, String>(); 
    
    public MyLang(String languageFileName)
    {
        config = new Config(new File(getDataFolder(), languageFileName), Config.YAML);
    }
    
    public void reload()
    {
        config.reload();
        lang.clear();
        
        // 一个标志，如果有缺少的关键字，会被补全，然后保存config，以便调试
        boolean supplement = false;
        
        // 现在是以Lang.values()进行遍历，而不是config.getKeys()，注意
        for(L key : L.values())
        {
            Object value = config.get(key.name());
            
            // 如果这个关键字不存在，会自动补齐，并设置标志位
            if(v==null)
            {
                config.set(key.name(), key.getDefaultLangText());
                supplement = true;
                lang.put(key, key.getDefaultLangText());
            }

            if(value instanceof String)
            {
                lang.put(key, (String) value);
            }
        }
        
        // 如果有补齐，则需要保存这个config，以便用户可以在config内查看到以定位问题
        if(supplement)
        {
            config.save();
        }
            
    }
    
    public String getLang(L key, String... argsPair) // 这里使用可变参数，当做数组一样处理即可
    {
        String rawStr = lang.get(key);
        
        int argCount = argsPair.length / 2; // 计算出有多少"对"参数，末尾的孤立参数会被舍弃
        
        for(int i=0;i<argCount;i++)
        {
            String reg = argsPair[i*2]; // 占位符
            String replacement = argsPair[i*2+1]; // 具体数值
            
            // 风格检查，检查是否以{开头，以}结尾
            if(reg.startsWith("{") && reg.endsWith("}"))
            {
                reg = reg.replaceAll("\\{", "\\\\{"); // 构建正则表达式，把{替换成\{
                reg = reg.replaceAll("\\}", "\\\\}"); // 构建正则表达式，把}替换成\}
                rawStr = rawStr.replaceAll("\\$"+reg, replacement); // 执行替换
                // 最终reg 等于  \$\{占位符名字\}   并在rawStr中执行替换
            }
            
        }
        
        return rawStr;
    }
    
    public enum L // L : Language
    {
        PLACE_MESSAGE,
        BROKE_MESSAGE,

        // 当config中找不到时的默认文本，这里直接返回字段名
        public String getDefaultLangText()
        {
            return name();
        }
        
    }
    
}
```

4. 主类开始使用enum作为关键字

```java
// 可以使用static import 来简化代码
//static import MyLang.L.*;

public class TipsPlugin extends PluginBase implements Listener
{
    MyLang lang;
    
    @Override
    public void onEnable() 
    {
        saveResource("language.yml", false);
        
        lang = new MyLang("language.yml");
        lang.reload();
        
        
        getServer().getPluginManager().registerEvents(this, this);
    }
    
    @EventHandler
    public void onPlayerBrokeBlock(BlockBreakEvent e)
    {
        String playerName = e.getPlayer().getName(); // 这里把int 转换为 String 类型
        String id = String.valueOf(e.getBlock().getId());
        
        // 这里使用常量作为关键字
        String message = lang.getLang(MyLang.L.BROKE_MESSAGE, "{PLAYER}", playerName, "{ID}", id); 
        
        e.getPlayer().sendMessage(message);// xxx破坏了一个ID 137 的方块
    }
    
    @EventHandler
    public void onPlayerPlaceBlock(BlockPlaceEvent e)
    {
        String id = String.valueOf(e.getBlock().getId());
        
        // 这里使用常量作为关键字
        String message = lang.getLang(MyLang.L.PLACE_MESSAGE, "{ID}", id); 
        
        // 你放置了一个ID 137 的方块
        e.getPlayer().sendMessage(message);
    }
    
}
```

5. enum的优点特别明显，能避免拼写错误和支持IDE提示，当然enum也有缺点，必须要保持yml的关键字和enum成员字段名一致才行。

## 6. 总结

本篇只是一个最简单的解决方案，当然大家还可以按自己的需求添加更多的功能，如果感兴趣，可以参考我的另一个大量使用了此多语言方案的**[插件项目](https://github.com/innc11/QuickShopX/blob/master/src/cn/innc11/QuickShopX/config/LangConfig.java)**，添加了对config中多行文字写法的支持和彩色代码的支持。如果有任何问题欢迎提交issue，感谢阅读！

