1. 背景与目标

最近项目中加入多语言翻译需求:一个实体列表里,可能有多处需要显示化 / 翻译的字段,比如枚举状态、国家名称等。一开始想用ActionFilter来实现,但是项目要用到hangfire,我们经常要用hangfire去导出excel,这里经常需要翻译,所以舍弃了这种思路,还是选择在业务代码中写入相关的翻译代码,但是写多了以后就感觉很麻烦了,于是想着再封装一个方法方便进行翻译以及赋值。

目标:以链式流式语法收集所有“待翻译文本 + 回填策略”,统一调用服务一次,最后批量回填

2. 期望的调用方式(直觉 DSL)

最开始脑海里大概是这样设计的:

var res = await users
    .MapTranslation(a => a.IsEnable, (a, translated) => a.IsEnableName = translated)
    .MapTranslation(a => a.Country, (a, translated) => a.Country = translated)
    .ExecuteAsync();

语义解读:

  • MapTranslation:收集一个“源字段 + 翻译后如何写回”的规则
  • ExecuteAsync():统一翻译 + 回填翻译后的内容

3. 初版实现与问题分析

下面是最初的一个版本:

public static class ExpressionBuilder
{
    public static TranslatedContext<T> BuildCollect<T>(IEnumerable<T> items) where T : class
        => new(items);
}

public class TranslatedContext<T> where T : class
{
    private readonly IEnumerable<T> _source;
    private readonly List<(T item, string value, Action<T, string> action)> _translations;

    public TranslatedContext(IEnumerable<T> source){ 
        _source = source;
        _translations=new()
    }

    public IEnumerable<T> MapTranslation(Func<T, string> selector, Action<T, string> action)
    {
        foreach (var item in _source)
        {
            var result = selector(item);
            _translations.Add((item, result, action));
            yield return item;
        }
    }

    public IEnumerable<T> Execute()
    {
        foreach (var (item, enumValue, action) in _translations)
            action(item, enumValue);
        foreach (var item in _source)
            yield return item;
    }
}

4. 服务注入的演进

如果把翻译Service加入流程中的话就要在 BuildCollect 里接收翻译服务:

var res = await users
    .BuildCollect(translateService)
    .MapTranslation(a => a.IsEnable, (a, translated) => a.IsEnableName = translated)
    .ExecuteAsync();

又觉得顺序不自然,有点反直觉了,于是思考了一下将 BuildCollect 挪进 TranslateService后,长这样:

var res = await translateService
    .BuildCollect(users)
    .MapTranslation(a => a.IsEnable.GetDescription(), (a, translated) => a.IsEnableName = translated)
    .ExecuteAsync();

5. 改进代码

public class TranslateContext<T> where T : class
{
    private readonly TranslateService _service;
    private readonly IEnumerable<T> _items;
    private readonly List<(T Item, string SourceText, Action<T, string> Action)> _translations;

    public TranslateContext(TranslateService service, IEnumerable<T> items)
    {
        _service = service;
        _items = items ?? throw new ArgumentNullException(nameof(items));
        _translations = new();
    }

    /// <summary>
    /// 收集“待翻译文本 + 回填策略”
    /// </summary>
    public TranslateContext<T> MapTranslation(Func<T, string> selector, Action<T, string> action)
    {
        ArgumentNullException.ThrowIfNull(selector);
        ArgumentNullException.ThrowIfNull(action);
        foreach (var item in _items)
        {
            var source = selector(item);
            _translations.Add((item, source, action));
        }
        return this;
    }

    public async Task<IEnumerable<T>> ExecuteAsync()
    {
        var pending = _translations
            .Select(t => t.SourceText)
            .Where(s => !string.IsNullOrWhiteSpace(s))
            .Select(s => s.Trim())
            .Distinct()
            .ToList();

        var dic = await _service.GetTranslationDic(pending);

        foreach (var (item, source, action) in _translations)
        {
            action(item, dic.TryGetValue(source, out var translated) ? translated : source);
        }
        return _items;
    }
}

public class TranslateService
{
    private readonly ILogger<TranslateService> _logger;
    public TranslateService(ILogger<TranslateService> logger) => _logger = logger;

    public TranslateContext<T> BuildCollect<T>(IEnumerable<T> items) where T : class
        => new(this, items);

    public async Task<Dictionary<string, string>> GetTranslationDic(IEnumerable<string> pendingTranslations)
    {
        if (pendingTranslations == null)
            return new();

        var dic = new Dictionary<string, string>();
        foreach (var text in pendingTranslations)
        {
            dic[text] = text switch
            {
                "启用" => "Enable",
                "禁用" => "Disable",
                _ => text
            };
        }
        return await Task.FromResult(dic);
    }
}

