65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Vue.js 和 ASP.NET Core MVC 实现 CQRS 模式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (9投票s)

2020年3月16日

CPOL

7分钟阅读

viewsIcon

18739

本文主要关注 CQRS 模式。我们将介绍如何使用 MediatR 实现 CQRS 模式。还将介绍如何在 ASP.NET MVC Core 和 Vue.Js 中使用 CQRS 模式。

引言

如果您是一名软件专业人士,那么您应该熟悉软件增强和维护工作。这是软件开发生命周期的一部分,您可以在其中纠正错误、删除/增强现有功能。如果使用软件架构模式,选择正确的技术,了解未来的行业趋势,考虑当前和未来的资源可靠性/可用性,在代码中使用设计模式/原则,重用代码并为未来的扩展保留选项等,那么软件维护成本就可以降到最低。总之,如果您在应用程序中使用任何已知的软件架构模式,那么其他人就能轻松理解您应用程序的结构/组件设计。我将演示一个使用 MediatR 在 ASP.NET Core MVC 中结合 vue.js 实现 CQRS 模式的示例项目。

涵盖主题

  • CQRS 模式的实现
  • 配置 ASP.NET Core 的 MediatR 包
  • 使用 MediatR 实现数据读/写处理程序
  • 将 MediatR 集成到 API 控制器/控制器
  • 使用 MediatR 传递命令/查询对象
  • 将图像转换为字节数组/字节数组转换为 base64 字符串

必备组件

深入了解基础知识

  • CQRS 模式:简而言之,命令-查询职责分离 (CQRS) 模式将返回数据而不更改数据库/系统的 READ 查询操作与更改数据库/系统中数据的 WRITE 命令(插入/更新/删除)操作分开。切勿混合读写操作。
  • 中介者模式:这是一种设计模式,当您需要集中控制和协调多个类/对象之间的通信时,就会影响到代码。例如,Facebook Messenger 就是一个将消息发送给多个用户的中介。
  • MVC 模式:这是一种应用程序的架构模式,其中模型、视图和控制器根据其职责进行分离。模型是对象的 数据;视图向用户展示数据并处理用户交互;控制器充当视图和模型之间的中介。

应用程序解决方案结构

本项目的主要目标是解释 CQRS 架构模式。我计划实现一个小型单页应用程序 (SPA) 项目。技术选择很重要,应根据您的需求进行选择。对于用户界面 (UI) 和表示逻辑层 (PLL),我选择了 ASP.NET Core MVC 和 Vue.js(JavaScript 框架)。对于数据访问,我选择了 Entity Framework (EF) Core Code First 方法,并将其实现到数据访问层 (DAL) 中。为了尽量缩短本文的篇幅,我将故意省略单独的业务逻辑层 (BLL) 和其他层。

图片上传与显示应用程序

在此项目中,考虑 CQRS 模式,首先,我将上传图片文件并将其保存到数据库;这将解释写命令操作。其次,我将从数据库读取数据来显示图片;这将解释读查询操作。

我在同一解决方案中添加了两个单独的项目。一个是名为“HR.App.DAL.CQRS”的 ClassLibrary (.NET Core) 项目,另一个是名为“HR.App.Web”的 ASP.NET Core Web 应用程序项目。

MVC 与 JS 框架之间的通信设计

在此阶段,我将重点介绍 UI/PLL 以及它们如何相互通信。请看下图。我在视图和 Web API 控制器之间放置了 JS 框架。

根据上图,ASP.NET MVC 控制器渲染视图。JS 将来自视图的 HTTP 请求(GET/PUT/POST/DELETE)传递给 Web API 控制器,同时还将来自 Web API 控制器的响应数据(JSON/XML)更新到视图。

注意:我假设您知道如何在 ASP.NET Core MVC 项目中配置 Vue.js。如果您需要有关在 ASP.NET Core 中配置 Vue.js 的分步说明以及示例项目,则建议阅读:将 Vue.js 集成/配置到 ASP.NET Core 3.1 MVC 中

在 SPA 中,将 UI 和 PLL 添加到表示层

在“HR.App.Web”项目中,添加 Index.cshtml 视图和 Index.cshtml.js 文件。我在 Index.cshtml 中为图片上传和图片查看标签/控件添加了以下 HTML 脚本。这些脚本与读写操作相关。

