相信绝大部分开发者都接触过用户注册的流程,通常情况下大概的流程如下所示:

  1. 接收用户提交注册信息
  2. 持久化注册信息(数据库+redis)
  3. 发送注册成功短信(邮件)
  4. 写操作日志(可选)

伪代码如下:

public async Task<IActionResult> Reg([FromBody] User user)
{
_logger.LogInformation("持久化数据开始");
await Task.Delay(50);
_logger.LogInformation("持久化结束");
_logger.LogInformation("发送短信开始");
await Task.Delay(100);
_logger.LogInformation("发送短信结束");
_logger.LogInformation("操作日志开始");
await _logRepository.Insert(new Log { Txt = "注册日志" });
_logger.LogInformation("操作日志结束");
return Ok("注册成功");
}

在以上的代码中,我使用Task.Delay方法阻塞主线程,用以模拟实际场景中的执行耗时。以上流程应该是包含了绝大部分注册流程所需要的操作。对于任何开发者来讲,以上业务流程没任何难度,无非是顺序的执行各个流程的代码即可。

稍微有点开发经验的应该会将以上的流程进行拆分,但有些人可能就要问了,为什么要拆分呢?拆分之后的代码应该怎么写呢?下面我们就来简单聊下如此场景的正确打开方式。

首先,注册成功的依据应该是是否成功的将用户信息持久化(至于是先持久化到数据库,异或是先写到redis不在本篇文章讨论的范畴),至于发送注册短信(邮件)以及写日志的操作应该不能成为影响注册是否成功的因素,而发送短信/邮件等相关操作通常情况下也是比较耗时的,所以在对此接口做性能优化时,可优先考虑将短信/邮件以及写日志等相关操作与主流程(持久化数据)拆分,使其不阻塞主流程的执行,从而达到提高响应速度的目的。

知道了为什么要拆,但具体如何拆分呢?怎样才能用最少的改动,达到所需的目的呢?

条条大路通罗马,所以要达成我们的目的也是有很多方案的,具体选择哪种方案需要根据具体的业务场景,业务体量等多种因素综合考虑,下面我将一一介绍分析相关方案。

在正式介绍可用方案前,笔者想先介绍一种很多新手容易错误使用的一种方案(因为笔者就曾经天真的使用过这种错误的方案)。

提到异步,绝大部分.net开发者应该第一想到的就是Task,async,await等,的确,async,await的语法糖简化了.net开发者异步编程的门槛,减少了很多代码量。通常一个返回Task类型的方法,在被调用时,会在方法的前面加上await,表示需要等待此方法的执行结果,再继续执行后面的代码。但如果不加await时,则不会等待方法的执行结果,进而也不会阻塞主线程。所以,有些人可能就会将发送短信/邮件以及写日志的操作如下方式进行改造。

public async Task<IActionResult> Reg1([FromBody] User user)
{
_logger.LogInformation("持久化数据开始");
await Task.Delay(50);
_logger.LogInformation("持久化结束");
_ = Task.Run(async () =>
{
_logger.LogInformation("发送短信开始");
await Task.Delay(100);
_logger.LogInformation("发送短信结束");
_logger.LogInformation("操作日志开始");
await _logRepository.Insert(new Log { Txt = "注册日志" });
_logger.LogInformation("操作日志结束");
});
return Ok("注册成功");
}

然后使用jmeter分别压测改造前和改造后的接口,结果如下:

有没有被惊讶到?就这样一个简单的改造,吞吐量就提高了三四倍。既然已经提高了三四倍,那为什么说这是一种错误的改造方法吗?各位看官且往下看。

熟悉.netcore的大佬,应该都知道.netcore的依赖注入的生命周期吧。通常情况下,注入的生命周期包括:Singleton,Scope,Transient。

在以上的流程中,假如写操作日志的实例的生命周期是Scope,当在Task中调用Controller获取到的实例的方法时,因为Task.Run并没有阻塞主线程,当调用Action return后,当前请求的scope注入的对象会被回收,如果对象会回收之前,Task.Run还未执行完,则会报System.ObjectDisposedException: Cannot access a disposed object. 异常。意思是,不能访问一个已disposed的对象。正确的做法是使用IServiceScopeFactory创建一个新的作用域,在新的作用域中获取获取日志仓储服务的实例。这样就可以避免System.ObjectDisposedException异常了。