6. 表达式树增强版:自动生成 Setter

现在其实已经大差不差了,但是一看每次都需要写(a, translated) => a.Property = translated,这部分,感觉有点麻烦,或许我们可以再优化一下? 我研究一下private readonly List<(T Item, string SourceText, Action<T, string> Action)> _translations;这部分,我发现其实可以用表达式树构建一下,因为其实只涉及到了两个字段,一个是待翻译,一个是翻译后要被回填的字段,所以我想了下。或许我可以简化一下,于是我创建一个MapTranslation的重载

public TranslateContext<T> MapTranslation(
        Func<T, string> sourceExpr,
        Expression<Func<T, string>> targetExpr)
    {
        if (sourceExpr == null || targetExpr == null) throw new ArgumentNullException();

        // var getter = sourceExpr.Compile();

        // 解析目标属性
        if (targetExpr.Body is not MemberExpression member || member.Member is not PropertyInfo prop)
            throw new InvalidOperationException("目标表达式必须是可写属性");
        if (!prop.CanWrite) throw new InvalidOperationException("属性不可写");

        // 构造 setter 委托: (T e, string v) => e.Prop = v;
        var pEntity = Expression.Parameter(typeof(T), "e"); //创建一个类型为 T 的参数,名字叫 "e"
        var pValue = Expression.Parameter(typeof(string), "v"); //创建一个类型为 string 的参数,名字叫 "v"
        var assign = Expression.Assign(Expression.Property(pEntity, prop), pValue); // 创建赋值表达式 e.Prop = v
        var setter = Expression.Lambda<Action<T, string>>(assign, pEntity, pValue).Compile(); // 创建 Lambda 表达式并编译成委托

        foreach (var item in _items)
        {
            var source = sourceExpr(item);
            _translations.Add((item, source, setter));
        }
        return this;
    }

调用示例:

var res = await translateService
    .BuildCollect(users)
    .MapTranslation(u => u.IsEnable.GetDescription(), u => u.IsEnableName)
    .ExecuteAsync();

但是这样的话会有一些性能缺点,运行期表达式解析 + 动态编译会有轻微的性能开销。

7. 示例输出

示例运行结果(与原始枚举翻译示例一致):

[
  { "name": "张三", "country": "中国", "isEnable": 1, "isEnableName": "Enable" },
  { "name": "李四", "country": "美国", "isEnable": 0, "isEnableName": "Disable" },
  { "name": "王五", "country": "英国", "isEnable": 1, "isEnableName": "Enable" },
  { "name": "赵六", "country": "法国", "isEnable": 0, "isEnableName": "Disable" },
  { "name": "田七", "country": "德国", "isEnable": 1, "isEnableName": "Enable" }
]

8. 可进一步扩展的方向

  • 缓存翻译结果(本地内存 / 分布式缓存)
  • 将表达式树重载的 setter 生成再做一次缓存,避免重复编译

9.表达式树重载的 setter 生成再做一次缓存

其实这一步操作只需要考虑一个点就可以了:用什么当缓存key 我思考了一下,因为我是一个Action委托,我是没有入参的,所以我们只需要把两个入参缓存了即可,也就是T和属性名,所以我的Key设计为:

var key = $"{typeof(T).FullName}:{prop.Name}";

FullName表示的是一个类型的完整名称,是包括命名空间的,比如typeof(System.String).FullName 的结果是 System.String prop.Name就是属性名了

相关代码如下,只要添加private static readonly Dictionary<string, Action<T, string>> _setterCache = [];再修改表达式树构建委托那部分就可以


var key = $"{typeof(T).FullName}:{prop.Name}";

if (!_setterCache.TryGetValue(key, out var setter))
{
    // 构造 setter 委托: (T e, string v) => e.Prop = v;
    var pEntity = Expression.Parameter(typeof(T), "e"); //创建一个类型为 T 的参数,名字叫 "e"
    var pValue = Expression.Parameter(typeof(string), "v"); //创建一个类型为 string 的参数,名字叫 "v"
    var assign = Expression.Assign(Expression.Property(pEntity, prop), pValue); // 创建赋值表达式 e.Prop = v
    setter = Expression.Lambda<Action<T, string>>(assign, pEntity, pValue).Compile(); // 创建 Lambda 表达式并编译成委托
    _setterCache[key] = setter;
    Console.WriteLine($"缓存新委托: {key},当前缓存数量:{_setterCache.Count}");
}

源码仓库

源码参考:batchFieldCollectDemo 源码仓库