@{
    ViewData["Title"] = "Home Page";
}

    <div id="view" v-cloak>
        <div class="card">
            <div class="card-header">
                <div class="row">
                    <div class="col-10">
                        <h5>Upload File</h5>
                    </div>
                </div>
            </div>
            <div class="card-body">
                <dropzone id="uploadDropZone" url="/HomeApi/SubmitFile"
                          :use-custom-dropzone-options="useUploadOptions"
                          :dropzone-options="uploadOptions" v-on:vdropzone-success="onUploaded"
                          v-on:vdropzone-error="onUploadError">
                    <!-- Optional parameters if any! -->
                    <input type="hidden" name="token" value="xxx">
                </dropzone>
            </div>
        </div>
        <br/>
        <div class="card">
            <div class="card-header">
                <div class="row">
                    <div class="col-10">
                        <h5>Image viewer</h5>
                    </div>
                </div>
            </div>
            <div class="card-body">
                <img v-bind:src="imageData" v-bind:alt="imageAlt" style="width:25%;
                 height:25%; display: block;margin-left: auto; margin-right: auto;" />
                <hr />
                <div class="col-6">
                    <button id="viewFile" ref="viewFileRef" type="button" 
                     class="btn btn-info" v-on:click="viewImageById">View Image</button>
                    <button type="button" class="btn btn-info" v-on:click="onClear">
                     Clear</button>
                </div>

            </div>
        </div>
    </div>
<script type="text/javascript">
</script>
<script type="text/javascript" src="~/dest/js/home.bundle.js" 
        asp-append-version="true"></script>

Index.cshtml.js 文件中添加以下 Vue.js 脚本以进行 HTTP GETPOST 请求

import Vue from 'vue';
import Dropzone from 'vue2-dropzone';

document.addEventListener('DOMContentLoaded', function (event) {
    let view = new Vue({
        el: document.getElementById('view'),
        components: {
            "dropzone": Dropzone
        },
        data: {
            message: 'This is the index page',
            useUploadOptions: true,
            imageData: '',
            imageAlt: 'Image',
            imageId: 0,
            uploadOptions: {
                acceptedFiles: "image/*",
                //acceptedFiles: '.png,.jpg',
                dictDefaultMessage: 'To upload the image click here. Or, drop an image here.',
                maxFiles: 1,
                maxFileSizeInMB: 20,
                addRemoveLinks: true
            }
        },
        methods: {
            onClear() {
                this.imageData = '';
            },
            viewImageById() {
                try {
                    this.dialogErrorMsg = "";
                    //this.imageId = 1;
                    var url = '/HomeApi/GetImageById/' + this.imageId;

                    console.log("===URL===>" + url);
                    var self = this;

                    axios.get(url)
                        .then(response => {
                            let responseData = response.data;

                            if (responseData.status === "Error") {
                                console.log(responseData.message);
                            }
                            else {
                                self.imageData = responseData.imgData;
                                console.log("Image is successfully loaded.");
                            }
                        })
                        .catch(function (error) {
                            console.log(error);
                        });
                } catch (ex) {

                    console.log(ex);
                }
            },
            onUploaded: function (file, response) {
                if (response.status === "OK" || response.status === "200") {
                    let finalResult = response.imageId;
                    this.imageId = finalResult;
                    console.log('Successfully uploaded!');
                }
                else {
                    this.isVisible = false;
                    console.log(response.message);
                }
            },
            onUploadError: function (file, message, xhr) {
                console.log("Message ====> " + JSON.stringify(message));
            }
        }
    });
});

在此 JS 文件中,“viewImageById”方法用于读取请求,“onUploaded”方法用于写入请求。屏幕看起来像这样

用于数据读写操作的数据访问层

我假设您了解 EF Core Code First 方法,并且您拥有域模型和上下文类。您可能使用了不同的方法。在这里,我将实现读写操作以进行数据访问。请看下图以了解整个应用程序流程。

包安装

在“HR.App.DAL.CQRS”项目中,我使用 NuGet 包管理器安装了 MediatR.Extensions.Microsoft.DependencyInjectionMicrosoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer

我需要 MediatR 来实现命令和查询处理程序。我将使用 MediatR.Extensions for ASP.NET Core 来解析依赖项。

读取查询处理程序实现

为了从数据库获取图像,我添加了 GetImageQuery.cs 类,其中包含以下代码