改造后的示例代码如下:

public async Task<IActionResult> Reg1([FromBody] User user)
{
_logger.LogInformation("持久化数据开始");
await Task.Delay(50);
_logger.LogInformation("持久化结束");
_ = Task.Run(async () =>
{
using (var scope = _scopeFactory.CreateScope())
{
var sp = scope.ServiceProvider;
var logRepository = sp.GetService<ILogRepository>();
_logger.LogInformation("发送短信开始");
await Task.Delay(100);
_logger.LogInformation("发送短信结束"); _logger.LogInformation("操作日志开始");
await logRepository.Insert(new Log { Txt = "注册日志" });
_logger.LogInformation("操作日志结束");
}
});
return Ok("注册成功");
}

虽然得到了正解,但上述的代码着实有点多,如果一个项目有多个相似的业务场景,就要考虑对CreateScope相关的操作进行封装。

下面就来一一介绍下笔者觉得实现此业务场景的几种方案。

1.消息队列

2.Quartz任务调度组件

3.Hangfire任务调度组件

4.Weshare.TransferJob(推荐)

首先说下消息队列的方式。准确的说,消息队列应该是这种场景的最优解决方案,消息队列的其中一个比较重要的特性就是解耦,从而提高吞吐量。但并不是所有的应用程序都需要上消息队列。有些业务场景使用消息队列时,往往会给人一种"杀鸡用牛刀"的感觉。

其次Quartz和Hangfire都是任务调度框架,都提供了可实现以上业务场景的逻辑,但Quartz和Hangfire都需要持久化作业数据。虽然Hangfire提供了内存版本,但经过我的测试,发现Hangfire的内存版本特别消耗内存,所以不太推荐使用任务调度框架来实现类似于这样的业务逻辑。

最后,也就是本文的重点,笔者结合了消息队列和任务调度的思想,实现了一个轻量级的转移作业到后台执行的组件。此组件完美的解决了Scope生命周期实例获取的问题,一行代码将不需要等待的操作转移到后台线程执行。

接入步骤如下:

1.使用nuget安装Weshare.TransferJob

2.在Stratup中注入服务。

services.AddTransferJob();

3.通过构造函数或其他方法获取到IBackgroundRunService的实例。

4.调用实例的Transfer方法将作业转移到后台线程。

_backgroundRunService.Transfer(log=>log.Insert(new Log(){Txt = "注册日志"}));

就是这么简单的实现了这样的业务场景,不仅简化了代码,而且大大提高了系统的吞吐量。

下面再来一起分析下Weshare.TransferJob的核心代码(毕竟文章要点题)。各位器宇不凡的看官请继续往下看。

下面的代码是AddTransferJob方法的实现:

public static IServiceCollection AddTransferJob(this IServiceCollection services)
{
services.AddSingleton<IBackgroundRunService, BackgroundRunService>();
services.AddHostedService<TransferJobHostedService>();
return services;
}

聪明"绝顶"的各位看官应该已经发现上述代码的关键所在。是的, 你没有看错,此组件的就是利用.net core提供的HostedService在后台执行被转移的作业的。

我们再来一起看看TransferJobHostedService的代码:

public class TransferJobHostedService:BackgroundService
{
private IBackgroundRunService _runService;
public TransferJobHostedService(IBackgroundRunService runService)
{
_runService = runService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _runService.Execute(stoppingToken);
}
}
}

这个类的代码也很简单,重写了BackgroundService类的ExecuteAsync,循环调用IBackgroundRunService实例的Execute方法。所以,最最关键的代码是IBackgroundRunService的实现类中。

详细代码如下:

