基于Key-Value的软件国际化(多语言)支持

发布时间 2023-10-16 12:19:51作者: bsmith

软件国际化,主要有两个方面:

1,软件界面静态内容的国际化,如标签,按钮,菜单等文本的多语言显示

2,软件动态内容的国际化,如动态消息,错误提示,确认信息,日志等的多语言显示,这些动态内容往往伴随着一些额外的参数,如删除确认信息,往往需要同时展示带删除内容的相关信息。

综合上面的特征,可以通过Key-Value配置文件的形式保存国际化的内容,Key是固定项,而Value根据不同的语言,配置不同的文本模板,同时根据需要,可以配置额外的参数标记符,显示时通过Key获取具体语言的文本模板,加上可选的额外参数进行格式化,生成最终的语言文本。

如下面代码所示的中文和英文的语言配置:

// 英语配置
AppConf.LoadFail = Load [{0}] fail: {1}

AeadRepConf.Password = 
AeadRepConf.KeyGens = Password Key Generate algorithms
AeadRepConf.MasterKeySize = Master Key Size
AeadRepConf.FileIdSize = File Id Size
AeadRepConf.BlockSize = Block Size
AeadRepConf.KeyDerive = Key Derive algorithm
AeadRepConf.DataCrypt = Data Encryption
AeadRepConf.DirCrypt = Directory Encryption

// 中文配置
AppConf.LoadFail = 读取配置[{0}]出错:{1}

AeadRepConf.Password = 加密库访问密码
AeadRepConf.KeyGens = 密码哈希算法
AeadRepConf.MasterKeySize = 加密库主密钥长度
AeadRepConf.FileIdSize = 加密文件标识符长度
AeadRepConf.BlockSize = 加密文件数据分组大小
AeadRepConf.KeyDerive = 密钥衍生算法
AeadRepConf.DataCrypt = 文件数据加密算法
AeadRepConf.DirCrypt = 目录加密算法

语言配置文件放置到单独的目录中如[lang]目录,<地区代码/名称>.lang作为文件名,<地区代码/名称>使用操作系统定义的全球通用代码或名称,如windows系统下中文的代码和名称分别为:zh-CN, 中文(中国), Chinese (Simplified, China),使用这样的名称时,可以在程序启动时通过检测当前运行环境,自动选择合适的显示语言。

 

下面通过C#来展示国际化的实现方法,其他编程语言也能进行类似的实现。

初始化,加载程序默认语言或者用户选择的语言:

        public static void init()
        {
            dir.trydo(() => 
            {
                var code = defaultCodes().first(c => langPath(c).fileExist());
                if (code != null)
                    loadLang(code);
            });
        }

 

动态文本翻译,通过Key将本文翻译为当前语言文本:

        public static string trans(this object obj, string item, params object[] args)
        {
            return trans(getKey(obj.GetType().Name, ref item), args);
        }

        public static string trans(this Type type, string item, params object[] args)
        {
            return trans(getKey(type.Name, ref item), args);
        }

        static string getKey(string cls, ref string name)
        {
            if (name.Length > 0 && char.IsLower(name[0]))
                name = $"{char.ToUpper(name[0])}{name.Substring(1)}";
            return $"{cls}.{name}";
        }

        public static string trans(this string key, params object[] args)
        {
            if (values.TryGetValue(key, out string value))
            {
                if (args.Length > 0)
                    value = key.tryget(() => format(value, args));
                if (value != null)
                    return value;
            }
            value = args.Length > 0 
                ? $"{key}({string.Join(",", args)})" : key;
            if (trace)
                value.msg();
            return value;
        }

翻译的方法除了直接通过Key翻译外,还扩展了使用object/Type+item作为参数的方法,使用object时,会将通过object.GetType将object转换为Type,最终使用Type.Name即类名+item的形式,即Key = Type.Name+"."+item,这样程序调用时可以简单的通过this.trans("OpenBtn", ...)这样的形式,而不用额外代码组装Key。

 