using HR.App.DAL.CQRS.Models;
using HR.App.DAL.CQRS.ViewModel;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace HR.App.DAL.CQRS.Query
{
    public class GetImageQuery : IRequest<ImageResponse>
    {
        public int ImageId { get; set; }
    }

    public class GetImageQueryHandler : IRequestHandler<GetImageQuery, ImageResponse>
    {
        private readonly HrAppContext context;

        public GetImageQueryHandler(HrAppContext context)
        {
            this.context = context;
        }

        public async Task<ImageResponse> Handle(GetImageQuery request, CancellationToken cancellationToken)
        {
            ImageResponse imageResponse = new ImageResponse();

            try
            {
                UploadedImage uploadedImage = await context.UploadedImage.AsNoTracking()
                    .Where(x => x.ImageId == request.ImageId).SingleAsync();
                
                if (uploadedImage == null)
                {
                    imageResponse.Errors.Add("No Image found!");
                    return imageResponse;
                }

                imageResponse.UploadedImage = uploadedImage;
                imageResponse.IsSuccess = true;
            }
            catch (Exception exception)
            {
                imageResponse.Errors.Add(exception.Message);
            }

            return imageResponse;
        }
    }
}

GetImageQuery 类继承了 IRequest<ImageResponse>ImageResponse 类型表示响应。另一方面,GetImageQueryHandler 类继承了 IRequestHandler<GetImageQuery, ImageResponse>,其中 GetImageQuery 类型表示请求/消息,ImageResponse 类型表示响应/输出。这个 GetImageQueryHandler 类实现了 Handle 方法,该方法返回 ImageResponse 对象。

写入命令处理程序

为了将图像保存到数据库,我添加了 SaveImageCommand.cs 类,其中包含以下代码

using HR.App.DAL.CQRS.Models;
using HR.App.DAL.CQRS.ViewModel;
using MediatR;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace HR.App.DAL.CQRS.Command
{
    public class SaveImageCommand : IRequest<ResponseResult>
    {
        public UploadedImage UploadedImage { get; set; }
    }

    public class SaveImageCommandHandler : IRequestHandler<SaveImageCommand, ResponseResult>
    {
        private readonly HrAppContext context;

        public SaveImageCommandHandler(HrAppContext context)
        {
            this.context = context;
        }

        public async Task<ResponseResult> Handle
               (SaveImageCommand request, CancellationToken cancellationToken)
        {
            using (var trans = context.Database.BeginTransaction())
            {
                ResponseResult response = new ResponseResult();

                try
                {
                    context.Add(request.UploadedImage);
                    await context.SaveChangesAsync();
                    trans.Commit();
                    response.IsSuccess = true;
                    response.ImageId = request.UploadedImage.ImageId;
                }
                catch (Exception exception)
                {
                    trans.Rollback();
                    response.Errors.Add(exception.Message);
                }

                return response;
            }
        }
    }
}

将 MediatR 集成到 API 控制器/控制器

在“HR.App.Web”项目中,我使用 NuGet 包管理器安装了 MediatR.Extensions.Microsoft.DependencyInjection。它可能会要求您允许安装依赖包(Microsoft.Extensions.DependencyInjection.Abstractions)。

配置 MediatR

Startup.cs 类的 ConfigureServices 方法中添加以下代码以注册 MediatR

services.AddMediatR(typeof(Startup));

如果所有处理程序类都位于 ASP.NET Core MVC 项目(例如,“HR.App.Web”)的同一程序集中,则此配置效果很好。如果您在同一项目解决方案中使用不同的程序集(例如 HR.App.DAL.CQRS),则需要注释掉上面的代码并添加以下代码

services.AddMediatR(typeof(GetImageQuery));

如果您在同一项目解决方案中使用多个程序集(例如 AssemblyAAnotherAssemblyB),则需要添加所有程序集的类型

services.AddMediatR(typeof(AssemblyAClassName), typeof(AnotherAssemblyBClassName));

将依赖项注入 Web API 控制器/控制器

HomeApiController.cs 类中,我添加了“SubmitFile”和“GetImageId”操作,这些操作将使用 MediatR 发送命令和查询对象。下面的代码表明我在 HomeApiController 构造函数中注入了一个 Mediator 对象依赖项。顺便说一句,Web API 控制器返回 Json/XML 数据。

