Asp.net core使用MediatR程式內釋出/訂閱

GUOKUN發表於2019-06-10

1、背景

  最近,一個工作了一個月的同事離職了,所做的東西懟了過來。一看程式碼,慘不忍睹,一個方法六七百行,啥也不說了吧,實在沒法兒說。介紹下業務場景吧,一個公共操作A,業務中各個地方都會做A操作,正常人正常思維應該是把A操作提取出來封裝,其他地方呼叫,可這哥們兒偏偏不這麼幹,程式碼到處複製。仔細分析了整個業務之後,發現是一個典型的事件/訊息驅動型,或者叫釋出/訂閱型的業務邏輯。鑑於系統是單體的,所以想到利用程式內釋出/訂閱的解決方案。記得很久之前,做WPF時候,用過Prism的EventAggregator(是不是暴露年齡了。。。),那玩意兒不知道現在還在不在,支不支援core,目前流行的是MediatR,跟core的整合也好,於是決定採用MediatR。

2.Demo程式碼

Startup服務註冊:

 public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
            services.AddScoped<IService1, Service1>();
            services.AddScoped<IService2, Service2>();
            services.AddScoped<IContext, Context>();
            services.AddMediatR(typeof(SomeEventHandler).Assembly);
        }

 服務1:

public class Service1 : IService1
    {
        private readonly ILogger _logger;
        private readonly IMediator _mediator;
        private readonly IContext _context;
        private readonly IService2 _service2;

        public Service1(ILogger<Service1> logger,
            IMediator mediator,
            IContext context)
        {
            _logger = logger;
            _mediator = mediator;
            _context = context;
            //_service2 = service2;
        }

        public async Task Method()
        {
            _context.CurrentUser = "test";
            //await _service2.Method();
            //_service2.Method();
            await _mediator.Publish(new SomeEvent());
            //_mediator.Publish(new SomeEvent());

            await Task.CompletedTask;
        }
    }

  可以看到,在服務1的method方法中,釋出了SomeEvent事件訊息。

服務2程式碼:

public class Service2 : IService2
    {
        private readonly ILogger _logger;
        private readonly IContext _context;

        public Service2(ILogger<Service2> logger,
            IContext context)
        {
            _logger = logger;
            _context = context;
        }

        public async Task Method()
        {
            _logger.LogDebug("當前使用者:{0}", _context.CurrentUser);
            await Task.Delay(5000);
            //_logger.LogDebug("當前使用者:{0}", _context.CurrentUser);
            _logger.LogDebug("Service2 Method at :{0}", DateTime.Now);
        }
    }

 

解釋下,為啥服務2 Method方法中,要等待5秒,因為實際專案中,有這麼一個操作,把一個壓縮程式包傳遞到遠端,然後在遠端程式碼操作IIS建立站點,這玩意兒非常耗時,大概要1分多鐘,這裡我用5s模擬,意思意思。這個5s至關重要,待會兒會詳述。

再看事件訂閱Handler:

public class SomeEventHandler : INotificationHandler<SomeEvent>, IDisposable
    {
        private readonly ILogger _logger;
        private readonly IServiceProvider _serviceProvider;
        private readonly IService2 _service2;

        public SomeEventHandler(ILogger<SomeEventHandler> logger,
            IServiceProvider serviceProvider,
            IService2 service2)
        {
            _logger = logger;
            _serviceProvider = serviceProvider;
            _service2 = service2;
        }

        public void Dispose()
        {
            _logger.LogDebug("Handler disposed at :{0}", DateTime.Now);
        }

        public async Task Handle(SomeEvent notification, CancellationToken cancellationToken)
        {
            await _service2.Method();
            //using (var scope = _serviceProvider.CreateScope())
            //{
            //    var service2 = scope.ServiceProvider.GetService<IService2>();
            //    await service2.Method();
            //}
        }
    }

 

然後,我們的入口Action:

[HttpGet("test")]
        public async Task<ActionResult<string>> Test()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendFormat("開始時間:{0}", DateTime.Now);
            sb.AppendLine();
            await _service1.Method();
            sb.AppendFormat("結束時間:{0}", DateTime.Now);
            sb.AppendLine();

            return sb.ToString();
        }

 

至此,Demo要乾的事情,脈絡應該很清晰了:控制器接收HTTP請求,然後呼叫Service1的Method,service1的Method又釋出訊息,訊息處理器接收到訊息,呼叫Service2的Method完成後續操作。我們執行起來看下:

  

http請求開始到結束,耗時5s,看似沒問題。我們看系統輸出日誌:

 

 Service2的Method方法也確實被訂閱執行了。

