返回

关于单位转换相关问题的常见思路

请原谅我使用了这样一个“直白”的标题,因为我实在想不到更好的描述方法。或许,是因为临近年底的“996”式冲刺,让许久没有读完一本书的我,第一次感受到输出时的闭塞。是时候为自己的知识体系补充新鲜血液啦,而不是输给那些“无聊”的流程和关系。说这句话的缘由,是想到《Unnatural》中的法医三澄美琴,一个视非正常死亡为敌人的女法医。而对程序员来说,真正的敌人则是难以解决 Bug 和问题。可更多的时间,我们其实是在为流程和关系方面的事情消耗精力。

我越来越发现,人类所面对的绝大多数问题,都并非是寻求一个最优解,而是在于平衡和牵制。人类总是不可避免地堕入熵增的圈套,伴随着流程产生的除了规范还有复杂度。每当人们试图为这种复杂度找一种友好的说辞的时候,我终于意识到,有的人不愿意去寻找问题的本质,它们需要的就只是一种友好的说辞,仿佛只要有了这种说辞,问题就能自动解决一样。我想,我大概知道这段时间感到焦灼的原因了,因为这样的事情在工作中基本是常态。人类每天面对的事情,无外乎两种:“明知不可为而为之"和"什么都想兼顾的美好理想”。

我今天想说的是,一个业务中遇到的单位转换的问题,我们平时在存储货物的重量时,默认都是以千克作为单位来存储的,直到我们对接了一家以大宗商品交易作为主要业务的客户,对方要求我们在界面上统一用吨来展示数据,因为这样更符合客户方的使用习惯。按理说,这是一个非常简单的需求,是不需要用一篇博客来说这件事情的,可我觉得这是个有意思的话题,还是想和大家一起来聊聊相关方案的思路。带着问题,我首先拜访了Cather Wong大佬,大佬微微一笑,表示在视图层上加个字段就可以了嘛。的确,这是最简单的做法,大概是下面这个样子:

class OrderInfoQueryDTO
{
   /// <summary>
   /// 以千克为单位的净重
   /// </summary>
   public decimal? NET_WEIGHT { get; set; }

   /// <summary>
   /// 以吨为单位的净重
   /// </summary>
   public decimal? NET_WEIGHT_WITH_TON
   {
       get { return NET_WEIGHT / 1000; }
   }
}

我不甘心地追问,客户要在原来的字段上显示这个数值啊,这样能行吗?大佬稍作沉思,随即问道:“你们公司的项目就算做不到 DDD,AutoMapper 这种实体间映射转换的东西总有吧!”。我连忙接话道:“这个自然是有的”。其实我心里想的是,总算有点符合我的心理预期啦,这样的方案还像个大佬的样子。按照大佬的提示,使用 AutoMapper 来做单位的转换,应该是下面这样:

var config = new MapperConfiguration(cfg => {
    cfg.CreateMap<order_info, OrderInfoQueryDTO>()
        .ForMember(d => d.NET_WEIGHT, opt => opt.MapFrom(x => x.NET_WEIGHT/1000));
});

这样看起来是比加字段要好一点,可实际项目中,我们往往会把单位作为一种配置持久化到数据库中,以我们公司为例,我们实际上是支持千克和吨两种单位混合使用的,不过在表头汇总的时候,为了统一到一起,所以使用了千克作为单位。这样就引申出一个新问题,假如我在数据库里存了多行明细的重量,当需要在表头展示汇总以后的总重量,那么,这个总重量到底是汇总好存在数据库里,还是展示的时候交由调用方 Sum()呢?

我个人倾向于第二种,因为它能有效避免表头和明细行数据不一致的问题,当然缺点是给了调用方一定的计算压力。我们项目中采用的第一种方案,我印象非常深刻,在计算件数、重量和体积的时候,必须要等所有明细行都计算完以后,再通过调用 Sum()方法给表头赋值,实际上这个表头字段,完全可以通过只读属性的方式取值啊,更何况我们还使用了外键,表头实体本身就引用了明细表实体,因为有外键的存在,序列化表头实体的时候会出现循环引用,对此,我想说,干得漂亮!

通过 AutoMapper 中的 ForMember 扩展方法,可以实现我们这里这个功能。可考虑到要在 AutoMapper 里引入权限啊、角色啊这些东西,AutoMapper 作为实体映射的纯粹性就被彻底破坏了。为此,我们考虑使用 AutoMapper 中提供的Value ConvertersType Converters。关于这两者的区别,大家可以参考官方文档中的描述。此时,我们可以通过下面的方式使用这些“转换器”:

var config = new MapperConfiguration(cfg => {
    cfg.CreateMap<order_info,OrderInfoQueryDTO>()
      .ForMember(d => d.NET_WEIGHT, opt => opt.ConvertUsing<WeightValueConverter,decimal?>());
});
  
var mapper = config.CreateMapper();
var orderInfo = new order_info() {
    ORDER_ID = Guid.NewGuid().ToString("N"),
    NET_WEIGHT = 1245.78M,
    CREATED_DATE = DateTime.Now,
    CREATED_BY = "灵犀一指陆小凤"
};