控制器返回视图。在 HomeController.cs 中,我仅使用默认的 Index 操作来返回视图。

如何发送命令/查询请求

我们可以使用中介对象发送命令/查询对象:mediator.Send(command/query Object); 请看下面的代码

完整的代码如下

using HR.App.DAL.CQRS.Command;
using HR.App.DAL.CQRS.Models;
using HR.App.DAL.CQRS.Query;
using HR.App.DAL.CQRS.ViewModel;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.IO;
using System.Threading.Tasks;

namespace HR.App.Web.Controllers
{
    [Route("api")]
    [ApiController]
    public class HomeApiController : Controller
    {
        private readonly IMediator mediator;

        public HomeApiController(IMediator mediator)
        {
            this.mediator = mediator;
        }

        [HttpPost("/HomeApi/SubmitFile")]
        public async Task<ActionResult> SubmitFile(IFormFile file)
        {
            try
            {
                #region Validation & BL
                if (file.Length == 0)
                {
                    return Json(new { status = "Error", message = "Image is not found!" });
                }

                if (!file.ContentType.Contains("image"))
                {
                    return Json
                     (new { status = "Error", message = "This is not an image file!" });
                }

                string fileName = file.FileName;

                if (file.FileName.Length > 50)
                {
                    fileName = string.Format($"{file.FileName.Substring(0, 45)}
                               {Path.GetExtension(file.FileName)}");
                }
                #endregion

                byte[] bytes = null;

                using (BinaryReader br = new BinaryReader(file.OpenReadStream()))
                {
                    bytes = br.ReadBytes((int)file.OpenReadStream().Length);
                }

                UploadedImage uploadedImage = new UploadedImage()
                {
                    ImageFileName= fileName,
                    FileContentType = file.ContentType,
                    ImageContent = bytes
                };

                SaveImageCommand saveImageCommand = new SaveImageCommand()
                {
                    UploadedImage = uploadedImage
                };

                ResponseResult responseResult = await mediator.Send(saveImageCommand);

                if (!responseResult.IsSuccess)
                {
                    return Json(new { status = "Error", 
                           message = string.Join("; ", responseResult.Errors) });
                }

                return Json(new { status = "OK", imageId= responseResult.ImageId });
            }
            catch (Exception ex)
            {
                return Json(new { status = "Error", message = ex.Message });
            }
        }

        [HttpGet("/HomeApi/GetImageById/{imageId:int}")]
        public async Task<ActionResult> GetImageById(int imageId)
        {
            try
            {
                ImageResponse imageResponse = await mediator.Send(new GetImageQuery()
                {
                    ImageId = imageId
                });

                UploadedImage uploadedImage = imageResponse.UploadedImage;

                if (!imageResponse.IsSuccess)
                {
                    return Json(new { status = "Error", 
                           message = string.Join("; ", imageResponse.Errors) });
                }
                if (uploadedImage.FileContentType == null || 
                            !uploadedImage.FileContentType.Contains("image"))
                {
                    return Json(new { status = "Error", 
                           message = string.Join("; ", imageResponse.Errors) });
                }

                string imgBase64Data = Convert.ToBase64String(uploadedImage.ImageContent);
                string imgDataURL = string.Format("data:{0};base64,{1}", 
                    uploadedImage.FileContentType, imgBase64Data);
                
                return Json(new { status = "OK", imgData = imgDataURL });
            }
            catch (Exception ex)
            {
                return Json(new { status = "Error", message = ex.Message });
            }
        }
    }
}

在上面的代码中,“SubmitFile”操作接收带有图像的 IFormFile 对象的 HttpPost 请求,并将其转换为字节数组。最后,它使用中介对象发送 SaveImageCommand 对象。

另一方面,GetImageById 操作接收带有 imageIdHttpGet 请求并使用中介对象发送查询请求。最后,它将图像内容从字节数组处理为 base64 字符串以发送到视图。

总之,现在运行项目,您将看到以下屏幕用于上传和查看图像

在很多情况下,我们需要读写操作来完成单个任务。例如,假设我需要更新对象的几个属性。首先,我可以调用查询操作来读取对象,然后在输入所需值后,调用更新操作将其存储。在这种情况下,切勿将读查询和写命令混入同一操作/处理程序中。将它们分开,这样将来修改起来会更容易。

历史

  • 2020年3月16日:初始版本
© . All rights reserved.