2023年2月

在系统处理中,有时候需要发送邮件通知用户,如新增用户的邮件确认,密码找回,以及常规订阅消息、通知等内容处理,都可以通过邮件的方式进行处理。本篇随笔介绍结合VUE+Element 前端,实现系统的邮件参数配置管理,以及基于邮件模板的方式进行邮件的发送。

1、邮件参数的配置管理

邮件参数一般需要配置如用户名、密码、邮件地址,显示名称,以及其他邮件所需的必要配置,一般我们可以通过界面管理的方式进行常规的参数配置,如下界面所示。

其中的登录密码,现在一般是授权登录密码,而不是原始的账号密码了。以163为例,可以在设置中添加一个授权密码。

前端参数的配置管理,我们构建一个Api类,用于调用ABP后端的Api接口,如下所示。

查看窗体中显示邮件参数数据的代码如下所示。

showAbpEmail() {this.resetForm('emailForm')
setting.GetEmailSettingsForApplication().then(data
=>{if(data.result) {
Object.assign(
this.abpEmailForm, data.result)
}
this.isAbpEmail = true //编辑状态 })
},

2、ABP后端邮件的发送处理

系统参数配置完成后,我们需要根据这些邮件参数进行邮件的发送,ABP框架基于.net core 的实现,我们发送邮件,需要添加一个Abp.MailKit的依赖,如下所示。

然后在模块的依赖上,添加对应的AbpMailkitModule的依赖即可。

一般来说,我们发送邮件,还需要重写DefaultMailKitSmtpBuilder的配置处理项,以便自定义发送处理过程。

    /// <summary>
    ///重写默认的SmtpBuilder类/// </summary>
    public classMyMailKitSmtpBuilder : DefaultMailKitSmtpBuilder
{
private readonlyISmtpEmailSenderConfiguration _smtpEmailSenderConfiguration;private readonlyIAbpMailKitConfiguration _abpMailKitConfiguration;publicMyMailKitSmtpBuilder(ISmtpEmailSenderConfiguration smtpEmailSenderConfiguration, IAbpMailKitConfiguration abpMailKitConfiguration)
:
base(smtpEmailSenderConfiguration, abpMailKitConfiguration)
{
_smtpEmailSenderConfiguration
=smtpEmailSenderConfiguration;
_abpMailKitConfiguration
=abpMailKitConfiguration;
}
/// <summary> ///配置发送处理/// </summary> /// <param name="client"></param> protected override voidConfigureClient(SmtpClient client)
{
client.CheckCertificateRevocation
= false;
client.ServerCertificateValidationCallback
= (sender, certificate, chain, errors) => true;

client.Connect(_smtpEmailSenderConfiguration.Host, _smtpEmailSenderConfiguration.Port, GetSecureSocketOption());
if(_smtpEmailSenderConfiguration.UseDefaultCredentials)
{
return;
}
var username =_smtpEmailSenderConfiguration.UserName;var password =_smtpEmailSenderConfiguration.Password;//password = SimpleStringCipher.Instance.Decrypt(originalPass);//如果不重写自定义配置MySmtpEmailSenderConfiguration,那么密码需要解密 client.Authenticate(username, password);//base.ConfigureClient(client); }
}

但是由于基类处理中,默认的邮件配置密码是直接从数据库读取信息的,没有进行加密,如基类SmtpEmailSenderConfiguration的实现如下。

但是我们应用程序的设置信息,密码是经过加密过的,因此需要重写这个配置项,进行对应的密码解密。

    /// <summary>
    ///邮件发送参数配置类/// </summary>
    public classMySmtpEmailSenderConfiguration : SmtpEmailSenderConfiguration
{
public MySmtpEmailSenderConfiguration(ISettingManager settingManager) : base(settingManager)
{

}
/// <summary> ///重写密码处理,需要解密密码/// </summary> public override string Password=>SimpleStringCipher.Instance.Decrypt(GetNotEmptySettingValue(EmailSettingNames.Smtp.Password));
}

最后,在Module中初始化中处理下对应的自定义发送和自定义配置项的处理类。

这样默认注入的发送邮件的接口就正常了,我们接下来就是根据邮件的模板进行内容发送即可。

3、邮件模板的处理

一般的邮件模板,是一个独立的文件方式,文件中定义一些预设的内容,然后实际处理的时候,替换这些变量即可。

邮件模板的内容替换,我喜欢用基于模板引擎的方式处理变量的替换,一般我用NVelocity来进行处理。

我在早期介绍过一些关于NVelocity的知识,需要可以参考:


使用NVelocity生成内容的几种方式


强大的模板引擎开源软件NVelocity》


Database2Sharp版本更新之自定义模板生成


使用NVelocity0.5实现服务器端页面自动生成

例如,我在文本中定义一个连接:http://www.iqidi.com/h5/EmailValidate?callback=${callback} 其中${callback} 就是变量定义,可以在运行中进行变量替换的。

我们在Host项目中定义一些邮件文件模板,如下所示。

然后在使用的应用服务类中注入对应的邮件发送接口以供使用。

具体的邮件发送,就是读取模板内容,进行替换变量,然后调用接口进行邮件的发送即可。

        /// <summary>
        ///发送校验的电子邮件/// </summary>
        /// <returns></returns>
        public asyncTask SendEmailValidate(SendEmailActivationLinkDto input)
{
var user = awaitGetUserByChecking(input.EmailAddress);//user.SetNewEmailConfirmationCode(); #region 根据模板生成邮件正文 //使用相对路径进行构造处理 string template = string.Format("/UploadFiles/Email/EmailValidate.html");var helper = newNVelocityHelper(template);//${标题} ${内容} ${称呼} ${日期} var tilte = "邮箱验证通知";
helper.AddKeyValue(
"title", tilte);
helper.AddKeyValue(
"callname", "");
helper.AddKeyValue(
"date", DateTime.Now.ToLongDateString());//邮箱校验码 var callback =Guid.NewGuid().ToString();
helper.AddKeyValue(
"callback", callback);var html =helper.ExecuteString();#endregion #region 构建邮件内容对象 发送邮件 string toEmail = "wuhuacong@163.com";await _emailSender.SendAsync(newSystem.Net.Mail.MailMessage
{
To
={ toEmail },
Subject
=tilte,
Body
=html,
IsBodyHtml
= true});#endregionLogHelper.Logger.Info(string.Format("校验邮件发送给:{0}, {1}", toEmail, "发送邮件成功"));
}

为了处理模板内容的方便,我们把规则放在辅助类 NVelocityHelper 中处理即可。

上面就是整个发送模板邮件的过程代码了。

发送邮件成功后,我们可以在邮箱中查看到对应的邮件,我们一般根据自己的业务需要定义不同的邮件模板即可。

邮件发送成功后,查看邮件效果如下所示。

为了方便读者理解,我列出一下前面几篇随笔的连接,供参考:

循序渐进VUE+Element 前端应用开发(1)--- 开发环境的准备工作

循序渐进VUE+Element 前端应用开发(2)--- Vuex中的API、Store和View的使用

循序渐进VUE+Element 前端应用开发(3)--- 动态菜单和路由的关联处理

循序渐进VUE+Element 前端应用开发(4)--- 获取后端数据及产品信息页面的处理

循序渐进VUE+Element 前端应用开发(5)--- 表格列表页面的查询,列表展示和字段转义处理


循序渐进VUE+Element 前端应用开发(6)--- 常规Element 界面组件的使用

循序渐进VUE+Element 前端应用开发(7)--- 介绍一些常规的JS处理函数

循序渐进VUE+Element 前端应用开发(8)--- 树列表组件的使用


循序渐进VUE+Element 前端应用开发(9)--- 界面语言国际化的处理

循序渐进VUE+Element 前端应用开发(10)--- 基于vue-echarts处理各种图表展示

循序渐进VUE+Element 前端应用开发(11)--- 图标的维护和使用


循序渐进VUE+Element 前端应用开发(12)--- 整合ABP框架的前端登录处理


循序渐进VUE+Element 前端应用开发(13)--- 前端API接口的封装处理

循序渐进VUE+Element 前端应用开发(14)--- 根据ABP后端接口实现前端界面展示

循序渐进VUE+Element 前端应用开发(15)--- 用户管理模块的处理

循序渐进VUE+Element 前端应用开发(16)--- 组织机构和角色管理模块的处理

循序渐进VUE+Element 前端应用开发(17)--- 菜单管理

循序渐进VUE+Element 前端应用开发(18)--- 功能点管理及权限控制

VUE+Element 前端应用开发框架功能介绍

循序渐进VUE+Element 前端应用开发(19)--- 后端查询接口和Vue前端的整合

使用代码生成工具快速生成基于ABP框架的Vue+Element的前端界面

循序渐进VUE+Element 前端应用开发(20)--- 使用组件封装简化界面代码

循序渐进VUE+Element 前端应用开发(21)--- 省市区县联动处理的组件使用

循序渐进VUE+Element 前端应用开发(22)--- 简化main.js处理代码,抽取过滤器、全局界面函数、组件注册等处理逻辑到不同的文件中

循序渐进VUE+Element 前端应用开发(23)--- 基于ABP实现前后端的附件上传,图片或者附件展示管理

循序渐进VUE+Element 前端应用开发(24)--- 修改密码的前端界面和ABP后端设置处理

循序渐进VUE+Element 前端应用开发(25)--- 各种界面组件的使用(1)

循序渐进VUE+Element 前端应用开发(26)--- 各种界面组件的使用(2)

电商商品数据库的设计和功能界面的处理

循序渐进VUE+Element 前端应用开发(27)--- 数据表的动态表单设计和数据存储

循序渐进VUE+Element 前端应用开发(28)--- 附件内容的管理

循序渐进VUE+Element 前端应用开发(29)--- 高级查询条件的界面设计

部署基于.netcore5.0的ABP框架后台Api服务端,以及使用Nginx部署Vue+Element前端应用

循序渐进VUE+Element 前端应用开发(30)--- ABP后端和Vue+Element前端结合的分页排序处理

循序渐进VUE+Element 前端应用开发(31)--- 系统的日志管理,包括登录日志、接口访问日志、实体变化历史日志

循序渐进VUE+Element 前端应用开发(32)--- 手机短信动态码登陆处理

循序渐进VUE+Element 前端应用开发(33)--- 邮件参数配置和模板邮件发送处理

使用代码生成工具快速开发ABP框架项目

使用Vue-TreeSelect组件实现公司-部门-人员级联下拉列表的处理

使用Vue-TreeSelect组件的时候,用watch变量方式解决弹出编辑对话框界面无法触发更新的问题

整理一下自己之前的Winform开发要点,以图文的方式展示一些关键性的技术特点,总结一下。

1、主体界面布局

2、权限管理系统

3、工作流模块

4、字典管理

5、通用的附件管理模块

6、系统模块化开发和集成

模块化类似小孩子搭积木的方式构建一个所要的形状,虽然软件和建筑在这方面肯定更加复杂化,但是模块化系统是大势所趋,也是简化开发、易于维护、提高系统健壮性的重要举措。

数据库方面

支持多种数据库,支持分拆数据库,同时使用多个数据库等方式。

Winform混合框架的开发架构

7、Database2Sharp代码生成工具辅助快速开发

8、报表打印和处理

整合使用FastReport、XtraReport、RDLC、Grid++等报表展示方式。

9、串口开发及Modbus

封装串口接口的开发,以及Modbus串口和网络连接的处理。

10、SignalR通讯开发

11、TX TextControl控件使用

12、配置管理界面

13、Web API的访问和接口处理

14、综合案例

提供各种各样的开发测试案例代码

以上是我整理这几年来写Winform开发框架的技术方面图片,都是来自我Winform开发框架或者Winform混合式开发框架中的特性总结,基本覆盖了重要Winform开发的技术要点和方向,不足之处希望大家指正。

在实际的业务中,往往还有很多需要使用Winform来开发应用系统的,如一些HIS、MIS、MES等系统,由于Winform开发出来的系统界面友好,响应快速,开发效率高等各方面原因,还有一些原因是独立的WInform应用系统方便部署使用,可以快速的在内部系统中安装使用,小型的系统往往不需要涉及到云服务等分布式的处理,那么界面的流畅使用、友好呈现、快速的开发响应等特点就有非常明显的优势了。

本篇不针对性的讨论Winform开发的优缺点,只是让大家了解常规Winform开发的一些处理场景,在满足需求的情况下,如何快速的开发Winform的应用系统。

1、基于某个特定的主体界面布局

采用一个特定的界面布局方式,这样可以快速搭建各个模块的应用,也是给客户第一好印象的开始,毕竟现在界面的处理越来越丰富,不过总体原则还是要以实用为主,易于找到功能入口。

如基于Ribbon的工具条的组织方式界面布局

或者基于树形列表的左侧处理方式

或者一个兼顾经典菜单模式和常规列表处理的方式布局

以上这些对于功能点比较多,而且需要分来管理的界面是非常不错的,而对应窗体,一般都是采用多文档窗口的方式,方便管理不同的界面数据。

而这些模块中,往往由我们自己的Winform开发框架、混合开发框架来统一提供,可以根据需要进行调整即可。而且对于菜单,一般在后端权限系统中配置即可,运行系统的时候就会动态加载,这种就是插件化管理的概念了,可以动态增加、维护某个模块的加载和展示。

2、设计数据库表

数据库设计基本上上我们开发者的基本功吧,一般就是根据需要设计好表的字段、备注、约束、索引等信息,虽然现在很多系统要求支持多种数据库,但一般情况下是用在一个类型数据库之上的,我们可以以常规方便使用的Sqlserver来设计数据库信息,如需要其他数据库支持的,到时候使用转换方式生成其他数据库设计文件即可。

一般数据库设计工具,我们采用的是使用比较广泛、又非常方便的PowerDesigner进行处理,我们使用它的好处就是可以随时进行调整数据库表信息,并且数据库脚本生成非常方便;另外我们可以先生成Sqlserver的数据库设计文件,如需要还可以转换为其他数据库的设计文件。

我们可以生成没有外键关系的数据表

但是每个表的字段,我们都建议加上中文备注信息,不仅仅是因为这样可以好理解表的关系,也方便代码生成的时候,附带上说明信息,以及界面生成的时候,作为中文标签处理。

也可以为了系统完整性,生成一些外键关系的表。

这样处理好系统表或者业务表后,我们可以利用PowerDesigner工具的功能快速的生成SQL脚本,用于在MS SQLServer上执行生成表。

如果我们以后有需要迁移到MySql数据库里面,那么可以利用Navicat Premium进行转义到Mysql数据库即可。

详细过程可以参考我之前的一篇随笔《
ABP框架使用Mysql数据库,以及基于SQLServer创建Mysql数据库的架构和数据

3、结合代码生成工具快速常规操作接口代码

对于一个新建的业务表,我们需要开发的需要底层的实现和界面层的展示,这些工作量也是非常巨大的,如果基于控件细粒度的处理,也是非常繁琐的工作,因此基于这些开发过程的考虑,我们引入了提高效率开发的代码生成工具Database2Sharp,专门为我们基于开发框架基础上的框架实现代码开发,和业务界面展示的快速开发。

代码生成工具,不仅能够让它生成我们常规开发的界面层以下的实现代码(包括BLL、DAL、Entity、IDAL等层,以及混合框架的WCF、Web API的实现层和调用封装层),以及界面层的调用代码。

有了这些的处理,我们可极大减轻工作量。

我们开发新业务表的处理没有发生变化,需要设计好自己的业务表,然后利用代码生成工具生成对应代码,最后合并到项目里面并调整界面为合适的展示方式。

对于一般的业务系统,可能都会涉及到不同数据库的使用,如SQLServer、Oracle、Mysql、PostgreSQL等数据库,那么这部分,可以通过使用微软企业库或者其他ORM方式进行处理,以实现系统对多种数据库的良好支持。

这些,我们利用代码生成工具来开发后端代码,已经对这些不同数据库的处理进行了相应的支持,使用起来就更加方便了。

Winform开发架构如下所示。

而对于混合方式的Winform开发,那么还设计Web API模块的部署,以及客户端对Web API调用的封装,如下所示。

4、结合代码生成工具快速生成界面代码并整合

利用代码生成,根据我们数据库的字段信息,可以快速生成相应的列表、编辑界面等常规界面,生成后进行相应的微调即可。

我们把常规的列表界面,新增、编辑、查看、导入等界面放在一起,除了列表页面,其他内容以弹出层对话框的方式进行处理,如下界面示意所示。

一般情况下,我们利用代码生成工具生成的界面都是需要调整的,但是界面的控件名称已经根据字段进行了对应,后台的数据显示、输入验证、数据保存等需要控件和实体类一一对应的关系,也已经做好了,所做的只需要把界面调整的更加好看即可。

所以,生成的界面大致情况如下所示。

首先,我们知道,DevExpress的LayoutControl布局控件,对于其中的控件位置,都可以在设计模式进行动态拖动调整的,如下所示。

另外,我们可以在设计模式下,控件的右上角上,调整控件为自己希望的类型,如下所示。

这样我们可以把生成的文本框,调整为下来列表,然后可以通过后端代码一行代码,进行字典的绑定。

这样就非常简化了我们对字典数据源的绑定操作了,非常方便易读,下面是其中一个功能界面的下拉列表展示。

根据需要调整控件、拖动位置,增加一些特殊的控件等,可以把界面整理的比较规范化、美观一些。

或者更多数据的人员信息的数据编辑界面效果如下所示,通过分门别类,我们可以有效管理看似很凌乱的数据内容了。

生成好后端代码和Winform界面代码,就需要根据框架的特点进行增量式的整合使用了

详细可以参考一下《
循序渐进开发WinForm项目(4)--Winform界面模块的集成使用

3、整合一些常有的项目模块

由于我们框架提供了很多基础的模块来给我们新业务开发提供便利,如公用类库、分页控件、字典管理、权限管理系统、自动升级、附件管理、人员管理、工作流管理等可以反复重用的模块。

有了这些模块的加持,我们开发项目很多基础的处理就不用所有的东西都从头来过。

软件和建筑工程很类似,都是需要构建一个庞大而功能完整的一个系统,而工程化也意味着需要多人协作,那么就需要把一个庞大的系统横向或者纵向划分为各个可以独立施工完成的模块,虽然各个模块之间有所衔接或者交互,但是基本上可以以模块化的方式来构建,这个也是工程化开发的精髓所在。

以一个软件管理系统为例,我们尽可能把精力焦点放在客户的业务需求上,而对于常规的如权限控制、字典管理等一些常用的内容,由于它们的处理逻辑在特定领域上基本上比较固定一些,可以尽可能独立并重复使用,

而有时候,以某个特定的业务来说,同时很多处理规则也是不变的,因此也可以以业务模块的方式来划分,从而类似通用模块的方式重复使用。

模块化类似小孩子搭积木的方式构建一个所要的形状,虽然软件和建筑在这方面肯定更加复杂化,但是模块化系统是大势所趋,也是简化开发、易于维护、提高系统健壮性的重要举措。

什么是Egg.js

Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。详细的了解可以参考Egg.js的官网:
https://eggjs.org/zh-cn/intro/

Egg.js 奉行『约定优于配置』,按照一套统一的约定进行应用开发,Egg 有很高的扩展性,可以按照团队的约定定制框架,团队内部采用这种方式可以减少开发人员的学习成本。

可以理解Egg.js是一个Node框架,同时它也是基于Koa框架基础上的框架,我们大概了解一下它的前身和主要特点即可。

它的特点有:

本篇随笔不是细说Egg.js 的详细内容,毕竟官网介绍还是比较清晰的,我们主要说使用它来做一个后端的API接口系统,后端肯定需要对数据库进行各种操作,用一个JS的方式来访问数据库,利用egg-sequelize插件,创建和数据库表进行绑定的模型进行操作,还是比较新鲜的,用了会发现确实很方便。用Egg.js来开发后端系统,相当于用前端的语言、做法,来开发后端系统了(虽然Egg.js 也可以用来做前端)。

我们知道,常规的Asp.net或者WebAPI 应用里面,一般有MVC,模型、视图、控制器这些对象,Egg.js 里面也有类似的概念,我们这里没有用用来做前端,那么可以不用它的视图(Egg.js 视图就是一个带变量的模板文件);
控制器就是我们这里用到需要为前端提供API入口和返回JSON的地方,类似我们Web API里面的控制器概念;模型这里可以理解为对数据库对象的封装对象吧;另外和我们常规前端开发一样(类似Vue+Element系统),获取数据的操作逻辑,我们可以封装在Service层,这样可以降低我们控制器里面的逻辑代码,同时也方便重用逻辑处理函数。MVC+Service的关系,大概如下所示。

2、 使用egg.js开发后端API接口系统所需插件

我依照官网的简单案例进行快速初始化,如下所示。

我们推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(
npm >=6.1.0
):

$ mkdir egg-example && cd egg-example
$ npm init egg
--type=simple
$ npm i

启动项目:

$ npm run dev

其实我们还需要一些额外的插件来跑起来,我的包依赖文件如下所示。

package.json

{"name": "example","version": "1.0.0","description": "## Development","dependencies": {"egg": "^2.10.0",
"egg-cors": "^2.2.3",
"egg-jwt": "^3.1.7",
"egg-mysql": "^3.0.0",
"egg-redis": "^2.4.0",
"egg-scripts": "^2.5.0",
"egg-sequelize": "^4.0.2",
"egg-view-nunjucks": "^2.3.0",
"moment": "^2.29.1",
"mysql2": "^2.2.5",
"node": "^15.10.0"
},"devDependencies": {"autod": "^3.0.1","autod-egg": "^1.0.0","egg-bin": "^4.15.0","egg-mock": "^3.19.2","eslint": "^4.18.1","eslint-config-egg": "^7.0.0","factory-girl": "^5.0.2","sequelize-cli": "^4.0.0"},

我们来看看红色部分的内容,其中

egg 是本身的框架需要的插件,这个是整个框架的核心基础;egg-scripts 这是部署eggjs项目的工具;

egg-corss 是跨域处理所需要的,用于设置csrf的配置等;

egg-jwt 是用于对用户身份认证的处理插件;

egg-mysql + Mysql2 是我们做Mysql数据库处理说需要的插件;

egg-redis 是我们用到redis操作,所需要的插件,可选。

egg-sequelize 是我们操作数据库的一个插件,提供很多方便的接口进行处理,可以搭配Mysql或者PostgreSQL、MS SQLServer数据库插件进行处理的

egg-view-nunjucks 是展示视图模板的一个插件。

moment 是一个日期处理插件,可以处理各种日期格式、转换的一个插件库。

大概就是这些,如果需要结合前端JS的处理插件,可以引入更多的内容,不过我们这里主要介绍后端访问Mysql数据库的处理操作,提供JSON数据接口的,基本上这些也够了。
另外,我们需要知道egg.js的目录很多是约定位置的,因此我们需要知道常规的几个文件夹的意义。我们简单了解下目录约定规范。
egg-project
├── package
.json
├── app
.js (可选)├── agent.js (可选)├── app
| ├── router
.js
│ ├── controller
│ | └── home
.js
│ ├── service
(可选)│ | └── user.js
│ ├── middleware
(可选)│ | └── response_time.js
│ ├──
view (可选)│ | └── home.tpl
│ └── extend
(可选)│ ├── helper.js (可选)├── config
| ├── plugin
.js
| ├── config
.default.js
│ ├── config
.prod.js

我们这里大概知道以上文件夹和文件的意思即可。

  • app/router.js
    用于配置 URL 路由规则,具体参见
    Router
  • app/controller/**
    用于解析用户的输入,处理后返回相应的结果,具体参见
    Controller
  • app/service/**
    用于编写业务逻辑层,可选,建议使用,具体参见
    Service
  • app/middleware/**
    用于编写中间件,可选,具体参见
    Middleware
  • app/extend/**
    用于框架的扩展,可选,具体参见
    框架扩展
  • config/config.{env}.js
    用于编写配置文件,具体参见
    配置
  • config/plugin.js
    用于配置需要加载的插件,具体参见
    插件
1)插件的配置
我们引入的插件模块,需要在app/plugin.js里面启用,如下代码所示。
app/plugin.js
'use strict';

exports.sequelize
={
enable:
true,
package:
'egg-sequelize',
};
exports.mysql
={
enable:
true,
package:
'egg-mysql',
};

exports.nunjucks
={
enable:
true,
package:
'egg-view-nunjucks'};
exports.redis
={
enable:
true,
package:
'egg-redis',
};
exports.jwt
={
enable:
true,
package:
'egg-jwt',
};

exports.cors
={
enable:
true,
package:
'egg-cors',
};

为了访问Mysql数据库,我们还需要在config/config.default.js文件中配置好对应的关系。

config/config.default.js

'use strict';

module.exports
= appInfo =>{
const config
= exports ={};//use for cookie sign key, should change to your own and keep security config.keys = appInfo.name + '_{{keys}}';

config.jwt
={
secret:
'123456', //自定义token的加密条件字符串,可按各自的需求填写 };//Mysql config.sequelize ={
dialect:
'mysql',
host:
'localhost',
port:
3306,
database:
'myprojectdb',
username:
'root',
password:
'123456',
define: {
//freezeTableName默认值为false,会自动在表名后加s freezeTableName: true,//timestamps默认值为true,会自动添加create_time和update_time timestamps: false}
};
//csrf 安全配置 config.security ={
csrf: {
enable:
false,
ignoreJSON:
true},//允许访问接口的白名单 domainWhiteList: ['*'] //['http://localhost:8080'] };
config.cors
={
origin:
'*',
allowMethods:
'GET, HEAD, PUT, POST, DELETE, PATCH'};//........其他配置............... returnconfig;
};

为了给前端提供Web API接口,我们需要为不同的业务对象提供路由入口,路由定义,统一在app/route.js文件中定义。

app/route.js

 module.exports = app =>{
const { router, controller, jwt }
=app;

router.get(
'/', controller.home.index);
router.get(
'/news', controller.news.list);
router.post(
'/login', controller.users.login); //登录并生成Token router.resources('users', '/users', controller.users);
};

以上我们users 是RESTful 的方式来定义路由, 我们提供了
app.router.resources('routerName', 'pathMatch', controller)
快速在一个路径上生成
CRUD
路由结构。

类似RESTful定义

router.resources('posts', '/api/posts', controller.posts);

我们只需要在
posts.js
里面实现对应的函数就可以了。

我这里的users实现了上面部分的接口,以提供列表展示-L、创建-C、获取-R、更新-U、删除-D等操作。

app\controller\users.js

'use strict';

const Controller
= require('egg').Controller;//控制器类入口//实现路由几个常规函数,包括列表及CRUD的操作 class UserController extends Controller {

async index() {
//展示列表数据-L const ctx = this.ctx;const query={
limit: ctx.helper.parseInt(ctx.query.limit),
offset: ctx.helper.parseInt(ctx.query.offset),
};
var data =await ctx.service.user.list(query);var json =ctx.helper.json(data)
ctx.body
=json
}

async show() {
//显示某记录具体的数据-R const ctx = this.ctx;
ctx.body
=await ctx.service.user.find(ctx.helper.parseInt(ctx.params.id));
}

async create() {
//新增一个记录-C const ctx = this.ctx;
const user
=await ctx.service.user.create(ctx.request.body);
ctx.status
= 201;
ctx.body
=user;
}

async update() {
//更新指定的记录-U const ctx = this.ctx;
const id
=ctx.helper.parseInt(ctx.params.id);
const body
=ctx.request.body;
ctx.body
=await ctx.service.user.update({
id,
updates: body
});
}

async destroy() {
//删除指定的记录-D const ctx = this.ctx;
const id
=ctx.helper.parseInt(ctx.params.id);
await ctx.service.user.del(id);
ctx.status
= 200;
}
}

module.exports
= UserController;

这里UserController 控制器没有直接访问数据库,而是间接通过service对象进行操作数据库的。service中的user.js代码如下所示。

app\service\user.js

'use strict';

const Service
= require('egg').Service;//服务类入口,用于封装具体的数据库访问 class User extends Service {

async login(usernameOrEmail, password) {
var user = await this.ctx.model.User.findOne({
where: {
$or: [
{ username: usernameOrEmail },
{ emailaddress: usernameOrEmail }
]
}
});
var success = false;var error = "";if(user) {
success
= true}return{
success,
error
}
}

async list({ offset
= 0, limit = 10}) {return this.ctx.model.User.findAndCountAll({
offset,
limit,
order: [[
'creationtime', 'desc' ], [ 'id', 'desc']],
});
}

async find(id) {
const user
= await this.ctx.model.User.findByPk(id);if (!user) {this.ctx.throw(404, 'user not found');
}
returnuser;
}

async create(user) {
return this.ctx.model.User.create(user);
}

async update({ id, updates }) {
const user
= await this.ctx.model.User.findByPk(id);if (!user) {this.ctx.throw(404, 'user not found');
}
returnuser.update(updates);
}

async del(id) {
const user
= await this.ctx.model.User.findByPk(id);if (!user) {this.ctx.throw(404, 'user not found');
}
returnuser.destroy();
}
}

module.exports
= User;

而Service中,访问数据库主要通过
egg-sequelize
插件中提供的 this.ctx.model.User 对象进行操作数据库的

sequelize
是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。

app\model\user.js

'use strict';

module.exports
= app =>{
const { STRING, INTEGER, DATE }
=app.Sequelize;

const User
= app.model.define('abpusers', {
id: { type: INTEGER, primaryKey:
true, autoIncrement: true},
name: STRING(
64),
username: STRING(
64),
phonenumber: STRING(
64),
creationtime: DATE,
lastmodificationtime: DATE,
});
returnUser;
};

sequelize
定义了数据库不同的类型,它的类型定义如下所示。

Sequelize.STRING                      //VARCHAR(255)
Sequelize.STRING(1234)                //VARCHAR(1234)
Sequelize.STRING.BINARY               //VARCHAR BINARY
Sequelize.TEXT                        //TEXT
Sequelize.TEXT('tiny')                //TINYTEXT
Sequelize.CITEXT                      //CITEXT      PostgreSQL and SQLite only.
Sequelize.INTEGER//INTEGER
Sequelize.BIGINT                      //BIGINT
Sequelize.BIGINT(11)                  //BIGINT(11)
Sequelize.FLOAT//FLOAT
Sequelize.FLOAT(11)                   //FLOAT(11)
Sequelize.FLOAT(11, 10)               //FLOAT(11,10)
Sequelize.REAL//REAL        PostgreSQL only.
Sequelize.REAL(11)                    //REAL(11)    PostgreSQL only.
Sequelize.REAL(11, 12)                //REAL(11,12) PostgreSQL only.
Sequelize.DOUBLE//DOUBLE
Sequelize.DOUBLE(11)                  //DOUBLE(11)
Sequelize.DOUBLE(11, 10)              //DOUBLE(11,10)
Sequelize.DECIMAL//DECIMAL
Sequelize.DECIMAL(10, 2)              //DECIMAL(10,2)
Sequelize.DATE//DATETIME for mysql / sqlite, TIMESTAMP WITH TIME ZONE for postgres
Sequelize.DATE(6)                     //DATETIME(6) for mysql 5.6.4+. Fractional seconds support with up to 6 digits of precision
Sequelize.DATEONLY                    //DATE without time.
Sequelize.BOOLEAN                     //TINYINT(1)
Sequelize.ENUM('value 1', 'value 2')  //An ENUM with allowed values 'value 1' and 'value 2'
Sequelize.ARRAY(Sequelize.TEXT)       //Defines an array. PostgreSQL only.
Sequelize.ARRAY(Sequelize.ENUM)       //Defines an array of ENUM. PostgreSQL only.
Sequelize.JSON//JSON column. PostgreSQL, SQLite and MySQL only.
Sequelize.JSONB                       //JSONB column. PostgreSQL only.
Sequelize.BLOB//BLOB (bytea for PostgreSQL)
Sequelize.BLOB('tiny')                //TINYBLOB (bytea for PostgreSQL. Other options are medium and long)
Sequelize.UUID//UUID datatype for PostgreSQL and SQLite, CHAR(36) BINARY for MySQL (use defaultValue: Sequelize.UUIDV1 or Sequelize.UUIDV4 to make sequelize generate the ids automatically)
Sequelize.CIDR//CIDR datatype for PostgreSQL
Sequelize.INET                        //INET datatype for PostgreSQL
Sequelize.MACADDR                     //MACADDR datatype for PostgreSQL
Sequelize.RANGE(Sequelize.INTEGER)//Defines int4range range. PostgreSQL only.
Sequelize.RANGE(Sequelize.BIGINT)     //Defined int8range range. PostgreSQL only.
Sequelize.RANGE(Sequelize.DATE)       //Defines tstzrange range. PostgreSQL only.
Sequelize.RANGE(Sequelize.DATEONLY)   //Defines daterange range. PostgreSQL only.
Sequelize.RANGE(Sequelize.DECIMAL)    //Defines numrange range. PostgreSQL only.
Sequelize.ARRAY(Sequelize.RANGE(Sequelize.DATE))//Defines array of tstzrange ranges. PostgreSQL only.
Sequelize.GEOMETRY//Spatial column.  PostgreSQL (with PostGIS) or MySQL only.
Sequelize.GEOMETRY('POINT')           //Spatial column with geometry type. PostgreSQL (with PostGIS) or MySQL only.
Sequelize.GEOMETRY('POINT', 4326)     //Spatial column with geometry type and SRID.  PostgreSQL (with PostGIS) or MySQL only.

关于它的接口,可以参考下文档
https://itbilu.com/nodejs/npm/sequelize-docs-v5.html
了解下。

另外,我们可以在app\extend\helper.js中定义一些常规的辅助函数,方便在控制器或者service对象中使用。

app\extend\helper.js

'use strict';
const moment
= require('moment');

module.exports
={
json(data, code, msg, addition) {
returnObject.assign({
result: code
? 'fail' : 'success',
code: code
|| 0,
message: msg,
data,
}, addition);
},
parseInt(string) {
if (typeof string === 'number') returnstring;if (!string) returnstring;return parseInt(string) || 0;
},

changeTime(time) {
return moment(time * 1000).format('YYYY-MM-DD HH:mm:ss');
},
relativeTime(time) {
return moment(new Date(time * 1000)).fromNow()
},

最后,我们使用npm run dev跑项目

测试下我们用户列表部分的处理。

其他CRUD接口,可以结合C#代码进行客户端的测试,也可以在一个新建的Vue+Element前端项目中进行axios的调用,获取对应的JSON进行测试。

在使用egg.js开发的时候,总体还是很方便,不过就是有时候一些拼写错误,或者一些配置原因,控制台 提示信息不是很明确,需要自己掌握各种排错的经验才行。

在ABP框架里面,默认会带入SignalR消息处理技术,它同时也是ABP框架里面实时消息处理、事件/通知处理的一个实现方式,SignalR消息处理本身就是一个实时很好的处理方案,我在之前在我的Winform框架中的相关随笔也有介绍过SIgnalR的一些内容《
基于SignalR的服务端和客户端通讯处理
》,本篇基于.net Core的ABP框架介绍SignalR的后端处理,以及基于Winform程序进行一些功能测试,以求我们对SignalR的技术应用有一些了解。

SignalR是一个.NET Core/.NET Framework的开源实时框架. SignalR的可使用Web Socket, Server Sent Events 和 Long Polling作为底层传输方式。

SignalR基于这三种技术构建, 抽象于它们之上, 它让你更好的关注业务问题而不是底层传输技术问题。

SignalR将整个信息的交换封装起来,客户端和服务器都是使用JSON来沟通的,在服务端声明的所有Hub信息,都会生成JavaScript输出到客户端,.NET则依赖Proxy来生成代理对象,而Proxy的内部则是将JSON转换成对象。

Hub类里面, 我们就可以调用所有客户端上的方法了. 同样客户端也可以调用Hub类里的方法.

SignalR可以将参数序列化和反序列化. 这些参数被序列化的格式叫做Hub 协议, 所以Hub协议就是一种用来序列化和反序列化的格式.

Hub协议的默认协议是JSON, 还支持另外一个协议是MessagePack。MessagePack是二进制格式的, 它比JSON更紧凑, 而且处理起来更简单快速, 因为它是二进制的.

此外, SignalR也可以扩展使用其它协议。

SignalR
可以与
ASP.NET Core authentication
一起使用,以将用户与每个连接相关联。
在中心中,可以从
HubConnectionContext
属性访问身份验证数据。

1、ABP框架中后端对SignalR的处理

如果需要在.net core使用SignalR,我们首先需要引入aspnetcore的SiganlR程序集包

另外由于我们需要使用ABP基础的SignalR的相关类,因此需要引入ABP的SignalR模块,如下所示。

[DependsOn(typeof(WebCoreModule),typeof(AbpAspNetCoreSignalRModule))]public classWebHostModule: AbpModule
{
private readonlyIWebHostEnvironment _env;private readonlyIConfigurationRoot _appConfiguration;publicWebHostModule(IWebHostEnvironment env)
{
_env
=env;
_appConfiguration
=env.GetAppConfiguration();
}

然后在Web.Host中发布SiganlR的服务端名称,如下所示。

public voidConfigure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
{
........................

app.UseEndpoints(endpoints
=>{
endpoints.MapHub
<AbpCommonHub>("/signalr");
endpoints.MapHub<ChatHub>("/signalr-chat"
);

endpoints.MapControllerRoute(
"defaultWithArea", "{area}/{controller=Home}/{action=Index}/{id?}");
endpoints.MapControllerRoute(
"default", "{controller=Home}/{action=Index}/{id?}");

});

注册 SignalR 和 ASP.NET Core 身份验证中间件的顺序。

UseSignalR
之前始终调用
UseAuthentication
,以便 SignalR 在
HttpContext
上有用户。

在基于浏览器的应用程序中,cookie 身份验证允许现有用户凭据自动流向 SignalR 连接。
使用浏览器客户端时,无需额外配置。
如果用户已登录到你的应用,则 SignalR 连接将自动继承此身份验证。

客户端可以提供访问令牌,而不是使用 cookie。
服务器验证令牌并使用它来标识用户。
仅在建立连接时才执行此验证。
连接开启后,服务器不会通过自动重新验证来检查令牌是否撤销。

ABP框架本身提供了可用的基类OnlineClientHubBase和AbpHubBase,内置了日志、会话、配置、本地化等组件,都继承自基类Microsoft.AspNetCore.SignalR.Hub。

Abp的AbpCommonHub提供了用户链接到服务和断开链接时,ConnectionId和UserId的维护,可以在IOnlineClientManger中进行访问,IOnlineClientManger提供如下方法:

  • bool IsOnline()

而ChatHub则是我们自定义的SignalR聊天处理类,它同样继承于OnlineClientHubBase,并整合了其他一些对象接口及进行消息的处理。

例如,我们这里SendMessage发送SIgnalR消息的逻辑如下所示。

        /// <summary>
        ///发送SignalR消息/// </summary>
        /// <param name="input">发送的消息体</param>
        /// <returns></returns>
        public async Task<string>SendMessage(SendChatMessageInput input)
{
var sender =Context.ToUserIdentifier();var receiver = newUserIdentifier(input.TenantId, input.UserId);try{using(ChatAbpSession.Use(Context.GetTenantId(), Context.GetUserId()))
{
await_chatMessageManager.SendMessageAsync(sender, receiver, input.Message, input.TenancyName, input.UserName, input.ProfilePictureId);return string.Empty;
}
}
catch(UserFriendlyException ex)
{
Logger.Warn(
"Could not send chat message to user:" +receiver);
Logger.Warn(ex.ToString(), ex);
returnex.Message;
}
catch(Exception ex)
{
Logger.Warn(
"Could not send chat message to user:" +receiver);
Logger.Warn(ex.ToString(), ex);
return _localizationManager.GetSource("AbpWeb").GetString("InternalServerError");
}
}

而消息对象实体,如下所示

    /// <summary>
    ///发送的SignalR消息/// </summary>
    public classSendChatMessageInput
{
/// <summary> ///租户ID/// </summary> public int? TenantId { get; set; }/// <summary> ///用户ID/// </summary> public long UserId { get; set; }/// <summary> ///用户名/// </summary> public string UserName { get; set; }/// <summary> ///租户名/// </summary> public string TenancyName { get; set; }/// <summary> ///个人图片ID/// </summary> public Guid? ProfilePictureId { get; set; }/// <summary> ///发送的消息内容/// </summary> public string Message { get; set; }
}

为了和客户端进行消息的交互,我们需要存储用户发送的SignalR的消息到数据库里面,并需要知道用户的好友列表,以及获取未读消息,消息的已读操作等功能,那么我们还需要在应用层发布一个ChatAppService的应用服务接口来进行交互。

[AbpAuthorize]public classChatAppService : MyServiceBase, IChatAppService
{
private readonly IRepository<ChatMessage, long>_chatMessageRepository;private readonlyIUserFriendsCache _userFriendsCache;private readonly IOnlineClientManager<ChatChannel>_onlineClientManager;private readonly IChatCommunicator _chatCommunicator;

客户端通过和 signalr-chat 和ChatAppService进行联合处理,前者是处理SignalR消息发送操作,后者则是应用层面的数据处理。

2、Winform程序对SignalR进行功能测试

前面说过,SignalR消息应用比较多,它主要用来处理实时的消息通知、事件处理等操作,我们这里用来介绍进行聊天回话的一个操作。

客户端使用SignalR需要引入程序集包Microsoft.AspNetCore.SignalR.Client。

首先我们建立一个小的Winform程序,设计一个大概的界面功能,如下所示。

这个主要就是先通过ABP登录认证后,传递身份,并获取用户好友列表吧,连接到服务端的SiganlR接口后,进行消息的接收和发送等操作。

首先是用户身份认证部分,先传递用户名密码,登陆认证成功后获取对应的令牌,存储在缓存中使用。

        private async void btnGetToken_Click(objectsender, EventArgs e)
{
if(this.txtUserName.Text.Length == 0)
{
MessageDxUtil.ShowTips(
"用户名不能为空");return;
}
else if (this.txtPassword.Text.Length == 0)
{
MessageDxUtil.ShowTips(
"用户密码不能为空"); return;
}
var data = newAuthenticateModel()
{
UserNameOrEmailAddress
= this.txtUserName.Text,
Password
= this.txtPassword.Text
}.ToJson();

helper.ContentType
= "application/json";//指定通讯的JSON方式 helper.MaxTry = 2;var content = helper.GetHtml(TokenUrl, data, true);
Console.WriteLine(content);
var setting = new JsonSerializerSettings() { ContractResolver = newCamelCasePropertyNamesContractResolver() };var result = JsonConvert.DeserializeObject<AbpResponse<AuthenticateResultModel>>(content, setting);if (result != null && result.Success && !string.IsNullOrWhiteSpace(result.Result.AccessToken))
{
//获取当前用户 Cache.Instance["AccessToken"] = result.Result.AccessToken;//设置缓存,方便ApiCaller调用设置Header currentUser = await UserApiCaller.Instance.GetAsync(new EntityDto<long>(result.Result.UserId));

Console.WriteLine(result.Result.ToJson());
Cache.Instance[
"token"] = result.Result; //设置缓存后,APICaller不用手工指定RequestHeaders的令牌信息 EnableConnectState(false);
}
this.Text = string.Format("获取Token{0}", (result != null && result.Success) ? "成功" : "失败");//获取用户身份的朋友列表 GetUserFriends();
}

其次是获取用户身份后,获得对应的好友列表加入到下拉列表中,如下代码所示。

        private voidGetUserFriends()
{
var result =ChatApiCaller.Instance.GetUserChatFriendsWithSettings();this.friendDict = new Dictionary<long, FriendDto>();foreach (var friend inresult.Friends)
{
this.friendDict.Add(friend.FriendUserId, friend);this.txtFriends.Properties.Items.Add(newCListItem(friend.FriendUserName, friend.FriendUserId.ToString()));
}
}

然后就是SignalR消息通道的连接了,通过HubConnection连接上代码如下所示。

connection = newHubConnectionBuilder()
.WithUrl(ChatUrl, options
=>{
options.AccessTokenProvider
= () =>Task.FromResult(token.AccessToken);
options.UseDefaultCredentials
= true;
})
.Build();

整块创建SignalR的连接处理如下所示。

        private asyncTask StartConnection()
{
if (connection == null)
{
if (!Cache.Instance.ContainKey("token"))
{
MessageDxUtil.ShowTips(
"没有登录,请先登录");return;
}
var token = Cache.Instance["token"] asAuthenticateResultModel;if (token != null)
{
connection
= newHubConnectionBuilder()
.WithUrl(ChatUrl, options
=>{
options.AccessTokenProvider
= () =>Task.FromResult(token.AccessToken);
options.UseDefaultCredentials
= true;
})
.Build();
//connection.HandshakeTimeout = new TimeSpan(8000);//握手过期时间//收到消息的处理 connection.On<string>("MessageReceived", (str) =>{
Console.WriteLine(str);
this.richTextBox.AppendText(str);this.richTextBox.AppendText("\r\n");this.richTextBox.ScrollToCaret();
});
awaitconnection.StartAsync();

EnableConnectState(
true);
}
}
awaitTask.CompletedTask;
}

客户端传递身份进行SignalR连接,连接成功后,收到消息回显在客户端。

每次用户登录并连接后,显示未读的消息到客户即可。

this.messages = new List<ChatMessageDto>();//清空数据

var result = awaitChatApiCaller.Instance.GetUserChatMessages(input);if (result != null && result.Items.Count > 0)
{
this.messages = result.Items.Concat(this.messages);await ChatApiCaller.Instance.MarkAllUnreadMessagesOfUserAsRead(new MarkAllUnreadMessagesOfUserAsReadInput() { TenantId = 1, UserId =currentUser.Id });
}
this.richTextBox.Clear();foreach (var item in this.messages)
{
var message = string.Format("User[{0}]:{1} -{2}", item.TargetUserId, item.Message, item.CreationTime);this.richTextBox.AppendText(message);this.richTextBox.AppendText("\r\n");
}
this.richTextBox.ScrollToCaret();

而客户端需要发送消息给另外一个好友的时候,就需要按照消息体的对象进行属性设置,然后调用SignalR接口进行发送即可,也就是直接调用服务端的方法了。

//当前用户id为2,发送给id为8的
var data = newSendChatMessageInput()
{
Message
= this.txtMessage.Text,
UserId
=friend.FriendUserId,
TenantId
=friend.FriendTenantId,
UserName
=friend.FriendUserName,
TenancyName
=friend.FriendTenancyName,
ProfilePictureId
=Guid.NewGuid()
};
try{//调用服务chathub接口进行发送消息 var result = await connection.InvokeAsync<string>("SendMessage", data);
Console.WriteLine(result);
if (!string.IsNullOrWhiteSpace(result))
{
MessageDxUtil.ShowError(result);
}
else{await GetUserChatMessages(); //刷新消息 this.txtMessage.Text = ""; //清空输入 this.Text = string.Format("消息发送成功:{0}", DateTime.Now.ToString());
}
}
catch(Exception ex)
{
MessageDxUtil.ShowTips(ex.Message);
}

最后我们看看程序的效果,如下所示。

消息已经被 序列化到ABP的系统表里面了,我们可以在表中查看到。

用户的好友列表在表AppFriendships中,发送的消息则存储在AppChatMessages中

我们在ABP开发框架的基础上,完善了Winform端的界面,以及Vue&Element的前端界面,并结合代码生成工具的快速辅助,使得利用ABP框架开发项目,更加方便和高效。

ABP框架代码生成