我们先从接手的一个项目中间穿插所提的一个需求开始,甲方有专门的app客户端,在其中配置一个模块,用户点击该模块进行单点登录,成功后嵌入我们的H5页面进行操作,相对应的操作将会触发调用甲方接口推送消息提醒。


友好提醒:本文可能略长,请耐心阅读,若有更好方案,请于评论中给出


业务分析


甲方所提供的消息推送接口很简单,只不过有些需要注意的地方和甲方团队沟通好就完事。消息推送接口签名也很常见,主要是将各参数按照字母顺序排序,然后加时间戳、秘钥等进行签名。


针对用户进行可操作的各个状态流转比较多,在不同操作状态后都需要进行消息推送提醒,这里我们可能就想到了常用的消息队列(RabbitMQ),但还是得根据具体业务具体分析


推送数据我们不会进行持久化存储,即使消息推送提醒没有成功也没所影响,因为它对整个流程不会有啥影响,只是无法立马接受到当前流转信息,影响使用体验,只要再次进入H5,数据流状态依然可见。所以基于以上分析,通过引入RabbitMQ等消息队列组件显得略重


上述我们已经讨论过,用户每进行到对应操作流转时,都会进行消息提醒,很显然,为了不影响主流程即快速响应用户操作结果,我们要进行异步处理或另开线程在后台运行处理,这也是必然的。


但又由于是.NET项目,数据库操作这一层,全部是写原生SQL语句,同时所有之前操作都是同步处理,时间上也有限,所以利用异步处理,改动会非常大。所以只能通过另开线程去单独进行消息推送


根据业务分析于此,我们就要想落地可实施的方案,如下是我想到的几种实施方案


简单消息队列


我们可采取基于事件的发布-订阅机制,用户进行流转操作时,需要进行消息推送时则发布事件,订阅处理。我们可以通过定义一个消息推送全局并发队列,将订阅数据先推送到该队列中,然后一一取出处理,以避免出现阻塞无法推送的情况出现。此种方案貌似听起来可行,这其中会存在处理过程比较耗时问题。


我们采取采用生产者-消费者队列机制处理,这也是我最终定下来的方案。在写第一版时,只要推送消息就创建一个线程处理,简单而粗暴,然后觉得这种方式怕是会出问题,因为操作用户数虽不是很多,估计也有几十个,然后再加上要考虑服务器配置,担心引起CPU虚高,还是保险起见一点好。


这里我写一个简单的demo来演示下生产者-消费者实现方案,我们将其封装定义为一个类,当然支持泛型以传入不同参数最佳,这里演示我们直接以打印数字来处理。先看如下完整代码,然后我们一一来进行分析以及可能存在的问题


public class SimpleMessageQueue
{
    private int ThreadCount => 2;

    private readonly BlockingCollection<int> InputQueue = new BlockingCollection<int>();

    private readonly BlockingCollection<int> OutPutQueue = new BlockingCollection<int>();

    public void Pulish(List<int> request)
    {
        foreach (var item in request)
        {
          InputQueue.Add(item);
        }

        Task.Factory.StartNew(() =>
        {
          Run();
        });
    }

    private void Run()
    {
        Task.Factory.StartNew(Consumer);

        var workers = Enumerable.Range(1, ThreadCount).Select(d => new Task(() => Producer())).ToArray();
        Parallel.ForEach(workers, (w) => w.Start());

        InputQueue.CompleteAdding();

        Task.WaitAll(workers);

        OutPutQueue.CompleteAdding();
    }

    private void Producer()
    {
        foreach (var workItem in InputQueue.GetConsumingEnumerable())
        {
          Console.WriteLine($"生产者处理:{workItem}");

          Thread.Sleep(2000);
          OutPutQueue.Add(workItem);
        }
    }

    private void Consumer()
    {
        foreach (var workItem in OutPutQueue.GetConsumingEnumerable())
        {
          Console.WriteLine($"消费者处理:{workItem}");

          //模拟处理操作(2秒)
          Thread.Sleep(2000);
        }
        Console.WriteLine("消费者处理完成");
    }
}

上述生产者-消费者模式通过线程安全的BlockingCollection来实现,这一点我们并无疑义


因为无法动态评估所启用线程任务数量,所以这里默认定义为2,同时我们可以看到,当将数据推送到生产者队列中后,直接重新开了一个Task,这里主要是为了向用户快速响应操作结果,姑做了这一步处理(不知这样设计是否合理,待验证),其他通过代码一看便知,没有什么太多要去详细分析的。

new SimpleMessageQueue().Pulish(Enumerable.Range(120).ToList());

Console.WriteLine("---------操作完成---------");


上述我们在控制台尝试推送20条数据进行模拟测试,最终测试结果如下(消费者处理完成没有截图全):

.NET/.NET Core实现简单消息队列_java


整个简单过程大概就是如上所述了,难道上述实现就没有任何问题了吗?我们不枉思考在前面我更新过一篇文章所给的问题,Task并不适用于耗时长的任务,若对Task不做任何选项配置,则默认使用线程池线程,所以可能会出现线程池线程耗尽的情况。


所以我们将消费者采用基于操作系统级别线程(Thread),标志在后台运行来的更加合理一些呢?

  var thread = new Thread(Consumer)
  {
      IsBackground = true
  };
  thread.Start();


这里我倾向于使用Thread,但是项目解决方案还是使用的Task,这个问题待我验证。那么我们是不是也可以得出结论,并非Task抛头露面,而Thread毫无用武之地。


通篇阅读本文,其实我想传达一个观点:根据具体业务具体情况分析才最佳,别跑偏了路,实践是检验真理的唯一标准。若有实践机会,我们应好好把握,选择最佳方案才是,而非囫囵吞枣草草了事,虽完成了任务,但长此以往终不利于个人成长。