文章

【RxMvvmLight】0x07 数据验证与本地化

函数式才是真正的救赎之路,请无视其他信仰。160回合 |+3 时代得分

【RxMvvmLight】0x07 数据验证与本地化

我讨厌无谓的仪式,从出生起,便是如此。

起因

一切要展示文本的地方,最终都需要本地化,验证消息同样如此。

现在问题来了,像下面这个规则,如何才能本地化呢?

1
2
3
4
5
6
7
    extension(PropertyValidatorBuilder<string> builder)
    {
        public PropertyValidatorBuilder<string> MaxLength(int length, string message = "长度不得超过{0}")
        {
            return builder.Must(v => v?.Length <= length, string.Format(message, length));
        }
    }

这是我期待的形式,因为这样非常容易定义规则。只需要一个扩展方法,只需要三行代码。

那本地化怎么支持?

  • 至少要支持硬编码吧,有的是项目不用本地化。
  • 至少要支持一个 lambda 吧,没有 lambda,本地化没法搞。
  • 我这个项目是用的错误码,你能支持吗
  • 我这个项目是用的 IObservable<string> 本地化的,你能支持吗
  • 我这个项目是用的字典,你能支持吗?
  • 我这是用数据库管理的,你能支持吗?
  • 我这…

头大了。

掉坑

我发现我丢掉 WithMessage 之后,没有一个合理的位置放这些重载,因为给不同的资源类型都提供一份重载是不现实的,这才是 FluentValidation 选择这种高噪音语法的原因:只需要定义少量的 WithMessage 就可以给无数的规则统一设置资源。

但我依旧不喜欢 WithMessage,因为如果这样:

  • 每个规则几乎必须是一个类,而无法像我这样,写一个扩展就行
  • 语法噪音
  • 语法利用率-我造了多个方法,但是你总是只用其中一种?

所以我决定,主动给自己穿小鞋:在现有设计的基础上继续完善。

这几乎是不可能的,因为留给我的只有一个参数的位置,而我需要用这一个位置提供几乎无限的扩展,同时,要限制重载的数量,不能让它指数级增长。

尝试:只保留 Lambda

这个不用试,肯定行,但是它强制要求所有类型都走lambda,不清真,实在没办法了再考虑用这个。

尝试:自定义操作符

新版本 C# 允许在扩展中定义操作符,所以我可以给 string 定义一个一元加法运算,返回一个 resourceKey:

1
2
3
4
5
6
7
8
9
10
11
12
    public record Key(string Name);

    extension(string message)
    {
        public static Key operator +(string name) => new (name);
    }


    // 使用:
    this.RuleFor(x => x.Name)
        .Required(+"required")
        .Subscribe();

这疑似有些太隐晦了。

而且这看起来就不好扩展。

尝试:扩展索引器

正在预览中的 C# 版本里,允许用户扩展索引器,所以理论上我可以写下面的代码

1
2
3
4
this.RuleFor(x => x.Name)
    .Required()["required"]
    .Required()[()=>"required"]
    .Subscribe();

这本质上就是 WithMessage 优点就是短,缺点与 WithMessage 是几乎一致的,而且,索引器不是这么用的,很别扭。

尝试:隐式转换

我来定义一个 MessageProvider 类,把 stringFunc<string> , 如果用户有自己的类型,也可以定义一个隐式转换,理论上这个方案的扩展性是无限的。

死于 lambda 无法一步到位隐式转换为 MessageProvider,此外,接口也不能隐式转换。

等等

你刚刚说 lambda 无法一次转换为 MessageProvider 对吧?那如果,我把一个lambda 传递给一个泛型方法,它是什么类型的?

1
2
3
4
5
void Foo<T>(T value){

}

Foo(() => 1); // 这个lambda 是什么类型?

答案是 Func<int>

这是一个冷知识,来自 C# 10 的改进,现在 Lambda 可以有一个自然类型。

于是我有了一个大胆的想法:使用泛型消息,这应该可以提供无限的可能性,至少,它可以兼容 stringFunc<string>

实现

只要有了方向,一切就顺理成章,首先,现在我的参数是 TMessage, 但我需要的是 string,所以,我需要一个注册中心,让我知道 TMessage 如何转换成 string:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static class MessageConverter
{
    class ConverterRegistry<T>
    {
        public static Func<T, string> Converter = null!;
    }

    static MessageConverter()
    {
        ConverterRegistry<string>.Converter = message => message;
        ConverterRegistry<Func<string>>.Converter = messageFunc => messageFunc();
    }

    public static void Register<T>(Func<T, string> converter)
    {
        ConverterRegistry<T>.Converter = converter;
    }

    public static string Convert<T>(T value)
    {
        if(ConverterRegistry<T>.Converter == null)
        {
            throw new InvalidOperationException($"No message converter registered for type '{nameof(T)}'.");
        }
        return ConverterRegistry<T>.Converter(value);
    }
}