var orderInfoQueryDTO = mapper.Map<order_info,OrderInfoQueryDTO>(orderInfo);

而对于 WeightValueConverter 这个类而言,它实现了 IValueConverter 接口:

 public class WeightValueConverter : IValueConverter<decimal?, decimal?> 
 {
    public decimal? Convert (decimal? sourceMember, ResolutionContext context) 
    {
        //TODO:可以查数据库或者是由规则决定,是否转换以及如何转换
        if (!sourceMember.HasValue)
            return null;
        return sourceMember.Value / 1000;
    }
}

现在,虽然代码还是这个代码,可至少我们不用在 MapFrom 里写太重的业务逻辑了,而且这个转换器是可以复用的。显然,我们的系统中不会只有订单模块会涉及到重量、体积的转换。此时,我们可以考虑使用 ITypeConverter 接口,遗憾地是,这个接口在实现的时候就必须指定源类型和目标类型,这样离我们设想地全局转换器实际上是有一点差距的。例如,我们有时候希望源类型中 Null 值不会覆盖到目标类型,最常见的情况是,从一个 EditDTO 转化为数据库实体对象并更新数据库。为了解决这个问题,AutoMapper 下面的做法就非常棒:

cfg.ForAllMaps((a, b) => b.ForAllMembers(opt => opt.Condition((src, dest, sourceMember) => sourceMember != null)));

可对于我们这里这个场景,显然,我们必须要提供一部分类型信息,我们几乎很难给所有的 Map 增加一个通用的类型转换器。我最终还是通过反射解决了这个问题,即在使用 AutoMapper 前,从数据库查出数据后,首先要做的第一件事情就是对数值进行转换:

var userSetting = UserContext.GetLoginUser().UserSettng;
var formatSetting = userSetting.FormatSetting;

//当默认重量单位为KG时不做任何处理
if (formatSetting.DefaultWeightUom == WeightUnit.KG)
    return;


var properties = typeof(TDestination).GetProperties()
    .Where(p => p.Name.EndsWith("WEIGHT") || p.Name.EndsWith("Weight"));
if (properties == null || !properties.Any())
     return;

foreach(var item in destList)
{
    //转化结果为吨
    foreach(var property in properties)
    {
         var weightValue = property.GetValue(item, null);
         if(property.PropertyType == typeof(decimal))
         {
             property.SetValue(item, (decimal)weightValue / 1000);
         }
         else if(property.PropertyType == typeof(Nullable<decimal>))
         {
             if (weightValue != null)
                  property.SetValue(item, (decimal)weightValue / 1000);
         }
         else if(property.PropertyType == typeof(string))
         {
             if (!string.IsNullOrEmpty(weightValue.ToString()))
                 property.SetValue(item, decimal.Parse(weightValue.ToString()) / 1000);
             }
         }
    }
}

不得不说,这段代码相当无聊,可无论多么无聊的功能,只要客户觉得好就给积极地去做,对吧!其实,说到底,这是我们在设计数据库表结构时遗留的一个问题。假如我们在存储的时候就存储为吨,问题还不会有什么不一样呢?实际上,它还是会有问题,因为你不得不去设计一个单位转换表,类似下面这样的:

原始单位目标单位进率
KgT1/1000
TKg1000
gKg1/1000
Kgg1000

我们目前设计的表结构中实际上是有重量单位的,不同的是,我们以千克为单位存储的量,数据库中对应的 WEIGHT_UOM 存储的是 1,而以吨为单位存储的量,数据库中对应的 WEIGHT_UOM 存储的是 1000。所以,理论上真实的重量都应该是数据库中存储的量 X WEIGHT_UOM。这样看起来是没有问题的,可当你结合今天这篇博客的背景来看是,就会发现一个问题,所有的数值在展示的时候都必须要知道,数据库里存储的数值的原始单位是什么,而使用者希望在界面上看到的数值的单位又是什么。

不单单如此,当用户通过界面查询的时候,一个简单的数字便不等再用简单地使用像大于、小于、等于、不等于这样的查询条件,因为现在每个量都带着单位,你必须明确得知道,用户认为的单位是什么,而数据库里对应的单位又是什么?这样听起来貌似还是统一使用一种单位比较好,正因为如此,博主可以在查询前把吨转化为千克,而在查询后则可以把千克转换为吨。

人类世界总是存在着这些奇奇怪怪的规则,不同的小数位精度要求,不同的货币金额展示方式,不同的日期格式显示要求,就在我写下这篇博客的时候,产品同事反馈我千克转成吨展示以后,应该至少保留三位小数,否则会让人觉得数字会丢失了精度。我还能说什么呢?联想到最近软通因为加班而猝死的同行,我大概只能说一句:**恭喜你,还请节哀顺变,欢迎来到无法随心所欲的爱与欲望的世界!**作为拖延症中晚期的博主,努力写完每月一篇的博客,抽空读读书、看看电影,这已然是种简单的幸福了呢!好了,这篇博客就先写到这里!

Built with Hugo v0.110.0
Theme Stack designed by Jimmy
已创作 266 篇文章,共计 1005454 字