静态内容翻译,软件界面的按钮、菜单等的翻译:

        public static void trans(this Control ui)
        {
            ui.layoutOnce(() => 
            {
                var cls = ui.GetType().Name;
                foreach (var fld in ui.GetType().GetFields())
                {
                    var obj = fld.GetValue(ui);
                    if (obj is UserControl uc)
                        trans(uc);
                    else if (obj is ToolStripItem tb)
                    {
                        tb.Text = transUIFld(cls, fld.Name, out var key);
                        tb.ToolTipText = values.TryGetValue($"{key}Tip", 
                                        out var tip) ? tip : tb.Text;
                    }
                    else if (obj is Control ct)
                        ct.Text = transUIFld(cls, fld.Name);
                    else if (obj is ColumnHeader ch)
                        ch.Text = transUIFld(cls, fld.Name);
                }
                if (ui is Form form)
                    form.Text = trans($"{cls}.Title");
            });
        }

这个翻译方法能处理所以继承自Control类型的UI控件,包括Form,UserControl,Panel等,该方法通过反射获取公共的Control成员变量,并根据变量类型,使用不同的方法翻译这些控件的文本,所以需要将Form或者UserControl的待翻译变量设置为公共成员才能翻译,同时可以根据需要,将一些不需要翻译的控件设置为私有成员,而实现忽略翻译的功能。

 

绑定语言菜单,当程序需要提供给用户选择语言时,通过语言菜单扩展函数,可以将可用语言显示到菜单列表上:

        public static void initLang(this ToolStripMenuItem menu, 
            Action update)
        {
            menu.trydo(() => 
            {
                menu.Text = "语言(Language)";
                menu.Tag = update;
                menu.DropDownOpening += menuOpen;

                foreach (var p in Directory.EnumerateFiles(dir, "*.lang"))
                {
                    var code = Path.GetFileNameWithoutExtension(p).Trim();
                    menu.addLangItem(code, code);
                }
            });
        }

update会在用户通过菜单改变语言的时候被调用,这样程序可以通过调用Control.trans()来更新用户选择的语言。

 

异常消息处理,程序的异常消息国际化通过扩展异常类实现:

    public class Error : Exception
    {
        public Type type;
        public string item;
        public object[] args;

        public Error(object obj, string act, params object[] args)
        {
            this.type = obj.GetType();
            this.item = act;
            this.args = args;
        }

        public Error(Type type, string act, params object[] args)
        {
            this.type = type;
            this.item = act;
            this.args = args;
        }

        public override string Message 
            => type.trans(item, args);

        public string Json
            => new ErrorJson
            {
                code = $"{type.Name}.{item}",
                args = args,
            }.json();
    }

多语言的异常类类似动态本文翻译,其内部保存了key和相关参数,获取异常消息时,会自动调用trans来返回翻译后的文本。

效果图:

 

完整代码:Lang.cs

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
using util.ext;

namespace util
{
    public static class Lang
    {
        public static string dir = $"{Dir.AppDir}/lang";
        public static string current = null;
        public static Dictionary<string, string> values = new Dictionary<string, string>();
        public static bool trace = false;

        static string CurrentPath => $"{dir}/current";

        static IEnumerable<string> defaultCodes()
        {
            if (CurrentPath.fileExist())
                yield return dir.tryget(() 
                    => CurrentPath.readText().Trim());
            var cu = CultureInfo.CurrentCulture;
            yield return cu.NativeName;
            yield return cu.EnglishName;
            yield return cu.Name;
        }

        static string langPath(string code)
            => $"{dir}/{code}.lang";

        public static void init()
        {
            dir.trydo(() => 
            {
                var code = defaultCodes().first(c => langPath(c).fileExist());
                if (code != null)
                    loadLang(code);
            });
        }

        public static string trans(this object obj, string item, params object[] args)
        {
            return trans(getKey(obj.GetType().Name, ref item), args);
        }

        public static string trans(this Type type, string item, params object[] args)
        {
            return trans(getKey(type.Name, ref item), args);
        }

        static string getKey(string cls, ref string name)
        {
            if (name.Length > 0 && char.IsLower(name[0]))
                name = $"{char.ToUpper(name[0])}{name.Substring(1)}";
            return $"{cls}.{name}";
        }

        public static string trans(this string key, params object[] args)
        {
            if (values.TryGetValue(key, out string value))
            {
                if (args.Length > 0)
                    value = key.tryget(() => format(value, args));
                if (value != null)
                    return value;
            }
            value = args.Length > 0 
                ? $"{key}({string.Join(",", args)})" : key;
            if (trace)
                value.msg();
            return value;
        }