ok,有了这个 MessageConverter,需要string 的地方只需要调用一下 MessageConverter.Convert 就可以获取字符串,比如开始举的这个例子:

1
2
3
4
5
6
7
8
9
    extension(PropertyValidatorBuilder<string> builder)
    {
        public PropertyValidatorBuilder<string> MaxLength<TMessage>(int length, TMessage message)
        {
            // 这里入参是 TMessage
            // 但是 Must 的 TMessage 是 Func<string>
            return builder.Must(v => v?.Length <= length, () => string.Format(MessageConverter.Convert(message), length));
        }
    }

然后,我在最终注册规则的地方,统一把他们封装成 Func<string>

1
2
3
4
5
6
7
8
9
    public PropertyValidatorBuilder<T> RegisterAsyncRule<TMessage>(Func<T, CancellationToken, Task<bool>> evaluate, TMessage messageProvider)
    {
        var rule = new AsyncTokenFuncRule<T> 
        { 
            Evaluate = evaluate, 
            MessageProvider = () => MessageConverter.Convert(messageProvider)
        };
        return AddRule(rule);
    }

这样,验证器面对的就是统一的 Func<string> 而不是乱七八糟的 TMessage。如此就实现了内置的两种消息类型。

增加第三种内置消息

作为一个框架,我当然是需要内置一些消息的,再简陋也得至少放一门语言进去,我这里选择使用枚举:

1
2
3
4
5
6
7
8
9
10
11
public enum BuiltInMessage
{
    Required = 0,
    TextNotBlank = 100,
    TextMinLength = 101,
    TextMaxLength = 102,
    TextLength = 103,
    InvalidFormat = 201,
    InvalidEmailFormat = 202,
    InvalidUrlFormat = 203,
}

然后我修改一下内置消息注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    static MessageConverter()
    {
        ConverterRegistry<string>.Converter = message => message;
        ConverterRegistry<Func<string>>.Converter = messageFunc => messageFunc();
        ConverterRegistry<BuiltInMessage>.Converter = builtInMessage => builtInMessage switch
        {
            BuiltInMessage.Required => "此字段为必填项",
            BuiltInMessage.TextNotBlank => "此字段不能为空",
            BuiltInMessage.TextMinLength => "长度不能少于{0}个字符",
            BuiltInMessage.TextMaxLength => "长度不能超过{0}个字符",
            BuiltInMessage.TextLength => "长度必须在{0}到{1}个字符之间",
            BuiltInMessage.InvalidFormat => "格式不正确",
            BuiltInMessage.InvalidEmailFormat => "邮箱格式不正确",
            BuiltInMessage.InvalidUrlFormat => "URL格式不正确",
            _ => throw new ArgumentOutOfRangeException("Invalid built-in message")
        };
    }

再给内置操作符加一些无参重载,此时缺点其实就暴露出来了,再怎么泛型自动推导,也得有参数才行,无参的只能写重载。好在重载很容易写:

1
2
3
4
5
6
7
8
9
10
11
    extension(PropertyValidatorBuilder<string> builder)
    {
        public PropertyValidatorBuilder<string> MaxLength(int length) => builder.MaxLength(length, BuiltInMessage.TextMaxLength);

        public PropertyValidatorBuilder<string> MaxLength<TMessage>(int length, TMessage message)
        {
            // 这里入参是 TMessage
            // 但是 Must 的 TMessage 是 Func<string>
            return builder.Must(v => v?.Length <= length, () => string.Format(MessageConverter.Convert(message), length));
        }
    }

演示一下如何集成第三方本地化库

这是对接第三方库 Irihi.Lingua 的示例,它使用 IObservable<string?> 驱动 Avalonia View 本地化:

1
2
3
4
5
6
7
8
9
MessageConverter.Register<IObservable<string?>>(x =>
{
    return ((LinguaObservableString)x).CurrentValue!;
});

MessageConverter.Register<BuiltInMessage>(x =>
{
   // 这里写内置消息的映射
});

是的,只需要这么注册一下就够了。

做一下收尾工作

本地化支持还需要支持刷新,这个十分简单,在 ReactiveValidator 的构造函数里把它放进一个静态弱引用列表里,然后提供一个静态刷新方法,用来遍历所有ReactiveValidator重新刷新一次消息,然后在你框架的语言变更事件中调用它就可以了。

这个属于脏活累活,我就不贴代码了。

延申

不知道你有没有注意到:我其实没有限制你注册 stringFunc<string> 的转换器,这其实也是允许的。你可以注册一个 string 转换器,然后把它作为一个资源键处理,如果你使用资源键管理消息的话。

至于 Func<string> 就不太建议重写了,因为它才是真正的核心路线,当然你非要重写也不是不行,只是需要注意一点点细节罢了。

结语

最终,这个验证库成功的把 C# 用出了 F# 的风格:类型推导/闭包/自动转换,不得不说,像这种小东西,用函数式来处理是最方便的,不然为了封装一个判断建一个类还是太麻烦了。

本文由作者按照 CC BY 4.0 进行授权