public class BackgroundRunService : IBackgroundRunService
{
private readonly SemaphoreSlim _slim;
private readonly ConcurrentQueue<LambdaExpression> queue;
private ILogger<BackgroundRunService> _logger;
private readonly IServiceProvider _serviceProvider;
public BackgroundRunService(ILogger<BackgroundRunService> logger, IServiceProvider serviceProvider)
{
_slim = new SemaphoreSlim(1);
_logger = logger;
_serviceProvider = serviceProvider;
queue = new ConcurrentQueue<LambdaExpression>();
}
public async Task Execute(CancellationToken cancellationToken)
{
try
{
await _slim.WaitAsync(cancellationToken);
if (queue.TryDequeue(out var job))
{
using (var scope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
var action = job.Compile();
var isTask = action.Method.ReturnType == typeof(Task);
var parameters = job.Parameters;
var pars = new List<object>();
if (parameters.Any())
{
var type = parameters[0].Type;
var param = scope.ServiceProvider.GetRequiredService(type);
pars.Add(param);
}
if (isTask)
{
await (Task)action.DynamicInvoke(pars.ToArray());
}
else
{
action.DynamicInvoke(pars.ToArray());
}
}
}
}
catch (Exception e)
{
_logger.LogError(e.ToString());
}
}
public void Transfer<T>(Expression<Func<T, Task>> expression)
{
queue.Enqueue(expression);
_slim.Release();
}
public void Transfer(Expression<Action> expression)
{
queue.Enqueue(expression);
_slim.Release();
}
}

纳尼?嫌代码多看不懂?那咱们一起来剖析下吧。

首先,此类有三个较重要的私有变量,对应的类型分别是SemaphoreSlim, ConcurrentQueue<LambdaExpression>,IServiceProvider。

其中SemaphoreSlim是为了控制后台作业执行的顺序的,在构造函数中初始化了此对象的信号量为1,表示在后台服务的ExecuteAsync方法的循环中每次只能有一个作业执行。

ConcurrentQueue<LambdaExpression>的对象是用来存储被转移到后台服务执行的作业的逻辑,所以使用LambdaExpression作为队列的类型。

IServiceProvider是为了解决依赖注入的生命周期的。

然后在Execute方法中,第一行代码如下:

await _slim.WaitAsync(cancellationToken);

作用是等待一个信号量,当没有可用的信号量时,会阻塞线程的执行,这样在后台服务的ExecuteAsync方法的死循环就不会一直执行下去,只有获取到信号量才会继续执行。

当获取到信号量后,则说明有新的作业等待执行,所以此时则需要从队列中读出要执行的LambdaExpression表达式,创建一个新的Scope后,编译此表达式树,判断返回类型,获取泛型的具体类型,最后获取到泛型对应的实例,执行对应的方法。

另外,Transfer方法就是暴露给调用者的方法,用于将表达式树写到队列中,同时释放信号量。

到此为止,Weshare.TransferJob的实现原理已分析完毕,由于此组件的原理只是将任务转移到后台进行执行,所以并不是适合对事务有要求的场景。正如本文开头所假设的场景,TransferJob最适合的场景还是那些和主操作关联性较低的、失败或成功并不会影响业务的正常运行。

同时,此组件的定位就是小而美,像延迟执行、定时执行的功能在最初的规划中其实是有的,后来发现这些功能quartz已经有了,所以没必要重复造这样的轮子。

后期会根据使用场景,尝试加入异常重试机制,以及异常通知回调机制。

最后,不知道有没有较真的看官想计算下代码量是否超过120行。

为了证明我不是标题党,现将此组件进行开源,地址是:

https://github.com/fuluteam/WeShare.TransferJob

桥豆麻袋,笔者辛苦敲的代码,难道各位看官想白嫖吗? 点个赞再走呗。点完赞还有力气的话,如果git上能点个star的话,那也是最好不过的。小生这厢先行谢过。