        static string format(string value, object[] args)
            => string.Format(value, args);

        public static void trans(this Control ui)
        {
            ui.layoutOnce(() => 
            {
                var cls = ui.GetType().Name;
                foreach (var fld in ui.GetType().GetFields())
                {
                    var obj = fld.GetValue(ui);
                    if (obj is UserControl uc)
                        trans(uc);
                    else if (obj is ToolStripItem tb)
                    {
                        tb.Text = transUIFld(cls, fld.Name, out var key);
                        tb.ToolTipText = values.TryGetValue($"{key}Tip", 
                                        out var tip) ? tip : tb.Text;
                    }
                    else if (obj is Control ct)
                        ct.Text = transUIFld(cls, fld.Name);
                    else if (obj is ColumnHeader ch)
                        ch.Text = transUIFld(cls, fld.Name);
                }
                if (ui is Form form)
                    form.Text = trans($"{cls}.Title");
            });
        }

        static string transUIFld(string cls, string name)
            => transUIFld(cls, name, out var key);

        public static string transUIFld(string cls, 
            string name, out string key)
        {
            key = getKey(cls, ref name);
            if (values.TryGetValue(name, out var value))
                return value;
            if (values.TryGetValue(key, out value))
                return value;
            if (trace)
                key.msg();
            return defaultUIText(name);
        }

        static string defaultUIText(string name)
        {
            int idx = name.Length - 1;
            while (idx >= 0 && !char.IsUpper(name[idx]))
            {
                idx--;
            }
            return idx < 0 ? name : name.Substring(0, idx);
        }

        public static void initLang(this ToolStripMenuItem menu, 
            Action update)
        {
            menu.trydo(() => 
            {
                menu.Text = "语言(Language)";
                menu.Tag = update;
                menu.DropDownOpening += menuOpen;

                foreach (var p in Directory.EnumerateFiles(dir, "*.lang"))
                {
                    var code = Path.GetFileNameWithoutExtension(p).Trim();
                    menu.addLangItem(code, code);
                }
            });
        }

        static void addLocaleItem(this ToolStripMenuItem menu)
        {
            var item = new ToolStripMenuItem()
            {
                Text = GenLocales,
            };
            item.Click += localeClick;
            menu.DropDownItems.Add(item);
        }

        const string GenLocales = "地区列表(Locales)";

        static void localeClick(object s, EventArgs e)
        {
            e.trydo(() => 
            {
                var path = $"{dir}/locales.txt";
                using (var fout = File.CreateText(path))
                {
                    fout.WriteLine($"Code,\tNativeName,\tEnglishName");
                    foreach(var c in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
                    {
                        fout.WriteLine($"{c.Name},\t{c.NativeName},\t{c.EnglishName}");
                    }
                }
                $"{GenLocales}: {path}".msg();
            });
        }

        static void addLangItem(this ToolStripMenuItem menu, 
            string code, string text)
        {
            var item = new ToolStripMenuItem()
            {
                Text = text,
                Tag = code,
            };
            item.Click += langClick;
            menu.DropDownItems.Add(item);
        }

        static void menuOpen(object s, EventArgs e)
        {
            (s as ToolStripMenuItem).DropDownItems
                .each<ToolStripMenuItem>(it => 
                it.Checked = (it.Tag as string) == current);
        }

        static void langClick(object s, EventArgs e)
        {
            e.trydo(() => 
            {
                var menu = s as ToolStripMenuItem;
                var code = menu.Tag as string;
                if (code == Lang.current)
                    return;

                loadLang(code);
                var notify = menu.OwnerItem.Tag as Action;
                notify();
                File.WriteAllText(CurrentPath, code);
            });
        }

        static void loadLang(string code)
        {
            try
            {
                Lang.values = langPath(code).kvLoad();
                Lang.current = code;
            }
            catch (Exception err)
            {
                string msg = $"加载语言[{code}]失败(Fail to load language): {err.Message}";
                throw new Exception(msg);
            }
        }
    }
}
View Code

Github:

https://github.com/bsmith-zhao/vfs/blob/main/util/Lang.cs