3.問題

  上述一切的一切,看似沒問題。執行成功沒?成功了。對不對?好像也對。有沒問題?大大的問題!HTTP從開始到結束,要耗時5s,實際專案中,那是一分鐘,這整整一分鐘,你要前端掛起等待麼一直?理論上,這種耗時的後端操作,合理做法是HTTP迅速響應前端,並返給前端業務ID,前端根據此業務ID長輪詢後端查詢操作結果狀態,直至此操作完成,決不能一直卡死的,否則互動效果不說,超過一定時間,HTTP請求會直接超時的!這就必須動刀子了,將Service2操作後臺任務化且不等待。Service1的Method程式碼調整如下:

public async Task Method()
        {
            _context.CurrentUser = "test";
            //await _service2.Method();
            //_service2.Method();
            //await _mediator.Publish(new SomeEvent());
            _mediator.Publish(new SomeEvent());

            await Task.CompletedTask;
        }

 

見註釋前後,改進地方只有一處,釋出事件程式碼去掉了await,這樣系統釋出事件之後,便不會等待Service2而是繼續執行並立刻響應HTTP請求。好,我們再來執行看下效果:

 我們看到,系統立即響應了HTTP請求(22:40:15),5s之後,Service2才執行完成(22:40:20)。看似又沒問題了。那是不是真的沒問題呢?我們注意,Service1和Service2中,都注入了一個Context上下文物件,這個物件是我用來模擬一些Scope型別物件,例如DBContext的,程式碼如下:

public class Context : IContext, IDisposable
    {
        private bool _isDisposed = false;

        private string _currentUser;
        public string CurrentUser
        {
            get
            {
                if (_isDisposed)
                {
                    throw new Exception("Context disposed");
                }

                return _currentUser;
            }
            set
            {
                if (_isDisposed)
                {
                    throw new Exception("Context disposed");
                }

                _currentUser = value;
            }
        }

        public void Dispose()
        {
            _isDisposed = true;
        }
    }

 

裡邊就一個屬性,當前上下文使用者,並實現了Dispose模式,並且當前上下文被釋放時,對該上下文物件任何操作將引發異常。從上文的Service1及Service2截圖中,我們看到了,兩個服務均注入了這個context物件,Service1設定,Service2中獲取。現在我們將Service2的Method方法稍作調整,如下:

public async Task Method()
        {
            //_logger.LogDebug("當前使用者:{0}", _context.CurrentUser);
            await Task.Delay(5000);
            _logger.LogDebug("當前使用者:{0}", _context.CurrentUser);
            _logger.LogDebug("Service2 Method at :{0}", DateTime.Now);
        }

  調整隻有一處,就是獲取當前上下文使用者的操作,從5s延時之前,放到了5s延時之後。我們再來看看效果:

http請求上看,貌似沒問題,立即響應了,是吧。我們再看看程式日誌輸出:

WFT!Service2 Method沒成功執行,給了我一個異常。我們看看這個異常:

 

 Context dispose異常,就是說上下文這時候已經被釋放掉,對它任何操作都無效並引發異常。很容易想到,這裡就是為了模擬DBContext這種通常為Scope型別的物件生命週期,這種吊毛它就這樣。為啥會釋放?因為HTTP請求結束那會兒,core執行時就會Dispose相應scope型別物件(注意,釋放,不一定是銷燬,具體銷燬時間不確定)。那麼,怎麼解決?如果對基於DI生命週期比較熟悉,就會知道,這兒應該基於HTTP 的Scope之外,單獨起一個Scope了,兩個scope互補影響,HTTP對應的scope結束,另外的照常執行。我們將Handler處調整如下:

public async Task Handle(SomeEvent notification, CancellationToken cancellationToken)
        {
            //await _service2.Method();
            using (var scope = _serviceProvider.CreateScope())
            {
                var service2 = scope.ServiceProvider.GetService<IService2>();
                await service2.Method();
            }
        }

  無非就是Handle中單獨起了一個Scope。我們再看執行效果:

OK,HTTP請求23:02:58響應,Service2 Method 23:03:03執行完成。至此,問題才算得到解決。

順便提一下,大家注意看截圖,當前使用者null,因為scope之後,原來的設定過CurrentUser的context已經釋放掉了,新開的scope中注入的context是另外的,所以沒任何資訊。這裡你可能會問了,那我確實需要傳遞上下文怎麼辦?答案是,訂閱事件,本文中SomeEvent未定義任何資訊,如果你需要傳遞,做對應調整即可,比較簡單,也不是重點,不做贅述。

 

4、總結

  感覺,沒什麼好總結的。紮實,細心,實踐,沒什麼解決不了的。

 

相關文章