120行代码打造.netcore生产力工具-小而美的后台异步组件的更多相关文章

  1. 150行代码打造.net core生产力工具,你值得拥有

    你是否在初学 .net core时,被依赖注入所折磨? 你是否在开发过程中,为了注入依赖而不停的在Startup中增加注入代码,而感到麻烦? 你是否考虑过或寻找过能轻松实现自动注入的组件? 如果有,那 ...

  2. 基于Java和Bytemd用120行代码实现一个桌面版Markdown编辑器

    前提 某一天点开掘金的写作界面的时候,发现了内置Markdown编辑器有一个Github的图标,点进去就是一个开源的Markdown编辑器项目bytemd(https://github.com/byt ...

  3. 100行代码打造属于自己的代理ip池

    经常使用爬虫的朋友对代理ip应该比较熟悉,代理ip就是可以模拟一个ip地址去访问某个网站.我们有时候需要爬取某个网站的大量信息时,可能由于我们爬的次数太多导致我们的ip被对方的服务器暂时屏蔽(也就是所 ...

  4. 只用120行Java代码写一个自己的区块链

    区块链是目前最热门的话题,广大读者都听说过比特币,或许还有智能合约,相信大家都非常想了解这一切是如何工作的.这篇文章就是帮助你使用 Java 语言来实现一个简单的区块链,用不到 120 行代码来揭示区 ...

  5. 通过 Mesos、Docker 和 Go,使用 300 行代码创建一个分布式系统

    [摘要]虽然 Docker 和 Mesos 已成为不折不扣的 Buzzwords ,但是对于大部分人来说它们仍然是陌生的,下面我们就一起领略 Mesos .Docker 和 Go 配合带来的强大破坏力 ...

  6. 通过Mesos、Docker和Go,使用300行代码创建一个分布式系统

    [摘要]虽然 Docker 和 Mesos 已成为不折不扣的 Buzzwords ,但是对于大部分人来说它们仍然是陌生的,下面我们就一起领略 Mesos .Docker 和 Go 配合带来的强大破坏力 ...

  7. 打造程序员的高效生产力工具-mac篇

    打造程序员的高效生产力工具-mac篇 1   概述 古语有云:“工欲善其事,必先利其器” [1] ,作为一个程序员,他最重要的生产资源是脑力知识,最重要的生产工具是什么?电脑. 在进行重要的脑力成果输 ...

  8. Android Studio 单刷《第一行代码》系列 02 —— 日志工具 LogCat

    前情提要(Previously) 本系列将使用 Android Studio 将<第一行代码>(书中讲解案例使用Eclipse)刷一遍,旨在为想入坑 Android 开发,并选择 Andr ...

  9. 转: 通过不到100行Go代码打造你自己的容器

    备注:这个文章讲容器,讲的比较的浅显易懂.推荐,前期入行者看. 转: http://www.infoq.com/cn/articles/build-a-container-golang?utm_sou ...

随机推荐

  1. vue 3.0新特性

    参考:  https://www.cnblogs.com/Highdoudou/p/9993870.html https://www.cnblogs.com/ljx20180807/p/9987822 ...

  2. 使用包时,报 xxx.default is not a function

     最近做了一个导出功能,代码如下 import request from 'request-promise-native'; export default class Form { // 导出 @po ...

  3. php连接数据库 需要下载adodb

    <?include('adodb/ADOdb.inc.php'); # 加载ADODB$conn = &ADONewConnection('odbc_mssql'); # 建立一个连结$ ...

  4. etcd分布式锁及事务

    前言 分布式锁是控制分布式系统之间同步访问共享资源的一种方式.在分布式系统中,常常需要协调他们的动作.如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互 ...

  5. 201771010128王玉兰《面向对象程序设计(Java)》第十六周学习总结

    第一部分:理论基础 1.线程的概念 进程:进程是程序的一次动态执行,它对应了从代码加 载.执行至执行完毕的一个完整过程.  多线程:多线程是进程执行过程中产生的多条执行线索.  线程:线程是比进程执行 ...

  6. poi——读取excel数据

    单元格类型 读取Excel数据 package com.java.test.poi; import java.io.File; import java.io.FileInputStream; impo ...

  7. oracle计算两日期相差多少秒,分钟,小时,天,周,月,年

    --计算两个时间差相差多少秒select ceil((sysdate-t.transdate)* 24 * 60 * 60),t.transdate,sysdate from esc_trans_lo ...

  8. 谈谈对ThreadLocal类的理解

    源码中对于ThreadLocal类的解释是: /** * This class provides thread-local variables. These variables differ from ...

  9. Docker容器启动时初始化Mysql数据库

    1. 前言 Docker在开发中使用的越来越多了,最近搞了一个Spring Boot应用,为了方便部署将Mysql也放在Docker中运行.那么怎么初始化 SQL脚本以及数据呢? 我这里有两个传统方案 ...

  10. PAT 1032 Sharing (25分) 从自信到自闭

    题目 To store English words, one method is to use linked lists and store a word letter by letter. To s ...