请点击目录中的节点查看具体文档

Nebula 介绍

Nebula是一个基于 .NET 技术的开发框架体系,核心特色是【支持SaaS多租户】、【支持微服务开发】和【实时可观测能力】。


Nebula包含2大部分:

  • 开发框架类库
  • 公共基础服务

整体逻辑架构如下:

xx



TOP10技术特性

  1. 支持SaaS多租户架构
  2. 支持微服务架构开发
  3. 支持 Linux+docker+K8S 的部署方式
  4. 强大且实时的在线可观测能力,超越各种流行APM
  5. 监控对象自动识别,可配置可扩展的监控指标
  6. 强大易用的消息管道开发模型
  7. 简单易用的后台任务开发模型
  8. 统一的全局帐号管理,解决帐号泄漏问题,所有配置可在线调整
  9. 全异步支持
  10. 更少的内存占用



支持的平台与技术规范

  • 支持多种CPU架构:x86/x64/arm64
  • 支持多种Linux发行版
  • 支持Docker/K8S部署
  • 支持多种数据库:SQLSERVER/MySQL/PostgreSQL/InfluxDB/VictoriaMetrics
  • 支持多种通知发送渠道:企业微信, 钉钉, 飞书,邮件,短信
  • 支持多种流行的Web技术规范:Restful/JWT/Swagger
  • 支持多种配置文件格式:XML/JSON/YAML



Nebula类库模块

Nebula开发框架类库有2个

  • ClownFish:包含开发单个应用程序的基础功能。
  • Nebula:基于ClownFish,主要实现:微服务,SaaS,可观测性监控

【特别说明】:由于Nebula是基于ClownFish的,所以有时候并没有刻意区分它们,因为这对开发过程来说,完全是无感的!

总共包含以下模块
xx

TOP10技术特性

本文主要介绍Nebula的10个最重要技术特性

  1. 支持SaaS多租户架构
  2. 支持微服务架构开发
  3. 支持 Linux+docker+K8S 的部署方式
  4. 强大且实时的在线可观测能力,超越各种流行APM
  5. 监控对象自动发现,可配置可扩展的监控指标
  6. 强大易用的消息管道开发模型
  7. 简单易用的后台任务开发模型
  8. 统一的全局帐号管理,解决帐号泄漏问题,所有配置可在线调整
  9. 全异步支持
  10. 更少的内存占用



SaaS多租户架构

SaaS架构,也称 “多租户” 架构。概念这里就不多说~~

数据库设计

  • 主库:一个SaaS项目一个主库,存放一些全局数据。
  • 租户库:为每租户创建一个租户数据库,存放与租户相关的数据,用于保证各租户之间的数据隔离。

应用站点

  • 应用站点与服务的部署实例不区分租户(共享),可以接受所有租户的用户访问。
  • 用户Token包含租户ID,用于区分租户
  • 框架根据租户ID,在运行时动态切换到对应的租户数据库中,实现租户的数据库路由访问。





Linux+docker+K8S 的部署环境

支持以下部署技术:

xx





微服务架构开发

微服务与单体应用程序有很大差别,主要表现在:

  • 经常需要跨进程调用,而不是直接的方法调用
    • 因此需要知道:调用的目标服务部署在哪里?
  • 需要部署多个服务站点,又会带来新的问题:
    • 参数在哪里配置? 如何避免重复问题?
    • 日志怎么统一收集,查看?
    • 出现故障时,怎么排查问题?

Nebula对于微服务的支持可参考:微服务架构开发





强大的在线可观测能力

重点体现在:


细节内容包括:





监控对象自动发现,可配置可扩展的监控指标

Nebula自动识别的监控对象分为4大类:

  • 配置服务中注册的:服务地址
  • 配置服务中注册的:连接帐号,例如:RabbitMQ的连接帐号
  • 配置服务中注册的:数据库连接,例如:某个MySQL数据库的连接参数
  • 集群中微服务的运行实例

可监控指标包含3大类:

所有这些数据都可以配置为监控规则,点击引处查看文档。


有了这些监控对象和监控规则,Nebula就能产生告警通知。

Nebula内置4种类别的告警通知

  • 服务/中间件的【可用性】告警
  • 服务节点的 心跳/打卡 告警
  • 基于 指标规则 的监控告警
  • 应用程序启动失败的异常日志告警

以上4类告警通知都可以发送到指定的IM聊天群。





消息管道开发模型

为了方便消息订阅和消息处理,ClownFish提供了一种称为消息管道的开发模型,如下图所示:

xx

此模型有以下优点:

  • 规范代码:将消息处理划分为多个阶段,避免代码风格迥异
  • 功能增强:提供完善的日志和监控,支持重试处理,支持异常处理
  • 统一模型:支持一套消息处理代码,订阅不同的消息服务(消息来源),可理解为与消息服务解耦。
  • 简化代码:隐藏所有技术差异:订阅模式差异,线程模型差异,订阅者数量管理,提交方式,序列化等等细节。
    消息处理只负责实现业务逻辑即可。





后台任务开发模型

后台任务也是一种很常见的业务开发模式,例如:

  • 每天晚上执行一次数据统计
  • 每小时执行一次XXXX检查

虽然 Quartz和Hangfire 也能完成这类任务,但是它们没有提供完善的日志和监控功能,
没法与Nebula的日志和监控整合,因此,ClownFish直接内置了周期性后台任务的开发模型。

下表是ClownFish内置的 BackgroundTask 和 Hangfire 的差别对比

技术特性BackgroundTaskHangfire
依赖ASP.NET
依赖数据库持久化
同一任务重叠执行
支持异步任务
支持秒级触发
支持性能日志
支持Venus监控
支持立即执行延迟
支持临时任务





统一的全局帐号管理,解决帐号泄漏问题

许多开发框架和客户端类库虽然都提供了参数配置能力,
例如:

  • 可以把 MySqlConnection 的连接字符串配置在 appsettings.json 中,
  • ASP.NET甚至允许我们为不同环境指定不同的配置文件,

但是这种设计没有解决帐号泄漏的安全问题,因为:配置文件很容易通过源代码方式造成泄漏!

在Nebula框架中,采用了以下方式来解决帐号泄漏问题

  • 配置服务:统一管理所有连接帐号,以及各种密码之类的敏感参数
  • 封装各种客户端:强制从配置服务中获取必要的连接参数
  • 最终结果:应用程序的代码不会包含帐号类的敏感内容

点击此处查看使用示例





全异步支持

异步是一个非常有用的技术特性,它与同步调用相比,可以在相同的硬件资源下提供更高的系统吞吐量。
尤其是国内各种【互联网应用】和【信息化系统】,因为它们有以下特点:

  • 大量的数据库CURD操作
  • 大量的服务之间相互调用

这些都是【异步】最适合的使用场景。

Nebula和ClownFish中,几乎所有涉及远程调用的API都提供异步版本,例如:

  • 所有数据库CURD操作
  • 所有HTTP调用
  • 各种中间件服务的客户端





更少的内存占用

Nebula在内存占用方面做了大量优化,主要包含4大类:

  • 在框架内部的代码热点路径上涉及的大对象尽可能地使用对象缓存
  • 提供许多参数开关用来控制不必要的对象分配
  • 代码优化,尽量减少内存的占用
  • 运行环境:GC参数的配置优化

例如下图是线上部分服务的真实运行情况,可以关注下 MEM 这个数据,
我们可以看到基于Nebula开发的服务,它们的内存占用都比较低,

xx



公共基础服务

Nebula提供以下公共基础服务



Nebula.Moon

配置服务,主要提供以下配置管理能力:

  • 数据库连接,例如:
    • SQLSERVER
    • MySQL
    • PostgreSQL
    • MongoDB
    • InfluxDB
    • VictoriaMetrics
    • Elasticsearch
  • 全局配置参数
    • 普通的配置参数,即name=value
    • 各种连接参数,例如:RabbitMQ, Redis, OSS
    • 密码及敏感文本
    • 各种服务地址/URL参数
  • 独立配置文件
    • xml
    • json
    • yam
    • text



Nebula.AdminUI

后台配置界面,提供一种友好的方式维护一些后台配置参数。

xx



Nebula.Neptune

一个通用的 数据查询服务,服务接口可以在Nebula.AdminUI中配置,

xx



Nebula.Mercury

一个后台服务(没有界面),用于实时统计各个应用的各个节点的调用次数及性能/异常状况。
这个数据最后给Venus展示。



Nebula.Venus

监控展示站点,主要有以下功能:

主界面如下图:

xx



Nebula.Cers

WebHook服务,主要有以下特性:

  • 解耦合:应用程序(发布者)不需要与事件的订阅者交互,甚至可以不用知道事件的订阅者是否存在
  • 易用性:应用程序(发布者)只负责发布事件,所有 注册/订阅/推送 过程全都不需要关注
  • 通用性:Ceres接受多个应用程序的事件,并将事件发送到匹配的第三方程序订阅者
  • 可靠性:内置复杂且可靠的重试机制,尽量保证事件能成功发送给第三方



Nebula.Juno

日志数据清理服务 是一个通用的数据表清理服务,它主要解决了以下问题:

  1. 日志表越来越大,需要清理【较老】的数据。
  2. 有可能一天就产生大量数据,所以需要及时清理。
  3. 避免人工执行大量DELETE语句产生大量数据库锁,影响业务系统稳定运行,甚至拖死数据库。
  4. 支持SaaS的多租户模式



Nebula.Log2DB

日志同步服务 用于将操作日志从ES中同步到RMDB。
按照默认的设置,程序产生的各种日志都会写入Elasticsearch,此时对于运营分析人员就不友好了,
他她们更愿意使用SQL查询来分析数据,因此Log2DB就是为这种需求而设计的。
Log2DB重点包含以下技术特性:

  • 允许配置筛选日志范围
  • 允许配置将日志同步到各种RMDB
  • 后台持续/实时同步



Nebula.Metis

通知服务 主要解决以下问题:

  • 应用程序与消息发送过程解耦:程序不需要关注消息发给谁,以及用什么方式发送
  • 应用程序与消息内容编排解耦:程序只管提交消息数据,最终发出的消息格式由模板决定
  • 消息的发送方式灵活可配置:消息可以用短信发出,或者邮件,或者聊天工具发出
  • 发送方式多样性支持:目前已支持 12 种通知方式,4类消息格式

代码执行主体

通常而言,绝大多数业务需求最后会以3类技术方式实现:

xx

以上3类实现方式,ClownFish将它们抽象为3类代码执行主体:


设计意图

ClownFish抽象出这3类代码执行主体,主要是为了:

  • 规范代码的开发方式,防止出现五花八门的设计
  • 提供完整的日志与监控支持。目前几乎所有的APM监控只能针对HttpAction
  • 简化开发过程。框架会提供必要的稳定性封装,项目代码只需要关注具体的业务逻辑即可

相同点

  • 都属于顶层代码(没有直接的方法调用)
  • 反复执行
  • 框架提供完善的
    • 调用日志
    • 性能监控
    • 异常处理和异常日志

重要差异点

  • HttpAction:通常它是无状态的,拥有极好的水平扩展性(当一个部署节点不够时,可以增加新的节点)
  • MessageHandler:如果消息没有严格的顺序处理要求,它的水平扩展性和HttpAction一样,因此尽量避免 消息必须顺序处理 的设计。
  • BackgroundTask:某些场景下,如果只需要一个部署节点来运行,那么就需要将这类代码放在一个独立的项目中。

开发参考链接

应用程序类型

在 ASP.NET Core 项目模板中,每个项目都会有 一大堆的初始化代码,它们分散在 Program.cs和Startup.cs 文件中, .net6虽然可以合并在一起,但内容几乎没有减少。

直接使用这种项目模板会带来有3个问题:

  • 每个项目都有 Program.cs和Startup.cs 这2文件,且内容大体上类似
  • 每个项目的 Startup.cs中的 ConfigureServices,Configure 方法不仅类似,而且又臭又长
  • 如果项目中需要引入一种新的组件,每个Startup.cs文件又要添加一样的内容!

当然了,我们也应该要认识到,这并不是 ASP.NET Core 的问题,它的项目模板支撑单个Web应用程序是没有问题的, 这些问题之所以会出现,是因为和微服务架构有关,微服务的数量越多,上面说的3个问题就越明显!

Nebula为了解决这些问题,将 Program.cs和Startup.cs 2个文件中的代码压缩成一个方法调用,即:

public class Program
{
    public static void Main(string[] args)
    {
        AppStartup.RunAsXXXXX("ApplicationName", args);
    }
}

XXXXX 就是以下4种应用程序类型:

  • WebSite(Web站点)

    • 允许对外公开的站点,HttpAction 需要做授权检查
    • 加载Razor页面所需组件,支持静态页面访问,占用内存较多

  • PublicServices(外部服务)

    • 允许对外公开的服务,HttpAction 需要做授权检查
    • 不支持 Razor页面和静态页面,内存占用比 Website 低
    • 建议用做 RESTful 数据接口服务

  • InternalServices(内部服务)

    • 为了安全隔离,仅供内部使用(不对外公开),HttpAction 可以不做授权检查
    • 不支持 Razor页面和静态页面,内存占用比 Website 低
    • 建议用做 内部使用的 RESTful 数据接口服务

  • Console(控制台应用)

    • 不-提供HTTP访问接口,运行环境稳定且不容易被攻击
    • 由于不加载ASP.NET Core的那些组件,因此 占用内存最少
    • 建议使用场景:MQ的消费者程序,后台作业。

前3类应用在创建时,都需要选择 ASP.NET Core Web 应用程序项目模板。


选择建议

  • 如果需要支持 Razor 页面,请选择 WebSite 类型
  • 前后端分离 的项目,请选择 PublicServices 类型
  • 给第三方应用提供数据来源的WebApi服务,请选择 PublicServices 类型
  • 返回敏感数据的WebApi服务,请选择 InternalServices 类型,且不提供外网访问权限
  • 不需要对外访问的WebApi服务,请选择 InternalServices 类型,且不提供外网访问权限
  • 处理消息订阅(MessageHandler),请选择 Console 类型
  • 执行后台任务(BackgroundTask),请选择 Console 类型




代码执行主体 和 应用程序类型 的关系

代码执行主体 和 应用程序类型 是二个不同层级的事物,

  • 代码执行主体:是一个代码片段
  • 应用程序类型:是一个项目类别

根据前面描述

  • MessageHandler,BackgroundTask 由于不需要监听HTTP端口,所以可以适用于以上4种应用程序类型
  • 但是:建议将 MessageHandler,BackgroundTask 独立到单独的项目中,并使用 Console 应用程序类型




行为差异

Website/PublicServices/InternalServices 这3类程序都属于Web应用程序,
它们在一些细节处理上存在差异:

  • Website
    • 支持登录身份凭证续期自动处理
    • Action结果做JSON序列化时
      • DateTime输出格式 yyyy-MM-dd HH:mm:ss
      • 包含 NULL 值成员
  • PublicServices
    • 支持登录身份凭证续期自动处理
    • Action结果做JSON序列化时
      • DateTime输出格式 yyyy-MM-ddTHH:mm:ss.FFFFFFFK
      • 忽略 NULL 值成员
  • InternalServices
    • 【不】支持登录身份凭证续期自动处理
    • 对于未处理的异常,响应内容会包含异常详细内容,即:startOption.AlwaysShowFullException = true;
    • Action结果做JSON序列化时
      • DateTime输出格式 yyyy-MM-ddTHH:mm:ss.FFFFFFFK
      • 忽略 NULL 值成员

如果需要调整JSON序列时DateTime的输出格式,可参考以下代码

public static void Main(string[] args)
{
    AppStartOption startOption = new AppStartOption {

        // 设置JSON序列化的时间格式
        DateFormatString = "yyyy-MM-dd HH:mm:ss"
    };

    AppStartup.RunAsPublicServices("OneTestService", args, startOption);
}

微服务架构开发

微服务架构的一个显著特点是:一个业务系统有很多个独立运行的服务节点。
为了让这些服务运行起来,微服务架构至少需要以下基础能力:

  • 服务注册
  • 服务发现
  • 服务调用
  • 安全访问
  • 服务监控
  • 链路日志
  • 服务治理
  • 简化开发

1,Nebula内置了专门设计的 配置服务,主要为了解决以下问题:

2, 微服务相互调用 拥有以下技术特性:

  • HTTP协议
  • JSON序列化
  • ClwonFish提供的HttpClient
  • 支持重试
  • 使用配置服务中的服务注册地址
  • 完整的链路日志
  • 透明传输用户身份凭证

3,与传统的API网关的授权检查思路完全不同,基于Nebula的微服务架构在安全访问控制方面由2部分构成:

  • 框架负责透明传输用户身份凭证到每个被调用的服务节点
  • 目标服务节点的Nebula框架会完成授权检查,并允许业务自行扩展授权检查逻辑

这种做法的好处在于,永远不可能绕过安全检查!


4,服务监控主要体现在:

  • 实时监控每个服务的运行状态
  • 实时监控每个服务的业务指标
  • 实时监控每个服务节点的进程状态
  • 各服务及中间件的可用性
  • 内置4种类别的 告警通知,可以让开发和运维人员及时了解并处理线上故障问题

5,Nebula内置完善的 日志支持,会在服务相互调用后生成完整的链路日志,可用于:


6,为了促进 性能和稳定性治理,Nebula提供大屏页面,让所有人都能一目了然的知道线上服务的运行状况是否健康稳定。


7,简化开发过程:虽然ASP.NET Core对微服务项目有着很好的支持,但它的项目模板是为单个服务而准备的,每个项目会包含2个固定类型Program,Startup, 用它们来配置服务的启动及初始化过程。实际使用过程中Startup的代码(初始化过程)会比较复杂,项目越多维护成本越高。

想像一下:如何方便维护50甚至100个项目的 Startup 代码?

为了解决这个问题,Nebula内置了这些模板代码(应用程序类型),并根据启用参数来控制是否启用,我们只需要调用 AppStartup.RunAsXXX(...) 就可以了, 它会自动拥有Nebula所支持的一切功能(日志/监控/初始化/数据访问/身份认证/授权检查/..等等)。









请点击目录中的节点查看具体文档

应用程序开发架构

基于Nebula开发的应用程序应该采用以下 架构原则,并规划对应目录

  • 经典的三层架构
    • Controller
    • BLL
    • DAL
  • 强类型的数据结构,并区分用途
    • Entity
    • DTO
  • WebSite项目再增加2个层
    • ViewModel
    • View

说明:

  • 由于采用微服务开发模式,每个服务不应该非常复杂,所以建议采用经典的三层架构



解决方案目录结构

xx



项目目录结构

xx xx

Data项目结构

  • 为了方便数据结构的共用,可以创建一个独立的 Data 类库项目
  • Data 项目中按应用名称创建不同的子目录,存放所需要数据结构
  • 再分别创建 DTO 和 Entites 二级子目录,存放DTO和实体类型

应用程序目录结构说明:

  • App_Data:存放配置文件
  • BLL: 业务逻辑层代码

  • Controllers: 控制器代码
  • Messages: 消息处理器代码
  • Workers: 一些后台任务

  • Customize: 针对框架的定制化代码
  • Monitor: 业务监控相关代码
  • Utils: 工具类

  • ViewModels: MVC的视图数据模型
  • Views: MVC的视图页面

补充说明

  • Controllers/Messages/Workers 它们都是【顶层】的执行主体,不应该相互调用。
  • DAL层没有单独的体现,因为许多重复性的代码由ClownFish封装代替了。

准备开发环境

Nebula支持2种开发场景:

  • 单体应用程序
  • 微服务&集群的复杂项目



单体应用程序

这种场景基本上不需要特别的准备工作,直接引用Nebula类库就可以了,
例如:

dotnet add package ClownFish.Nebula.net --version 9.24.1121.1

具体开发过程可参考:开发独立应用程序





微服务&集群的复杂项目

在这种场景下,必须先部署一个【配置服务】,主要是解决以下目的:

部署配置服务有2个步骤:

  • 创建 NebulaDb 数据库,并执行脚本文件 Config_db.sql
  • 运行配置服务容器,可参考下面的命令

数据库创建后,就可以用以下命令来运行配置服务:
docker run -d  --restart=always --name moon  -p 8503:80 -m300m  \
  -e dbConnectionString="server=mysql_ip;database=configdb;uid=xxxx;pwd=xxxxx" \
  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/moon:202411191116_net9

注意请根据实际情况调整 dbConnectionString 参数。





在项目中引用配置服务

在每个项目的根目录下创建一个 ClownFish.App.config 文件,并增加以下内容:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="configServiceUrl" value="http://LinuxTest:8503" />
    </appSettings>
</configuration>

注意:

  • 请根据实际情况调整 configServiceUrl 参数。
  • 其它的 本地参数 也可以在这个文件中添加

全局引用

建议为每个解决方案准备一个 GlobalUsing.cs 文件,然后所有项目都以【链接】方式添加引用

好处是:

  • 每个 cs 文件会更清爽(文件顶部没有一大堆的 using)
  • 写代码更流畅



建议的 GlobalUsing.cs 文件内容

global using System;
global using System.Buffers;
global using System.Collections;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.Collections.Specialized;
global using System.ComponentModel.DataAnnotations;
global using System.Data;
global using System.Data.Common;
global using System.Diagnostics;
global using System.Dynamic;
global using System.IO;
global using System.Linq;
global using System.Reflection;
global using System.Runtime.CompilerServices;
global using System.Security.Cryptography;
global using System.Security.Principal;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Channels;
global using System.Threading.Tasks;
global using System.Xml.Serialization;

global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.Extensions.DependencyInjection;

global using Newtonsoft.Json;

global using ClownFish.Base;
global using ClownFish.Base.Exceptions;
global using ClownFish.Base.Reflection;
global using ClownFish.Data;
global using ClownFish.Http.Clients.RabbitMQ;
global using ClownFish.Http.Pipleline;
global using ClownFish.Http.Proxy;
global using ClownFish.Http.Utils;
global using ClownFish.ImClients;
global using ClownFish.Log;
global using ClownFish.Log.Attributes;
global using ClownFish.Log.Logging;
global using ClownFish.Log.Models;
global using ClownFish.MQ;
global using ClownFish.MQ.Messages;
global using ClownFish.MQ.MMQ;
global using ClownFish.MQ.Pipeline;
global using ClownFish.NRedis;
global using ClownFish.Rabbit;
global using ClownFish.Tasks;
global using ClownFish.Web.Aspnetcore;
global using ClownFish.Web.AspnetCore.ActionResults;
global using ClownFish.Web.Modules;
global using ClownFish.Web.Security;
global using ClownFish.Web.Security.Attributes;
global using ClownFish.Web.Security.Auth;
global using ClownFish.Web.Utils;
global using ClownFish.WebClient;
global using ClownFish.DTO;
global using ClownFish.Jwt;

global using Nebula.Clients.ServiceClients;
global using Nebula.Common;
global using Nebula.Components.GlobalEvent;
global using Nebula.Utils;
global using Nebula.Web.AspnetCore;
global using Nebula.Web.Security;
global using Nebula.Web.Startup;

快速入门

快速上手,是Nebula在设计时非常注重的一个目标!

通常来说,一个框架 是否易于使用 取决于引入了多少类型,尤其是 IDE不能提示 的使用场景,例如:

  • 基类,包含:抽象类和接口
    • 用于业务项目中的类型定义,此时IDE不可能给予提示!
  • 工具类
    • 用于完成一些特定的功能,如果不知道类名,自然是没法调用!
  • Attribute
    • 用于对类或者方法进行修饰,例如:[Authorize]
    • 如果不知道有哪些[xxxx],功能就没法实现!



核心类型

在Nebula开发框架中,你只需要知道以下类型名称就可以使用到Nebula 80% 的功能,可以畅快地进行开发任务(剩下的事情IDE会告诉你)。

  • 用户登录
    • WebUserInfo:表示一个已登录的用户身份
    • AuthenticationManager:用于登录和身份识别的工具类
  • 授权检查
    • [Authorize]
  • Controller开发
    • BaseController:抽象类,提供了许多实用方法(请在IDE中查看)
    • BaseBLL:抽象类,提供了许多实用方法(请在IDE中查看)
  • 数据访问
    • DbContext:封装了数据库连接和执行命令
    • DbConnManager:工具类,用于打开连接
    • Entity:实体的基类。基本上不需要手工编写实体类,有工具可以辅助生成实体类型。
  • HTTP调用
    • HttpOption:定义了发送HTTP请求的数据参数
  • RabbitMQ操作
    • RabbitClient:用于 创建队列,发送消息
    • RabbitSubscriber:用于开启消息订阅
  • Redis操作
    • Redis:工具类,封装了Redis的连接,是Redis操作的入口类
  • 消息处理
    • BaseMessageHandler, AsyncBaseMessageHandler:2个抽象基类
  • 后台任务
    • BackgroundTask, AsyncBackgroundTask:2个抽象基类
  • 配置读取
    • LocalSettings:用于读取本地配置参数
    • Settings:用于读取本地和远程的配置参数
    • ConfigFile:用于读取本和远程的配置文件
  • 重要工具类
    • CacheDictionary, CacheItem:实现进程内缓存
    • HashHelper:提供了一些哈希操作

分层开发&职责

Controller层职责

  • 定义URL路由 [Route]
  • 定义业务功能描述 [ControllerTitle], [ActionTitle]
  • 创建 BLL 实例(只需要定义BLL属性即可)
  • 限制HTTP调用,例如:[HttpGet] / [HttpPost]
  • 权限检查,可以是 [Authorize]标记
  • 参数验证,可以是 [Required]标记
  • 切换数据库连接,控制连接范围
  • 调用BLL
    • 查询数据
    • 数据判断与结构转换
    • 数据导出

避免事项

  • 直接数据库操作
  • 编写大量业务逻辑
  • 校验Action参数失败不要抛出ArgumentException及子类,应该抛出ValidationException



代码示例

demo-code

代码说明

  • 14行,Controller 必须定义在 Controllers 目录下
  • 17,24行,[ControllerTitle], [ActionTitle] 用于描述当前Controller/Action是做的,它们将最终写入操作日志(什么人在什么时候执行了什么功能)
  • 18,25行,[Route] 定义 URL 路由
  • 19行,Controller 必须继承自 BaseController
  • 21行,业务逻辑类的实例采用私有属性的方式引用
  • 34行,数据库连接对象,必须使用 using(.............) 来管理连接范围,确保及时释放



BLL层职责

具体的业务逻辑实现

  • 数据库增删改查
  • 数据判断与结构转换
  • 其它数据源操作(例如OSS)

代码结构特点

  • 小方法
    • 利于复用
  • 职责单一
    • 数据查询
    • 数据加工
  • 不操作 Request/Response



代码示例

demo-code

代码说明

  • 10行,BLL类必须定义在 BLL 目录下
  • 12行,BLL类型必须继承于 BaseBLL



本地配置参数

本地参数包含以下形式

  • 环境变量(系统级/用户级/进程级)
  • AppConfig(ClownFish.App.config)

查找优先级:

  • 环境变量
  • AppConfig



环境变量

docker run -d  --name xxxx  -p 8208:80 -m500m  \
  -e ASPNETCORE_ENVIRONMENT=TEST \
  -e XyzName=abc \
  -v /etc/hosts:/etc/hosts \
   nebula/venus:latest 

上例定义了2个环境变量:

  • ASPNETCORE_ENVIRONMENT
  • XyzName

获取配置参数

string value = LocalSettings.GetSetting("XyzName");



ClownFish.App.config 配置文件

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>

        <add key="configServiceUrl" value="http://LinuxTest:8503" />
        
    </appSettings>
    
</configuration>

获取配置参数

string value = LocalSettings.GetSetting("configServiceUrl");



对象化参数值

通常我们说的配置参数是 name = value 这种形式,二边都是字符串。

然而有些场景下,value可能会是一个对象。
例如:以下数据类型,它包含3个成员

public sealed class XxxConfig
{
    public int Id { get; set; }

    public string Name { get; set; }

    public string Key { get; set; }
}

这个时候如果强行把它拆分成3个参数就很不方便了,此时可以将 value部分 写成以下形式:

Id=1111111111;Name=222222222;Key=xxxxxxxxxx

然后可以这样获取:

XxxConfig config = LocalSettings.GetSetting<XxxConfig>("参数名称");

小结:

  • 包含多个子项参数,可以将value写成 a=b;c=d 格式
  • 本小节介绍的参数值写法对于配置服务也是支持的

注意:

  • 如果参数的子项值包含 = ; 这2个特殊字符,可以将value写成 JSON格式
  • 如果参数对象是一个多层级的,建议使用【独立配置文件】




自定义配置文件

如果配置参数比较复杂,可以采用XML或者JSON文件方式存储, 使用时反序列化读取。

例如:

string filePath = Path.Combine(AppContext.BaseDirectory, "XxxConfig.xml");
var config = XmlHelper.XmlDeserializeFromFile<T>(filePath);

或者

string filePath = Path.Combine(AppContext.BaseDirectory, "XxxConfig.json");
var config = File.ReadAllText(filePath).FromJson<T>();

远程配置参数

远程配置参数是指由 配置服务 统一管理的各种运行参数:

  • 全局配置参数
  • 数据库连接参数
  • 远程独立配置文件
  • 远程配置文件(xxx.App.config, xxx.Log.config)




获取全局配置参数

string xxkey = Settings.GetSetting("xx-key");

注意:

  • Settings 读取来源&优先级:环境变量 > 配置服务 > AppConfig
    • 不启用配置服务时,Settings 等同于 LocalSettings
    • LocalSettings 读取来源&优先级:环境变量 > AppConfig
  • 环境变量:它仅用于产生环境中以最高级别形式做特殊调整参数(覆盖性使用)

全局配置参数分类:

  • 公共参数:供多个应用使用(至少2个)
  • URL地址:内部服务,外部服务
  • 敏感信息数据
    • 连接类参数:RabbbitMQ/Redis/es/oss/...
    • 帐号类参数:邮箱帐号,IM应用帐号
    • 密码密钥类:登录密码,JWT密钥

定义要求

  • 建议参数值长度不超过 300 字符

命名要求

  • 要能体现出是使用的什么用途
  • 参数名称包含应用的名称(防止冲突)
  • 例如:Uranus_NotifyService_Rabbit




获取数据库连接参数

DbConfig config = DbConnManager.GetAppDbConfig("xx_Db");

以上代码虽然可以获取数据库连接参数,但它还不能执行数据库操作,
通常情况下可使用下面的代码来获取数据库连接(在Controller代码中):

using( DbContext dbContext = this.CreateAppDbConnection("xx_Db") ) {
    return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
}

注意:

  • DbConnManager 既能读取 配置服务中的数据库连接,也能读取 AppConfig中定义的连接
    • 启用配置服务时,DbConnManager 从配置服务中获取连接参数
    • 当不启用配置服务时,DbConnManager 从 AppConfig 中读取




获取独立配置文件

string json =  ConfigFile.GetFile("xxxapp.filename.jon");
var config = json.FromJson<T>();

或者

string xml =  ConfigFile.GetFile("xxxapp.filename.xml");
var config = xml.FromXml<T>();

注意:

  • ConfigFile 既能读取 远程独立配置文件,也能读取 本地独立配置文件
  • ConfigFile 的读取优先级: 远程配置文件 > 本地独立配置文件
    • 当不启用配置服务时,ConfigFile 直接从本地目录中查找文件



独立配置文件适用场景:

  • 包含敏感信息,内容较大,例如:RSA密钥
  • 多个配置项之间关联紧密,例如:某个第三方组件的多个配置参数
  • 应用程序的配置参数结构复杂(嵌套结构),通常是一个XML/JSON/YAML文件




远程配置文件

ClownFish.App.config, ClownFish.Log.config 原本是各个应用项目中的本地配置文件, 为了方便线上及时调整它们的文件内容,
可以在配置服务的【配置文件】中新建一个名为 appname.App.Config, 将内容复制上去,以后就可以在线调整参数了。

appname 就是调用 AppStartup.RunAsXxxx(...) 的第一个参数

类似的方法,可将 ClownFish.Log.config 放给配置服务来管理,对应的名称:appname.Log.Config

说明:

  • 这2个特殊的配置文件由框架负责读取,不需要在代码中操作。
  • 远程的App.config中,并不是任何参数都可以在线调整,
    • 部分框架定义的参数在初始化期间就已读取,只能通过环境变量来修改。

SaaS多租户架构--数据库

Nebula支持的SaaS架构有以下特点:

  • 一份程序:所有租户共用相同版本的程序,可以部署为多个节点/POD
  • 数据隔离:各个租户的业务数据采用不同的DataBase来实现数据隔离



数据存储架构

可参考下图

xx

数据库分为3大类:

  • 主库(红色):也称为 master库
    • 存放租户元数据信息,例如租户管理表
    • 只有一个DataBase
  • 租户库(蓝色)
    • 存放各个租户的业务数据,例如:订单,合同,收支金额等等
    • 每个租户一个DataBase, 1万个租户就对应1万个DataBase
    • 允许将DataBase划分到不同的数据库实例(Server)中
  • 应用库(绿包)
    • 存放特定用途的数据,例如:全局配置,日志,等等数据(不需要区分租户,且用途单一)
    • DataBase数量不固定



数据访问

对于SaaS应用程序来说,数据访问操作的关键点其实就是:数据库连接的切换问题。

根据上图所示,切换数据库连接有4种场景:

  1. 访问主库
    • 例如:需要校验 "租户ID" 这类操作
  2. 访问应用库
    • 例如:读配置,写日志
  3. 用户登录
    • 此时用户身份没有确定,不知道属于哪个租户
  4. 已登录用户访问租户业务数据
    • 根据当前用户身份,访问对应的租户数据

下面来介绍如何实现以上4种场景的数据访问,示例代码全部用于Controller代码。



访问主库(Controller代码)

public string ConvertToTenantId(string tenantCode)
{
    string sql = "select tenantId from p_tenants where tenantCode = @tenantCode";
    var args = new { tenantCode };

    // 连接到主库
    using DbContext dbContext = this.CreateMasterConnection();

    return dbContext.CPQuery.Create(sql, args).ExecuteScalar<string>();
}

访问应用库(Controller代码)

private void WirteLog(string text)
{
    string sql = "insert into .....";
    var args = new { text };

    // 连接到应用库, "logging" 是连接名称,它注册在配置服务中
    using DbContext dbContext = this.CreateAppDbConnection("logging");
    dbContext.CPQuery.Create(sql, args).ExecuteNonQuery();
}

说明:

  • 主库和应用库的连接,必须事先在配置服务中注册,例如下图所示
  • 主库的连接名称固定为:master

xx



用户登录(Controller代码)

[HttpPost]
[LoginAction]    
[Route("login-demo.aspx")]
public int UserLoginDemo([Required][FromForm] string userCode, [Required][FromForm] string pwd, [Required][FromForm] string tenantCode)
{
    // 租户ID对用户不友好,用户只知道租户Code,所以转换得到 tenantId
    string tenantId = ConvertToTenantId(tenantCode);

    string pwdHash = HashHelper.Sha256(pwd);

    string sql = "select count(*) from LoginUsers where userCode = @userCode and pwd = @pwd";
    var args = new { userCode, pwd = pwdHash };

    // 连接到指定的租户数据库
    using( DbContext dbContext = this.CreateTenantConnection(tenantId) ) {

        // 查询数据库,判断用户名和密码是否正确
        DemoLoginUser user = dbContext.CPQuery.Create(sql, args).ToSingle<DemoLoginUser>();

        if( user != null ) {
            // 用户名密码正确
            WebUserInfo userInfo = new WebUserInfo { TenantId = tenantId, UserCode = userCode  /*  设置各数据成员  */  };
            AuthenticationManager.Login(userInfo, 3600 * 24 * 7);
            return 200;
        }
        else {
            // 用户名或者密码 不正确
            return 0;
        }
    }
}


已登录用户访问租户业务数据(Controller代码)

[Authorize]  // 确保只能【已登录用户】才能访问
[HttpGet]
[Route("demo2.aspx")]
public void ReadTenantData(int xxxId)
{
    string sql = "select * from TableXXX where id = @id";
    var args = new { id = xxxId };

    // 根据当前用户身份,切换到对应的租户库
    using DbContext dbContext = this.CreateTenantConnection();
    object data = dbContext.CPQuery.Create(sql, args).ToScalarList<string>();
}

说明:

  • CreateTenantConnection() 会根据当前【已登录】用户身份中的 TenantId 自动切换到对应的租户库
  • 如果当前用户没有登录,或者 用户身份的 TenantId 为空,则抛出异常!




在消息处理或者后台任务中访问数据库

可参考以下代码

// 打开 主库 的连接
using DbContext dbContext = DbConnManager.CreateMaster();

// 打开  某个应用库 的连接
using DbContext dbContext = DbConnManager.CreateAppDb("logging");

// 打开 某个租户库 的连接
using DbContext dbContext = DbConnManager.CreateTenant(tenantId);

SaaS多租户架构--应用程序

本文主要介绍SaaS架构中除 数据库 之外的开发过程。


租户标识

通常有2个:

  • TenantId
  • TenantCode

使用建议:

  • TenantId:用于数据持久化,对用户不可见
  • TenantCode:供用户输入或显示,它与 TenantId 一一对应



租户存储

Nebula的设计目标是一款通用的开发框架,因此对 租户存储 没有任何定义,应用程序可以根据实际业务需求来自由定义数据结构,这里只给出几条参考建议:

  • 租户总表存放在 master 数据库,其中不要包含【数据库连接字符串】
    • 数据库连接字符串 由配置服务来管理
  • 不要在内存中缓存所有租户数据,想像下未来可能会有100W个租户



用户身份

多租户架构中,最核心的就是用户身份的设计,因为一切数据来源都是用户的操作。

Nebula提供了一个接口来定义用户身份信息:

public interface IUserInfo
{
    string TenantId { get; }
    string UserId { get; }
    string UserName { get; }
    string UserRole { get; }

其中一个重要的成员 TenantId 就是租户ID,所以当用户执行操作时会知道TA对应哪个租户库。

Nebula提供2个实现类供开发者使用

  • WebUserInfo:表示一个普通用户(浏览器前端)
  • AppClientInfo:用于第三方的应用程序客户端

如果这些类型的数据成员不能满足你的需求,你可以自行实现这个接口。

当执行用户登录时,调用 AuthenticationManager.Login(...) 方法,第一个参数就是 IUserInfo 类型。
在已登录场景,调用 nhttpContext.GetUserInfo() 返回的就是 登录时 传入的 IUserInfo 对象



URL

核心原则:

  • URL地址能区分租户(URL中要包含TenantCode),例如使用场景:链接分享
  • 对于已登录用户,URL中的TenantCode必须和用户身份中的TenantId保持一致(注意校验)

建议实现方式:

  • 二级域名,例如:http://{TenantCode}.YourApp.com
  • URL片段,例如:http://www.YourApp.com/v20/api/xxapp/{TenantCode}/....



RabbitMQ

虽然RabbitMQ有内建的多租户架构支持,即:使用 vhost 来隔离,但是不建议使用这个特性,原因如下:

  • 当租户数量较多时,例如 1万个租户,那么 1万个 vhost 在界面中就没法看了!
  • 会导致大量的连接,极度浪费连接资源(线程和内存),流量负载也不均衡。

所以,建议的方式是:

  • 在消息体中增加一个数据成员 TenantId 来区分租户
  • 不同的业务数据,使用不同的队列
  • 如果业务的响应时间要求较高,可规划不同的优先级,并创建不同的队列



Redis

建议:

  • key 中包含 TenantId 信息
  • 根据业务用途,可以使用到不同的Redis实例



OSS存储

建议:

  • path格式: bucket/{TenantId}/{业务用途}/{日期-可选部分}/{filename}

识别&切换租户

在SaaS应用程序中,识别租户非常重要,是个必须的操作。

本文分为4类场景分别介绍:

  • 人工触发操作
    • 普通用户的页面操作(HttpAction)
    • 后台管理员的页面操作(HttpAction)
  • 消息处理(MessageHandler)
  • 周期性后台任务(BackgroundTask)



普通用户操作

这是最常见的场景:用户"张三"登录到租户A,那么他的后续操作都是基于租户A

实现过程

  • 登录时,根据登录表单提交的tenantId,创建 WebUserInfo 时指定,例如:
WebUserInfo userinfo = new WebUserInfo{
    TenantId = tenantId // 登录表单提交的 tenantId
    UserId = "U0001",
    UserName = "张三",
    UserRole = "NormalUser"
}
AuthenticationManager.Login(userInfo, seconds);
  • Action授权要求
[Authorize(Roles = "NormalUser")]
// 或者
[Authorize(Roles = "NormalUser,Admin")]
  • Action中获取租户ID
string tenantId = this.GetTenantId();
// 或者
string tenantId = this.GetUserInfo().TenantId;

从示例代码中应该可以看出:租户信息和用户身份信息是绑定在一起的

注意:

  • Nebula的这个设计与其它框架有很大的差异
  • 其它框架提供了多种租户解析器(CurrentUser/QueryString/Form/Route/Header/Cookie/Domain),还允许自定义租户解析器

Nebula这样设计的原因是:为了安全!

因为:

  • 此类应用程序对外公开,任何用户都可能访问到
  • 访问用户几乎是外部用户,任何已登录用户都可能发起安全攻击
  • HTTP请求的数据是可以伪造的!
  • 只有JWT-token的数据是防伪造的。




后台管理员操作

在SaaS应用程序中,租户管理操作通常以一个后台站点形式存在,这个站点有以下特点:

  • 有独立的域名,不对外公开
  • 只允许 后台管理员 登录和访问
  • 可以处理任何租户
    • 后台管理员的身份不同某个租户绑定
    • 每次操作的租户是明确的

实现过程

  • 登录时,从master库校验管理员帐号是否正确,创建 WebUserInfo 时指定,例如:
WebUserInfo userinfo = new WebUserInfo{
    TenantId = Consts.ZeroTenantId,  // 一个特殊的租户ID,不发挥作用
    UserId = "S0001",
    UserName = "李四",
    UserRole = "SaasAdmin"
}
  • Action授权要求
[Authorize(Roles = "SaasAdmin")]
  • Action中获取租户ID

此时租户ID从 提交数据 中获取,根据实际场景来定义数据结构即可。

说明:

  • 后台管理员与普通用户相比,身份不绑定租户,这是由于业务的需要而决定的
  • 理论上可以使用后台管理员的身份来发起伪造的HTTP请求,然而:
    • 没有必要,因为后台管理员本身就拥有这种权限
    • 后台管理站点的操作有限,管理员能执行的操作也是有限的
    • 前台站点可以不允许后台管理员登录,授权检查也能阻止后台管理员访问




消息处理

这种场景下,由于用户没法直接参入,因此可认为消息数据是无法伪造的。

所以,租户ID(TenantId)可以直接定义在消息结构中,例如:

public class XxxMessage1 {
  public string TenantId {get; set;}
  // 其它的消息成员
}

通常来说,消息是在 HttpAction 中创建并发送到消息队列的,只要保证HttpAction按照本文前面介绍的方法来实现,消息数据就是安全的(不会被伪造)




周期性后台任务

使用这种场景的租户操作,大致有2类:

  • 一次性处理所有租户(维护类任务)
    • 读取租户总表,然后循环处理每个租户
  • 根据配置处理特定的租户
    • 读取配置参数值,拆分成数组,然后循环处理每个租户

因此,这类场景并不存在安全伪造问题,这里就不再细述。




切换租户

按照 多租户架构 的介绍,各个租户的数据是隔离的,因此切换租户也就是切换到对应的租户数据库。

Nebula提供以下方法可供使用

// Controller
DbContext dbContext = this.CreateTenantConnection();

// MessageHandler or BackgroudTask
DbContext dbContext = DbConnManager.CreateTenant(tenantId);

安全开发须知

说明:本文只介绍与Nebula相关的安全开发内容。


密码与帐号

例如:

  • 邮箱帐号
  • OSS连接帐号

这种帐号一般可以在公网上使用,一旦泄漏会造成比较严重的安全问题。

使用建议:这些帐号必须保存在配置服务中,代码中可以通过下面方法来获取:

var smtpConfig = Settings.GetSetting<SmtpConfig>("邮箱参数名称");



数据库连接与帐号

这类帐号也必须在配置服务中注册。

甚至Nebula提供的很多方法只接受“连接名称”。

具体可参考:数据库连接



用户登录

接受用户名和密码的WebApi接口,要符合以下要求:

  • 仅支持 POST 方式调用
  • Acton方法上标记 [LoginAction]
  • 对于多次密码错误,要能锁定帐号



SQL查询

开发要求:

  • 尽量使用参数化SQL
  • 尽量使用强类型数据,例如:int, DateTime 类型的数据不要定义成 string



数据加密

开发要求:

  • 密钥不要写死在代码中
  • 尽量使用 ClownFish 提供的加密或者HASH方法
  • 用户登录密码不要明文保存,只保存HASH值即可
  • X509证书,以【独立配置文件】方式保存在配置服务中

不推荐做法

为了更适合微服务开发,以及服务治理问题,以下做法是不推荐的:

  • 轮询数据库
    • 这应该是最差劲的设计了!!
    • 建议使用消息队列来代替。
  • appsettings.json 及 Configuation API
  • .net logging API,以及 log4j 这类日志组件
  • Console.Write
  • Quartz.NET or Hangfire
    • 增加数据存储压力
    • 日志不完整
    • 没有监控
    • 建议使用 BackgroundTask
  • 大量 try...catch
    • 建议做法:如果没有 try...catch 程序不崩溃,那就不要写!
  • 把Redis当成数据库来保存持久化数据
    • 不推荐原因:Redis的持久化设计的非常简陋,而且没有事务支持
    • 建议做法:
      • Redis只是一个缓存服务,如果缓存没有就查询数据库
      • 不要非常频繁的调用Redis,这样会导致性能极差
      • 需要频繁访问的缓存数据,建议使用静态变量来代替,性能会有极大提升!









请点击目录中的节点查看具体文档

数据表&实体

创建数据表

请根据业务需求创建对应的数据表,这里不举例,只说几点要求:

  • 【强制】字段名只允许 英文字母数字和下划线,不允许用界定符包含特殊符号
  • 【必须】为每个数据表创建 主键(聚集索引)
  • 【必须】主键必须是 单个字段,禁止使用复合主键,建议采用自增 int/long 列
  • 【必须】如果需要保证数据行不重复,需要创建一个GUID的 唯一索引
  • 【禁止】定义无【无符号】数字类型的 字段 (MySQL)
  • 【建议】增加必要的 冗余字段 降低查询的复杂度
  • 【建议】所有字段建议 NOT NULL
  • 【建议】字段名和表名避开数据库的关键字,例如:server, database
  • 【建议】根据业务需求识别经常要查询的字段,创建必要的普通索引



其它要求及建议可参考:数据库设计规范



生成实体类型

数据表对应的实体类型请用工具来生成,不要手写代码!

xx

然后将实体代码复制到Data项目即可。
注意:请根据具体的项目及目录,保留命名空间




Data项目说明

  • 它是一个类库项目,
  • 必须按下面方式 添加一个AssemblyInfo.cs
// ###################################
// EntityAssembly 标记这个程序集包含数据实体的定义
// ###################################

// 这个标记将告诉Nebula启动时搜索程序集中定义的实体类型,
// 并为它们生成代理类型。

// 如果实体没有代理类型,将不能使用Nebula中一些高级的数据访问功能。




// 再说一遍:包含实体的程序集一定要加这个标记!
[assembly: ClownFish.Data.EntityAssembly]



Entity & DTO

一些原则和技巧:

  • Entity(数据实体)类型与一个数据表对应
  • 无法与数据表匹配的数据结构请使用 DTO
  • [DbColumn]既可用于 Entity,也可用于 DTO
  • 数据库字段名和 .NET类型的属性名称不一样,可用 [DbColumn(Alias = "xxx")] 来设置映射别名
  • 数据库字段名和.NET类型的属性名映射时,不区分大小写

数据库连接

在使用ClownFish访问数据库时,必须先创建一个 DbContext 实例,它有2个作用:

  • 封装着ADO.NET的数据库连接
  • 所有数据操作方法的入口,可参考:执行命令

本文将介绍这个对象的创建方法。



在独立应用中

using( DbContext dbContext = DbContext.Create(connectionString, providerName) ) {
    // .......
}

参数providerName可以使用下定义的常量

/// <summary>
/// 一些常用的数据访问客户端 ProviderName
/// </summary>
public static class DatabaseClients
{
    public static readonly string SqlClient = "System.Data.SqlClient";

    public static readonly string MySqlClient = "MySql.Data.MySqlClient";

    public static readonly string PostgreSQL = "Npgsql";

    public static readonly string SQLite = "System.Data.SQLite";

    public static readonly string DaMeng = "Dm";
}

或者

using( DbContext dbContext = DbContext.Create(connName) ) {
    // .......
}

参数 connName 表示一个在 ClownFish.App.config 注册的数据库连接名称,例如:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>

    <!-- 数据库连接配置 -->
    <connectionStrings>
        <add name="db1" providerName="System.Data.SqlClient"
			 connectionString="server=MsSqlHost;database=MyNorthwind;uid=user1;pwd=xxxx"/>
        <add name="db2" providerName="MySql.Data.MySqlClient"
			 connectionString="server=MySqlHost;database=MyNorthwind;uid=user1;pwd=xxxx"/>
        <add name="db3" providerName="Npgsql"
            connectionString="Host=PgSqlHost;database=mynorthwind;Username=postgres;Password=xxxx"/>
    </connectionStrings>

    <!-- 或者把数据库连接配置在这里 -->
    <dbConfigs>
        <add name="s1" dbType="SQLSERVER" server="MsSqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="" />
        <add name="m1" dbType="MySQL" server="MySqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="" />
        <add name="pg1" dbType="PostgreSQL" server="PgSqlHost" database="mynorthwind" uid="postgres" pwd="xxxxxx" args="" />
    </dbConfigs>
</configuration>

其中,"db1", "db2", "db3", "s1", "m1", "pg1" 都是有效的【数据库连接名称】





在微服务项目中

Nebula采用了多租户的架构设计,因此将数据库按用途分为3大类:

  • 租户库:存储各个租户的业务数据,保证隔离性
  • 主库:存储全局业务数据(不区分租户),例如:租户表
  • 应用库:存储特定应用场景的数据(不区分租户),例如:框架配置

在Controller中,可以调用BaseController提供的方法打开数据库连接

  • CreateMasterConnection(): 打开主库连接
  • CreateTenantConnection(): 打开与当前 已登录 用户关联的租户库连接
  • CreateTenantConnection(string tenantId):打开指定的租户库连接
  • CreateAppDbConnection(string connName):根据连接名称打开连接

在Controller之外(消息处理或者后台任务),可以调用DbConnManager来实现相同的功能:

  • CreateMaster(): 打开主库连接
  • CreateTenant(string tenantId):打开指定的租户库连接
  • CreateAppDb(string connName):根据连接名称打开连接

注意事项:

  • 在执行任何数据库操作之前,都需要先打开数据库连接。
  • 数据库连接用 DbContext 类型封装,它与线程没有关联,跨层使用时,可通过调用参数来传递。
  • DbContext实现了IDisposable接口,在创建后需要使用 using 语句块包起来



连接到 主库

public class ConnDbController : BaseController
{
    public async Task<string> Test1()
    {
        // 连接到【主库】
        using( DbContext dbContext = this.CreateMasterConnection() ) {

            return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
        }
    }
}



连接到 租户库

用户登录场景

public async Task<string> Login()
{
    // 从请求中获取到的租户信息
    // 此时用户还没有经过登录验证,这时候需要在提交登录数据时把 租户ID 一起发送到服务端
    // 服务端根据 租户ID 就可以连接到相应的租户库
    string tenantId = "xxxxxxxxxx";

    using( DbContext dbContext = this.CreateTenantConnection(tenantId) ) {  // 连接到 指定的【租户库】

        User user = await dbContext.CPQuery.Create("select * from users where username = @username and pwd = @pwd").ToSingelAsync<User>();
    }
}

【已登录】用户的后续访问

public async Task<string> Test()
{
    // 连接到  与当前已登录用户对应的 【租户库】
    using( DbContext dbContext = this.CreateTenantConnection() ) {

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}



连接到 应用库

public async Task<string> Test()
{
    // 连接到 某个特定的 【应用库】
    // config 是一个连接名称,在【配置服务】中有对应的连接设置

    using( DbContext dbContext = this.CreateAppDbConnection("notify") ) {

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}



嵌套连接

public async Task<int> TestNestConnection()    // 演示【嵌套连接】
{
    // 根据用户身份信息(租户ID)切换到【当前租户库】,
    using( DbContext dbContext = this.CreateTenantConnection() ) {

        // 在【当前租户库】中执行第一个数据操作
        dbContext.XmlCommand.Create("query1").ExecuteNonQuery();

        // 打开第二个连接,
        using( DbContext dbContext2 = this.CreateAppDbConnection("notify") ) {

            // 在 database2 中执行数据操作
            dbContext2.XmlCommand.Create("query2").ExecuteNonQuery();
        }

        // 继续在【当前租户库】中执行数据操作
        await dbContext.XmlCommand.Create("query3").ExecuteNonQueryAsync();
    }

    return 1;
}



在Controller之外(消息处理或者后台任务) 打开连接

using( DbContext dbContext = DbConnManager.CreateMaster() ) {
    // .......
}

using( DbContext dbContext = DbConnManager.CreateAppDb("connName") ) {
    // .......
}

using( DbContext dbContext = DbConnManager.CreateTenant(tenantId) ) {
    // .......
}

实体CURD

本文将演示基于数据实体的CURD操作。



单个实体 CURD / Entity方法版本

public async Task<long> Insert(Customer customer)
{
    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
        return dbContext.Entity.Insert(customer, InsertOption.GetNewId);
    }
}

public async Task<int> Delete(int id)
{
    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
        return await dbContext.Entity.DeleteAsync<Customer>(id);
    }
}

public async Task<int> Update(int id, Customer customer)
{
    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
        customer.CustomerID = id;
        return dbContext.Entity.Update(customer);
    }
}

public async Task<Customer> GetById(int id)
{
    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
        return await dbContext.Entity.GetByKeyAsync<Customer>(id);
    }
}

注意NULL值

  • 上面的 dbContext.Insert(...) / dbContext.Update(...) 在执行时会忽略实体中的 NULL值字段
  • 如果需要将空值写入数据库,请使用下面的的【实体代理版本】





单个实体 CURD / 实体代理版本

using( DbContext db = DbConnManager.CreateAppDb("mysqltest") ) {
    // 插入一条记录,只给2个字段赋值
    ModelX obj = db.Entity.CreateProxy<ModelX>();
    obj.IntField = 1978;
    obj.StringField = "abc";
    obj.Insert();

    // 检验刚才插入的数据行
    ModelX m1 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNotNull(m1);
    Assert.AreEqual("abc", m1.StringField);

    // m1 进入编辑状态
    m1 = db.Entity.CreateProxy(m1);
    m1.StringField = "12345";
    int effect = m1.Update();        // 提交更新,WHERE过滤条件由主键字段决定
    Assert.AreEqual(1, effect);

    // 检验刚才更新的数据行
    ModelX m2 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNotNull(m2);
    Assert.AreEqual("12345", m2.StringField);

    // 删除数据行
    ModelX obj2 = db.Entity.CreateProxy<ModelX>();
    obj2.IntField = 1978;
    effect = obj2.Delete();
    Assert.AreEqual(1, effect);

    // 检验删除结果
    ModelX m3 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNull(m3);
}





多行更新和删除

using( DbContext db = DbConnManager.CreateAppDb("mysqltest") ) {

    dbContext.Entity.Update<Product>(
        /* set */    x => { x.Unit = "x"; x.Quantity = 9; }, 
        /* where */  x => x.UnitPrice < 12 && x.CategoryID == 0);

    dbContext.Entity.Delete<Product>(
        /* where */  x => x.UnitPrice < 12 && x.CategoryID == 0);
}





实体批量插入/加载


public async Task<int> BatchInsert()
{
    int newOrderId = 0;

    // 从前端传递回来的主从表对象
    Order order = GetOrderObject(/****/);

    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {

        // 建议在事务中执行批量插入操作
        dbContext.BeginTransaction();

        // 先插入主表记录
        newOrderId = dbContext.Entity.Insert(order, InsertOption.GetNewId);

        // 将 新ID 赋值给各子表实体
        order.Details.ForEach(x=>x.OrderID = newOrderId);

        // 批量插入 子表记录
        await dbContext.Batch.InsertAsync(order.Details);

        dbContext.Commit();
    }

    return newOrderId;
}


public async Task<Order> GetOrderById(int id)
{
    // 查询单个Order实体对象
    Order order = await (from x in dbContext.Entity.Query<Order>()
                            where x.OrderID == id
                            select x).FirstOrDefaultAsync();

    if( order != null )
        // 查询 OrderDetail 列表
        order.Details = await (from x in dbContext.Entity.Query<OrderDetail>()
                                where x.OrderID == id
                                select x).ToListAsync();

    return order;
}

实体代理类型

实体代理的用途就是识别实体的属性变更,并基于变更支持实体对象的 Insert/Update/Delete 操作。
因此实体对象提供的 Insert/Update/Delete 方法都要在实体代理类型上才能调用。

例如下面的示例:

using( DbContext db = DbConnManager.CreateAppDb("mysqltest") ) {

    // 先删除之前测试可能遗留下来的数据
    ModelX xx = db.Entity.CreateProxy<ModelX>();
    xx.IntField = 1978;
    xx.Delete();


    // 插入一条记录,只给2个字段赋值
    ModelX obj = db.Entity.CreateProxy<ModelX>();
    obj.IntField = 1978;
    obj.StringField = "abc";
    obj.Insert();

    // 检验刚才插入的数据行
    ModelX m1 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNotNull(m1);
    Assert.AreEqual("abc", m1.StringField);

    // m1 进入编辑状态
    m1 = db.Entity.CreateProxy(m1);
    m1.StringField = "12345";
    int effect = m1.Update();        // 提交更新,WHERE过滤条件由主键字段决定
    Assert.AreEqual(1, effect);

    // 检验刚才更新的数据行
    ModelX m2 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNotNull(m2);
    Assert.AreEqual("12345", m2.StringField);

    // 删除数据行
    ModelX obj2 = db.Entity.CreateProxy<ModelX>();
    obj2.IntField = 1978;
    effect = obj2.Delete();
    Assert.AreEqual(1, effect);

    // 检验删除结果
    ModelX m3 = (from x in db.Entity.Query<ModelX>() where x.IntField == 1978 select x).FirstOrDefault();
    Assert.IsNull(m3);
}



下面来看一个具体的示例,假如有一个实体:
xx

它对应的代理类型结构是:
xx



实体代理类型的产生

实体代理类型不需要在开发时编写,通常会在程序启动时自动产生,所以我们只需要知道它的存在以及用途就可以了。

基于Nebula开发的程序,会在程序启动时搜索打有 [assembly: ClownFish.Data.EntityAssembly] 标记的程序集, 并在其中搜索公开的实体类型,并为它们产生实体代理类型。 这个过程将会消耗一点时间,本文后面会介绍如何优化这部分时间的消耗。



实体的加载器类型

实体加载器类型的用途就是优化从数据库结果转实体对象的过程, 它用一种最优化的实现方式来减少这个过程的时间消耗, 大致思路就是自动生成一些机械但高效的代码来实现数据实体的加载过程。

这里还是以前面的 Category 类型举例,它对应的加载器类型的结构如下:
xx

最终的性能提升可以查看:https://www.cnblogs.com/fish-li/archive/2012/07/17/ClownFish.html

实体的代理程序集

程序启动消耗

由于实体代理类型和实体加载器类型都不需要事先编写,通常会在程序启动时自动产生,因此会占用程序的启动时间,

例如下面的服务启动耗时统计:
xx

这个时间并不固定,和机器的性能,繁忙程度、以及实体类型的数量有关。

如果你希望消除这个时间消耗,方法是使用 ClownFish.CodeGen 这个命令行工具来提前完成程序启动的那部分工作,它会生成一个程序集,包含所有实体的代理类和加载器类。 有了这个程序集之后,程序启动时就会跳过这个过程。



ClownFish.CodeGen

ClownFish.CodeGen是一个命令行工具,目前的功能只有一个:

  • 为数据实体类型生成代理类和加载器类,并编译生成程序集。



命令行用法:

dotnet.exe ClownFish.CodeGen.dll  d:\xxx\bin\net7.0  xxx.EntityProxy.dll



建议在 docker build 前运行此工具,例如下面的脚本片段:

pushd ext/Nebula.Moon/bin

# 运行命令行工具,生成代理类和加载器类
docker run --rm  -v $PWD:/xbin  -w /xbin yyw-registry-vpc.cn-hangzhou.cr.aliyuncs.com/nebula/sdk:7.0  dotnet /clownfish/ClownFish.CodeGen.dll  /xbin/publish

# 为应用程序构建镜像,然后推送到镜像仓库
cp ../Dockerfile .
docker build -t nebula_moon  .
docker tag moon yyw-registry-vpc.cn-hangzhou.cr.aliyuncs.com/nebula/moon:$version
docker push yyw-registry-vpc.cn-hangzhou.cr.aliyuncs.com/nebula/moon:$version
popd

说明:上面示例中,我先做了一个用于编译的docker镜像,里面就包含 ClownFish.CodeGen



优化后的启动时间消耗

还是用上面那个服务为例,新的时间消耗如下图:
xx



在诊断页 /nebula/debug/sysinfo.aspx 的下面,我们可以看到:
xx

表示在程序启动时,没有编译生成任何类型,但加载了一个代理程序集。
cfd_default_EntityProxy.dll 是一个默认的名称,因为我在前面脚本中没有指定新的程序集名称。

LINQ查询

对于一些简单查询场景,可以不需要写SQL,用 LINQ查询 代替。

使用场景

  • 编辑界面
  • 查看详情
  • 填充列表框

注意事项

  • 一个LINQ查询只支持一张表
  • LINQ查询只是为了解决一些简单场景,复杂场景请使用 SQL查询



单表查询

public class OrderBLL : BaseBLL
{
    /// <summary>
    /// 时间范围查询,WHERE中包含二个字段过滤
    /// </summary>
    /// <param name="start"></param>
    /// <param name="end"></param>
    /// <returns></returns>
    public async Task<List<Order>> GetByTimeRange(DateTime start, DateTime end)
    {
        var query = from t in dbContext.Entity.Query<Order>()
                    where t.OrderDate >= start && t.OrderDate < end
                    select t;

        return await query.ToListAsync();
    }
}



主从表查询

/// <summary>
/// 演示多个查询,合并构造一个复杂对象
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public async Task<Order> GetById(int id)
{
    // 查询单个Order实体对象
    Order order = await (from x in dbContext.Entity.Query<Order>()
                         where x.OrderID == id
                         select x).ToSingleAsync();

    if( order != null )
        // 查询 OrderDetail 列表
        order.Details = await (from x in dbContext.Entity.Query<OrderDetail>()
                               where x.OrderID == id
                               select x).ToListAsync();

    return order;
}



查询部分字段

var quey = from x in dbContext.Entity.Query<Customer>()
           where x.CustomerID == 100
           // 注意下面这行代码,将只从数据库中查询二个字段
           select new Customer { CustomerID = 0, CustomerName = "" };

Customer customer = await quey.ToSingleAsync();

说明:只需要在 select 子句中列出要查询的字段即可,上例就列出了2个字估。



IN查询

string b = "aaa";
string c = null;

int[] array = new int[] { 1, 2, 3, 4, 5 };

var query = from t in db.Entity.Query<Product>()
            where (t.ProductID == _f5
                    || array.Contains(t.CategoryID)
                    || t.ProductName.StartsWith(b)
                    )
                    && t.Remark != c
            select t;

List<Product> list = query.ToList();



LIKE查询操作符

  • 右模糊查找: StartWidth
  • 全模糊查找: Contains



支持的操作符范围

xx

SQL查询

本文将介绍直接使用SQL语句的查询使用方法,主要有2种方式:

  • XmlCommand
  • CPQuery



XmlCommand

如果希望 SQL代码与C#代码分离,我们可以将复杂的SQL语句放在XML文档中,例如以下片段:

<XmlCommand Name="batchDelete">
    <Parameters>
        <Parameter Name="@CustomerID" Type="Int32" />
    </Parameters>
    <CommandText><![CDATA[
    delete from Customers where CustomerID > @CustomerID
    ]]></CommandText>
</XmlCommand>

然后可以这样调用它:

public class CustomerBLL : BaseBLL
{
    public async Task<int> BatchDelete(int id)
    {
        var args = new { CustomerID = id };
        return await dbContext.XmlCommand.Create("batchDelete", args).ExecuteNonQueryAsync();
    }
}

补充说明:

  • 一个XML文件中可以包含多个XmlCommand节点
  • XML文件的取名没有特殊要求,扩展名要求是 .config
  • XML文件编码要求是 UTF-8 with BOM
  • 多个XML文件中的XmlCommand节点名称不能相同
  • 建议使用DataTools来维护XmlCommand,自然符合以上所有要求



CPQuery

CPQuery: Concat Parameterized Query,

设计意图可参考:CPQuery, 解决拼接SQL的新方法

使用示例:

public class CustomerBLL : BaseBLL
{
    public async Task<int> BatchDelete2(int id)
    {
        string sql = "delete from Customers where CustomerID > @CustomerID";
        var args = new { CustomerID = id };
        return await dbContext.CPQuery.Create(sql, args).ExecuteNonQueryAsync();
    }
}

高级查询

本文介绍一些复杂查询的使用场景。

IN 查询

XmlCommand定义

<XmlCommand Name="getByIn">
    <Parameters />
    <CommandText><![CDATA[
    select * from Customers where CustomerID in ( {customerIdList} )
    ]]></CommandText>
</XmlCommand>

注意上面使用了一个 占位参数 {customerIdList}

C#调用代码

public async Task<List<Customer>> GetByIn(int[] ids)
{
    var args = new { customerIdList = ids };
    return await dbContext.XmlCommand.Create("getByIn", args).ToListAsync<Customer>();
}

或者用 CPQuery 来实现

public async Task<List<Customer>> GetByIn2(int[] ids)
{
    string sql = "select * from Customers where CustomerID in ( {customerIdList} )";
    var args = new { customerIdList = ids };
    return await dbContext.CPQuery.Create(sql, args).ToListAsync<Customer>();
}

小结

  • IN查询需要在SQL语句中使用一个占位参数
  • 调用时,参数用数组或者List来赋值
  • 参数类型范围:
    • int[], List<int>
    • long[], List<long>
    • string[], List<string>
    • Guid[], List<Guid>



动态条件查询

示例代码,请仔细阅读代码中的注释

public class ProductBLL : BaseBLL
{
    /// <summary>
    /// 演示动态条件查询(多字段的组合查询)
    /// </summary>
    /// <param name="product"></param>
    /// <param name="pagingInfo"></param>
    /// <returns></returns>
    public async Task<List<Product>> Search(ProductSearchParam product, PagingInfo pagingInfo)
    {
        // 这里演示一种【动态查询】,也就是根据一定条件拼接查询

        // 先创建一个CPQuery基础对象,它包含查询哪些表,用于后面追加查询条件
        CPQuery query = dbContext.CPQuery.Create("select * from Products where (1=1)");

        if( product.CategoryID > 0 ) 
            //【数字参数】可以直接相加,会自动变成SQL参数
            query = query + " and CategoryID = " + product.CategoryID; 

        if( product.UnitPrice > 0 )
            // 【数字参数】可以直接相加,会自动变成SQL参数
            query = query + " and UnitPrice > " + product.UnitPrice; 

        if( string.IsNullOrEmpty(product.Unit) == false )
            // 【字符串参数】由于不能自动识别(到底是SQL的一部分,还是参数),所以需要强制类型转换
            query = query + " and Unit = " + (QueryParameter)product.Unit; 

        if( string.IsNullOrEmpty(product.ProductName) == false )
            // 【字符串参数】,也可以调用扩展方法,转成参数对象
            query = query + " and ProductName like " + product.ProductName.AsQueryParameter(); 

        // 添加排序部分
        query = query + " order by ProductId";

        // 执行查询,获取结果
        return await query.ToPageListAsync<Product>(pagingInfo);
    }
}

注意:

  • CPQuery和各种数据类型 相加 ,这里不是简单的字符串拼接!
  • CPQuery实现了运算符重载,上面那些 + 会做特殊处理,将后面的数据转成标准SQL参数
  • string数据需要 显式转换或者调用AsQueryParameter() ,之后才能与CPQuery相加
  • 非string类型可以直接与CPQuery相加
  • IN查询支持的数据类型,与可以和CPQuery相加



嵌套查询

占位参数还有另一种用法是实现【嵌套查询】,这里的嵌套是指 CPQuery和XmlCommand之间的嵌套。

可参考以下示例:

/// <summary>
/// WHERE中包含占位参数。 CPQuery 嵌套 CPQuery
/// </summary>
/// <returns></returns>
public async Task<DataTable> DemoNestQuery()
{
    // 定义一个基础的SQL查询,它包含一个【固定SQL参数】,以及二个【占位符参数】
    // {orderFilter} , {productFilter} 就是二个【占位符参数】,它们的位置将被另一个CPQuery实例来替换
    string sql = @"
SELECT od.*, p.ProductName, p.Unit, p.UnitPrice, c.CustomerName, os.SumMoney, os.OrderDate
FROM   Orders as os inner join OrderDetails as od on os.orderId = od.orderId
inner join Customers as c on os.CustomerID = c.CustomerID
INNER JOIN  Products as p ON od.ProductID = p.ProductID
WHERE {orderFilter} and os.OrderDate > @OrderDate and {productFilter} ";

    // 获取CPQuery的调用参数,包括2个占位参数
    var args = GetArgs();
    
    // 执行最终的查询
    return await dbContext.CPQuery.Create(sql, args).ToDataTableAsync();
}

请认真查看示例代码中的注释。GetArgs()的代码如下:

private object GetArgs()
{
    // 以下参数变量应该从方法外部传入,为了简单,这里直接硬编码
    int oid = 1;
    decimal money = 500;
    string name = "%原装%";
    decimal price = 20;


    // 构造第一个【占位符参数】,也是根据一系列条件,动态生成
    CPQuery orderFilter = dbContext.CPQuery.Create("(1=1)");
    if( oid > 0 )
        orderFilter = orderFilter + " and os.OrderID > " + oid;

    if( money > 0 )
        orderFilter = orderFilter + " and os.SumMoney > " + money;

    // 构造第二个【占位符参数】,也是根据一系列条件,动态生成
    CPQuery productFilter = dbContext.CPQuery.Create("(2=2)");
    if( string.IsNullOrEmpty(name) == false )
        productFilter = productFilter + " and p.ProductName like " + name.AsQueryParameter();
    if( price > 0 )
        productFilter = productFilter + " and p.UnitPrice > " + price;

    return new {
        OrderDate = new DateTime(2010, 1, 30),
        orderFilter,    // 注意这个参数赋值,它用于替换 {orderFilter}
        productFilter   // 注意这个参数赋值,它用于替换 {productFilter}
    };
}

如果使用XmlCommand,C#代码会简洁一些:

/// <summary>
/// WHERE中包含占位参数。 XmlCommand 嵌套 CPQuery
/// </summary>
/// <returns></returns>
public async Task<DataTable> DemoNestQuery2()
{
    // 获取XmlCommand的调用参数,包括2个占位参数
    var args = GetArgs();

    // 与上例相同的结果,只是将SQL分离到 XmlCommand 中
    return await dbContext.XmlCommand.Create("demoNest", args).ToDataTableAsync();
}

执行命令

ClownFish支持5大类方式来操作数据库:

  • 使用 CPQuery 来构造或者实现动态查询
  • 使用 XmlCommand 执行配置化的SQL查询
  • 使用 StoreProcedure 执行存储过程命令(不推荐)
  • 基于实体 Entity 的CRUD/LINQ/Proxy
  • 批量执行CUD操作

这5类操作可以通过 DbContext 的5个属性对象来访问:

  • dbContext.CPQuery.Create(...).ExecuteXXX()/ToXXX()
  • dbContext.XmlCommand.Create(...).ExecuteXXX()/ToXXX()
  • dbContext.StoreProcedure.Create(...).ExecuteXXX()/ToXXX()
  • dbContext.Entity.XXX()
  • dbContext.Batch.XXX()

CPQuery/XmlCommand/StoreProcedure 它们继承同一个基类BaseCommand,因此拥有相同的操作方法。



注意:本文只列出了所有同步方法,以Async结尾的异步方法没有列出



BaseCommand支持的操作方法

ExecuteXXX / 执行修改操作

/// <summary>
/// 执行命令,并返回影响函数
/// </summary>
/// <returns>影响行数</returns>
public int ExecuteNonQuery()


/// <summary>
/// 执行命令,返回第一行第一列的值
/// </summary>
/// <typeparam name="T">返回值类型</typeparam>
/// <returns>结果集的第一行,第一列</returns>
public T ExecuteScalar<T>()

ToXXX / 查询数据

/// <summary>
/// 执行命令,并返回第一列的值列表
/// </summary>
/// <typeparam name="T">返回值类型</typeparam>
/// <returns>结果集的第一列集合</returns>
public List<T> ToScalarList<T>()


/// <summary>
/// 执行命令,将结果集转换为实体列表
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <returns>实体集合</returns>
public List<T> ToList<T>() where T : class, new()


/// <summary>
/// 执行命令,将结果集的第一行转换为实体
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <returns>实体</returns>
public T ToSingle<T>() where T : class, new()


/// <summary>
/// 执行查询,以DataTable形式返回结果
/// </summary>
/// <param name="tableName">DataTable的表名</param>
/// <returns>查询结构的数据集</returns>
public DataTable ToDataTable(string tableName = null)


/// <summary>
/// 执行查询,以DataSet形式返回结果
/// </summary>
/// <returns>数据集</returns>
public DataSet ToDataSet()


/// <summary>
/// 执行查找命令,生成分页结果,将结果集转换为实体列表
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="pagingInfo"></param>
/// <returns></returns>
public List<T> ToPageList<T>(PagingInfo pagingInfo) where T : class, new()


/// <summary>
/// 执行查找命令,生成分页结果
/// </summary>
/// <param name="pagingInfo"></param>
/// <returns></returns>
public DataTable ToPageTable(PagingInfo pagingInfo)



Entity支持的操作方法

开始LINQ查询

/// <summary>
/// 开始LINQ查询
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public EntityQuery<T> Query<T>() where T : Entity, new()

创建实体代理对象

/// <summary>
/// 创建实体的代理对象。
/// 实体代理对象可感知属性的所有变更情况,提供动态SQL生成能力,
/// 实体代理对象提供了简便的 Insert/Update/Delete 方法。
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="entity"></param>
/// <returns></returns>
/// <example>
/// var product = Entity.BeginEdit(product);
/// product.ProductId = 123;
/// product.ProductName = "xxxx";
/// product.Insert() or Update() or Delete()
/// </example>
/// <exception cref="NotSupportedException"></exception>
public T CreateProxy<T>(T entity = null) where T : Entity, new()

CURD 方法

/// <summary>
/// 将一个实体插入到数据库
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="entity">实体对象</param>
/// <param name="flags">Insert操作控制参数</param>
/// <returns></returns>
public long Insert<T>(T entity, InsertOption flags = InsertOption.NoSet) where T : Entity, new()


/// <summary>
/// 用当前实体的属性值(非NULL值)更新数据库
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="entity">实体对象</param>
/// <returns></returns>
public int Update<T>(T entity) where T : Entity, new()



/// <summary>
/// 根据实体类型和对应的主键字段值,删除对应的数据表记录
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="key">主键字段值</param>
/// <returns></returns>
public int Delete<T>(object key) where T : Entity, new()


/// <summary>
/// 根据实体类型和对应的主键字段值,查询对应的实体对象
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="key">主键字段值</param>
/// <returns></returns>
public T GetByKey<T>(object key) where T : Entity, new()


InsertOption 解释

/// <summary>
/// Insert操作控制参数
/// </summary>
[Flags]
public enum InsertOption
{
    /// <summary>
    /// 默认行为。
    /// </summary>
    NoSet = 0,

    /// <summary>
    /// 为全部字段生成INSERT语句,
    /// 如果不指定此项,则(根据指定过的属性动态生成INSERT语句)
    /// </summary>
    AllFields = 1,

    /// <summary>
    /// 执行INSERT之后,需要获取新产生的 自增ID,
    /// 目前仅支持 SQLSERVER/MySQL/PostgreSQL
    /// </summary>
    GetNewId = 2,

    /// <summary>
    /// 实现幂等操作,忽略重插入复异常,
    /// 如果出现异常,Inert方法返回 -1
    /// </summary>
    IgnoreDuplicateError = 4
}

insert 会有以下几类需求:

  1. 根据指定过的属性动态生成INSERT语句,未指定的字段使用数据库默认值。
  2. 为全部字段生成INSERT语句,此方法性能会更好。
  3. 执行INSERT之后,需要获取新产生的 自增ID
  4. 实现幂等操作,忽略重插入复异常(由唯一索引触发)

insert 返回值规则定义:
  1. 对于需求1,如果不能生成INSERT语句(没有给任何属性赋值),则返回 0
  2. IgnoreDuplicateError=true 且出现 重复插入异常,则返回 -1
  3. GetNewId=false,正常情况返回 ExecuteNonQuery() 结果
  4. GetNewId=true,正常情况返回 新产生的自增ID

有序GUID

目前主流的数据库都有聚集索引的概念,当我们将主键字段使用GUID类型时,

  • 无序GUID的字段值 会影响数据行的插入性能
  • 有序GUID的字段值 将会有接近int的插入性能

因此建议当使用GUID类型的主键时,使用【有序GUID的字段值】



ClownFish对以下数据库及类型提供了有序GUID的支持(已测试):

  • SQLSERVER: UniqueIdentifier 字段类型
  • PostgreSQL: uuid 字段类型
  • MySQL: char(36) 字段类型
  • 达梦: char(36) 字段类型

获取有序GUID的值

Guid guid = dbContext.NewSeqGuid();

使用示例

private static readonly string s_insertSQL = "insert into TestGuid(RowGuid, IntValue) values(@RowGuid, @IntValue)";

private void ExecuteTest(DbContext db)
{
    for( int i = 0; i < 1000; i++ ) {

        var args = new {
            RowGuid = db.NewSeqGuid(),
            IntValue = i + 1
        };

        db.CPQuery.Create(s_insertSQL, args).ExecuteNonQuery();
    }
}

说明:

  • NewSeqGuid方法会根据 数据库类别 生成有效的【有序GUID的字段值】

批量执行命令

基于 for 循环的批量操作

以.NET6之前,我们可以使用以下方式来批量执行数据库修改操作:

using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
    foreach(var x in list){
        dbContext.Entity.Insert(x);
    }
}  

这种方式虽然可以正确完成操作,但是性能不是最好的。
如果希望提升批量操作的性能,可以使用本文下面介绍的方法。




新的批量操作API

可参考以下示例:

public async Task<int> BatchInsert()
{
    // 从前端提交的数据来构造实体对象
    Order order = CreateOrderObject(/****/);

    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {

        // 建议在事务中执行批量插入操作,
        // 如果数据的完整性不是非常重要,也可以不启用事务,获取更好的性能
        dbContext.BeginTransaction();

        // 先插入主表记录
        int newOrderId = dbContext.Entity.Insert(order, InsertOption.GetNewId);

        // 将 新ID 赋值给各子表实体
        order.Details.ForEach(x=>x.OrderID = newOrderId);

        // ############# 批量插入 子表记录 #############
        await dbContext.Batch.InsertAsync(order.Details);

        dbContext.Commit();
        return newOrderId;
    }    
}

小结: 所有的批量操作API可以通过以下方式访问

dbContext.Batch.XXXXX()

目前支持的API:

/// <summary>
/// 将实体列表以批量形式 插入 到数据表中
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="list">实体列表</param>
public int Insert<T>(List<T> list) where T : Entity, new()


/// <summary>
/// 将实体列表以批量形式 插入 到数据表中
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="list">实体列表</param>
public async Task<int> InsertAsync<T>(List<T> list) where T : Entity, new()


/// <summary>
/// 将实体列表以批量形式 更新 到数据表中
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="list">实体列表</param>
public int Update<T>(List<T> list) where T : Entity, new()


/// <summary>
/// 将实体列表以批量形式 更新 到数据表中
/// </summary>
/// <typeparam name="T">实体类型</typeparam>
/// <param name="list">实体列表</param>
public async Task<int> UpdateAsync<T>(List<T> list) where T : Entity, new()


/// <summary>
/// 批量执行一些CRD操作
/// </summary>
/// <param name="list"></param>
/// <returns></returns>
public int Execute(List<BaseCommand> list)


/// <summary>
/// 批量执行一些CRD操作
/// </summary>
/// <param name="list"></param>
/// <returns></returns>
public async Task<int> ExecuteAsync(List<BaseCommand> list)

事务操作

本文将介绍事务的使用方法。

示例代码

[HttpGet]
[Route("throwEx.aspx")]
public async Task<int> throwEx()
{
    CustomerBLL bll = this.CreateBLL<CustomerBLL>();

    // 切换到主库
    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {

        // 开启数据库事务
        dbContext.BeginTransaction();

        var args = new { id = 1, addr = "abd" };
        DataTable table = await dbContext.XmlCommand.Create("name111", args).ToDataTableAsync();

        // 提交事务
        dbContext.Commit();
    }
    
    return 100;
}

小结:

  • 调用 BeginTransaction() 显式开启事务
  • 操作结束后,调用 Commit() 提交事务
  • 如果事务不提交,连接关闭时会回滚
  • 不需要写try...catch 实现回滚

数据同步

需求背景:

  1. 在主库中维护一些标准规则或者基础表
  2. 租户库可以有选择性的引入主库的标准规则,并允许添加客户定制的规则
  3. 租户表允许添加额外的数据字段
  4. 数据的同步是单向的,即:发布模式



目前支持2种模式的数据发布:

  • 基于主外键的数据发布
    xx

  • 基于相同主键的数据发布
    xx



API调用

using DbContext dbContext = DbContext.Create("mysql");
using DbContext dbContext2 = DbContext.Create("mysql2");

DataSyncArgs args = new DataSyncArgs {
    BizName = "Test_1",                      // 业务场景名称。此名称【必须保证唯一】
    SrcDbContext = dbContext,                // 源表的数据连接对象
    DestDbContext = dbContext2,              // 目标表的数据连接对象
    SrcTableName = "products",               // 源表名称
    DestTableName = "products_sync1",        // 目标表名称
    SrcKeyField = "ProductID",               // 源表的主键字段
    DestRelatedKey = "PID",                  // 目标表中,与源表的主键对应字段,可以是【外键】,也可以是【主键】
    SrcFilterSql = "CategoryID = 1",         // Where 语句片段,用于获取源表数据行
};

await DataSync.ExecuteAsync(args);

数据导入

使用场景

  • 从非SQL数据库(例如:influxdb)查询到一个数据结果集
  • 从第三方来源获取一些文本数据,例如:xml, json, csv
  • 从其它各种数据源清洗到一些结构数据表
  • 需要将以上数据结果导入 MySQL



实现过程

  • 将各种来源先转换成 DataTable
  • 再调用 ClownFish 提供的【数据导入】功能写入 MySQL



数据导入API示例

using DbContext dbContext = DbContext.Create("mysql");

DataImportArgs args = new DataImportArgs {
    DestDbContext = dbContext,           // 目标表的数据连接对象
    DestTableName = "TestImport",        // 目标表名称
    Data = datatable,                    // 将要导入的数据
    AllowAutoIncrement = true,           // 是否允许自增列的主键
    WithTranscation = false              // 是否启用事务
};

await DataImport.ExecuteAsync(args);

MySQL异步

ClownFish同时支持3个不同的 MySQL 客户端:

  • MySql.Data
    https://dev.mysql.com/downloads/connector/net/
    它是MySQL的官方客户端,历史较久,因此稳定性会比较好,但是它不支持TAP风格的异步模式。

  • MySqlConnector老版本
    0.x 版本,使用 MySql.Data.MySqlClient 命名空间

  • MySqlConnector新版本
    1.0 以上版本,使用 MySqlConnector 命名空间
    https://mysqlconnector.net/
    它虽然不是官方提供,但它却支持TAP风格的异步模式,而且拥有较好的性能。



切换选择

Nebula已引用 MySqlConnector 2.x ,因此项目中不需要再引用此包。
如果你需要使用 MySql.Data ,请手动添加引用,并按照下面介绍的方法来切换。

强烈建议:不要使用 MySql.Data,除非你要访问阿里的ADS,这方面请阅读本文后续部分。

我们可以通过 环境变量 或者 ClownFish.App.config 来切换使用不同的客户端。

  • MySqlClientProviderSupport=1
    在内部注册:ProviderName=MySql.Data.MySqlClient 指向 MySql.Data

  • MySqlClientProviderSupport=2
    在内部注册:ProviderName=MySql.Data.MySqlClient 指向 MySqlConnector

  • MySqlClientProviderSupport=3
    同时支持 MySql.Data 和 MySqlConnector

    • ProviderName=MySql.Data.MySqlClient,指向 MySqlConnector
    • ProviderName=MySqlConnector,指向 MySqlConnector
    • ProviderName=MySql.Data,指向 MySql.Data
  • MySqlClientProviderSupport=0 或者 不指定此配置

    • 由框架自动判断,引用哪个客户端就使用哪个

说明:

  • 2类客户端的行为存在少量差别,具体可参考:https://mysql-net.github.io/AdoNetResults/
  • 在使用 MySql.Data 时,对于某个XxxxAsyn方法,其实它是以同步方式执行的,在切换到MySqlConnector后请做好全面测试,因为那些XxxxAsyn方法将以异步方式执行。
  • 建议分批次切换生产环境中应用程序,如果发现行为有异常,立即切换到默认设置。

配置示例:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="MySqlClientProviderSupport" value="2" />
    </appSettings>    
</configuration>



确认设置生效

可以查看应用程序的控制台输出,确认包含:

Register DbClient Provider: MySql.Data.MySqlClient => ClownFish.Data.MultiDB.MySQL.MySqlConnectorClientProvider



MySqlClientProviderSupport=3

启动时会输出:

Register DbClient Provider: MySql.Data.MySqlClient => ClownFish.Data.MultiDB.MySQL.MySqlConnectorClientProvider
Register DbClient Provider: MySql.Data => ClownFish.Data.MultiDB.MySQL.MySqlDataClientProvider
Register DbClient Provider: MySqlConnector => ClownFish.Data.MultiDB.MySQL.MySqlConnectorClientProvider

在这种【双模式】下强制指定使用哪种客户端,示例代码如下:

public string Test1()
{
    // 默认使用  MySqlConnector 客户端
    using( DbContext dbContext = DbConnManager.CreateAppDb("dbconn-name") ) {
        return dbContext.CPQuery.Create("select now()").ExecuteScalar<DateTime>().ToTimeString();
    }
}

public string Test2()
{
    // 强制使用 MySqlConnector 客户端
    using( DbContext dbContext = DbConnManager.CreateAppDb("dbconn-name", false, "MySqlConnector") ) {
        return dbContext.CPQuery.Create("select now()").ExecuteScalar<DateTime>().ToTimeString();
    }
}

public string Test3()
{
    // 强制使用 MySql.Data 客户端
    using( DbContext dbContext = DbConnManager.CreateAppDb("dbconn-name", false, "MySql.Data") ) {
        return dbContext.CPQuery.Create("select now()").ExecuteScalar<DateTime>().ToTimeString();
    }
}



查看异步效果

示例代码如下:

public async Task<string> TestAsync()
{
    StringBuilder sb = new StringBuilder();
    sb.AppendLineRN("before OpenConnection: " + Thread.CurrentThread.ManagedThreadId);

    using( DbContext dbContext = DbConnManager.CreateAppDb("mysqltest") ) {
        sb.AppendLineRN("after  OpenConnection: " + Thread.CurrentThread.ManagedThreadId);

        //--------------------------------------------------------------
        var data = await dbContext.GetByKeyAsync<Customer>(90);
        sb.AppendLineRN("after  GetById(90): " + Thread.CurrentThread.ManagedThreadId);

        //--------------------------------------------------------------
        data = await dbContext.GetByKeyAsync<Customer>(91);
        sb.AppendLineRN("after  GetById(91): " + Thread.CurrentThread.ManagedThreadId);

        //--------------------------------------------------------------
        Task<Customer> task = dbContext.GetByKeyAsync<Customer>(92);
        sb.AppendLineRN("before await GetById(92): " + Thread.CurrentThread.ManagedThreadId);
        data = await task;
        sb.AppendLineRN("after  await GetById(92): " + Thread.CurrentThread.ManagedThreadId);

        //--------------------------------------------------------------
        task = dbContext.GetByKeyAsync<Customer>(93);
        sb.AppendLineRN("before await GetById(93): " + Thread.CurrentThread.ManagedThreadId);
        data = await task;
        sb.AppendLineRN("after  await GetById(93): " + Thread.CurrentThread.ManagedThreadId);

    }

    return sb.ToString();
}

使用MySqlConnector的服务端响应如下:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
x-RequestId: 19aaa3c3d3e940359fa11061eda2d84f
x-HostName: f2de9a0d19ad
x-AppName: XDemo.WebSiteApp
x-dotnet: .NET 5.0.5
x-Nebula: 1.21.708.100
x-PreRequestExecute-ThreadId: 110
x-PostRequestExecute-ThreadId: 77
Content-Type: text/plain; charset=utf-8
Date: Tue, 13 Jul 2021 08:26:44 GMT
Server: Kestrel

before OpenConnection: 110
after  OpenConnection: 110
after  GetById(90): 55
after  GetById(91): 64
before await GetById(92): 64
after  await GetById(92): 53
before await GetById(93): 53
after  await GetById(93): 77

使用MySql.Data的服务端响应如下:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
x-RequestId: 7e43d40dcc644fe881e87b2966a1d50a
x-HostName: f2de9a0d19ad
x-AppName: XDemo.WebSiteApp
x-dotnet: .NET 5.0.5
x-Nebula: 1.21.708.100
x-PreRequestExecute-ThreadId: 105
x-PostRequestExecute-ThreadId: 105
Content-Type: text/plain; charset=utf-8
Date: Tue, 13 Jul 2021 08:27:07 GMT
Server: Kestrel

before OpenConnection: 105
after  OpenConnection: 105
after  GetById(90): 105
after  GetById(91): 105
before await GetById(92): 105
after  await GetById(92): 105
before await GetById(93): 105
after  await GetById(93): 105


使用阿里的ADS

ADS这东西虽然一直号称与MySQL兼容,但并非完全兼容!

如果MySqlConnector,你会发现:

  • 第一次能连接ADS
  • 第二次就不能连接(异常)
  • 第三次可以连接
  • 第四次又不能连接
  • .......周而反复

具体异常内容大致是:

MySqlConnector.MySqlException: HResult=0x80004005,
  Message=[30000, 2023121110252517201620317003151105564] syntax error. SQL => xxxxxxxx
  Source=MySqlConnector
  StackTrace:
   在 MySqlConnector.Core.ServerSession.<ReceiveReplyAsync>d__107.MoveNext()
   在 System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable`1.ConfiguredValueTaskAwaiter.GetResult()
   在 MySqlConnector.Core.ServerSession.<TryResetConnectionAsync>d__99.MoveNext()
   在 MySqlConnector.Core.ConnectionPool.<GetSessionAsync>d__13.MoveNext()
   在 MySqlConnector.Core.ConnectionPool.<GetSessionAsync>d__13.MoveNext()
   在 System.Threading.Tasks.ValueTask`1.get_Result()
   在 MySqlConnector.MySqlConnection.<CreateSessionAsync>d__133.MoveNext()
   在 System.Threading.Tasks.ValueTask`1.get_Result()
   在 MySqlConnector.MySqlConnection.<OpenAsync>d__29.MoveNext()
   在 MySqlConnector.MySqlConnection.Open()

所以,你只能切换到 MySql.Data

然而,当你使用 MySql.Data 的新版本(8.0.26之后的版本),你会看到以下异常:

System.FormatException: The input string 'ON' was not in a correct format.
在 System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   在 System.Convert.ToInt32(String value)
   在 MySql.Data.MySqlClient.Driver.LoadCharacterSets(MySqlConnection connection)
   在 MySql.Data.MySqlClient.Driver.Configure(MySqlConnection connection)
   在 MySql.Data.MySqlClient.MySqlConnection.Open()

原因是,新版本的MySql.Data在打开连接时,在MySql.Data.MySqlClient.Driver.LoadCharacterSets() 方法中增加了一段代码:

serverProps.TryGetValue("autocommit", out var value);
//-----------省略一些代码
if (Convert.ToInt32(value) == 0 && Version.isAtLeast(8, 0, 0))  

这样就会抛出异常(System.FormatException:Input string was not in a correct format.),因为 ON, OFF 不能转成数字!!

阿里云不认为这个是BUG ~~~

所以,解决办法是:使用 MySql.Data 8.0.26 之前的版本!

<PackageReference Include="MySql.Data" Version="8.0.25" />

读写分离

基于Nebula的项目启用数据库 读写分离 需要2个步骤:

  • 在配置服务中注册只读库
  • 在调用代码中指定参数使用读库



在配置服务中注册只读库

这个过程其实就是创建一个与【普通连接】类似的【只读连接】配置, 以下图为例:
xx
示例解释:

  • yunwei-s1 是一个可读可写的数据库连接配置
  • yunwei-s1_readonly 是一个只读的数据库连接配置
  • _readonly 这个后缀是固定的(一个约定),用于配置服务查找只读连接



在调用代码中指定参数使用读库

以下是一个 Action 的示例代码:

public async Task<string> Test2()
{
    // 连接到  与当前用户对应的 【只读/租户库】
    using( DbContext dbContext = this.CreateTenantConnection(tenantId:"xxxxx", readonlyDB:true) ) {

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}

在这个示例中,调用 CreateTenantConnection 方法时第2个参数 readonlyDB:true 就表示使用【只读库】



也可以直接调用 DbConnManager.CreateTenant 方法

/// <summary>
/// 根据 租户ID 创建对应的SQL数据库连接
/// </summary>
/// <param name="tenantId">租户ID</param>
/// <param name="readonlyDB">是否连接【只读库】</param>
/// <returns></returns>
public static DbContext CreateTenant(string tenantId, bool readonlyDB = false)

InfluxClient也支持类似的只读库连接:

/// <summary>
/// 构造InfluxClient实例,并根据 租户ID 创建对应的连接
/// </summary>
/// <param name="tenantId">租户ID</param>
/// <param name="readonlyDB">是否连接【只读库】</param>
/// <returns></returns>
public static InfluxClient CreateTenant(string tenantId, bool readonlyDB = false)

数据库分库

数据库分库(或者叫“拆分数据库”)通常有2种做法:

  • 按业务边界垂直拆分
  • 按用户水平拆分

Nebula同时支持这2种做法:

  • 垂直拆分:在Nebula中称为“应用库”拆分,也就是某个应用单独操作某个数据库
  • 水平拆分:Nebula支持按客户来拆分数据库(客户隔离),拆分后的数据库被称为“租户库”



注册应用库

应用库在配置服务中就是一个普通的数据库连接,如下图所示:
xx

在表格中,Name 列的名称将在代码中被引用。



连接到应用库

public async Task<string> Test4()
{
    // 连接到 某个特定的 【应用库】
    // xxx_Db 是一个连接名称,在【配置服务】中有对应的连接设置

    using( DbContext dbContext = this.CreateAppDbConnection("xxx_Db") ) {

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}

有2种方法连接到【应用库】

  • 调用 BaseController.CreateAppDbConnection("name") 方法
  • 调用 DbConnManager.CreateAppDb("name") 方法



注册租户库

这里又分为2个步骤:

  • 为租户的数据库实例注册数据库连接
  • 为每个租户指定连接名字

xx
这里假设所有租户的数据库部署在3个数据库实例上,
我们就可以分别为它们创建3个数据库连接:yunwei-s1, yunwei-s2, yunwei-s3

xx
在tenantconn表中,可以为每个租户指定与数据库实例的映射关系。



连接到租户库

可参考下面示例代码:

public async Task<string> Test1()
{
    // 从请求中获取到的租户信息
    // 通常是登录操作,此时用户还没有经过登录验证,这时候需要在提交登录数据时把 租户ID 一起发送到服务端
    // 服务端根据 租户ID 就可以连接到相应的租户库
    string tenantId = "xxxxxxxxxx";

    using( DbContext dbContext = this.CreateTenantConnection(tenantId) ) {  // 连接到 指定的【租户库】

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}

public async Task<string> Test2()
{
    // 连接到  与当前用户对应的 【租户库】
    using( DbContext dbContext = this.CreateTenantConnection() ) {

        return await dbContext.CPQuery.Create("select now()").ExecuteScalarAsync<string>();
    }
}

或者直接调用 DbConnManager.CreateTenant 方法:

/// <summary>
/// 根据 租户ID 创建对应的SQL数据库连接
/// </summary>
/// <param name="tenantId">租户ID</param>
/// <param name="readonlyDB">是否连接【只读库】</param>
/// <returns></returns>
public static DbContext CreateTenant(string tenantId, bool readonlyDB = false)

使用达梦数据库

ClownFish已支持达梦数据库,具体使用方法可参考本文档。


1,引用 NuGet 包

在csproj项目文件中添加

<PackageReference Include="dmdbms.DmProvider" Version="1.1.3" />
<PackageReference Include="ClownFish.net" Version="9.24.1121.1" />



2,配置数据库连接

在ClownFish.App.config中添加:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
    </appSettings>

    <dbConfigs>
        <add name="dm1" dbType="DaMeng" server="DamengHost" port="5237" database="MyNorthwind" uid="SYSDBA" pwd="xxxxxxx" />
    </dbConfigs>
</configuration>

说明:也可以在配置服务中注册连接。



3,CRUD开发

这个过程与使用 SQLSERVER/MySQL/PostgreSQL 没有什么差别。
以下是实际的测试代码片段:

using DbContext db = DbContext.Create("dm1");

var args = new { CategoryID = 1 };

List<Product> list = db.CPQuery.Create("select * from Products where CategoryID = @CategoryID", args).ToList<Product>();

Console.WriteLine(list.ToXml());
using DbContext db = DbContext.Create("dm1");

db.CPQuery.Create("truncate table TestGuid").ExecuteNonQuery();

string insertSQL = "insert into TestGuid(RowGuid, IntValue) values(@RowGuid, @IntValue)";
for( int i = 0; i < 1000; i++ ) {

    var args = new {
        RowGuid = db.NewSeqGuid(),
        IntValue = i + 1
    };

    db.CPQuery.Create(insertSQL, args).ExecuteNonQuery();
}
using DbContext db = DbContext.Create("dm1");

Product product = new();
product.LoadDefaultValues();

// insert
int newId = (int)product.Insert(db, true);

// query
Product product2 = db.GetByKey<Product>(newId);
Assert.IsNotNull(product2);


Product product3 = new Product{ ... };
// update
int effect = db.Update(product3);
Assert.AreEqual(1, effect);


Product product4 = db.GetByKey<Product>(newId);
Assert.IsNotNull(product4);


// delete
db.Delete<Product>(newId);

Product product5 = db.GetByKey<Product>(newId);
Assert.IsNull(product5);  

初始化数据访问层

基于Nebula开发的项目,可以不用关注本文所介绍的内容。

本文仅供直接使用 ClownFish 的项目参考。


引用数据库驱动包

首先需要在项目文件中引用所需的驱动包,例如下面支持了5种数据库

<ItemGroup>
    <PackageReference Include="System.Data.SqlClient" Version="4.8.5" />
    <PackageReference Include="MySqlConnector" Version="2.2.5" />
    <PackageReference Include="Npgsql" Version="6.0.4" />
    <PackageReference Include="System.Data.SQLite.Core" Version="1.0.114.3" />
    <PackageReference Include="dmdbms.DmProvider" Version="1.1.3" />
</ItemGroup>

说明:

  • 需要使用哪种数据库就引用哪个,ClownFish会根据DLL文件自动加载



ClownFish 的初始化

请确保以下代码能在程序启动时运行

ClownFishInit.InitDAL();



对 SQLSERVER 的特别支持

  • .net framework 项目
    • 内置支持,固定使用 System.Data.SqlClient
    • 不再需要引用任何SqlClient相关包,也不需要额外的初始化代码
  • .net6/7/8 项目
    • 支持2种驱动包:System.Data.SqlClient, Microsoft.Data.SqlClient
    • 引用哪个就使用哪个



对 MySQL 的特别支持

ClownFish 支持2种 MySQL 驱动类库,具体内容可参考:MySQL异步

例如:

<PackageReference Include="MySql.Data" Version="8.0.29" />
<PackageReference Include="MySqlConnector" Version="2.2.5" />

引用哪个就使用哪个!

使用EntityFrameworkCore

虽然Nebula默认使用ClownFish中提供的数据访问模块,但也支持使用其它的数据访问模块。

本文将介绍如何在Nebula中使用EntityFrameworkCore



添加NuGet包

例如:

<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.0" />



定义EF-DbContext继承类

示例代码如下

public class MyNorthwindEfDbContext : Microsoft.EntityFrameworkCore.DbContext
{
    public DbSet<Product> Product { get; set; }

  
    private readonly DbConfig _dbConfig;

    public MyNorthwindEfDbContext(DbConfig dbConfig)
    {
        _dbConfig = dbConfig;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        string connectionString = _dbConfig.GetConnectionString(true);

        if( _dbConfig.DbType == DatabaseType.SQLSERVER )
            optionsBuilder.UseSqlServer(connectionString);

        else if( _dbConfig.DbType == DatabaseType.MySQL ) {

            ServerVersion version = ServerVersion.AutoDetect(connectionString);
            optionsBuilder.UseMySql(connectionString, version, null);
        }

        else if( _dbConfig.DbType == DatabaseType.PostgreSQL )
            optionsBuilder.UseNpgsql(connectionString);

        else if( _dbConfig.DbType == DatabaseType.DaMeng )
            optionsBuilder.UseDm(connectionString);

        else
            throw new NotSupportedException();
    }
}



在代码中使用

以下代码来自 Controller 中的示例

[Route("search/{index}/{size}.aspx")]
public PageListResult<Product> Search([FromBody] ProductSearchParam product, int index, int size)
{
    PageListResult<Product> result = new PageListResult<Product>();
    result.PagingInfo = new PagingInfo();
    result.PagingInfo.PageIndex = index;
    result.PagingInfo.PageSize = size;

    DbConfig dbConfig = DbConnManager.GetAppDbConfig("conn-name");

    using( MyNorthwindEfDbContext dbContext = new MyNorthwindEfDbContext(dbConfig) ) {

        var query = dbContext.Product.Select(x => x);

        if( product.CategoryID > 0 )
            query = query.Where(x => x.CategoryID == product.CategoryID);

        if( product.UnitPrice > 0 )
            query = query.Where(x => x.UnitPrice > product.UnitPrice);

        if( product.Unit.HasValue() )
            query = query.Where(x => x.Unit == product.Unit);

        if( product.ProductName.HasValue() )
            query = query.Where(x => EF.Functions.Like(x.ProductName, product.ProductName + "%"));

        query = query.OrderBy(x => x.ProductID);

        result.Data = query.ToList();
        return result;
    }
}

性能日志展示如下:

xx









请点击目录中的节点查看具体文档

创建第一个项目

打开 Visual Studio (最新版本) 创建一个 ASP.NET Core web 应用,如下图所示:
xx



添加 nuget 包

按下图所示添加必要的包,请选择最新版本

xx



调整代码

  • 删除 Startup.cs
  • 按下面方式调整 Program.cs
[assembly: Microsoft.AspNetCore.Mvc.ApiController]

namespace YourProjectNameSpace;

public class Program
{
    public static void Main(string[] args)
    {
        AppStartup.RunAsPublicServices("ApplicationName", args);
    }
}

注意下面这行代码,

[assembly: Microsoft.AspNetCore.Mvc.ApiController]

有了它,写Action会简单些,建议在Program.cs添加





添加配置文件

ClownFish.App.config通常是一个项目必需的配置文件,
可从Nebula的demo项目中复制到你的新项目中,然后再调整,
也可以按照以下内容来创建(注意文件的编码方式 utf-8 with BOM)

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>

        <!-- 配置服务的访问地址 -->
        <add key="configServiceUrl" value="http://hostxxx:8503" />

    </appSettings>    
</configuration>



注意:configServiceUrl 参数是每个微服务项目必需要的,用于指定配置服务的连接地址。




项目设置

请参考以下截图

xx

xx


或者直接修改 launchSettings.json 文件,可参考以下代码

{
  "profiles": {
    "Nebula.Venus": {
        "commandName": "Project",
        "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        },
        "applicationUrl": "http://0.0.0.0:8208"
    }
  }
}

开发服务接口

按照分层开发原则,一个服务接口的开发过程有2块:

  • 开发一个 HttpAction 接口方法
  • 开发对应的 BLL 实现方法

Controller/BLL 的职责可参考:分层开发职责



HttpAction 接口方法

示例代码:

[Route("/v20/api/WebSiteApp/Customer/")]
public class CustomerController : BaseController
{
    private CustomerBLL customerBLL { get; set; }
            
    
    [HttpGet]
    [Route("findByTel.aspx")]
    public async Task<List<Customer>> FindByTel(string tel)
    {
        using( DbContext dbContext = CreateTenantConnection() ) {
            return await customerBLL.FindByTel(tel);
        }
    }

说明:

  • Controller继承于BaseController
  • Controller/Action 都必须标记[Route(xxxx)]
  • 每个Action方法必须显式标记[HttpGet] or [HttpPost]这类限定调用标记



BLL 实现方法

示例代码:

public class CustomerBLL : BaseBLL
{
    public async Task<List<Customer>> FindByTel(string tel)
    {
        var query = from x in dbContext.Entity.Query<Customer>()
                    where x.Tel.Contains(tel)
                    select x;

        return await query.ToListAsync();
    }

说明:

  • BLL类继承于BaseBLL
  • BLL方法不要打开数据库连接

分层开发,2个重要的基类

基于Nebula开发的项目应该尊守必要的 分层开发职责

本文主要介绍2个重要基类的核心功能。



BaseController核心功能

  • 用户身份识别
    • bool IsAuthenticated 属性
      • 判断当前请求是否为已登录用户
    • IUserInfo GetUserInfo()
      • 获取当前请求的用户身份信息
    • string GetTenantId()
      • 用于获取【当前用户】所属于的租户ID
      • 如果是未登录用户,则会抛出异常
  • 数据库连接管理
    • DbContext CreateMasterConnection()
      • 切换到主库
    • DbContext CreateAppDbConnection(string name)
      • 切换到应用库
    • DbContext CreateTenantConnection(string tenantId = null)
      • 切换到租户库,参数为null时调用GetTenantId()
  • 服务之间调用
    • Task<T> SendRequestAsync<T>(HttpOption httpOption)
      • 发送HTTP请求,并返回结果
    • Task TransferRequestAsync(string url, string method = null)
      • 将当前请求转发给一个内部服务来处理
  • HttpRequest/HttpResponse 方法封装
    • string GetHeader(string name)
    • void SetHeader(string name, string value)
    • string GetCookie(string name)
    • void SetCookie(string name, string value, TimeSpan? expiry = null)





BaseBLL核心功能

  • 用户身份识别
    • IUserInfo GetUserInfo()
    • string GetTenantId()
  • 数据库连接获取
    • DbContext tntContext 属性
      • 获取当前Controller中的【最近】打开的【租户库】连接对象
    • DbContext masterContext 属性
      • 获取当前Controller中的【最近】打开的【主库】连接对象
  • 通用 Insert/Update/Delete
    • Task<long> InsertAsync<T>(T entity, DbContext dbContext)
    • Task<int> UpdateAsync<T>(T entity, DbContext dbContext)
    • Task<int> DeleteAsync<T>(object key, DbContext dbContext)
    • Task<T> GetByKeyAsync<T>(object key, DbContext dbContext)

服务调用

本文所说的服务调用是指微服务之间的相互调用,它们采用 HTTP 传输协议。

示例代码:

private static readonly string s_userServiceUrl = Settings.GetSetting("UserService_Url", true);

public async Task<MysmUserInfo> GetUserLoginInfo()
{
    // 获取当前用户ID
    WebUserInfo userInfo = (WebUserInfo)this.NHttpContext.GetUserInfo();

    // 构造HTTP请求参数
    HttpOption httpOption = new HttpOption
    {
        Method = "GET",
        Url = s_userServiceUrl + "/v20/api/userservice/user/detail.svc",
        Data = new { userInfo.UserId },
        Format = SerializeFormat.Json
    };

    // 调用 UserService
    PUser pUser = await this.SendRequestAsync<PUser>(httpOption);

    // .................
}



简单来说,其实就2个步骤:

  1. 构造HttpOption对象,它表示一个HTTP请求参数
  2. SendRequestAsync(httpOption); 发起远程调用并获取远程服务的返回结果

补充说明:

  • HttpOption 还有3组(同步/异步)扩展方法:Send, GetResult, GetResult<T>,在微服务相互调用时,不要使用它们
  • SendRequestAsync 方法与上面3组方法相比,还会在调用时传递以下数据
    • 用户身份凭证(Token)
    • x-nebula- 开头的请求头
    • Cookie
      • 要求设置参数:Nebula_SendRequestWithCopyCookie = 1

用户登录

一个完整的用户登录包括以下步骤:

  1. 前端显示登录界面
  2. 前端提交用户登录数据
  3. 服务端接收用户登录数据
  4. 服务端校验用户名和密码
  5. 服务端生成登录凭证并发送登录凭证给客户端



服务端接收用户登录数据

需要开发一个Action,例如:

[LoginAction]
[HttpPost]
[Route("login.aspx")]
public string Login([FromForm]string userId, [FromForm]string password, [FromForm]string tenantId)

说明:

  • 本示例使用 表单 数据格式
  • 也可以用JSON方式提交



生成登录凭证并发送登录凭证给客户端

在生成登录凭证之前,我们需要创建一个用户信息对象来保存当前用户相关信息。
然后用这个用户对象生成 JWT Token,也就是登录凭证。
示例代码如下:

// 创建用户信息对象
IUserInfo userInfo = new WebUserInfo {
    TenantId = tenantId,
    UserId = userId,
    UserName = "Fish Li",
    UserRole = "Admin"
};

// 定义凭证过期时间
int seconds = 1200;

// 生成登录凭证并发送登录凭证给客户端
AuthenticationManager.Login(userInfo, seconds);

AuthenticationManager.Login方法内部有2个过程

  • 先生成登录凭证
  • 以COOKIE形式发送登录凭证给客户端



单独生成登录凭证

如果仅仅需要生成登录凭证,可以参考下面代码:

IUserInfo userInfo = new WebUserInfo {
    TenantId = tenantId,
    UserId = userId,
    UserName = "Fish Li",
    UserRole = "Admin"
};

int seconds = 1200;
string token = AuthenticationManager.GetLoginToken(userInfo, seconds);



用户信息对象

Nebula中定义了 IUserInfo 接口用来表示用户信息,
同时提供了2个实现类可供使用:

  • WebUserInfo
  • AppClientInfo

如果不满足需求,请自行实现接口。



登录凭证续期

Nebula支持2种方式从前端到服务端传递登录凭证:

  • Cookie模式
  • 请求头模式

无论采用哪种模式,都可以支持登录凭证续期:

  • Cookie模式:会产生一个新的Cookie,浏览器会自动接收,无需开发处理。
  • 请求头模式:会产生一个【响应头】,此时需要客户端识别并保存,然后在后续请求中使用它。

身份认证

词汇解释

  • Authenticate:身份认证,识别用户身份的过程。
  • Authorize:授权检查,确定当前用户是否有权访问资源的过程。



工作机制

  • Authenticate,由框架实现,对所有请求都有效(一直开启)
    • 它由一个专门的HttpModule来实现,当发现有用户登录身份凭证时(Cookie或者请求头),会自动识别
    • 会自动续期用户的登录身份凭证
    • 用户登录身份凭证可以微服务之间透明传递,例如 A->B->C 的服务调链,服务C仍然能获取用户身份并实现安全校验。
    • 用户识别的过程发生在进入 HttpAction 之前,所以可在 HttpAction 中获取当前用户信息

  • Authorize,框架提供必要(基础的)验证手段 [Authorize] 来实现
    • [Authorize] 可以标记在 Controller 或者 Action 上面,以表达不同的应用范围
    • [Authorize] 支持4类过滤方式:用户名, 角色,权限号,用户身份类型IUserInfo
    • [AllowAnonymous] 可以表示允许【匿名用户】访问




实现过程

当用户成功登录后,服务端会生成一个登录凭证(JWT Token),
只要后续请求带上这个登录凭证,服务端就可以知道当前是谁,即还原 UserInfo 对象,
这个过程是在 Nebula中自动完成的,包含凭证继期处理,开发人员不需要参与处理。

身份认证 结束后,就可以在 HttpModule/Controller 中获取用户信息了。

授权检查

词汇解释

  • Authenticate:身份认证,识别用户身份的过程。
  • Authorize:授权检查,确定当前用户是否有权访问资源的过程。



仅允许已登录用户访问

Nebula默认支持在Controller/Action上采用标记的方式做授权申明,例如:

[HttpPost]
[Authorize]  // 注意这里
[Route("aaa.aspx")]
public string SomeMethod()

[Authorize] 标记打在Action方法上,表示当前方法仅支持【已登录用户】访问。

也可以将 [Authorize] 标记在Controller类型上,例如:

[Authorize]  // 注意这里
[Route("/v20/api/WebSiteApp/Customer/")]
public class CustomerController : BaseTestDatabaseController

此时,【等同于】将 [Authorize] 标记在所有Action方法上。
如果此时需要排除某个方法,可以在Action上标记 [AllowAnonymous]

[HttpPost]
[AllowAnonymous]  // 注意这里
[Route("bbb.aspx")]
public string SomeMethod2()

小结:

  • [Authorize] 用于显式描述 当前功能仅限【已登录用户】访问
  • [AllowAnonymous] 用于显式描述 当前功能允许供【未 登录用户】访问
  • 以上2个标记可以出现在Controller/Action上,Action的标记将优先查找

注意:

  • [Authorize], [AllowAnonymous] 这2个修饰属性定义在 Nebula.Common 命名空间下面。



基于角色的授权

以下标记表示当前资源仅允许 Admin, RoleX 这2类角色访问

[Authorize(Roles = "Admin,RoleX")]

当前用户是什么角色在创建 IUserInfo 时指定,例如:

IUserInfo userInfo = new WebUserInfo {
    TenantId = tenantId,
    UserId = userId,
    UserName = "Fish Li",
    UserRole = "Admin"
};



基于用户身份类型的授权

[Authorize(UserInfoType = typeof(VenusUserInfo))]

// 或者验证第三方客户端
[Authorize(UserInfoType = typeof(AppClientInfo))]
[Authorize(UserInfoType = typeof(AppClientInfo), Users = "AppId_xxxx")]

对应的登录代码片段:

VenusUserInfo userInfo = new VenusUserInfo {
    UserId = user.Name,
    UserName = user.Value,
};

int seconds = 7 * 24 * 60 * 60; // 7 day
AuthenticationManager.Login(userInfo, seconds);

简单理解:

  • 在调用 AuthenticationManager.Login(...) 时使用了某个特定的【用户身份类型】
  • 在授权检查时,就要求必须与登录时指定的【用户身份类型】一致



基于特定用户的授权

如果是比较简单的小项目,可以采用直接用户名的方式授权,例如:

[Authorize(Users = "Name1,Name2")]
  • "Name1,Name2" 就是允许的登录名,
  • 也可以是一个,
  • 多个名称用分号分开



基于权限编号的授权

比较复杂的应用程序,可以按功能模块来实现授权,例如:

[Authorize(Rights = "A002")]

"A00002" 表示一个功能权限编号。

使用这个授权需要实现一个 BaseUserAuthProvider 抽象类,例如:

public class MyUserAuthProvider : BaseUserAuthProvider
{
    private IUserInfo _userInfo;

    protected override void SetDbContext(IUserInfo userInfo)
    {
        _userInfo = userInfo;

        // 注意:FindRole,FindUser 的默认实现方式是需要查询数据库的,
        // 因此需要在这里切换数据库连接,即设置基类的 _dbContext 字段
    }

    public override RightsRole FindRole(string roleId)
    {
        // 此方法用于获取角色对应的权限描述,这里不演示
        return null;
    }

    public override RightsUser FindUser(string userId)
    {
        // 这里是演示代码,就直接返回了,真实情况需要查询数据库或者缓存服务
        return new RightsUser {
            UserId = userId,
            UserName = _userInfo.UserName,
            RoleIds = "Admin",
            AllowRights = "22,23"
        };
    }
}

重点解释:

  • FindUser方法,返回某个用户直接允许的权限,
  • FindRole方法,返回某个用户所属的角色,
  • 角色也可以赋予权限,
  • 还可以给用户赋予否决权限,用于排除继承权限,
  • 所以最终用户的权限会合并:单独授权 + 角色继承 - 否决权限

注册 UserAuthProvider

public class Program
{
    public static void Main(string[] args)
    {
        AppStartOption startOption = new AppStartOption {

            // 自定义的授权提供者
            UserAuthProviderType = typeof(MyUserAuthProvider)
        };
        
        AppStartup.RunAsWebsite("XDemo.WebSiteApp", args, startOption);
    }        
}



在代码中判断用户和权限

可以调用 Controller.CheckRights("...") ,例如:

public class ActionBLL : BaseBLL
{
    private void CheckOwner(ApiAction existObject)
    {
        IUserInfo user = this.GetUserInfo();

        bool currentIsAdmin = this.Controller.CheckRights("d001");
        string currentUser = user.UserId;


        // 只有管理员可以修改其他用户创建的接口
        // 普通用户不允许修改其他用户创建的接口
        if( currentIsAdmin == false && existObject.Createor != currentUser )
            throw new ForbiddenException("不能修改其他用户创建的接口。");
    }



扩展[Authorize]标记

如果觉得[Authorize]的功能不能满足业务需求,还可以从AuthorizeAttribute继承实现自己的派生类,只要重写AuthenticateRequest方法就可以了,以下代码是AuthorizeAttribute的默认实现:

/// <summary>
/// 执行授权检查验证的标记
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class AuthorizeAttribute : Attribute
{
    /// <summary>
	/// 执行授权检查
	/// </summary>
	/// <param name="context"></param>
	/// <returns></returns>
	public virtual bool AuthenticateRequest(NHttpContext context)
    {
        IUserInfo userInfo = context.GetUserInfo();

        // 当前用户没有登录
        if( userInfo == null )
            return false;

        // 检查 IUserInfo 的实例类型是不是预期类型
        if( _userInfoType != null && userInfo.GetType() != _userInfoType )
            return false;

        // 没有任何明确的授权要求,此时只要是“已登录”用户就算通过检查
        if( _users == null && _roles == null && _rightsArray == null )
            return true;


        // 三种授权条件,只要一个符合就认为是检查通过
        if( CheckUser(context, userInfo) || CheckRole(context, userInfo) || CheckRights(context, userInfo) )
            return true;


        // 所有条件都不匹配,授权检查失败!
        return false;
    }

统一的认证与授权

现阶段,前后端分离已是一种非常流行的开发方式,后端以WebApi方式对外提供数据。
对于后端服务而言,它的调用方其实有3大类:

  • 运行在浏览器的前端JS代码
  • 集群内的其它服务
  • 第三方应用程序

对应的调用方的身份有2大类:

  • 真实的用户(人)
  • 代码调用的HttpClient(对应后面2类)

在微服务架构下,服务的调用链会非常复杂,比如:

xx

那么,这种场景下的 身份认证授权检查 该如何实现呢?



Nebula提供了一种 统一的认证与授权 方案,用来解决这2个问题:

  • 不同形式的客户调(调用方)
  • 微服务的复杂调用链

具体包含以下方面:

  • 不论何种调用方(客户端),统一用 IUserInfo 来表示身份,最后封装成 JWT-Token
    • JWT-Token可以通过3种方式传递
      • Cookie
      • 自定义请求头,例如:x-token: xxxxxxxxxxxxxxxxxx
      • 标准请求头,例如:Authorization: Bearer xxxxxxxxxxxxxxxxxx
  • 微服务相互调用时,自动传递 JWT-Token
    • 因此每个服务都可以知道当前的用户身份
  • 在服务端的Controller/Action上,使用 [Authorize(....)] 来执行授权检查
    • 没有[Authorize(....)]标记表示不做授权检查



实现过程

大致分类3个步骤:

  • 创建登录表2个:用户表 和 客户端应用表
    • 用户表:每行记录表示一个现实世界中的用户,有登录名,用户名,密码,角色... 这些字段
    • 客户端应用表:每行记录表示一个 第三方 客户端,通常包含 AppName, AppId, LoginKey... 这些字段
  • 登录检查时
    • 普通用户:登录名和密码验证通过后,创建一个 WebUserInfo 对象
    • 第三方客户端:AppId/LoginKey验证通过后,创建一个 AppClientInfo 对象
    • 然后调用 AuthenticationManager.Login 方法来生成登录凭证(JWT-Token)
  • Controller/Action的授权检查

使用缓存

缓存分类

缓存无处不在,例如:CPU内核缓存,硬盘缓存,操作系统缓存,数据库缓存,等等……

以上这些缓存不受我们控制,本文只谈我们能在开发过程中能做到的缓存设计。

它们大致可分为以下几类

  • 数据库结构缓存
    • 冗余表
    • 冗余字段
  • 进程外缓存
    • 例如Redis
  • 进程内缓存
    • 静态变量
    • 局部变量



冗余表/字段

有些时候为了优化查询性能,增加一些冗余的设计是很有必要的。

冗余表也称为“清洗表”,它需要结合后台任务来实现的,它通过定时快照方式将复杂的查询转成简单的结果表,让应用程序可以快速获取查询结果。

冗余字段就更简单了,它可以减少关联查询或者子查询的使用场景,简化查询,提升性能。



进程外缓存

将一些复杂的查询结果或者计算结果 放在独立的缓存服务中,本质上也是一种冗余的设计,通过冗余结果来简化获取过程,最终获得性能的提升。

最常见用法是缓存数据库查询结果,减少数据库的查询压力。

补充说明:

  • 进程外缓存不一定性能非常好,它的价值是 减少数据库的压力
  • 相比较进程内缓存,它可以实现在多个进程间共享缓存结果
  • 数据库通常难以实现水平扩展,而缓存服务则比较容易



局部变量

局部变量(或者私有成员)的作用是减少一些反复的计算任务,例如:重复读取某个配置参数,重复查找集合,等等。

它的作用范围比较小,可以是一个方法内部,也可以是一个实例的生存周期内。

使用场景:

  • 反复查找
    • 集合/字典
    • DOM
  • 反复计算(封装)
    • 调用方法求值
    • 查询数据库求值
    • 调用服务求值
    • 获取配置
    • 创建对象
  • 反复打开句柄
    • 数据库连接
    • Socket连接
    • 文件



静态变量

静态变量的生命周期和应用程序的生命周期一样长,可以长时间缓存结果,会有非常好的性能。

做为对比,从Redis获取结果会经过以下步骤:

  • 对象序列化(Set方法)
  • Redis ClientAPI 发起请求
  • 网络数据传输
  • Redis服务端处理
  • 网络数据传输
  • Redis ClientAPI 接收响应
  • 反序列化

如果使用静态变量做缓存,以上这些步骤都没有了,显然性能可以得到 极大 的提升,

因此,从静态变量中获取结果相比于Redis服务会非常快,反之 Redis其实非常慢!



在程序中使用静态变量需要考虑3个问题

  • 线程并发
  • 缓存时间
  • 内存占用

注意:如果不理解这几个问题,以及不知道解决方法,请不要使用静态变量!!



缓存工具类

为了简化使用过程,ClownFish提供了3个类型可供使用:

  • CacheItem<T>
  • CacheDictionary<T>
  • AppCache

CacheItem用法示例

/// <summary>
/// 缓存允许登录的用户清单
/// </summary>
private static CacheItem<List<LoginUser>> s_users = new CacheItem<List<LoginUser>>(null, DateTime.MinValue, true);


private async Task<LoginUser> FindUser(string loginName)
{
    List<LoginUser> list = s_users.Get();
    if( list == null ) {
        list = await List();
        s_users.Set(list, DateTime.Now.AddSeconds(UserCache.CacheSeconds));
    }

    return list.FirstOrDefault(x => x.LoginName.Is(loginName));
}

CacheDictionary用法示例

/// <summary>
/// 租户相关的缓存工具类
/// </summary>
public static class TenantCache
{
    private static readonly CacheDictionary<string> s_cache = new CacheDictionary<string>();

    /// <summary>
    /// 根据请求头中的 x-custid 获取对应的租户ID
    /// </summary>
    /// <param name="controller">BaseController实例</param>
    /// <returns>租户ID</returns>
    public static async Task<string> GetTenanatId( BaseController controller)
    {
        if( controller == null )
            throw new ArgumentNullException(nameof(controller));

        string customerId = controller.GetHeader("x-custid");
        if( customerId.IsNullOrEmpty() )
            throw new ValidationException("当前请求中不包含 x-custid 请求头。");


        // 首先从缓存中获取
        string tenantId = s_cache.Get(customerId);

        if( tenantId.IsNullOrEmpty() ) {

            // 缓存中没有,就查询数据库
            using( DbContext dbContext = controller.OpenMasterConnection() ) {

                string sql = "select tenant_id from p_tenant where code = @code";
                var args = new { code = tenantId };
                tenantId = await dbContext.CPQuery.Create(sql, args).ExecuteScalarAsync<string>();

                if( tenantId.IsNullOrEmpty() )
                    return null;
            }

            // 将数据库中查询的结果放入缓存
            s_cache.Set(tenantId, tenantId, DateTime.Now.AddYears(1));
        }

        return tenantId;
    }
}



AppCache是一个静态类,
内部包含了一个CacheDictionary<object>成员,提供了3个公开方法供调用

/// <summary>
/// 简单的缓存工具类,供应用程序所有业务代码共用
/// </summary>
public static class AppCache
{
    /// <summary>
    /// 尝试从缓存中获取一个对象,如果缓存对象不存在,则调用“加载委托”进行加载并存入缓存。
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="key">缓存键</param>
    /// <param name="loadFunc">对象“加载委托”,用于当缓存对象不存时获取对象,调用结束后,新产生的对象将插入缓存。</param>
    /// <returns></returns>
    public static T GetObject<T>(string key, Func<T> loadFunc = null) where T : class

    /// <summary>
    /// 将一个对象添加到缓存中。
    /// </summary>
    /// <param name="key">缓存键</param>
    /// <param name="value">需要缓存的对象</param>
    /// <param name="expiration">缓存的过期时间</param>
    public static void SetObject(string key, object value, DateTime expiration)

    /// <summary>
    /// 删除指定键对应的缓存对象
    /// </summary>
    /// <param name="key">缓存键</param>
    public static void RemoveObject(string key)
}



缓存注意事项


对于SaaS应用程序来说,缓存一定要考虑多租户,因此缓存Key应该包含租户ID









请点击目录中的节点查看具体文档

消息管道模型

为了方便消息订阅和消息处理,ClownFish提供了一种称为消息管道的开发模型,如下图所示:

xx

此模型有以下优点:

  • 统一模型:支持一套消息处理代码,订阅不同的消息服务(消息来源),可理解为 与消息服务解耦
  • 功能增强:提供完善的日志和监控,支持重试处理,支持异常处理
  • 简化代码:
    • 隐藏所有技术差异:
      • 订阅模式差异
      • 线程模型差异
      • 订阅者数量管理
      • 提交方式
      • 序列化等等细节
    • 消息处理只负责实现业务逻辑即可。
  • 规范代码:将消息处理划分为多个阶段,避免代码风格迥异



消息订阅者

  • 消息订阅者用于从消息队列服务中订阅(获取)消息,
  • 订阅者接收到消息后,将消息发送到 消息管道,然后再交给 消息处理器 来处理
  • 不同的队列来源 对应不同的订阅者(适配器模式)
  • Nebula已内置了多种消息订阅者(适配器)
  • 实际开发过程感知不到 消息订阅者 的存在,因为不需要与它交互



消息管道

消息处理管道的作用在于:

  • 屏蔽各消息服务的差异
  • 规范消息处理过程
  • 支持错误重试
  • 支持调用统计
  • 支持性能监控
  • 支持异常处理
  • 产生日志(4类)

管道中的消息处理阶段(消息处理器提供了对应的虚方法)

  • ValidateMessage
  • PrepareMessage
  • SaveMessage
  • ProcessMessage
  • AfterProcess
  • SaveState
  • OnEnd
  • OnError



消息处理器

无论使用哪种队列服务,所有消息最终是由 MessageHandler 来处理。

消息处理器

无论使用哪种队列服务,所有消息最终是由 MessageHandler 来处理。

特性说明及要求:

  1. MessageHandler的实例随队列订阅者一直存在,直到进程退出。
  2. 一个消息订阅者对应一个MessageHandler,开启多个订阅者可实现并行处理效果。
  3. MessageHandler中所有数据成员在生命周期内持续可用,但由于实例隔离,会比静态成员更安全。
  4. MessageHandler的实例化由框架来完成,自行实例化是没有意义的。
  5. MessageHandler 不必关注消息是从哪里来,只需要处理它就可以了。
  6. MessageHandler 响应消息管道的各个阶段,可以将复杂的逻辑拆分,有助于代码结构清晰易懂。
  7. MessageHandler在执行时如果出现异常,默认会有异常处理,并且会启动重试机制,重新执行整个管道流程。
  8. 由于消息管道的重试机制,以及消息未及时提交等等原因,消息有可能被重复发送到MessageHandler来处的,因此MessageHandler要实现 【幂等操作】



示例代码

/// <summary>
/// 消息处理器。
/// 它不关心消息从哪里来,只管处理特定类型的消息即可。
/// </summary>
public class PrintMessageHandler : BaseMessageHandler<Product>
{
    public override void ProcessMessage(PipelineContext<Product> context)
    {
        // 获取消息对象
        Product message = context.MessageData;

        string json = $"ProductID={message.ProductID},,{message.ProductName},,,{message.Remark}";
        string text = $"{_count.ToString().PadLeft(3, ' ')}; {json}";

        Console2.Info(text);
    }
}

小结:

  • 必须从BaseMessageHandler<T>继承
  • 类型参数T就是消息的数据类型
  • 访问 context.MessageData 可获取消息对象



异常处理

BaseMessageHandler基类提供了3个与异常相关的方法用于定制行为(可以保持默认行为):

  • OnError:当消息处理过程中发生异常时会被调用。
    默认行为:不处理。
    说明:每条消息在处理过程中都会产生一条OprLog日志,如果有异常是会有记录的。
  • IsNeedRetry:如果异常没有被处理,这个方法用于判断当前消息是否需要重试。
    默认行为:会根据异常类别做出判断。
  • ProcessDeadMessage:在消息最终无法处理时调用。
    默认行为:将消息写入 temp\deadmsg 目录下,每个消息一个文件,即死消息文件。



死消息

消息管道默认有异常处理和重试机制,以下2种情况下消息会无法处理,形成死消息:

  • 重试一直失败,达到最大次数
  • 遇到的异常类型是不需要重试的异常,例如:ValidationException,Htt400

此时消息将被放弃重试,执行以下过程:

  • 将消息写入 .\temp\deadmsg 目录下,每个消息一个文件。
  • 向MQ服务端发送确认信号(表示消息已处理)
  • 获取下一条消息,并继续处理

死消息文件示例

xx

消息处理--同步

消息处理的开发过程有2个步骤:



消息处理器--同步版本

示例代码(骨架):

public class PrintMessageHandler : BaseMessageHandler<Product>
{
    public override void ProcessMessage(PipelineContext<Product> context)
    {
        //...具体的实现逻辑...
    }
}





开启消息订阅

注意:必须在程序初始时开户消息订阅

public static class AppInitializer
{
    public static void Init()
    {
        // 在这里填写 消息订阅 代码,可参考下面示例
    }
}

RabbitMQ版本

RabbitSubscriber.Start<Product, PrintMessageHandler>(new RabbitSubscriberArgs {
    SettingName = "连接参数名称XXXXX",
    QueueName = null,  // 队列名称是 Product类型的全名
    SubscriberCount = 2,  // 订阅者数量
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
} );

Kafka版本

KafkaSubscriber.Start<Product, PrintMessageHandler>(new KafkaSubscriberArgs {
    SettingName = "连接参数名称XXXXX",
    GroupId = "消费组名称",
    Topic = null, // Topic 是 Product类型的全名
    SubscriberCount = 2,  // 订阅者数量
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
} );

Redis版本

RedisSubscriber.Start<Product, PrintMessageHandler>(new RedisSubscriberArgs {
    SettingName = "连接参数名称XXXXX",
    Channel = null,   // null 表示使用Product的类型全名代替
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

Pulsar版本

PulsarSubscriber.Start<Product, PrintMessageHandler>(new PulsarSubscriberArgs {
    SettingName = "连接参数名称XXXXX",
    Topic = "product",
    SubscriberCount = 2,  // 订阅者数量
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

MMQ版本

// 创建一个内存队列实例,消息的生产方可以给它发送消息。
private static readonly MemoryMesssageQueue<Product> s_mmq = new MemoryMesssageQueue<Product>(MmqWorkMode.Sync);

internal static void StartSubscriber()
{
    MmqSubscriber.Start<Product, PrintMessageHandler>(new MmqSubscriberArgs<Product> {
        Queue = s_mmq,
        RetryCount = 0,    // 重试次数
        RetryWaitMilliseconds = 1000   // 重试的间隔时间
    });
}

小结:

  • XxxxSubscriber.Start方法的第一个类型参数是:消息的类型
  • 第二个类型参数是:MessageHandler类型
  • 也就是:将什么类型的消息交给什么类型的处理器来处理
  • XxxxSubscriber.Start方法都只有一个参数,XxxSubscriberArgs(请按F12查看它的成员)

注意事项

  • 队列服务在使用前必须先在配置服务中注册,SettingName是连接的名称
  • SubscriberCount是指订阅者数量,这个值 不是 越大越好!
  • 最终全部订阅者数量 = SubscriberCount * 进程数量

消息处理--异步

消息处理的开发过程有2个步骤:



消息处理器--异步版本

示例代码(骨架):

public class PrintMessageHandler : AsyncBaseMessageHandler<Product>
{
    public override Task ProcessMessage(PipelineContext<Product> context)
    {
        //...具体的实现逻辑...
    }
}





启动消息订阅

注意:必须在程序初始时开户消息订阅

public static class AppInitializer
{
    public static void Init()
    {
        // 在这里填写 消息订阅 代码,可参考下面示例
    }
}

RabbitMQ版本

RabbitSubscriber.StartAsync<Product, PrintMessageHandler>(new RabbitSubscriberArgs {
    // 所有参数与同步版本一致,这里省略
} );

Kafka版本

KafkaSubscriber.StartAsync<Product, PrintMessageHandler>(new KafkaSubscriberArgs {
    // 所有参数与同步版本一致,这里省略
} );

Redis版本

RedisSubscriber.StartAsync<Product, PrintMessageHandler>(new RedisSubscriberArgs {
    // 所有参数与同步版本一致,这里省略
});

Pulsar版本

PulsarSubscriber.StartAsync<Product, PrintMessageHandler2>(new PulsarSubscriberArgs {
    // 所有参数与同步版本一致,这里省略
});

MMQ版本

// 创建一个支持异步操作的内存队列实例,消息的生产方可以给它发送消息。
private static readonly MemoryMesssageQueue<Product> s_mmq = new MemoryMesssageQueue<Product>(MmqWorkMode.Async);

internal static void StartSubscriber()
{
    MmqSubscriber.StartAsync<Product, PrintMessageHandler>(new MmqSubscriberArgs<Product> {
        // 所有参数与同步版本一致,这里省略
    });
}



小结:

  • XxxxSubscriber.StartAsync方法的第一个类型参数是:消息的类型
  • 第二个类型参数是:MessageHandler类型
  • 也就是:将什么类型的消息交给什么类型的处理器来处理
  • XxxxSubscriber.StartAsync方法都只有一个参数,XxxSubscriberArgs(请按F12查看它的成员)

注意事项

  • 队列服务在使用前必须先在配置服务中注册,SettingName是连接的名称
  • SubscriberCount是指订阅者数量,这个值 不是 越大越好!
  • 最终全部订阅者数量 = SubscriberCount * 进程数量

RabbitMQ基础概念

本文将介绍 RabbitMQ 相关的开发基础,主要包含以下内容

  1. RabbitMQ基础介绍
  2. RabbitMQ工作模型
  3. 交换机使用建议
  4. 消息持久化
  5. 死信队列
  6. Virtual Hosts
  7. 连接的生命周期
  8. 高可用



RabbitMQ基础介绍

一个完整的RabbitMQ消息流转过程如下图:

xx

其中包含以下对象:

  • Producers消息生产者:负责产生消息,发送到RabbitMQ
  • Exchanges交换机 :接收消息,并投递到与之绑定的队列
  • Queues消息队列:负责存储消息
  • Consumers消息消费者:负责处理消息

这里需要注意

  • 消息是发送到交换机,而不是队列
  • 交换机与队列是独立工作的,它们之间存在一种映射关系(路由规则)
  • 一条消息经过交换机,可以复制成多份,最终发送到不同的队列
  • 一个队列可供多个订阅者订阅(并行消费消息)

Exchange
生产者将消息发送到Exchange(交换器,下图中的X),由Exchange将消息路由到一个或多个Queue中(或者丢弃)。
xx

Routing key
生产者在将消息发送给Exchange的时候,一般会指定一个routing key,来指定这个消息的路由规则,而这个routing key需要与Exchange Type及binding key联合使用才能最终生效。 在Exchange Type与binding key固定的情况下(在正常使用时一般这些内容都是固定配置好的),我们的生产者就可以在发送消息给Exchange时,通过指定routing key来决定消息流向哪里。 RabbitMQ为routing key设定的长度限制为255 bytes。

Binding
RabbitMQ中通过Binding将Exchange与Queue关联起来,这样RabbitMQ就知道如何正确地将消息路由到指定的Queue了。
xx

Binding key
在绑定(Binding)Exchange与Queue的同时,一般会指定一个binding key;消费者将消息发送给Exchange时,一般会指定一个routing key;当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。 在绑定多个Queue到同一个Exchange的时候,这些Binding允许使用相同的binding key。binding key 并不是在所有情况下都生效,它依赖于Exchange Type,比如fanout类型的Exchange就会无视binding key,而是将消息路由到所有绑定到该Exchange的Queue。

Exchange Types
RabbitMQ常用的Exchange Type有fanout、direct、topic、headers这四种(AMQP规范里还提到两种Exchange Type,分别为system与自定义,这里不予以描述),下面分别进行介绍。

fanout
fanout类型的Exchange路由规则非常简单,它会把所有发送到该Exchange的消息路由到所有与它绑定的Queue中。
xx

direct
direct类型的Exchange路由规则也很简单,它会把消息路由到那些binding key与routing key完全匹配的Queue中。
xx

topic
前面讲到direct类型的Exchange路由规则是完全匹配binding key与routing key,但这种严格的匹配方式在很多情况下不能满足实际业务需求。topic类型的Exchange在匹配规则上进行了扩展,它与direct类型的Exchage相似,也是将消息路由到binding key与routing key相匹配的Queue中,但这里的匹配规则有些不同,它约定: routing key为一个句点号“. ”分隔的字符串 (我们将被句点号“. ”分隔开的每一段独立的字符串称为一个单词),如“stock.usd.nyse”、“nyse.vmw”、“quick.orange.rabbit” binding key与routing key一样也是句点号“. ”分隔的字符串 binding key中可以存在两种特殊字符“”与“#”,用于做模糊匹配,其中“”用于匹配一个单词,“#”用于匹配多个单词(可以是零个)
xx

headers
headers类型的Exchange不依赖于routing key与binding key的匹配规则来路由消息,而是根据发送的消息内容中的headers属性进行匹配。 在绑定Queue与Exchange时指定一组键值对,当消息发送到Exchange时,RabbitMQ会取到该消息的headers(也是一个键值对的形式),对比其中的键值对是否完全匹配Queue与Exchange绑定时指定的键值对,如果完全匹配则消息会路由到该Queue,否则不会路由到该Queue。



RabbitMQ工作模型

xx

  • Broker: 接受客户端连接,实现AMQP实体服务。
  • Vhost: 虚拟主机,一个broker里可以有多个vhost,用作不同用户的权限分离。
  • Exchange: 消息交换机,它指定消息按什么规则,路由到哪个队列。
  • Queue: 消息的载体,每个消息都会被投到一个或多个队列。
  • Binding: 绑定,它的作用就是把exchange和queue按照路由规则绑定起来.
  • Routing Key: 路由关键字,exchange根据这个关键字进行消息投递。
  • Producer: 消息生产者,就是投递消息的程序.
  • Consumer: 消息消费者,就是接受消息的程序.
  • Connection: 客户端与某个broker的网络连接。
  • Channel: 消息通道,在客户端的每个连接里,可建立多个channel.


交换机使用建议

  • 对于大多数情况下,使用RabbitMQ自带的 amq.direct 就足够了,不需要自行创建交换机
  • direct将也支持将一份消息发送到多个队列(复制),因此fanout并不是必要的
  • 为了简化开发及排查过程,建议用消息的类型名称做为 bindingkey



消息持久化

队列和交换机有一个创建时候指定的标志durable, durable的唯一含义就是具有这个标志的队列和交换机会在重启之后重新建立,它不表示说在队列中的消息会在重启后恢复

消息持久化包括3部分

  1. exchange持久化,在声明时指定durable => true hannel.ExchangeDeclare(ExchangeName, "direct", durable: true, autoDelete: false, arguments: null);//声明消息队列,且为可持久化的

  2. queue持久化,在声明时指定durable => true channel.QueueDeclare(QueueName, durable: true, exclusive: false, autoDelete: false, arguments: null);//声明消息队列,且为可持久化的

  3. 消息持久化,在投递时指定delivery_mode => 2 channel.basicPublish("", queueName, MessageProperties.PERSISTENT_TEXT_PLAIN, msg.getBytes());

如果exchange和queue都是持久化的,那么它们之间的binding也是持久化的,如果exchange和queue两者之间有一个持久化,一个非持久化,则不允许建立绑定.

注意:一旦创建了队列和交换机,就不能修改其标志了, 例如,创建了一个non-durable的队列,然后想把它改变成durable的,唯一的办法就是删除这个队列然后重现创建。



死信队列

死信队列介绍

  • 死信队列:DLX,dead-letter-exchange
  • 利用DLX,当消息在一个队列中变成死信 (dead message) 之后,它能被重新publish到另一个Exchange,这个Exchange就是DLX

消息变成死信有以下几种情况

  • 消息被拒绝(basic.reject / basic.nack),并且requeue = false
  • 消息TTL过期
  • 队列达到最大长度

死信处理过程

  • DLX也是一个正常的Exchange,和一般的Exchange没有区别,它能在任何的队列上被指定,实际上就是设置某个队列的属性。
  • 当这个队列中有死信时,RabbitMQ就会自动的将这个消息重新发布到设置的Exchange上去,进而被路由到另一个队列。
  • 可以监听这个队列中的消息做相应的处理。

参考链接:https://www.jianshu.com/p/986ee5eb78bc



Virtual Hosts

Introduction
RabbitMQ is multi-tenant system: connections, exchanges, queues, bindings, user permissions, policies and some other things belong to virtual hosts, logical groups of entities. If you are familiar with virtual hosts in Apache or server blocks in Nginx, the idea is similar. There is, however, one important difference: virtual hosts in Apache are defined in the configuration file; that's not the case with RabbitMQ: virtual hosts are created and deleted using rabbitmqctl or HTTP API instead.

Logical and Physical Separation
Virtual hosts provide logical grouping and separation of resources. Separation of physical resources is not a goal of virtual hosts and should be considered an implementation detail.

For example, resource permissions in RabbitMQ are scoped per virtual host. A user doesn't have global permissions, only permissions in one or more virtual hosts. User tags can be considered global permissions but they are an exception to the rule.

Therefore when talking about user permissions it is very important to clarify what virtual host(s) they apply to.


小结:

  • 如果应用程序使用的队列数量比较多,可以考虑采用这种方式来隔离,方便管理。
  • 也可以用于隔离多套测试环境。


连接的生命周期

Connections are meant to be long-lived. The underlying protocol is designed and optimized for long running connections. That means that opening a new connection per operation, e.g. a message published, is unnecessary and strongly discouraged as it will introduce a lot of network roundtrips and overhead.

Channels are also meant to be long-lived but since many recoverable protocol errors will result in channel closure, channel lifespan could be shorter than that of its connection. Closing and opening new channels per operation is usually unnecessary but can be appropriate. When in doubt, consider reusing channels first.

Channel-level exceptions such as attempts to consume from a queue that does not exist will result in channel closure. A closed channel can no longer be used and will not receive any more events from the server (such as message deliveries). Channel-level exceptions will be logged by RabbitMQ and will initiate a shutdown sequence for the channel (see below).


连接应该是长久的。底层协议是为长时间运行的连接而设计和优化的。这意味着每次操作打开一个新连接, 例如发布一条消息,是不必要的,并且强烈反对这样做,因为这会引入大量的网络往返和开销。

通道也意味着寿命很长,但是由于许多可恢复的协议错误将导致通道关闭,通道的寿命可能比连接的寿命短。 每次操作关闭和打开新的通道通常是不必要的,但可以是适当的。当有疑问时,首先考虑重用通道。

通道级异常,比如试图从不存在的队列中消费,将导致通道关闭。关闭的通道将不再被使用, 并且将不再从服务器接收任何事件(如消息传递)。通道级异常会被RabbitMQ记录下来, 并且会启动通道的关闭序列(见下文)。


在Nebula中

  • 订阅者使用的长连接,且支持中断后重新连接
  • 生产者也支持长连接,用名称来区分维护,
  • new RabbitClient时,如果不指定连接名(第2个参数),连接将在RabbitClient结束时释放。



高可用

RabbitMQ的高可用涉及以下方面:

  • 安装部署
    • 集群复制:https://www.rabbitmq.com/clustering.html
  • 参数配置
    • 镜像设置:https://www.rabbitmq.com/ha.html
    • 仲裁队列:https://www.rabbitmq.com/docs/quorum-queues
    • 队列策略:https://www.rabbitmq.com/parameters.html#policies
    • 持久化: 交换机,队列,消息
  • 客户端
    • 连接自动恢复:https://www.rabbitmq.com/dotnet-api-guide.html#recovery
    • 消息确认:Message acknowledgment


ClownFish默认使用 classic queues,如果希望使用 quorum-queues ,可在配置服务中添加一个参数:

ClownFish_RabbitMQ_DefaultQueueType=quorum

强烈建议 一定要为队列设置 【队列策略】 来限制每个队列的最大长度,例如:

xx


显示效果:

xx

使用RabbitMQ

本文将介绍RabbitMQ的使用技巧,主要包含以下内容

  1. 在配置服务中注册连接
  2. 创建队列
  3. 发送消息
  4. 订阅消息
  5. 消息的延迟(定时)处理
  6. 多队列&多进程-自动配对订阅



参考链接

官方API参考: https://www.rabbitmq.com/dotnet-api-guide.html

官方教程: https://www.rabbitmq.com/tutorials/tutorial-one-dotnet.html



在配置服务中注册连接

在Nebula中使用RabbitMQ必须先在配置服务中注册RabbitMQ连接,
具体做法中在settings表中增加一条记录(也可通过AdminUI界面来操作),例如:

xx

字段说明

  • name: 连接名称,在代码中使用
  • value: 连接参数,可用数据成员参考下面RabbitOption代码
  • restype=1: 用于开启Venus监控
/// <summary>
/// RabbitMQ的连接信息
/// </summary>
public sealed class RabbitOption
{
    /// <summary>
    /// VHost,默认值:"/"
    /// </summary>
    public string VHost { get; set; } = "/";
    
    /// <summary>
    /// RabbitMQ服务地址。【必填】
    /// </summary>
    public string Server { get; set; }
    
    /// <summary>
    /// 连接端口(大于 0 时有效)
    /// </summary>
    public int Port { get; set; }

    /// <summary>
    /// HTTP协议的连接端口(大于 0 时有效)
    /// </summary>
    public int HttpPort { get; set; }
    
    /// <summary>
    /// 登录名。【必填】
    /// </summary>
    public string Username { get; set; }
    
    /// <summary>
    /// 登录密码。
    /// </summary>
    public string Password { get; set; }
}

参数值配置示例

server=RabbitHost;username=fishli;password=aaaaaaaaaaaa;vhost=nbtest





创建队列

下面示例代码演示了2种创建队列的方法,
它们都使用了默认的 amq.direct 交换机。

public static class RabbitInit1
{
    public static readonly string SettingName = "Rabbit-DEMO";


    /// <summary>
    /// 创建【强类型】的消息队列
    /// </summary>
    private static void CreateQueue1()
    {
        using( RabbitClient client = new RabbitClient(SettingName) ) {

            // 队列名称,bindingKey 二者相同,都是 Product类型的全名
            client.CreateQueueBind(typeof(Product));
        }
    }
}
public static class RabbitInit2
{
    public static readonly string QueueName = "DEMO-QUEUE";

    public static readonly string SettingName = "Rabbit-DEMO";

    /// <summary>
    /// 创建【自由名称】的消息队列
    /// </summary>
    private static void CreateQueue2()
    {
        using( RabbitClient client = new RabbitClient(SettingName) ) {

            // 队列名称,bindingKey 二者相同,都是 "DEMO-QUEUE"
            client.CreateQueueBind(QueueName);
        }
    }
}



CreateQueueBind方法签名如下:

/// <summary>
/// 创建队列并绑定到交换机
/// </summary>
/// <param name="queue">队列名称</param>
/// <param name="exchange">交换机名称,默认值: "amq.direct"</param>
/// <param name="bindingKey">从交换机到队列的映射标记</param>
/// <param name="argument">调用QueueDeclare时传递的argument参数</param>
public virtual void CreateQueueBind(string queue, string exchange = null, string bindingKey = null, IDictionary<string, object> argument = null)

/// <summary>
/// 创建队列并绑定到交换机
/// </summary>
/// <param name="dataType">消息的数据类型,最终创建的队列名称就是消息数据类型的全名,bindingKey与队列同名</param>
/// <param name="exchange">交换机名称,默认值: "amq.direct"</param>
/// <param name="argument">调用QueueDeclare时传递的argument参数</param>
public void CreateQueueBind(Type dataType, string exchange = null, IDictionary<string, object> argument = null)





发送消息

可参考以下代码:

internal class RabbitProducer : BaseController
{
    /// <summary>
    /// 将数据发送到队列,【强类型】方式
    /// </summary>
    public void SendData1(Product product)  // 方法 1
    {
        // 如果只是     【偶尔】    需要发送消息,可以这样调用

        using( RabbitClient client = new RabbitClient(RabbitInit.SettingName) ) {

            // 往 RabbitMQ 发送一条消息,routingKey就是参数的类型全名
            client.SendMessage(product);
        }
    }

    public void SendData2(Product product)  // 方法 2
    {
        // 往 RabbitMQ 发送一条消息,routingKey就是参数的类型全名
        // 它会在整个应用程序内部维护一个共享连接,在多次调用时性能会更好。

        this.SendRabbitMessage(RabbitInit.SettingName, product);
    }

    public void SendData3(Product product)  // 方法 3
    {
        // RabbitClient构造方法的第二个参数是一个 “共享连接” 的名称,

        // 默认值是null,表示连接仅在 RabbitClient 内部使用,RabbitClient离开作用域后将关闭连接。

        // 如果指定了连接名称,那么连接将会保持,在下次构造RabbitClient时将会重用已存在的连接。
        // 共享连接的名称建议使用 nameof(类型名称) ,表示连接仅供某个类型共用。

        using( RabbitClient client = new RabbitClient(RabbitInit.SettingName, nameof(RabbitProducer)) ) {

            client.SendMessage(product);
        }
    }

    /// <summary>
    /// 将数据发送到队列,指定routingKey
    /// </summary>
    public void SendData4(Product product)
    {
        using( RabbitClient client = new RabbitClient(RabbitInit.SettingName, nameof(RabbitProducer)) ) {

            // 往 RabbitMQ 发送一条消息,routingKey由第3个参数指定
            client.SendMessage(product, null, RabbitInit.QueueName);
        }
    }

}



SendMessage方法签名如下:

/// <summary>
/// 往队列中发送一条消息。
/// </summary>
/// <param name="data">要发送的消息数据</param>
/// <param name="exchange">交换机名称,默认值: "amq.direct"</param>
/// <param name="routingKey">消息的路由键</param>
/// <param name="basicProperties"></param>
/// <returns>消息体长度</returns>
public int SendMessage(object data, string exchange = null, string routingKey = null, IBasicProperties basicProperties = null)



RabbitClient构造方法签名如下:

/// <summary>
/// 构造方法
/// </summary>
/// <param name="settingName">配置服务中的Rabbit连接名称</param>
/// <param name="connectionName">
/// 连接名称,
/// 如果不指定,表示连接在使用结束后关闭, 
/// 如果指定,那么连接将会一直打开,供后续同名的connectionName使用。</param>
public RabbitClient(string settingName, string connectionName = null)





订阅消息

可参考以下代码:

public class RabbitConsumer
{
    /// <summary>
    /// 订阅消息,【强类型】方式
    /// </summary>
    public static void Start1()
    {
        // 创建队列订阅者,并开启监听
        RabbitSubscriber.Start<Product, PrintMessageHandler>(new RabbitSubscriberArgs {
            SettingName = RabbitInit.SettingName,
            QueueName = null,  // 队列名称是 Product类型的全名
            SubscriberCount = LocalSettings.GetUInt("RabbitSubscriber.Count", 2)
        });
    }

    /// <summary>
    /// 订阅消息,【自由名称】方式
    /// </summary>
    public static void Start2()
    {
        // 创建队列订阅者,并开启监听
        RabbitSubscriber.Start<Product, PrintMessageHandler>(new RabbitSubscriberArgs {
            SettingName = RabbitInit.SettingName,
            QueueName = RabbitInit.QueueName,
            SubscriberCount = LocalSettings.GetUInt("RabbitSubscriber.Count", 2)
        });
    }
}





消息的延迟(定时)处理

实现思路:

  • 创建一个队列 WaitRetry,为它设置 死信队列 ReSend
  • 发送消息时指定过期时间,
  • 消息进入队列后,由于没有订阅者来消息,会在过期后进入死信队列
  • 订阅者订阅死信队列,获取到【延迟】的消息

注意:WaitRetry/ReSend 只是示例代码中的名称,并没有强制要求!

可参考下图:
xx

示例代码:

private static void CreateRetryMQ()
{
    //  “等待重试” 队列
    string waitRetryQueue = Res.GetWaitRetryQueue();

    // “重试” 队列
    string resendQueue = Res.GetReSendQueue();

    using( RabbitClient client = Res.CreateRabbitClient() ) {

        // 为 waitRetryQueue 配置 死信队列
        Dictionary<string, object> args = new Dictionary<string, object>();
        args["x-dead-letter-exchange"] = Exchanges.Direct;
        args["x-dead-letter-routing-key"] = resendQueue;
        
        client.CreateQueueBind(waitRetryQueue, null, null, args);                
        client.CreateQueueBind(resendQueue);
    }

    // 创建队列的消息订阅者
    RabbitSubscriber.Start<MessageX, ReSendHandler>(new RabbitSubscriberArgs {
        SettingName = Res.RabbitSettingName,
        QueueName = resendQueue,
    });
}

此时可以在RabbitMQ的管理界面看到:
xx


发送消息代码
public void SendRetryMessage(MessageX message, int waitMilliseconds)
{
    using( RabbitClient client = Res.CreateRabbitClient() ) {

        // 注意:消息发送的目标队列 是没有订阅者的,
		// 消息会一直等到 “过期” 被移到 “死信队列”,在哪里才会被处理。
        IBasicProperties properties = client.GetBasicProperties();
        properties.Expiration = waitMilliseconds.ToString();

        string routing = Res.GetWaitRetryQueue();
        client.SendMessage(message, null, routing, properties);
    }
}

剩下的事情就是订阅 ReSend 队列并处理消息,这里忽略这个过程……





多队列&多进程-自动配对订阅

回顾一下 简单的队列使用场景
假如我们有一个数据类型 XMessage
我们可以为它创建一个队列,名字也叫 XMessage,
然后我们可以用下面的代码来订阅这个队列:

RabbitSubscriberArgs args = new RabbitSubscriberArgs{
     SettingName = "Rabbit连接的配置名称",
     QueueName = "XMessage",
     SubscriberCount = 3
};
RabbitSubscriber.Start<XMessage, XMessageHandler>(args);

如果我们将程序运行2个实例,那么对于 XMessage 队列,它将有6个订阅者(2个实例 * 3个订阅线程),
所有消息将会均匀分配到6个订阅者。




多队列&多进程--使用场景
假设:XMessage类型的消息,由于种种原因需要分裂成多个“相同的”队列,
例如按租户ID取模,之后需要将同一个租户的所有消息固定发送到与它对应的队列。
此时进程(容器)运行多个实例,如何保证每个队列只被一个进程订阅(尤其要考虑进程重启)?


多队列场景示例
XMessage队列需要创建10个,分别是 XMessage1, XMessage2,,,,,XMessage10
它们仍然接收 XMessage 这个类型的消息,只是按租户ID将它们分开而已,
也就是说:

  • 如果XMessage消息中,tenantid = my596d74ca5eeb1,那么这条消息就投递到XMessage1队列,
  • 如果XMessage消息中,tenantid = my596d74ca5eeb2,那么这条消息就投递到XMessage2队列,
  • 如果XMessage消息中,tenantid = my596d74ca5eeb3,那么这条消息就投递到XMessage3队列,

此时我们的程序运行5个实例,那么订阅代码如何编写?
因为容器内的进程运行起来是一模一样的,不可能在代码或者配置中指定队列名称!


解决方案
  • 使用分布式锁,在锁范围内找到没有被订阅的队列,然后订阅它!

参考代码

/// <summary>
/// 多队列-多进程 自动配对订阅实现方案,
/// 假如有 10 个队列,5个进程,此时每个进程将订阅2个队列
/// </summary>
public static void Demo()
{
    // 此方案的大致思路是:
    // 1,先获取一个分布式锁,保证多个进程互斥(只有一个进程能进入执行)
    // 2,获取所有队列信息,检查将要订阅的队列是否有订阅者,如果队列没有订阅者,就订阅它


    // 创建一个分布式锁,锁将保证多个进程时,只有一个进程能进入执行
    using( GlobalLock locker = new GlobalLock("一个特殊的锁标识", TimeSpan.FromSeconds(30)) ) {

        RabbitMonitorClient client = new RabbitMonitorClient("rabbitmq-连接名称");

        // 获取所有队列信息,并过滤【特定】
        List<QueueInfo> list = client.GetQueues(10_000).Where(x => x.Name.StartsWith("队列名称前缀")).ToList();

        // 记录当前进程已订阅的队列数量
        int count = 0;

        foreach( var q in list ) {

            // 如果某个队列没有订阅者,就表示当前进程可以订阅它
            if( q.Consumers == 0 && count < 2 ) {

                // 订阅某个队列
                RabbitSubscriberArgs args = new RabbitSubscriberArgs {
                    SettingName = "Rabbit连接配置名称",
                    QueueName = q.Name,
                    SubscriberCount = 3
                };
                RabbitSubscriber.Start<XMessage, XMessageHanlder>(args);
                count++;
            }
        }
    }
}





RabbitMQ开发建议

主要包含以下内容

  1. 配置RabbitMQ连接
  2. 消息队列的创建
  3. 发送消息
  4. 发送消息使用建议
  5. 订阅消息
  6. 处理消息
  7. 幂等操作
  8. 一条消息多次使用
  9. 订阅方性能优化
  10. 指标监控



强烈建议

  • 尽量使用 direct 交换机,即:exchange 参数保持为 null
  • 队列名称,bindingKey,routingKey 三者保持相同,建议使用类型全名
  • 应用程序初始化时创建必需的队列,不管是发送消息还是订阅消息
  • 创建队列时,new RabbitClient(...)     不要     指定第2个参数!
  • RabbitMQ的连接配置名称放在一个 static readonly string 变量中,不要在代码中复制粘贴,也不要再做成参数!
  • 订阅者的数量不能硬编码,请使用 LocalSettings.GetUInt("MsgDataType_RabbitSubscriber_Count", 10)



配置RabbitMQ连接

RabbitMQ在使用时,会有一些与连接相关的参数,例如,服务地址,端口,用户名,密码之类的参数。
为了安全,请将这些信息保存到配置服务中。
这个时候,我们需要给配置项取一个连接名称。

建议:“连接名称” 要能体现出属于哪个应用。

例如: "Nebula_Metis_Rabbit"

这样做有3个好处:

  • 可以防止名称重复
  • 便于识别连接的用途
  • 便于以后实现队列迁移



消息队列的创建

基本概念介绍:

  • 在消息订阅时,会要求指定要订阅哪个队列,因此队列必须要提前创建。
  • 在消息发送时,消息发送到交换机就完事了,在RabbitMQ内部会有一个路由机制转发到队列,所以严格意义来说,发送方是不需要关心队列是否存在的。

建议:不管是发送方还是订阅方,在程序初始化时,都应该提前将队列创建好。

队列的创建方法:

public static class RabbitInit
{
    public static readonly string QueueName = "mq111111111";

    public static readonly string SettingName = "Nebula_Metis_Rabbit";


    /// <summary>
    /// 创建【强类型】的消息队列
    /// </summary>
    private static void CreateQueue1()
    {
        using( RabbitClient client = new RabbitClient(SettingName) ) {

            // 创建队列,队列名称是 Product类型的全名
            client.CreateQueueBind(typeof(Product));
        }
    }

    /// <summary>
    /// 创建【自由名称】的消息队列
    /// </summary>
    private static void CreateQueue2()
    {
        using( RabbitClient client = new RabbitClient(SettingName) ) {

            // 创建队列,队列名称由参数指定
            client.CreateQueueBind(QueueName);
        }
    }
}

注意:

  • CreateQueueBind方法的传入参数



发送消息

/// <summary>
/// 将数据发送到队列,【强类型】方式
/// </summary>
public void SendData1(Product product)  // 方法 1
{
    // 如果只是偶尔需要发送消息,可以这样调用

    using( RabbitClient client = new RabbitClient(RabbitInit.SettingName) ) {

        // 往 RabbitMQ 发送一条消息,队列名称就是参数的类型全名
        client.SendMessage(product);
    }
}

public void SendData2(Product product)  // 方法 2
{
    // 往 RabbitMQ 发送一条消息,队列名称就是参数的类型全名
    // 它会在整个应用程序内部维护一个共享连接,在多次调用时性能会更好。

    this.SendRabbitMessage(RabbitInit.SettingName, product);
}

public void SendData3(Product product)  // 方法 3
{
    // RabbitClient构造方法的第二个参数是一个 “共享连接” 的名称,
    // 默认值是null,表示连接仅在 RabbitClient 内部使用,RabbitClient离开作用域后将关闭连接。

    // 如果指定了连接名称,那么连接将会保持,在下次构造RabbitClient时将会重用已存在的连接。
    // 共享连接的名称建议使用 nameof(类型名称) ,表示连接仅供某个类型共用。

    using( RabbitClient client = new RabbitClient(RabbitInit.SettingName, nameof(RabbitProducer)) ) {

        client.SendMessage(product);
    }
}

/// <summary>
/// 将数据发送到队列,【自由名称】方式
/// </summary>
public void SendData4(Product product)
{
    using( RabbitClient client = new RabbitClient(RabbitInit.SettingName, nameof(RabbitProducer)) ) {

        // 往 RabbitMQ 发送一条消息,队列名称由第3个参数指定
        client.SendMessage(product, null, RabbitInit.QueueName);
    }
}

以上4种方式都可行,根据注释来选择。

它们都只提供了二个必需参数:

  • SettingName: 表示 RabbitMQ 在配置服务中的连接配置项
  • product: 消息对象,发送时将根据变量的类型名称,找到对应的队列名称



发送消息使用建议

Nebula提供4种消息发送方式:

  • 方法1:整体速度最差,主要慢在与RabbitMQ的连接上,用于偶尔发送一条消息的场景,每次调用结束后会关闭连接。
  • 方法2:其实是“方法3”的包装版本,将“连接名称”固定了而已。
  • 方法3:允许指定共享连接名称,如果程序有多个地方需要消息时,可以通过指定连接名称来减少访问冲突。
  • 方法4:指定队列名称(其实是路由名称),用于“自由队列”

使用建议:

  • 在程序初始化时,如果需要创建队列,使用“方法1” (用完就关闭的连接)
  • 如果消息的发送量非常少,也推荐使用“方法1”
  • 在Controller/Action中 推荐使用 “方法2”
  • 其它地方可以使用“方法3”或者“方法4”,但是连接名不要乱取,每个名字意味着会产生一个长连接(永不关闭)



订阅消息

可参考以下代码:

/// <summary>
/// 订阅消息,【强类型】方式
/// </summary>
public static void Start1()
{
    // 创建队列订阅者,并开启监听
    RabbitSubscriber.Start<Product, PrintMessageHandler>(new RabbitSubscriberArgs {
        SettingName = RabbitInit.SettingName,
        QueueName = null,  // ### 注意这个参数:队列名称是 Product类型的全名
        SubscriberCount = LocalSettings.GetUInt("Product_RabbitSubscriber_Count", 1)
    });
}

/// <summary>
/// 订阅消息,【自由名称】方式
/// </summary>
public static void Start2()
{
    // 创建队列订阅者,并开启监听
    RabbitSubscriber.StartAsync<Product, PrintMessageHandler2>(new RabbitSubscriberArgs {
        SettingName = RabbitInit.SettingName,
        QueueName = "mq111111111",  //  ### 注意这个参数
        SubscriberCount = LocalSettings.GetUInt("Product_RabbitSubscriber_Count", 1)
    });
}

注意:订阅者的数量一定可配置!

说明:

  • 每个订阅者不仅包含一个Rabbit连接,还包含了一个 “消息处理管道"
  • 在 消息处理管道 中,还包含了与消息类型匹配的 MessageHandler
  • 消息处理管道 内部有重试和异常处理机制,MessageHandler 可以不用关心



处理消息

请在项目中创建一个Messages目录,然后参考下图创建 MessageHandler

xx


xx

消息最终是由 MessageHandler来处理的。



幂等操作

幂等操作是什么,请自行搜索,这里不解释。

幂等操作常规实现方法(方法1)

  • 为消息指定一个GUID成员,例如: public Guid MessageGuid {get; set;}
  • 对应的数据库表中,为 MessageGuid 创建【唯一索引】
  • 在消息处理时
    • 打开数据库事务
    • 调用 entity.Insert2() 扩展方法将消息入库,判断返回值:是否为重复插入,
      • 如果数据行已存在,可以调用 EndProcess() 来结束当前管道过程。
      • 否则按以下步骤执行
        • 处理消息(执行具体的业务过程)
        • 提交数据库事务

方法2
如果:

  • 业务过程比较简单,例如:只有一个操作
  • 或者业务操作过程不支持回退(取消)

那么可以不使用上面的方法, 而是直接在处理消息!
因为消息在处理结束后,ClownFish 会给RabbitMQ发送一个ACK信号,此时消息将会删除,也不会被重复处理。


消息的幂等操作由于涉及到消息队列这个外部服务,因此需要分布式事务来保证一致性,但是,
分布式事务本质上都不是完美的,上面二种做法都存在极小概率的缺陷,所有不必纠结!



一条消息多次使用

场景举例:
客户端上传了一条日志,在服务端需要做三个处理

  1. 持久化到MySQL
  2. 同时写一份到Elasticsearch
  3. 实时数据分析

实现方法:

  • 创建三个队列,设置相同的 Routing key ,消息将同时发送到三个队列,然后分开处理。



订阅方性能优化

对于消息的订阅方而言,提升消息的吞吐量有2种方法:

  • 增加订阅者数量
  • 优化MessageHandler的代码性能

订阅者数量不可能无限增加,所以优化代码性能是很有必要的,超出性能阀值会记录到性能日志中。


订阅者 数量如何确定?

  1. 订阅者的数量不要太大,每个订阅者对应着4个后台线程(线程池), 所以每增加1个订阅者,也会增加4个线程,太多无用的线程对性能提升没有任何帮助,反而浪费CPU时间,
  2. 判断队列是否有压力的最简单办法就是打开RabbitMQ的管理界面,切换到 Queues 页, 查看队列的 Message/Total 列,如果一段时间内(1分钟),这个数量没有上升趋势, 就表示消息订阅者的数量是足够的。
  3. 订阅者数量较小也不行,会导致消息的大量堆积,也会让MQ越来越慢。

所以,订阅者的数量以 “消息不堆积” 为原则。
这也是为什么订阅者的数量要做成配置参数的原因。



“消息不堆积” 举例说明
xx
这张图片很明显 incoming 远大于deliver,因此 Total 越来越大,也就是消息堆积越来越多。

xx

在这张图片中,incoming和deliver基本上相当,结果就是 Total 持续保持在较低水平,表示消息没有堆积。



指标监控

使用消息队列的应用程序,至少应该实现对队列的监控统计,主要有3个指标:

  1. 消息积压数量
  2. 当天消息处理总量
  3. 消息吞吐量

例如:
xx

实现过程可参考: 业务指标监控开发

使用 Kafka

本文将介绍Kafka的使用技巧,主要包含以下内容

  1. 在配置服务中注册连接
  2. 发送消息
  3. 订阅消息
  4. 注意事项



在配置服务中注册连接

在使用 Kafka 前,请先在配置服务中注册连接参数,可参考下图:

xx



发送消息

示例代码:

[HttpPost]
[Route("send1.aspx")]
public int Send1(Product product)
{
    int index = Interlocked.Increment(ref s_index);
    product.Remark = $"Kafka message - {index}, {DateTime.Now.ToTimeString()}";

    using( KafkaClient client = new KafkaClient("Nebula.Log.Kafka") ) {
        return client.SendMessageNoWait(product);
    }
}



订阅消息

KafkaSubscriber.Start<Product, PrintMessageHandler>(new KafkaSubscriberArgs {
    SettingName = "Nebula.Log.Kafka",   // 连接的配置名称
    GroupId = null, // null 表示使用当前应用的名称 
    Topic = null,   // null 表示使用Product的类型全名代替
    CommitPeriod = 1,  // 自动提交周期,1 表示每处理1条消息就提交一次
    RetryCount = 0,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

订阅消息之后,如果收到消息会交给 PrintMessageHandler 来处理,

后面的过程请参考:消息管道



注意事项

Kafka在整个消息处理过程中很有可能会导致消息丢失:

  1. KafkaClient.SendMessageNoWait 方法,它只是将消息放入客户端的缓冲区队列,并不是立即发送, 而是由另外的后台线程执行发送,这样做的好处是: 异步可提高吞吐量,但是在应用程序重启/停止的时候,极有可能出现消息丢失。
  2. 服务端在收到消息后,由于没有严格的事务保证,也是有可能导致消息丢失的
  3. 消息在服务端只是写入日志文件,并不关心客户端的处理进度(不是常规的队列概念),在消息达到清理时间后,即使消息没有被处理,也会被清除

对于订阅者(消费端)来说,请注意:

  1. KafkaSubscriber默认采用自动提交方式,可以设置 EnableAutoCommit = false 来使用【批量提交】
  2. 通常为了提高所谓的吞吐量,会采用【批量提交】方式,例如:CommitPeriod = 10 , 如果前面8条消息处理成功,处理第9条时程序重启, 当重新启动订阅后,前面的8条消息会重新发给订阅者,因为必须要实现【幂等操作】




参考链接

使用 Pulsar

本文将介绍Pulsar的使用技巧,主要包含以下内容

  1. 在配置服务中注册连接
  2. 发送消息
  3. 订阅消息



在配置服务中注册连接

在使用 Pulsar 前,请先在配置服务中注册连接参数,可参考下图:

xx

4个参数项的含意:

  • ServiceUrl: 用发送和订阅消息,具体格式要求请参考DotPulsar的要求
  • WebServiceUrl: REST的访问地址,如果不指定Venus将不监控它的可用性
  • AuthToken:认证用的 JWT Token
  • RetryIntervalMs: The time to wait before retrying an operation or a reconnect. 可以不指定!



发送消息

示例代码:

private async Task<string> Send0(Product product, string topic)
{
    RuntimeData.PulsarCount.Increment();

    int index = Interlocked.Increment(ref s_index);
    product.Remark = $"Pulsar message - {index}, {DateTime.Now.ToTimeString()}";

    await using( NPulsarClient client = new NPulsarClient("Pulsar_test", topic) ) {
        var messageId = await client.SendMessageAsync(product);
        return messageId.ToString();
    }
}



订阅消息

PulsarSubscriber.StartAsync<Product, PrintMessageHandler2>(new PulsarSubscriberArgs {
    SettingName = "Pulsar_test",   // 连接的配置名称
    Topic = "product",
    SubscriberCount = 2,  // 订阅者数量
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

订阅消息之后,如果收到消息会交给 PrintMessageHandler 来处理,

后面的过程请参考:消息管道




压缩解压缩

相关解释

  • Pulsar服务端不处理压缩与解压缩
  • 压缩由发送端实现,可选择压缩算法
  • 解压缩由消费端在读取消息时判断,并自动调用对应的解压缩算法

Pulsar客户端支持4种压缩算法,可参考DotPulsar.CompressionType枚举,
相应的算法依赖包采用反射的方式加载,在运行时如果找不到合适的算法包会出现异常,
所以建议在程序部署时先添加必要的引用。

NPulsarClient默认使用Lz4压缩算法,如果需要使用其它算法,可在构造时指定:

/// <summary>
/// 构造方法
/// </summary>
/// <param name="settingName">配置服务中的Kafka连接名称</param>
/// <param name="topic">消息主题</param>
/// <param name="compressionType">压缩算法</param>
public NPulsarClient(string settingName, string topic, CompressionType compressionType = CompressionType.Lz4)




参考链接

使用 MMQ

MMQ 即 Memory Message Queue,它是一种进程内的消息队列,由ClownFish提供。

它有以下特点:

  • 性能好
  • 不做持久化



创建MMQ实例

示例代码如下:

/// <summary>
/// 同步版本的 MMQ
/// </summary>
private static readonly MemoryMesssageQueue<Product> s_syncMMQ 
                            = new MemoryMesssageQueue<Product>(MmqWorkMode.Sync);

/// <summary>
/// 异步版本的 MMQ
/// </summary>
private static readonly MemoryMesssageQueue<Product> s_asyncMMQ 
                            = new MemoryMesssageQueue<Product>(MmqWorkMode.Async);



发送消息

示例代码-同步版本:

[HttpPost]
[Route("send-sync.aspx")]
public int Send1(Product product)
{
    int index = Interlocked.Increment(ref s_index);
    product.Remark = $"MMQ message - {index}, {DateTime.Now.ToTimeString()}";

    s_syncMMQ.Write(product);   // 注意这行

    return index;
}

示例代码-异步版本:

[HttpPost]
[Route("send-async.aspx")]
public async Task<int> Send2(Product product)
{
    int index = Interlocked.Increment(ref s_index);
    product.Remark = $"MMQ message - {index}, {DateTime.Now.ToTimeString()}";

    await s_asyncMMQ.WriteAsync(product);   // 注意这行

    return index;
}



订阅消息

// 同步版本
MmqSubscriber.Start<Product, PrintMessageHandler>(new MmqSubscriberArgs<Product> {
    Queue = s_syncMMQ,
    SubscriberCount = 2,
    RetryCount = 0,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

// 异步版本
MmqSubscriber.StartAsync<Product, PrintMessageHandler2>(new MmqSubscriberArgs<Product> {
    Queue = s_asyncMMQ,
    SubscriberCount = 2,
    RetryCount = 0,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});

订阅消息之后,如果收到消息会交给 PrintMessageHandler 来处理,

后面的过程请参考:消息管道

使用 Redis MQ

ClownFish对Redis的封装并不多,主要在4个方面:

  • 连接的统一管理
  • API调用性能监控
  • 发布消息
  • 订阅消息



在配置服务中注册连接

在使用Redis前,请先在在配置服务中注册连接,可参考以下界面:

xx

此处的参数值格式可参考:https://stackexchange.github.io/StackExchange.Redis/Configuration#basic-configuration-strings



调用 Redis API

可参考以下代码

[HttpGet, HttpPost]
[Route("string-demo.aspx")]
public async Task<object> StringDemo()
{
    IDatabase db = ClownFish.NRedis.Redis.GetDatabase();

    await db.StringSetAsync("kk101", DateTime.Now.Ticks.ToString());

    await db.StringSetAsync("kk102", Guid.NewGuid().ToString());

    string value1 = await db.StringGetAsync("kk101");

    string value2 = await db.StringGetAsync("kk102");

    return new {
        kk101 = value1,
        kk102 = value2
    };
}

核心部分就是得到 IDatabase实例

IDatabase db = ClownFish.NRedis.Redis.GetDatabase();

剩下的就是调用 Redis API



发布消息

示例代码:

[HttpPost]
[Route("sendmessage.aspx")]
public int Send(Product product)
{
    int index = Interlocked.Increment(ref s_index);
    product.Remark = $"Redis message - {index}, {DateTime.Now.ToTimeString()}";

    // channel 取Product的类型全名
    Redis.GetClient().SendMessage(product);

    return index;
}



订阅消息

RedisSubscriber.Start<Product, PrintMessageHandler>(new RedisSubscriberArgs {
    SettingName = "redis.Connection",   // 连接的配置名称
    Channel = null,   // null 表示使用Product的类型全名代替
    RetryCount = 5,    // 重试次数
    RetryWaitMilliseconds = 1000   // 重试的间隔时间
});



注意事项

  • 不要把Redis当成数据库,它只是一个缓存服务!
  • 所有写入Redis的数据都需要 设置过期时间
  • 禁止将较大的数据块写入Redis,尽量控制数据长度在256K以内。

可靠地调用第三方

调用第三方服务可能看起来很简单:只需要发起一个HTTP请求即可!

实际上,如果真这样去做,迟早会出现各种稳定性或者数据不一致的问题,因为:

  • 网络有可能偶尔中断,HTTP调用就会失败。
  • 第三方可能在更新或者重启或者崩溃了,HTTP调用出现失败。

最麻烦的是:这种失败不是简单的 for循环+重试就能解决,因为不知道故障会持续多久!



调用第三方通常有2大场景:

  • 即时调用(同步等待调用):需要立即获取结果,然后继续执行
  • 当前任务执行结束,【通知】第三方做相应处理



解决办法:

  • 第一种场景:建议使用 for循环+重试,或者使用 ClownFish中的 HttpRetry 来解决。
  • 第二种场景:有2种方法

以上2种方法都依赖于 MQ 和 消息管道,差别:

  • 前者:开发简单,但是需要多部署一个服务以及相关配置
  • 后者:灵活度高









请点击目录中的节点查看具体文档

后台任务介绍

后台任务分为2大类:

  • 周期性后台任务
  • 临时性后台任务

本文主要介绍 周期性后台任务



周期性后台任务

很多时候我们需要执行一些周期性任务,它们有如下特性:

  • 后台执行:由后台线程执行
  • 周期规律性:每x分钟 或者 每x秒 就执行一次
  • 长时间运行:任务的生命周期和进程相同

对于此类场景,我们可以使用 BackgroundTask 来实现。


周期性后台任务有2种执行方式:

  • 同步方式:需要从 BackgroundTask 基类继承
  • 异步方式:需要从 AsyncBackgroundTask 基类继承

2个类的用法完全一致,下文将以同步方法来介绍用法。



BackgroundTask与Hangfire的主要差别


技术特性BackgroundTaskHangfire
依赖ASP.NET
依赖数据库持久化
同一任务重叠执行
支持异步任务
支持秒级触发
支持性能日志
支持Venus监控
支持立即执行延迟
支持临时任务



查看后台任务

可以从Venus中查看某个应用进程的后台作业执行情况,可参考下图:

xx


xx



周期性后台任务-同步版本

所有周期性后台任务需要实现 以下任意一个 抽象类:

  • BackgroundTask
  • AsyncBackgroundTask



BackgroundTask

BackgroundTask 是一个基类,当我们在项目中实现它的子类时,就能实现【后台周期性任务】,
AsyncBackgroundTask 是对应的异步版本。

它的主要公开成员如下:

/// <summary>
/// 表示一个后台运行的任务,它的子类会在程序启动时自动创建并运行
/// </summary>
public abstract class BackgroundTask
{
	/// <summary>
	/// 是否需要在每次执行Execute方法时自动生成日志(OprLog + InvokeLog)
	/// </summary>
	protected bool EnableLog => true;
	
	/// <summary>
	/// 获取休眠秒数,用于描述“周期任务”的间隔时间,例如:每5秒执行一次
	/// 注意:同步版本不支持时间跨度太久的休眠间隔。
	/// </summary>
	public virtual int? SleepSeconds {
		get => null;
	}

	/// <summary>
	/// 获取一个Cron表达式,用于描述“周期任务”的间隔时间。
	/// 这里使用的是 Quartz 支持的 Cron 格式,在线工具:https://www.pppet.net/
	/// 注意:同步版本不支持时间跨度太久的休眠间隔。
	/// </summary>
	public virtual string CronValue {
		get => null;
	}

	/// <summary>
	/// 执行任务前的初始化。
	/// 说明:执行当前方法时框架不做异常处理,如果产生异常会导致进程崩溃。
	/// </summary>
	/// <returns>如果 return false, 表示初始化失败,将中止任务</returns>
	public virtual bool Init()


	/// <summary>
	/// 执行任务的主体过程。
	/// </summary>
	public abstract void Execute();

	/// <summary>
	/// 异常处理方法。
	/// 默认行为:如果启用日志就不做任何处理,否则输出到Console
	/// </summary>
	/// <param name="ex"></param>
	public virtual void OnError(Exception ex)
	
}



要求&说明

子类的实现要求:

  • 类型的可见性必须是 public
  • 必须指定执行间隔属性: SleepSeconds 或者 CronValue

补充说明:

  • 子类的实例由Nebula在启动时创建并调用(项目中不需要实例化)
  • 内部已包含异常处理,如果需要额外的日志请重写OnError方法



BackgroundTask, AsyncBackgroundTask 差异

  • AsyncBackgroundTask 是异步版本,使用线程池执行作业
  • BackgroundTask 使用单独线程(不使用线程池),任务的及时触发率会更好
  • 如果对任务的【及时触发】要求不高,建议使用 AsyncBackgroundTask

  • AsyncBackgroundTask 支持较长的作业执行间隔,例如:一个月一次
  • BackgroundTask 【不支持】较长的作业执行间隔,建议 仅用于 10 秒内的间隔任务



示例1 - 休眠N秒的定时任务

public class Task1 : BackgroundTask
{
    public override int? SleepSeconds => 90;

    public override void Execute()
    {
        Console2.Info("Task1,每隔 90 秒执行一次!");
		// do something....
    }
}



示例2 - 基于CronValue的定时任务

public class Task2 : BackgroundTask
{
    public override string CronValue => "0/10 * * * * ? *";

    public override void Execute()
    {
        Console2.Info("Task2,每隔 10 秒执行一次!");
		// do something....
    }
}



周期性后台任务-异步版本

注意事项:

  • 基类是 AsyncBackgroundTask
  • 方法的签名是 Task ExecuteAsync()



示例1 - 休眠N秒的定时任务

public class Task1 : AsyncBackgroundTask
{
    public override int? SleepSeconds => 90;

    public override Task ExecuteAsync()
    {
        Console2.Info("Task1,每隔 90 秒执行一次!");
		// do something....

		await xxxxAsync();
    }
}



示例2 - 基于CronValue的定时任务

public class Task2 : AsyncBackgroundTask
{
    public override string CronValue => "0/10 * * * * ? *";

    public override Task ExecuteAsync()
    {
        Console2.Info("Task2,每隔 10 秒执行一次!");
		// do something....

		await xxxxAsync();
    }
}

临时性后台任务

针对一些复杂耗时的用户操作场景,一个常见的优化性能手段就是

  • 先检查必要的提交数据是否完整有效(同步执行)
  • 将复杂耗时部分改为异步执行(提前结束)

那么这里的【异步执行】就有多种实现方式了,例如:

  • 使用 Task.Run(...)
  • 使用 Hangfire 之类的组件
  • 将所有数据参数打包成一个消息结构,发送到MQ,再订阅处理

前2种方式都是 不建议 的做法,因为:

  • Task.Run,它其实是将操作任务提交到线程池,这里就有4个延伸问题:
    • 与HTTP请求争用线程,影响进程的吞吐量,甚至能出现HTTP503
    • 程序可能随时重启或者崩溃,没办法保证异步任务能执行结束
    • 没有日志
    • 缺乏监控
  • Hangfire,在这种场景下(临时任务)它其实是在解决Task.Run的问题,但是它有2大缺点
    • 增加数据库压力,尤其是非SQLSERVER数据库
    • 日志太弱,参考价值较小。




推荐做法

推荐做法:2个步骤

  1. 将所有数据参数打包成一个消息结构,发送到MQ
  2. 订阅MQ,根据消息内容执行具体任务

为什么是这样:

  • 消息一旦进入MQ,就不会丢失,程序重启或者崩溃都不受影响
  • 不依赖于数据库或者Redis,它们容易出现性能瓶颈问题
  • 充分利用ClownFish消息管道特性:
    • 重试
    • 多订阅者(数量可调,压力恒定)
    • 日志记录
    • 性能监控

使用Hangfire

第一步,在启动时指定 EnableHangfire = true

public class Program
{
    public static void Main(string[] args)
    {
        AppStartOption startOption = new AppStartOption {

            // 启用 Hangfire
            EnableHangfire = true,
        };
        
        AppStartup.RunAsWebsite("XDemo.WebSiteApp", args, startOption);
    }        
}

说明:

  • 应用的启动方式不能是:AppStartup.RunAsConsole,因为 Hangfire 依赖于 ASP.NET 环境



第二步,开发任务

internal class Task2 : NormalTask
{
    public override void Execute()
    {
        // 从数据库或者其它地方获取作业的执行参数
        // var args = LoadArgsFromDB(.....)

        // 执行具体作业内容
        HttpOption httpOption = new HttpOption {
            //Url = "http://tucao.mingyuanyun.com:1088/recommend.aspx",
            Url = "/1overall/home.html",
        };
        httpOption.Send();
    }
}

说明:

  • 后台任务的基类必须是 NormalTask,否则Nebula不会记录执行日志



第三步,在运行时添加临时任务

public string Test1()
{
    // 添加一个临时性作业
    return BackgroundJob.Enqueue<Task2>(x => x.Run(null));
}



查看后台任务

可以访问 http://xxxxxxxxx/hangfire/recurring,来查看任务,例如:

xx


界面的访问授权
  • 默认不允许匿名用户访问
    • 可以指定本地参数 HangfireDashboard_AllowAnonymous=1 来允许匿名用户访问
  • 仅允许已登录用户访问
    • 用户登录界面与登录实现过程,由宿主程序提供
    • 登录实现过程可参考:用户登录





查看执行日志

在 Kibana 中查看 Hangfire 作业的日志

  • 查看【全部】作业可以指定过滤条件 oprKind : ntask
  • 查看【失败】作业可以指定过滤条件 oprKind : ntask and hasError : 1

xx

多租户后台作业建议做法

作业特点

这类任务有2个明显特点:

  • 对所有租户执行相同的操作,例如:执行SQL脚本更新一些数据表
  • 执行时间长(因为第1点)




不建议的实现方式

以下用伪码举例

// 获取所有租户ID清单
List<string> list = GetAllTenantId();

// 用StringBuilder来存储所有租房的处理过程
StringBuilder sb = new ();

foreach(string tenantId in list) {

    // 在循环中一次处理一个租户
    string text = ProcessTenant(tenantId);
    sb.AppendLine(text);
}

// 给外界发送通知
SendNotify(sb.ToString());

这样设计有以下缺点:

  • 不利于任务重试!
    • 重试成本过高,重试只能从头开始
  • 不能中断!
  • 不利于性能评估!
    • 因为循环所有租户耗费的时间太长,不知道在某个租户上的具体耗时。
    • 带来一个新问题:由于没有性能监控,也 没法做性能优化!




推荐的实现方式

核心原则:能方便地评估代码的执行性能!

实现过程:

  • 拆分成2个部分
  • 主任务只做分裂处理(不做业务处理)
    • 如果是周期性的任务,则用 BackgroudTask 来实现
    • 如果是临时性的用户触发导致,可以直接直接由 HttpAction 实现

    • 每次执行时,按租户拆分成多个子任务
    • 每个子任务由一条消息表示,其中包含一个租户ID,然后将消息发送到MQ
  • 子任务(一定是后台运行)
    • 用 MessageHandler 来实现(有日志和性能监控
    • 根据消息中的租户ID,完成对一个租户的业务处理(任务足够小,性能评估才有意义)

以下是实现步骤的伪码。

1,先定义消息类型

public class TenantMssage {
    public string TenantId {get; set;}
    public string 其它所需的参数 {get; set;}


    // 用于记录执行过程中产生的消息(此属性不是必需)
    public StringBuilder Messages;

    // 用于记录执行了多少次(此属性不是必需)
    public ValueCounter ExecCounter;
}

2,程序初始化时注册消息订阅者

private static readonly MemoryMesssageQueue<TenantMssage> s_asyncMMQ 
                            = new MemoryMesssageQueue<TenantMssage>(MmqWorkMode.Async);

MmqSubscriber.StartAsync<TenantMssage, TenantMssageHandler>(new MmqSubscriberArgs {
    Queue = s_asyncMMQ,
    SubscriberCount = LocalSettings.GetUInt("TenantMssageHandler_Subscriber_Count", 1)
});

3-1,临时性主任务的实现-HttpAction

[Route("test1.aspx")]
public int Test1()
{
    // 获取要处理的租户ID清单
    List<string> list = GetAllTenantId();

    foreach(string tenantId in list) {
            TenantMssage msg = new TenantMssage{ TenantId = tenantId };
            client.SendMessage(msg);        
        }
}

3-2,周期性主任务的实现-BackgroudTask

public class TenantTask1 : BackgroundTask
{
    public override int? SleepSeconds => 3600;    // 假设1小时执行一次

    public override void Execute()
    {
        // 获取要处理的租户ID清单
        List<string> list = GetAllTenantId();

        using( RabbitClient client = new RabbitClient("SettingName", "Application111") ) {
            foreach(string tenantId in list) {
                TenantMssage msg = new TenantMssage{ TenantId = tenantId };
                client.SendMessage(msg);        
            }
        }
    }
}

4,执行租户处理逻辑-MessageHandler

public class TenantMssageHandler : BaseMessageHandler<TenantMssage>
{
    public override void ProcessMessage(PipelineContext<TenantMssage> context)
    {
        // 获取消息对象
        TenantMssage message = context.MessageData;
        
        ProcessTenant(message);
    }
}




任务的整体执行进度

如果需要获取任何的整体执行进度,可以参考下面的实现方式。

1,在消息结构中,增加一个总数,例如:

public class TenantMssage {
    public string TenantId {get; set;}
    public int TenantCount {get; set;}
    public string 其它所需的参数 {get; set;}
}

2,开始执行任务时

[Route("test1.aspx")]
public int Test1()
{
    // 获取要处理的租户ID清单
    List<string> list = GetAllTenantId();

    // ========================================= 注意下面这行代码
    // 创建一个计数器,用于累加执行进度
    Redis.GetDatabase().StringSet("XXX业务场景_key", 0);
    // =========================================

    using( RabbitClient client = new RabbitClient("SettingName", "Application111") ) {
        foreach(string tenantId in list) {
            TenantMssage msg = new TenantMssage{ TenantId = tenantId };
            client.SendMessage(msg);        
        }
    }
}

3,在消息处理时,用Redis的计数器功能累加进度:

public class TenantMssageHandler : BaseMessageHandler<TenantMssage>
{
    public override void ProcessMessage(PipelineContext<TenantMssage> context)
    {
        // 获取消息对象
        TenantMssage message = context.MessageData;
        
        ProcessTenant(message);

        // 更新执行进度
        Redis.GetDatabase().StringIncrement("XXX业务场景_key");
    }
}

4,获取整体执行进度

int count = Redis.GetDatabase().StringGet("XXX业务场景_key");

int 百分比 = count / TenantCount;

// 如果到了 100%,可以把这个 Redis 计数器删除。




任务的整体执行进度

大循环改造

有时候会看到在代码中存在一个大循环,整个过程需要执行很久,例如:

// 获取所有租户ID清单
List<string> list = GetAllTenantId();

// 用StringBuilder来存储所有租房的处理过程
StringBuilder sb = new ();

foreach(string tenantId in list) {

    // 在循环中一次处理一个租户
    string text = ProcessTenant(tenantId);
    sb.AppendLine(text);
}

// 给外界发送通知
SendNotify(sb.ToString());




推荐的实现方式

1,先定义消息类型

public class WorkArgs {
    public string TenantId {get; set;}
    public string 其它所需的参数 {get; set;}


    // 用于记录执行过程中产生的消息(此属性不是必需)
    public StringBuilder Messages;

    // 用于记录执行了多少次(此属性不是必需)
    public ValueCounter ExecCounter;
}

2,程序初始化时注册消息订阅者

private static readonly MemoryMesssageQueue<Product> s_syncMMQ 
                            = new MemoryMesssageQueue<Product>(MmqWorkMode.Sync);

MmqSubscriber.Start<WorkArgs, DemoMssageHandler>(new MmqSubscriberArgs {
    Queue = s_syncMMQ,
    SubscriberCount = LocalSettings.GetUInt("DemoMssageHandler_Subscriber_Count", 5)
});

3,用BackgroudTask来产生子任务消息

public class Task1 : BackgroundTask
{
    public override int? SleepSeconds => 3600;    // 假设1小时执行一次

    public override void Execute()
    {
        // 获取所有租户ID清单
        List<string> list = GetAllTenantId();

        // 用StringBuilder来存储所有租房的处理过程
        StringBuilder sb = new ();

        ValueCounter counter = new ();
        counter.Set(list.Count);

        foreach(string tenantId in list) {

            WorkArgs args = new WorkArgs{ 
                TenantId = tenantId,
                Messages = sb,
                ExecCounter = counter
            };
            s_syncMMQ.Write(args);
        }
    }
}

4,执行子任务逻辑-MessageHandler

public class DemoMssageHandler : BaseMessageHandler<WorkArgs>
{
    public override void ProcessMessage(PipelineContext<WorkArgs> context)
    {
        // 获取消息对象
        WorkArgs args = context.MessageData;
        
        string text = ProcessTenant(args);

        lock(args.Messages){
            args.Messages.AppendLine(text);
        }

        long count = args.ExecCounter.Decrement();

        if( count == 0 ){ // 所有子任务已全部执行结束
             SendNotify(sb.ToString());
        }
    }
}









请点击目录中的节点查看具体文档

序列化

ClownFish为XML和JSON序列化提供了便捷的扩展方法



XML序列化

/// <summary>
/// XML序列化与反序列化的扩展方法类
/// </summary>
public static class XmlExtensions
{
	/// <summary>
	/// 将对象执行XML序列化(使用UTF-8编码)
	/// </summary>
	/// <param name="obj">要序列化的对象</param>
	/// <returns>XML序列化的结果</returns>
	public static string ToXml(this object obj)


	/// <summary>
	/// 从XML字符串中反序列化对象(使用UTF-8编码)
	/// </summary>
	/// <typeparam name="T">反序列化的结果类型</typeparam>
	/// <param name="xml">XML字符串</param>
	/// <returns>反序列化的结果</returns>
	public static T FromXml<T>(this string xml)


	/// <summary>
	///  从XML字符串中反序列化对象(使用UTF-8编码)
	/// </summary>
	/// <param name="s"></param>
	/// <param name="type">反序列化的结果类型</param>
	/// <returns></returns>
	public static object FromXml(this string s, Type type)

}

JSON序列化

/// <summary>
/// JSON序列化的工具类
/// </summary>
public static class JsonExtensions
{
	/// <summary>
	/// 将一个对象序列化为JSON字符串。
	/// </summary>
	/// <param name="obj">要序列化的对象</param>
	/// <returns>序列化得到的JSON字符串</returns>
	public static string ToJson(this object obj)


	/// <summary>
	/// 将一个对象序列化为JSON字符串。
	/// </summary>
	/// <param name="obj">要序列化的对象</param>
	/// <param name="style">JSON序列化格式</param>
	/// <returns>序列化得到的JSON字符串</returns>
	public static string ToJson(this object obj, JsonStyle style)


	/// <summary>
	/// 将一个JSON字符串反序列化为对象
	/// </summary>
	/// <typeparam name="T">反序列的对象类型参数</typeparam>
	/// <param name="json">JSON字符串</param>
	/// <returns>反序列化得到的结果</returns>
	public static T FromJson<T>(this string json)


	/// <summary>
	/// 将一个JSON字符串反序列化为对象
	/// </summary>
	/// <param name="json">JSON字符串</param>
	/// <param name="destType">反序列的对象类型参数</param>
	/// <returns>反序列化得到的结果</returns>
	public static object FromJson(this string json, Type destType)
}



示例代码

public void Json_序列化_反序列化()
{
	var obj = new NameValue { Name = "aaaa", Value = "123" };

	// 将对象序列化成JSON字符串
	string json = obj.ToJson();

	// 将JSON字符串反序列化为指定的类型
	var object2 = json.FromJson<NameValue>();
}


public void Xml_序列化_反序列化()
{
	var obj = new NameValue { Name = "aaaa", Value = "123" };

	// 将对象序列化成XML字符串
	string xml = obj.ToXml();

	// 将XML字符串反序列化为指定的类型
	var object2 = xml.FromXml<NameValue>();
}


/// <summary>
/// 模拟一个配置类型
/// </summary>
public class RunOption { /* 这里省略具体的参数成员 */ }

public void Xml_配置文件_序列化_反序列化()
{
	RunOption option = new RunOption();
	string file = @"d:\aa\xx.config";

	// 将对象序列化成XML配置文件
	XmlHelper.XmlSerializeToFile(option, file);

	// 将XML配置文件反序列化为指定的类型
	option = XmlHelper.XmlDeserializeFromFile<RunOption>(file);
}

数据压缩

ClownFish为数据压缩提供了便捷的扩展方法

/// <summary>
/// GZIP压缩相关的工具方法
/// </summary>
public static class GzipHelper
{
	/// <summary>
	/// 用GZIP压缩一个字符串,并以BASE64字符串的形式返回压缩后的结果
	/// </summary>
	/// <param name="input"></param>
	/// <returns></returns>
	public static string Compress(string input)


	/// <summary>
	/// 用GZIP解压缩一个BASE64字符串
	/// </summary>
	/// <param name="base64"></param>
	/// <returns></returns>
	public static string Decompress(string base64)


	/// <summary>
	/// 将一个文本转指定编码后做Gzip压缩
	/// </summary>
	/// <param name="text"></param>
	/// <param name="encoding">默认UTF8</param>
	/// <returns></returns>
	public static byte[] ToGzip(this string text, Encoding encoding = null)


	/// <summary>
	/// 用Gzip压缩格式压缩一个二进制数组
	/// </summary>
	/// <param name="input"></param>
	/// <returns></returns>
	public static byte[] ToGzip(this byte[] input)


	/// <summary>
	/// 解压缩一个二进制数组
	/// </summary>
	/// <param name="input"></param>
	/// <returns></returns>
	public static byte[] UnGzip(this byte[] input)
}



ZIP文件压缩

示例代码

public void 将ZIP文件释放到指定目录()
{
	string zipPath = @"D:\my-github\bin.zip";
	string destPath = @"D:\test";

	ZipHelper.ExtractFiles(zipPath, destPath);
}

public void 在内存中读取ZIP文件()
{
	string zipPath = @"D:\my-github\bin.zip";

	var data = ZipHelper.Read(zipPath);

	// 结果是一个元组LIST
	// 每个元组中,Item1 就是压缩包内的文件名,Item2 就是文件内容。
	// 如果需要读取某个文件,可以从LIST中查找
}

public void 将指定目录打包成ZIP文件_含子目录()
{
	string srcPath = @"D:\test";
	string zipPath = @"D:\my-github\bin.zip";

	ZipHelper.CompressDirectory(srcPath, zipPath);
}

public void 将内存数据打包成ZIP文件()
{
	List<ZipItem> data = null;    // 需要准备好这个结构
	string zipPath = @"D:\my-github\bin.zip";

	ZipHelper.Compress(data, zipPath);
}

重试

对于一些可能会出现失败的操作,我们可以采用重试策略来执行,

例如:

  • 读写文件
  • 发送HTTP请求



示例代码

示例代码

public void 发送HTTP请求_支持重试()
{
    // 如果发生异常,最多重试5次,间隔 500 毫秒
    Retry retry = Retry.Create(5, 500);

    string text = new HttpOption {
        Url = "http://www.fish-test.com/abc.aspx",
        Data = new { id = 2, name = "abc" }
    }.GetResult(retry);
}

public void 发送HTTP请求_定制重试策略()
{
    Retry retry =
        Retry.Create(100, 2000)     // 重试 100 次,间隔 2 秒
        .Filter<WebException>();    // 仅当出现 WebException 异常时才重试。

    string text = new HttpOption {
        Url = "http://www.fish-test.com/abc.aspx",
        Data = new { id = 2, name = "abc" }
    }.GetResult(retry);
}

public void 读取文件_最大重试10次()
{
    string text =
        Retry.Create(100, 0) // 指定重试10,使用默认的重试间隔时间,且不分辨异常类型(有异常就重试)
            .Run(() => {
                return System.IO.File.ReadAllText(@"c:\abc.txt", Encoding.UTF8);
            });
}



工具类

ClownFish提供4个封装的工具可直接使用。

    /// <summary>
    /// 提供一些与 System.IO.File 相同签名且功能相同的工具方法,
    /// 差别在于:当出现IOException时,这个类中的方法支持重试功能。
    /// </summary>
    public static class RetryFile
    /// <summary>
    /// 提供一些与 System.IO.Directory 相同签名且功能相同的工具方法,
    /// 差别在于:当出现IOException时,这个类中的方法支持重试功能。
    /// </summary>
    public static class RetryDirectory
/// <summary>
/// 定义HttpClient的扩展方法的工具类
/// </summary>
public static class HttpOptionExtensions
{
	/// <summary>
	/// 根据指定的HttpOption参数,用【同步】方式发起一次HTTP请求
	/// </summary>
	/// <param name="option">HttpOption的实例,用于描述请求参数</param>
	/// <param name="retry">提供一个Retry实例,用于指示如何执行重试。如果此参数为NULL则不启用重试</param>
	/// <returns>返回服务端的调用结果,并转换成指定的类型</returns>
	/// <exception cref="RemoteWebException"></exception>
	public static string GetResult(this HttpOption option, Retry retry = null)


	/// <summary>
	/// 根据指定的HttpOption参数,用【异步】方式发起一次HTTP请求
	/// </summary>
	/// <param name="option">HttpOption的实例,用于描述请求参数</param>
	/// <param name="retry">提供一个Retry实例,用于指示如何执行重试。如果此参数为NULL则不启用重试</param>
	/// <returns>返回服务端的调用结果,并转换成指定的类型</returns>
	/// <exception cref="RemoteWebException"></exception>
	public async static Task<string> GetResultAsync(this HttpOption option, Retry retry = null)


	/// <summary>
	/// 根据指定的HttpOption参数,用【同步】方式发起一次HTTP请求
	/// </summary>
	/// <typeparam name="T">返回值的类型参数</typeparam>
	/// <param name="option">HttpOption的实例,用于描述请求参数</param>
	/// <param name="retry">提供一个Retry实例,用于指示如何执行重试。如果此参数为NULL则不启用重试</param>
	/// <returns>返回服务端的调用结果,并转换成指定的类型</returns>
	/// <exception cref="RemoteWebException"></exception>
	public static T GetResult<T>(this HttpOption option, Retry retry = null)


	/// <summary>
	/// 根据指定的HttpOption参数,用【异步】方式发起一次HTTP请求
	/// </summary>
	/// <typeparam name="T">返回值的类型参数</typeparam>
	/// <param name="option">HttpOption的实例,用于描述请求参数</param>
	/// <param name="retry">提供一个Retry实例,用于指示如何执行重试。如果此参数为NULL则不启用重试</param>
	/// <returns>返回服务端的调用结果,并转换成指定的类型</returns>
	/// <exception cref="RemoteWebException"></exception>
	public async static Task<T> GetResultAsync<T>(this HttpOption option, Retry retry = null)

}
/// <summary>
/// 创建发出HTTP请求时的重试策略工具类
/// </summary>
public static class HttpRetry
{
	/// <summary>
	/// 获取用于发送HTTP请求的重试策略。
	/// 当发生 “网络不通” 或者 “HTTP 502,503” 时,最大重试 7 次,每次间隔 1000 毫秒
	/// </summary>
	/// <returns></returns>
	public static Retry Create()
}

常识科普

  • 加密 和 哈希 不是一回事 !!!
  • 加密后的结果一定是可以解密的,也就是说可以根据结果得到加密前的输入内容
  • 哈希又称为单向散列,是一种计算消息摘要的方法,过程不可逆,不能根据结果得到哈希前的输入
  • 常见的哈希算法有:md5, sha1, sha256, sha512
  • 加密算法分为:对称加密算法 和 非对称加密算法
  • 常见的对称加密算法有:3DES, AES
  • 常见的非对称加密算法有:RSA
  • RSA算法的用途有:加密,解密,计算签名,验证签名
  • RSA算法的密钥分为:公钥(用于加密/计算签名), 私钥(用于解密/验证签名)
  • RSA算法的 公钥通常用 cer 文件存储,或者直接在代码中硬编码多行的BASE64密钥文本
  • RSA算法的 私钥通常用 pfx 文件存储,此文件需要密码才能被加载到内存
  • RSA的密钥可以导入操作系统的证书管理器,然后通过指纹引用,此时不需要密码(即使是私钥)
  • RSA的私钥通常不随客户端程序公开发布,私钥一定要保留在服务端,否则可以考虑使用 对称加密算法
  • RSA的加密算法只能加密小文本,通常是密码之类的敏感内容,长文本要结合对称加密算法做二次加密



Aes加密解密

/// <summary>
/// 对AES算法封装的工具类
/// </summary>
public static class AesHelper
{
	/// <summary>
	/// 使用AES算法加密字符串
	/// </summary>
	/// <param name="text"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static string Encrypt(string text, string password)

	/// <summary>
	/// 使用AES算法加密字节数组
	/// </summary>
	/// <param name="input"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static byte[] Encrypt(byte[] input, string password)

	/// <summary>
	/// 使用AES算法解密一个以Base64编码的加密字符串
	/// </summary>
	/// <param name="base64"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static string Decrypt(string base64, string password)

	/// <summary>
	/// 使用AES算法解密字节数组
	/// </summary>
	/// <param name="input"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static byte[] Decrypt(byte[] input, string password)
}

Hash

/// <summary>
/// 封装常用的HASH算法
/// </summary>
public static class HashHelper
{
	/// <summary>
	/// 计算字符串的 SHA1 签名
	/// </summary>
	/// <param name="text"></param>
	/// <param name="encoding"></param>
	/// <returns></returns>
	public static string Sha1(this string text, Encoding encoding = null)


	/// <summary>
	/// 计算字符串的 SHA256 签名
	/// </summary>
	/// <param name="text"></param>
	/// <param name="encoding"></param>
	/// <returns></returns>
	public static string Sha256(this string text, Encoding encoding = null)


	/// <summary>
	/// 计算字符串的 SHA512 签名
	/// </summary>
	/// <param name="text"></param>
	/// <param name="encoding"></param>
	/// <returns></returns>
	public static string Sha512(this string text, Encoding encoding = null)


	/// <summary>
	/// 计算文件的SHA1值
	/// </summary>
	/// <param name="filePath"></param>
	/// <returns></returns>
	public static string FileSha1(string filePath)


	/// <summary>
	/// 计算字符串的 MD5 签名
	/// </summary>
	/// <param name="text"></param>
	/// <param name="encoding"></param>
	/// <returns></returns>
	public static string Md5(this string text, Encoding encoding = null)


	/// <summary>
	/// 计算文件的MD5值
	/// </summary>
	/// <param name="filePath"></param>
	/// <returns></returns>
	public static string FileMD5(string filePath)
}

RSA 证书

/// <summary>
/// 包含一些查找X509证书的工具方法
/// </summary>
public static class X509Finder
{
	/// <summary>
	/// 根据证书指纹查找X509证书,优先查找LocalMachine存储区域,如果失败则再查找CurrentUser
	/// </summary>
	/// <param name="thumbprint">证书指纹</param>
	/// <param name="ifNotFoundThrowException">如果没有找到证书是否需要抛出异常</param>
	/// <returns></returns>
	public static X509Certificate2 FindByThumbprint(string thumbprint, bool ifNotFoundThrowException)



	/// <summary>
	/// 根据指定的证书指纹和位置,查找证书。
	/// </summary>
	/// <param name="thumbprint">证书指纹</param>
	/// <param name="storeLocation"></param>
	/// <param name="storeName"></param>
	/// <returns></returns>
	public static X509Certificate2 FindByThumbprint(string thumbprint,
													StoreLocation storeLocation = StoreLocation.LocalMachine,
													StoreName storeName = StoreName.My)


	/// <summary>
	/// 从一个公钥字符串中加载X509证书
	/// </summary>
	/// <param name="publicKey"></param>
	/// <returns></returns>
	public static X509Certificate2 LoadFromPublicKey(byte[] publicKey)


	/// <summary>
	/// 从一个公钥字符串中加载X509证书
	/// </summary>
	/// <param name="publicKeyText"></param>
	/// <returns></returns>
	public static X509Certificate2 LoadFromPublicKey(string publicKeyText)


	/// <summary>
	/// 从一个公钥文件中加载X509Certificate2
	/// </summary>
	/// <param name="publicKeyFilePath"></param>
	/// <returns></returns>
	public static X509Certificate2 LoadPublicKeyFile(string publicKeyFilePath)


	/// <summary>
	/// 从pfx文件内容中加载一个X509Certificate2对象
	/// </summary>
	/// <param name="pfxBody"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static X509Certificate2 LoadPfx(byte[] pfxBody, string password)


	/// <summary>
	/// 从pfx文件内容中加载一个X509Certificate2对象
	/// </summary>
	/// <param name="pfxFilePath"></param>
	/// <param name="password"></param>
	/// <returns></returns>
	public static X509Certificate2 LoadPfx(string pfxFilePath, string password)
}



RSA算法

/// <summary>
/// RSA算法(签名/验证签名/加密/解密)的封装工具类
/// </summary>
public static class X509Extensions
{
	/// <summary>
	/// 用X509证书对数据做签名
	/// </summary>
	/// <param name="cert"></param>
	/// <param name="data"></param>
	/// <returns></returns>
	public static string Sign(this X509Certificate2 cert, byte[] data)


	/// <summary>
	/// 用X509证书验证数据签名
	/// </summary>
	/// <param name="cert"></param>
	/// <param name="data"></param>
	/// <param name="signature"></param>
	/// <returns></returns>
	public static bool Verify(this X509Certificate2 cert, byte[] data, string signature)


	/// <summary>
	/// 用X509证书加密数据。
	/// 注意:这个方法只能加密比较短的内容(一般是密钥)
	/// </summary>
	/// <param name="cert"></param>
	/// <param name="data"></param>
	/// <returns></returns>
	public static byte[] Encrypt(this X509Certificate2 cert, byte[] data)



	/// <summary>
	/// 用X509证书解密数据
	/// </summary>
	/// <param name="cert"></param>
	/// <param name="data"></param>
	/// <returns></returns>
	public static byte[] Decrypt(this X509Certificate2 cert, byte[] data)
}

高性能的反射工具类

ClownFish提供了以下工具来优化反射调用的性能:



对象构造

public static class CtorExtensions
{
	/// <summary>
	/// 快速实例化一个对象
	/// </summary>
	/// <param name="instanceType"></param>
	/// <returns></returns>
	public static object FastNew(this Type instanceType)

}



Field读写

public static class FieldInfoExtensions
{
	/// <summary>
	/// 快速读取字段值
	/// </summary>
	/// <param name="fieldInfo"></param>
	/// <param name="instance"></param>
	/// <returns></returns>
	public static object FastGetValue(this FieldInfo fieldInfo, object instance)


	/// <summary>
	/// 快速给字段赋值
	/// </summary>
	/// <param name="fieldInfo"></param>
	/// <param name="instance"></param>
	/// <param name="value"></param>
	public static void FastSetValue(this FieldInfo fieldInfo, object instance, object value)

}



Property读写

public static class PropertyInfoExtensions
{
	/// <summary>
	/// 快速读取属性值
	/// </summary>
	/// <param name="propertyInfo"></param>
	/// <param name="instance"></param>
	/// <returns></returns>
	public static object FastGetValue(this PropertyInfo propertyInfo, object instance)

	/// <summary>
	/// 快速给属性赋值
	/// </summary>
	/// <param name="propertyInfo"></param>
	/// <param name="instance"></param>
	/// <param name="value"></param>
	public static void FastSetValue(this PropertyInfo propertyInfo, object instance, object value)
}



方法调用

public static class MethodInfoExtensions
{
	/// <summary>
	/// 快速调用方法
	/// </summary>
	/// <param name="methodInfo"></param>
	/// <param name="instance"></param>
	/// <param name="parameters"></param>
	/// <returns></returns>
	public static object FastInvoke(this MethodInfo methodInfo, object instance, params object[] parameters)
}

日志表清理

虽然 Nebula.Juno 是一个通用的日志数据表清理服务,可以让你不写代码就能实现对日志表的清理,
但是有些场景下,你不希望部署这样一个服务,
那么可以使用 ClownFish 提供的API来实现同样的功能,可参考以下代码:

public class DataCleanWorker : BackgroundTask
{
    private static readonly int s_days = LocalSettings.GetInt("UserAskLogKeepDays", 10);

    public override int? SleepSeconds => 3600;

    public override bool FirstRun => true;

    public override void Execute()
    {
        CleaningOption option = new CleaningOption {
            DbConfig = MyConfig.DbConfig,
            TableName = "user_ask_log",
            TimeFieldName = "ask_time",
            HoursAgo = 24 * s_days  //  保留10天的日志
        };

        DataCleaner cleaner = new DataCleaner(option);

        try {
			// 执行日志清理动作
            cleaner.Execute();
        }
        finally {
            OprLog log = this.Context.OprLog;
            if( log != null ) {
                log.Text1 = cleaner.GetLogs();
            }
        }
    }
}



核心代码:

CleaningOption option = new CleaningOption { .... };
DataCleaner cleaner = new DataCleaner(option);
cleaner.Execute();

JWT

ClownFish内置了 JWT 的实现, 可用来生成或者解析 JWT-TOKEN


生成 JWT-TOKEN

var data = new {
	iss = "JwtUtilsTest",
	sub = "all",
	iat = DateTime.Now.ToNumber(),
	exp = DateTime.Now.AddDays(1).ToNumber(),
	UseId = 123,
	UserName = "Fish Li",
	UserRole = "Admin"
};

string json = data.ToJson();

string token = JwtUtils.Encode(json, JwtKey, "HS512");

或者

WebUserInfo user = new WebUserInfo {
    UserCode = "code111",
    UserName = "name123",
    UserId = "id_111",
    UserRole = "admin",
    TenantId = "tid_2222",
    TenantCode = "tcode_333",
    UserType = "type111"
};

int expirationSeconds = 3600 * 24;  // token 有效期 24 小时
string token = AuthenticationManager.GetLoginToken(user, expirationSeconds)



解析 JWT-TOKEN

绝大多数情况下,业务代码不需要 解析JWT-TOKEN,因为这个步骤属于 身份认证 的范畴,框架会自动处理。

有2类场景下可能会需要:

  • MessageHandler
  • BackgroundTask

此时可以参考以下示例:

string token = ...............; // 假设你已获取一个 jwt-token
string payload = JwtUtils.Decode(token, secretKey, algorithmName);

示例代码中的 payload 通常是一个 json 字符串,后面你只需要根据实现情况做反序列化即可。

说明:

  • 如果参数 secretKey=null,那么将忽略签名验证
  • Decode不做有效期检查,需要自行实现。原因:有效性的定义没有形成标准,ClownFish没法做!
  • 框架自带的AuthenticateModule会始终执行 签名和有效期 验证。



支持的算法

JwtUtils工具类支持以下这些算法:

  • HS256
  • HS512
  • RS256
  • RS512
  • ES256
  • ES512

注意:后面4个是 非对称 算法,需要使用另外2个不同的方法

public static class JwtUtils
{
    public static string Encode2(string payload, X509Certificate2 x509, string algorithmName);

	public static string Decode2(string token, X509Certificate2 x509, string algorithmName);
}



注意事项&不推荐做法

JWT-TOKEN应该由服务端生成,永远不要让客户端生成TOKEN

有些项目中会把 签名密钥和算法名 交给调用方,由调用方来生成 JWT-TOKEN,这是一种 非常愚蠢的设计!!

这种做法有2个危害:

  • 不安全。因为 签名密钥 泄露了,调用方可以生成各种权限的TOKEN,服务端根本没法限制或者校验。
  • 数据结构没法升级。TOKEN中通常会包含一个数据结构(可参考前面示例),假设一开始 Token数据结构包含3个成员,和调用方约定后,人家按你的定义去实现了,你再想加其它成员就非常难了。
    (还有更离谱的真实案例)一开始的3个成员的赋值格式也可能不统一,典型的是 “有效期”这种时间格式,PHPer可能会给赋值一个数字~~ ,而你期望的是一个 DateTime









请点击目录中的节点查看具体文档

HttpClient

本文将演示HttpClient的使用方法。

说明:

  • 为了减少重复代码,本文只演示【同步】调用,例如:GetResult()
  • 如果需要【异步】,请调用 Async 结尾的方法,例如:GetResultAsync()


简单的GET请求

HttpOption httpOption = new HttpOption {
    Url = "http://www.fish-test.com/show-request2.aspx",
    Data = new { id = 2, name = "abc" }  // 这里的数据将会合并到URL中
};

string responseText = httpOption.GetResult();
Console.WriteLine(responseText);

或者

HttpOption httpOption = new HttpOption {
    Url = "http://www.fish-test.com/show-request2.aspx?id=2&name=abc",
};

string responseText = httpOption.GetResult();
Console.WriteLine(responseText);



POST 表单数据

string responseText = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request2.aspx",
    Data = new { id = 2, name = "abc" }
}.GetResult();
Console.WriteLine(responseText);



POST JSON数据

string responseText = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-body.aspx",
    Data = new { id = 2, name = "abc" },
    Format = SerializeFormat.Json     // 注意这里
}.GetResult();
Console.WriteLine(responseText);



POST XML数据

string responseText = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-body.aspx",
    Data = new NameValue { Name = "abc", Value = "123" },
    Format = SerializeFormat.Xml     // 注意这里
}.GetResult();
Console.WriteLine(responseText);



POST 二进制数据

var data = new NameValue { Name = "abc", Value = "123" };
byte[] bytes = data.ToJson().ToUtf8Bytes();

string responseText = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-body.aspx",
    Data = bytes,
    Format = SerializeFormat.Binary     // 注意这里
}.GetResult();
Console.WriteLine(responseText);



POST 数据流

var data = new NameValue { Name = "abc", Value = "123" };
byte[] bytes = data.ToJson().ToUtf8Bytes();

using( MemoryStream ms = new MemoryStream(bytes) ) {

    string responseText = new HttpOption {
        Method = "POST",
        Url = "http://www.fish-test.com/show-body.aspx",
        UserAgent = "ClownFish.UnitTest",
        Data = ms,
        Format = SerializeFormat.Binary     // 注意这里
    }.GetResult();
    Console.WriteLine(responseText);
}



POST 表单数据-含文件上传

string filename1 = "small_file.txt";
string filename2 = "small_file.bin";
string filename3 = "ClownFish.Log.config";

var result = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request2.aspx",
    Format = SerializeFormat.Multipart,  // 文件上传模式
    Data = new {
        id = 22222222222,
        name = "Fish Li",

        // 如果 Data 属性包含 FileInfo 或者 HttpFile 类型的属性值,就认为是上传文件
        file1 = new FileInfo(filename1),
        file2 = new FileInfo(filename2),
        file3 = new FileInfo(filename3),
    }
}.GetResult<HttpResult<string>>();
Console.WriteLine(result.Result);



提交压缩数据

var data = new NameValue { Name = "abc", Value = new string('x', 2048) };

HttpOption httpOption = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request2.aspx",
    Data = data,
    Format = SerializeFormat.Json,
    AutoGzipUpload = true     // 注意这里
};

string text = httpOption.GetResult();
Console.WriteLine(text);



直接根据请求文本发送请求

// 下面这段文本,可以从 Fiddler 或者一些浏览器的开发工具中获取
// 拿到这段文本,不需要做任何处理,直接调用 HttpOption.FromRawText 就可以了,就是这样简单!


string request = @"
POST http://www.fish-test.com/show-request2.aspx HTTP/1.1
Host: www.fish-test.com
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64; rv:36.0) Gecko/20100101 Firefox/36.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Referer: http://www.fish-test.com/Pages/Demo/TestAutoFindAction.htm
Cookie: hasplmlang=_int_; LoginBy=productKey; PageStyle=Style2;
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache

input=Fish+Li&Base64=%E8%BD%AC%E6%8D%A2%E6%88%90Base64%E7%BC%96%E7%A0%81
";

string responseText = 
    HttpOption.FromRawText(request)     // 构建 HttpOption 实例
    .GetResult();                       // 发送请求

Console.WriteLine(responseText);



结合RawText和Data发送请求

// 可以从 Fiddler 抓到所需要请求头,去掉:数据部分
string request = @"
POST http://www.fish-test.com/show-request2.aspx HTTP/1.1
Host: www.fish-test.com
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64; rv:36.0) Gecko/20100101 Firefox/36.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
X-Requested-With: XMLHttpRequest
Referer: http://www.fish-test.com/Pages/Demo/TestAutoFindAction.htm
Cookie: hasplmlang=_int_; LoginBy=productKey; PageStyle=Style2;
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
";

// 1,根据一段长文本 快速设置 URL, method, headers
HttpOption httpOption = HttpOption.FromRawText(request);

// 2,设置提交数据与格式
httpOption.Format = SerializeFormat.Form;
httpOption.Data = new { id = 2, name = "aaaa", time = DateTime.Now };

// 3,发送请求,获取结果
string result = httpOption.GetResult();
Console.WriteLine(result);



设置请求超时时间

string text = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request.aspx",
    Data = new { id = 2, name = "abc" },
    Timeout = 3_000     // 3 秒超时
}.GetResult();
Console.WriteLine(text);



string text2 = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request.aspx",
    Data = new { id = 2, name = "abc" },
    Timeout = 0     // 无限等待
}.GetResult();



发送时指定请求头

string text = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request2.aspx",
    Data = new { id = 2, name = "abc" },
    Headers = new Dictionary<string, string>() {
                { "X-Requested-With", "XMLHttpRequest" },
                { "User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)"} }
}.GetResult();
Console.WriteLine(text);



处理请求头与响应头

HttpResult<string> result = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/test-header.aspx",
    Data = new { id = 2, name = "abc" },

    Header = new {      // 指定二个请求头,服务端会将它们合并
        X_a = "a1",     // 对应请求头:X-a: a1
        X_b = "b2"      // 对应请求头:X-b: b2
    }
}.GetResult<HttpResult<string>>();

// 注意调用上面方法时指定的泛型参数
// 如果需要读取响应头,需要指定 HttpResult<T> 的类型参数

// 读取响应结果
string responseText = result.Result;    // this is html

// 读取响应头
string header = result.Headers["X-add-result"];
Assert.AreEqual("a1b2", header);

Console.WriteLine(responseText);



发送请求时指定身份信息

string text = new HttpOption {
    Url = "http://RabbitHost:15672/api/queues",
    Credentials = new NetworkCredential("fishli", "1qaz7410"),
    Timeout = 6_000,
}.GetResult();
Console.WriteLine(text);



发送当前进程的身份到服务端_服务端采用Windows身份认证

string text = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/show-request.aspx",
    Data = new { id = 2, name = "abc" },
    Credentials = CredentialCache.DefaultNetworkCredentials     // 注意这里
}.GetResult();
Console.WriteLine(text);



发送HTTP请求_维护会话COOKIE

// 准备一个Cookie会话容器
CookieContainer cookieContainer = new CookieContainer();

HttpOption page1 = new HttpOption {
    Url = "http://www.fish-test.com/user/page1.aspx",
};

// 没有登录,返回 403
int status = GetStatusCode(page1);
Assert.AreEqual(403, status);


// 登录
HttpOption login = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/user/login.aspx",
    Data = new { name = "fish li", pwd = "abc" },
    Cookie = cookieContainer // 接收cookie
};
string text = login.GetResult();
Console.WriteLine(text);
Assert.AreEqual("Login OK.", text);


// 再次访问,带上Cookie,就正常了
page1 = new HttpOption {
    Url = "http://www.fish-test.com/user/page1.aspx",
    Cookie = cookieContainer
};

int status2 = GetStatusCode(page1);
Assert.AreEqual(200, status2);
private static int GetStatusCode(HttpOption option)
{
    try {
        var xx = option.GetResult();
        return 200;
    }
    catch( RemoteWebException remoteWebException ) {
        return remoteWebException.StatusCode;
    }
    catch( WebException ex ) {
        return (int)(ex.Response as HttpWebResponse).StatusCode;
    }
    catch( Exception ) {
        return 500;
    }
}



获取各种类型的结果

// 先构造一个HttpOption对象
HttpOption httpOption = new HttpOption {
    Method = "POST",
    Url = "http://www.fish-test.com/test1.aspx",
    Header = new { X_a = "a1", X_b = "b2" },
    Format = SerializeFormat.Form,
    Data = new { id = 2, name = "abc" }
};


// 下面演示一些常见的获取结果的方式

// 1,以文本形式(含HTML)获取服务端的返回结果
string text = httpOption.GetResult();

// 2,以二进制形式获取服务端的返回结果
byte[] bin = httpOption.GetResult<byte[]>();

// 3,如果服务端返回 json / xml,可以直接通过反序列化得到强类型结果
Product product = httpOption.GetResult<Product>();

// 4,以文本形式获取服务端的返回结果,并需要访问响应头
HttpResult<string> httpResult1 = httpOption.GetResult<HttpResult<string>>();
string text2 = httpResult1.Result;
string header1 = httpResult1.Headers.Get("Content-Type");  // 读取响应头

// 5,以二进制形式获取服务端的返回结果,并需要访问响应头
HttpResult<byte[]> httpResult2 = httpOption.GetResult<HttpResult<byte[]>>();
byte[] bin2 = httpResult2.Result;

// 6,服务端返回 json / xml,结果反序列化,并需要访问响应头
HttpResult<Product> httpResult3 = httpOption.GetResult<HttpResult<Product>>();
Product product2 = httpResult3.Result;

// 7, 以Stream形式获取服务端的返回结果
// 注意:拿到结果后,请使用 using 包起来使用
Stream steram = httpOption.GetResult<Stream>();

// 8, 以HttpWebResponse形式获取服务端的返回结果
// 注意:拿到结果后,请使用 using 包起来使用
HttpWebResponse response = httpOption.GetResult<HttpWebResponse>();



仅仅发送请求,不需要读取结果

HttpOption httpOption = new HttpOption {
    Url = "http://www.fish-test.com/show-request2.aspx",
};

httpOption.Send();



发送请求_启用重试功能

HttpOption httpOption = new HttpOption {
    Url = "http://www.fish-test.com/show-request2.aspx",
};

// 创建默认的重试策略
// 当发生 “网络不通” 或者 “HTTP 502,503” 时,最大重试 7 次,每次间隔 1000 毫秒
Retry retry = HttpRetry.Create();
string html = httpOption.GetResult<string>(retry);



支持 unix-socket

例如调用 Docker API,curl 的命令是

curl --unix-socket /var/run/docker.sock http://localhost/v1.43/images/json

对应使用 HttpOption 的方式是:

HttpOption httpOption = new HttpOption {
    Url = "http://localhost/v1.43/images/json",
    UnixSocketEndPoint = "/var/run/docker.sock"
};
string json = httpOption.GetResult();

或者

string script = @"
GET http://localhost/v1.43/images/json HTTP/1.1
--unix-socket: /var/run/docker.sock
".Trim();

HttpOption httpOption = HttpOption.FromRawText(script);
string json = httpOption.GetResult();

Mail客户端

示例代码

public void SentTextMail()
{
    MailClient client = new MailClient("mail-config");
    client.SetReceivers("liqf01@mingyuanyun.com")
        .SetCC("liqf01@mingyuanyun.com")
        .SetSubject("MailClient_DEMO--TEXT---" + DateTime.Now.ToTimeString())
        .AddAttachment(@"files/asp.net.jpg", "image/jpeg")
        .SetBody("aaaaaaaaaaaaaaaaaa")
        .Send();
}


public void SendHtmlMail()
{
    string html = @"
<html>
<head><title>MailClient_DEMO</title></head>
<body><h1>MailClient_DEMO</h1><body>
</html>";

    MailClient client = new MailClient("mail-config");
    client.SetReceivers("liqf01@mingyuanyun.com")
        .SetCC("liqf01@mingyuanyun.com")
        .SetSubject("MailClient_DEMO--HTML---" + DateTime.Now.ToTimeString())
        .AddAttachment(@"files/asp.net.jpg", "image/jpeg")
        .SetHtmlBody(html)
        .Send();
}





指定收件人

指定【收件人】有3种方式,可参考下面示例代码:
【抄送人】的使用方式相同,所以没有特别指定。

client
    .SetReceivers("liqf01@mingyuanyun.com", "fangw@mingyuanyun.com", "yangmc@mingyuanyun.com", "412537239@qq.com")
    .SetSubject("Test MimeKit" + DateTime.Now.ToTimeString())
    .SetBody("aaaaaaaaaaaaaaaaaa")
    .Send();
client
    .SetReceivers("李奇峰1 <liqf01@mingyuanyun.com>", "方武2 <fangw@mingyuanyun.com>",
                  "杨敏超3 <yangmc@mingyuanyun.com>", "杨敏超4 <412537239@qq.com>")
    .SetSubject("Test MimeKit" + DateTime.Now.ToTimeString())
    .SetBody("aaaaaaaaaaaaaaaaaa")
    .Send();
client
    .SetReceivers( new NameValue("李奇峰1", "liqf01@mingyuanyun.com"), new NameValue("方武2", "fangw@mingyuanyun.com"), 
                   new NameValue("杨敏超3", "yangmc@mingyuanyun.com"), new NameValue("杨敏超4", "412537239@qq.com") )
    .SetSubject("Test MimeKit" + DateTime.Now.ToTimeString())
    .SetBody("aaaaaaaaaaaaaaaaaa")
    .Send();





SMTP服务器配置

在发送邮件前,请先在配置服务中注册SMTP服务器参数,可参考下图: xx

参数类型定义

public class SmtpConfig
{
    /// <summary>
    /// 服务器地址
    /// </summary>
    public string Host { get; set; }

    /// <summary>
    /// TCP端口
    /// </summary>
    public int Port { get; set; }

    /// <summary>
    /// 用户名
    /// </summary>
    public string UserName { get; set; }

    /// <summary>
    /// 密码
    /// </summary>
    public string Password { get; set; }

    /// <summary>
    /// 是否使用SSL连接
    /// </summary>
    public bool IsSSL { get; set; }
}

参数值配置示例

Host=smtp.exmail.qq.com;Port=587;UserName=xxx@mingyuanyun.com;Password=xxxxxx;IsSSL=1

IM客户端

ClownFish 对国内主流的IM提供了客户端支持可用于发送消息。
只需要一套代码即可支持3种IM


3种IM程序

  • 企业微信
  • 钉钉
  • 飞书

针对3种IM,又支持3大类的使用场景:

  • IM应用 给 单个用户 推送消息
  • IM应用 给 聊天群 推送消息
  • 以IM-Webhook 方式给 聊天群 推送消息



IM应用的连接认证配置

在发送消息前,请先在配置服务中注册IM所需的连接认证参数,可参考下图:

xx

参数类型定义

/// <summary>
/// 企业微信/钉钉/飞书 应用 登录参数
/// </summary>
public sealed class ImAppAuthConfig
{
	/// <summary>
	/// IM类别
	/// </summary>
	public ImType ImType { get; set; }

	/// <summary>
	/// 企业微信的 CorpId,飞书的 AppID,钉钉的 AppKey
	/// </summary>
	public string AppId { get; set; }

	/// <summary>
	/// 企业微信的 Secret,飞书的 AppSecret,钉钉的 AppSecret
	/// </summary>
	public string AppSecret { get; set; }

	/// <summary>
	/// 企业微信/钉钉的 AgentId,飞书不使用此参数
	/// </summary>
	public long AgentId { get; set; }
}

public enum ImType
{
	WxWork,
	DingDing,
	FeiShu
}

参数值配置示例

ImType=WxWork;AgentId=111111;AppId=xxxxxxxxxxxx;AppSecret=xxxxxxxxxxxxxxxx



IM应用给单个用户推送消息

示例代码

public static async Task Test1()
{
	string userId = "liqf01";
	ImAppMsgClient client = new ImAppMsgClient("WxWork.AppAuth.Config");
	
	await client.SendTextAsync(userId, TextMsg);
	await client.SendMarkdownAsync(userId, MarkdownMsg);
	await client.SendImageAsync(userId, @"e:\aaaaa.jpg");

	await client.SendFileAsync(userId, @"E:\bbbbb.pdf");
	await client.SendFileAsync(userId, @"E:\ccccc.txt");
	await client.SendFileAsync(userId, @"E:\ddddd.png");
	
	await client.SendCardAsync(userId, CardTitle, CardMsg, CardHref);
}





IM应用给聊天群推送消息

示例代码

public static async Task Test2()
{
    string chatId = "xxxxxxxxxxxxxxxxxxxxxxxxxxx";	
	ImGroupChatClient client = new ImGroupChatClient("WxWork.AppAuth.Config");	

	await client.SendTextAsync(chatId, TextMsg);
	await client.SendMarkdownAsync(chatId, MarkdownMsg);
	await client.SendImageAsync(chatId, @"e:\aaaaa.jpg");

	await client.SendFileAsync(chatId, @"E:\bbbbb.pdf");
	await client.SendFileAsync(chatId, @"E:\ccccc.txt");
	await client.SendFileAsync(chatId, @"E:\ddddd.png");
	
	await client.SendCardAsync(chatId, CardTitle, CardMsg, CardHref);
}

注意事项

  • A应用创建的“XX群”,B应用不能给“XX群”发消息
  • A应用要创建群,需要在企业微信后台的“可见范围”中设置必需的可见范围





以IM-Webhook方式给聊天群推送消息

示例代码

public static async Task Test1()
{
	ImWebhookClient client = new ImWebhookClient("WxWork.WebHookAuth.Config");
	
	await client.SendTextAsync(TextMsg);
	await client.SendMarkdownAsync(MarkdownMsg);
}

WebHook配置参数类型定义

/// <summary>
/// 企业微信/钉钉/飞书 WebHook 登录参数
/// </summary>
public sealed class ImWebHookConfig
{
	/// <summary>
	/// IM类别
	/// </summary>
	public ImType ImType { get; set; }

	/// <summary>
	/// WebHook URL
	/// </summary>
	public string WebHookUrl { get; set; }

	/// <summary>
	/// 签名密钥,可为空。
	/// </summary>
	public string SignKey { get; set; }
}





企业微信专用客户端

如果需要调用企业微信的服务端API,可以使用 WxworkClient 这个类型。

例如,获取用户信息:

WxworkClient client = new WxworkClient("WxWork.AppAuth.Config");

WxworkUserInfo userinfo = client.GetUserInfo("liqf01");

Console2.WriteLine(userinfo.ToJson(JsonStyle.Indented));

其它有用的方法

  • public T SendRequest(HttpOption httpOption) where T : IShitResult
  • public async Task SendRequestAsync(HttpOption httpOption) where T : IShitResult
  • public T SendData(object data) where T : IShitResult
  • public async Task SendDataAsync(object data) where T : IShitResult
  • public string UploadMedia(byte[] fileBody, string fileName, string mediaType)
  • public async Task UploadMediaAsync(byte[] fileBody, string fileName, string mediaType)

使用WxworkClient的好处:

  • 它会在内部自动处理企业微信所需的 access_token
  • 支持开发阶段查看完整的 请求/响应 过程,环境变量:Nebula_ImHttpClient_Debug_Enabled=1

例如:

xx





使用 InfluxDB

在配置服务中注册连接

在使用 InfluxDB 前,请先在配置服务中注册连接参数,可参考下图:

xx

上图注册了一个连接,名称是 InfluxDBTest




创建客户端实例

根据“连接名称”创建客户端实例 (示例代码使用了上图中的连接名称)

InfluxClient client = InfluxClient.Create("InfluxDBTest");

对于SaaS场景,可以采用下面的方式创建InfluxClient实例

InfluxClient client = InfluxClient.CreateTenant("tenantId");




写入数据到 InfluxDB

public static async Task Test_Write()
{
	// 生成要写入的测试数据
	List<RequestCount> list = CreateTestData();

	// 创建InfluxDB的客户端实例,Create方法的参数是一个“连接名称”,它已在配置服务中注册为数据库连接
	InfluxClient client = InfluxClient.Create("InfluxDBTest");

	// 执行插入操作
	await client.InsertAsync(list);
}

示例代码中数据结构的定义

public sealed class RequestCount
{
	[TagField]
	public string AppName { get; set; }
	[TagField]
	public string HostName { get; set; }

	[ValueField]
	public int Count { get; set; }

	[ValueField]
	public int Error { get; set; }

	[ValueField]
	public int Slow { get; set; }

	[ValueField]
	public int AvgTime { get; set; }

	[TimeField]
	public DateTime CreateTime { get; set; }
}




从 InfluxDB 中查询数据

示例-1--无参数

public static async Task Test_Query()
{
	// 查询最近 6 小时数据,30分钟为一个采样频率
	string query = @$"select sum(Count) as Count, sum(Error) as Error from RequestCount 
				WHERE AppName='TestApp0' and time > now() - 6h group by time( 30m )";

	// 执行查询
	InfluxClient client = InfluxClient.Create("InfluxDBTest");
	DataTable table = await client.ToTableAsync(query);

	Console.WriteLine(table.ToJson(JsonStyle.Indented));
}

示例-2--参数化查询

public static async Task Test_Query2()
{
	DateTime today = DateTime.Today;
	long start = today.GetNanoTime();
	long end = today.AddHours(6).GetNanoTime();
	
	// 带有占位参数的查询, $start, $end, 1个小时为一个采样频率
	string query = @$"select sum(Count) as Count, sum(Error) as Error from RequestCount 
				WHERE time >= $start and time < $end group by time( 1h )";

	var args = new { start, end };  // 查询参数

	// 执行查询
	InfluxClient client = InfluxClient.Create("InfluxDBTest");
	DataTable table = await client.ToTableAsync(query, args);

	Console.WriteLine(table.ToJson(JsonStyle.Indented));
}



下面这些InfluxDB查询都是有效的

select * from RequestCount 
WHERE AppName='TestApp0'  ORDER BY time DESC LIMIT 300

select * from RequestCount 
WHERE time >= '2020-10-15 00:00:00' and time < '2020-10-16 00:00:00' ORDER BY time DESC LIMIT 300

select sum(Count) as Count, sum(Error) as Error from RequestCount 
WHERE time >= '2020-10-15 00:00:00' and time < '2020-10-16 00:00:00' and AppName='TestApp0' group by time(1h)

select sum(Count) as Count, sum(Error) as Error from RequestCount 
WHERE AppName='TestApp0' and time > now() - 24h group by time(1h)



InfluxDB查询语法参考链接: https://docs.influxdata.com/influxdb/v1.8/query_language/




在查询服务中使用 InfluxDB

在查询服务中创建服务接口可参考下图:

xx


说明:

  • SQL中的参数名以 $ 开头
  • 如果需要查看 InfluxDB 的原始响应,调用时请指定:x-result-format=raw

数据查询服务支持以下返回格式:

  • json (默认值)
  • xml
  • xml2
  • csv
  • excel
  • raw

使用 VictoriaMetrics

在配置服务中注册连接

在使用 VictoriaMetrics 前,请先在配置服务中注册连接参数,可参考下图:

xx

上图注册了一个连接,名称是 vm-conn




创建客户端实例

根据“连接名称”创建客户端实例 (示例代码使用了上图中的连接名称)

VictoriaMetricsClient client = VictoriaMetricsClient.Create("vm-conn");

对于SaaS场景,可以采用下面的方式创建VictoriaMetricsClient实例

VictoriaMetricsClient client = VictoriaMetricsClient.CreateTenant("tenantId");




写入数据到 VictoriaMetrics

public static async Task Test_Write()
{
	// 生成要写入的测试数据
	List<RequestCount> list = CreateTestData();

	// 创建VictoriaMetrics的客户端实例,Create方法的参数是一个“连接名称”,它已在配置服务中注册为数据库连接
	VictoriaMetricsClient client = VictoriaMetricsClient.Create("vm-conn");

	// 将测试数据写入VictoriaMetrics
	await client.GetInsertRequest(list).SendAsync();
}

示例代码中数据结构的定义

public sealed class RequestCount
{
	[TagField]
	public string AppName { get; set; }
	[TagField]
	public string HostName { get; set; }

	[ValueField]
	public int Count { get; set; }

	[ValueField]
	public int Error { get; set; }

	[ValueField]
	public int Slow { get; set; }

	[ValueField]
	public int AvgTime { get; set; }

	[TimeField]
	public DateTime CreateTime { get; set; }
}




从 VictoriaMetrics 中查询数据

public static async Task Test_Query()
{
	// VictoriaMetrics 使用的 PromQL 查询
	string query = "RequestCount:Count";
	// 3个查询过滤参数
	DateTime start = DateTime.Now.AddHours(-3);
	DateTime end = DateTime.Now;
    int step = 0;

	// 创建VictoriaMetrics的客户端实例,Create方法的参数是一个“连接名称”,它已在配置服务中注册为数据库连接
	VictoriaMetricsClient client = VictoriaMetricsClient.Create("vm-conn");
	
	// 执行查询,此时返回的结果是JSON格式
	string json = await client.GetQueryRequest(query, start, end, step).GetResultAsync();

	// 将JSON数据转成 DataTable
	DataTable table = client.JsonToDataTable(json);

	Console.WriteLine(table.ToJson(JsonStyle.Indented));
}

说明:

  • VictoriaMetrics的返回结果只有一个数值列
  • JSON转DataTable时,会固定生成 XTime, XValue 这二个列,表示时间和数值




在查询服务中使用 VictoriaMetrics

在查询服务中创建服务接口可参考下图:

xx

说明:

  • 数据源:一定要选择 VictoriaMetrics,连接也要匹配
  • 查询脚本:就是 PromQL 查询
  • StartTime,EndTime,Step 这3个参数是必须的,用于过滤数据
  • 由于PromQL并不支持参数化查询,所以 @db 只是一个占位标记,在执行时会被替换




数据查询服务支持以下返回格式:

  • json (默认值)
  • xml
  • xml2
  • csv
  • excel
  • raw




查询演示

xx

xx

xx

使用 ElasticSearch

在配置服务中注册连接

在使用 ElasticSearch 前,请先在配置服务中注册连接参数,可参考下图:

xx

上图注册了一个连接,名称是 ClownFish_Log_Elasticsearch




创建客户端实例

根据“连接名称”创建客户端实例 (示例代码使用了上图中的连接名称)

EsConnOption option = EsConnOption.Create("ClownFish_Log_Elasticsearch");
SimpleEsClient client = new SimpleEsClient(option);




写入数据到 ElasticSearch

// 写入一个对象
NotifySendLog log = new NotifySendLog {.....};
await client.WriteOneAsync(log);

// 批量写入
List<NotifySendLog> list = .....;
await client.WriteListAsync(list);

数据在写入ES时,创建的索引名为:classname-yyyyMMdd,例如:notifysendlog-20220408









请点击目录中的节点查看具体文档

HTTP请求转队列

为了能应对客户端的大量数据提交请求,可以采用队列来缓冲请求压力,具体有2个步骤:

  • 将整个HttpRequest存入队列
  • 在另一个进程中订阅队列消息来处理HttpRequest



HttpRequest存入队列

[HttpPost]
public async Task<int> Databus()
{
    // 将请求序列化字节数组
    byte[] data = await this.RequestToBytesWithMonitorAsync();

    // 将当前请求发送到MQ
    this.SendRabbitMessage(ResNames.Rabbit, data, s_queueName);

    return data.Length;
}



如果需要将当前请求发到不同的MQ,请按下面方式处理:

[HttpPost]
public async Task<int> Databus()
{
    // 将请求序列化字节数组
    byte[] data = await this.RequestToBytesWithMonitorAsync();

    // 如果需要将当前请求发到不同的MQ,请按下面方式处理:
    if( /* 条件1 */ true )
        this.SendRabbitMessage(ResNames.Rabbit, data, "queue_1111");

    if( /* 条件2 */ true )
        this.SendRabbitMessage(ResNames.Rabbit, data, "queue_2222");

    if( /* 条件3 */ true )
        this.SendRabbitMessage(ResNames.Rabbit, data, "queue_3333");

    return data.Length;
}



处理HttpRequest

public class RequestMsgHandler : BaseMessageHandler<NHttpRequest>
{
    public override void ProcessMessage(PipelineContext<NHttpRequest> context)
    {
        NHttpRequest request = context.MessageData;

        // 在这里可以获取到从MQ中还原出来的 request 对象,你可以像 Controller中那样处理
        // 好处是,这里没有并发压力

        //// 例如:读取请求头
        //string contentType = request.ContentType;
        //string datatype = request.Header("x-datatype");

        //// 获取请求体内容
        //string body = request.BodyText;  // 注意:这里已经做过了 gzip 解压缩

        //// 获取客户端发送的 List 对象
        //List<OprLog> list = body.FromMultiLineJson<OprLog>();

        //foreach(var x in list ) {
        //    // 处理逻辑…………
        //}

    }
}

客户端上传数据与服务端队列处理

客户端上传数据

要求

  • 数据包不能无限大,服务端只允许1M大小
  • 数据上传前做 gzip 压缩



参考示例

这里假设采集到的数据是一个 List 集合

xx

代码解释:

  • spliter.GetNextPart() 的返回结果是一个 多行JSON,不是JSON数组
  • 返回结果是一个字符串,长度不超过 6M(参数)
  • 在发送前调用 ToGzip 方法可以实现数据压缩,可以控制在 1M 内



发送数据到服务端:

xx

代码解释:

  • x-datatype : 可用于服务端的兼容性处理(提示作用)
  • Content-Encoding = gzip :服务端就知道这是个 gzip 压缩数据
  • ContentType.Binary:指示请求体是一个二进制数据,避免一些服务端模块错误处理



云端入口服务

这里采用队列来缓冲,防止大量请求突然出现而丢失数据包。

xx

示例代码说明:

  • Action方法中可以不做作任何处理,直接把 request 发到MQ就可以了
  • request在发到MQ时,数据不做解压缩,不做反序列化,能保持数据包在一个较小的长度



订阅队列消息

xx



处理消息

xx

兼容性处理示例

xx



分析总结

  • 借助 DataSpliter 的分割功能,数据包大小可控
  • 上传时使用 gzip 压缩,数据包减少至少8倍
  • 整个过程只有一次JSON序列化和一次反序列化
  • 序列化是一个对象一个对象的进行,反序列化时不会占用大量内存
  • 整个过程只有一次 gzip 压缩 和 解压缩

HTTP转发代理

使用场景:

  • 少部分InternalService的服务接口需要通过PublicService对外公开,此时就需要在PublicService收到请求时转发到InternalService

新建一个PublicService

核心步骤就是在启动时调用 AppStartup.RunAsPublicServices(...) 方法,例如:

public class Program
{
    public static void Main(string[] args)
    {
        AppStartup.RunAsPublicServices("Nebula.Demo", args);
    }
}



新建配置文件

在应用程序目录下创建一个名为 Nebula.Demo.TransferMapRule.xml 的配置文件。

请注意转发配置文件的命名规则 :

  • 前缀,当前程序的名称,在这个示例中:Nebula.Demo
  • 后缀,固定为:.TransferMapRule.xml

配置文件内容示例如下:

<ProxyMapRule>
    <Rules>
        <rule src="/new/api/test/add"           dest="{service1_Url}/v20/api/WebSiteApp/test/Add.aspx" />
        <rule src="/new/api/test/ShowRequest"   dest="{service1_Url}/v20/api/WebSiteApp/test/ShowRequest.aspx" />
        <rule src="/new/api/test/login"         dest="{service2_Url}/v20/api/WebSiteApp/auth/login.aspx" />
        <rule src="/new/api/test/findByTel"     dest="{service2_Url}/v20/api/WebSiteApp/Customer/findByTel.aspx" />

        <rule src="/v20/api/moon/[any]"         dest="{configServiceUrl}/v20/api/moon/[any]" />
    </Rules>
</ProxyMapRule>

然后设置文件属性,参考下图:
xx



规则说明

  • 所有规则从上到下依次匹配,遇到匹配的规则后立即执行

  • src 包含 [any] 时
    • src 表示要执行匹配的URL模式
      • [any] 表示一个占位符,可以匹配任何多个字符
    • dest 表示将要转发的目标地址,它必须是一个绝对地址
      • dest也必须包含 [any],用于计算转发目标,它的值从 src的[any]中获得
    • 实际转发请求时,dest_url = dest + request.QueryString

  • src 不 包含 [any] 时
    • src 表示要执行匹配的URL路径
    • dest 表示将要转发的目标地址,它必须是一个绝对地址
    • 实际转发请求时,url = dest + request.Query

  • src = * 时
    • 表示匹配当前站点所有请求,所以如果需要这个规则,一定要放在最后。
    • dest 是一个URL路径前缀
    • 实际转发请求时,url = dest + request.PathAndQuery



举例说明

假设当前站点的根地址:http://www.abc.com/

src = "/new/api/test/add",
dest = "http://localhost2:8206/v20/api/WebSiteApp/test/Add.aspx"

示例请求:http://www.abc.com/new/api/test/add?id=2
转发目标:http://localhost2:8206/v20/api/WebSiteApp/test/Add.aspx?id=2



src = "/v20/api/moon/[any]",
dest = "http://LinuxTest:8503/v20/api/moon/[any]"

示例请求:http://www.abc.com/v20/api/moon/setting/get.svc?name=xxx
转发目标:http://LinuxTest:8503/v20/api/moon/setting/get.svc?name=xxx

WebHook事件

WebHook的用途有2点:

  • 应用解耦:应用程序产生WebHook事件时,不用关心有多少个订阅者程序
  • 应用集成:可通过订阅事件的方式实现数据同步

WebHook运行示意图

xx

可以发现

  • MyApp1 与 OtherApp1/2/3/4 不直接调用(没有耦合)
  • Event1 有 3个订阅者(MyApp1不用关心)
  • Event2 有 2个订阅者(MyApp1不用关心)

总结:

  • WebHook事件的发起方与订阅方完全解耦,它们只和WebHookServer这个中间交互。
  • 一个WebHook事件可以有多个订阅者,也可以没有任何订阅者,对于这些发起方不用关心
  • 发起方只需要一套代码即可整合多个其它应用程序




发送WebHook事件

// 创建事件数据对象
var alert = new {
	AlertId = 123,
	Message = "xxxxxxxxxxx",
	AlertType = 11,
	// ...............
};

// 发布WebHook事件
WebHookClient.Instance.PublishEvent("alert.create", alert);




WebHookServer


Nebula自带一个WebHookServer的实现,
具体内容可参考: WebHook服务

文本模板

文本模板可以理解为高级的string.Format(...),它的用途是将 数据绑定到模板的占位标记 并生成一段新文本。

示例代码

private static readonly string s_httpTemplate = @"
POST http://www.abc.com/test/callback.aspx?data={enc(data.cn)}&v={rand} HTTP/1.1
x-header1: qqqqqqqqqqqqq
x-header2: wwwwwwwwwwwww
x-xx1: {data.xx1}
x-xx2: {enc(data.xx2)}
x-xx3: {data.xx333}
x-client-app: TestApp2
x-client-reqid: {guid}
x-client-reqid32: {guid32}
x-client-url: http://www.fish-test.com/aaa/bb.aspx
Content-Type: application/json; charset=utf-8
x-null: #{xx}#
xx-guid: {rand.guid}
xx-guid32: {rand.guid32}
xx-guid36: {rand.guid36}
xx-char: {rand.char}
xx-char0: {rand.char0}
xx-char1: {rand.char1}
xx-char2: {rand.char2}
xx-char3: {rand.char3}
xx-char4: {rand.char4}
xx-char5: {rand.char5}
xx-int: {rand.int}
xx-int0: {rand.int0}
xx-int1: {rand.int1}
xx-int2: {rand.int2}
xx-int5: {rand.int5}
xx-int9: {rand.int9}
xx-int10: {rand.int10}
xx-time1: {rand.now}
xx-time2: {5秒前}
xx-time3: {5分钟前}
xx-time4: {5小时前}
xx-time5: {5天前}
xx-time6: {2月前}
xx-value01: {rand}
xx-value02: {now}
xx-value03: {昨天}
xx-value04: {今天}
xx-value05: {明天}
xx-value06: {月初}
xx-value07: {下月初}
xx-value08: {季度初}
xx-value09: {下季度初}
xx-value10: {年初}
xx-value11: {明年初}
xx-value12: {周一}
xx-value13: {下周一}
xx-local1: {LocalSetting_key1}
xx-local2: {LocalSetting_key2}
xx-local3: {LocalSetting_keyxx}

{data}
";


public void Test1()
{
    object data = new { xx1 = 223, xx2 = "abcd", cn="中文汉字~!@#$#%" };

    TextTemplate template = new TextTemplate();
    string text = template.Populate(s_httpTemplate, data.ToDictionary());

    Console2.WriteLine(text);
}

输出结果

POST http://www.abc.com/test/callback.aspx?data=%e4%b8%ad%e6%96%87%e6%b1%89%e5%ad%97%7e!%40%23%24%23%25&v=637685405048053925 HTTP/1.1
x-header1: qqqqqqqqqqqqq
x-header2: wwwwwwwwwwwww
x-xx1: 223
x-xx2: abcd
x-xx3: {data.xx333}
x-client-app: TestApp2
x-client-reqid: 87d746f2-ec14-4fff-940a-477f61b1ca67
x-client-reqid32: 33de20e3ed014faeb976b3d16c00c83b
x-client-url: http://www.fish-test.com/aaa/bb.aspx
Content-Type: application/json; charset=utf-8
x-null: #{xx}#
xx-guid: 9a6e0f34-59e3-41ee-b21b-57033b25552a
xx-guid32: 7f57af257b2a4ef7ba3fe94d61a290c8
xx-guid36: 0c49a8e4-60ca-4038-bec9-48c99fdabd59
xx-char: b
xx-char0: {rand.char0}
xx-char1: 2
xx-char2: f1
xx-char3: 7da
xx-char4: ed84
xx-char5: 67466
xx-int: 63400309
xx-int0: {rand.int0}
xx-int1: 3
xx-int2: 39
xx-int5: 88929
xx-int9: 490415950
xx-int10: 1174402031
xx-time1: 2024-01-30 16:21:30
xx-time2: 2024-01-30 16:21:25
xx-time3: 2024-01-30 16:16:30
xx-time4: 2024-01-30 11:21:30
xx-time5: 2024-01-25 16:21:30
xx-time6: 2023-11-30 16:21:30
xx-value01: 638422284904214607
xx-value02: 2024-01-30 16:21:30
xx-value03: 2024-01-29
xx-value04: 2024-01-30
xx-value05: 2024-01-31
xx-value06: 2024-01-01
xx-value07: 2024-02-01
xx-value08: 2024-01-01
xx-value09: 2024-04-01
xx-value10: 2024-01-01
xx-value11: 2025-01-01
xx-value12: 2024-01-29
xx-value13: 2024-02-05
xx-local1: abcd
xx-local2: 1234
xx-local3: {LocalSetting_keyxx}

{"xx1":223,"xx2":"abcd","cn":"中文汉字~!@#$#%"}


模板语法

{xxx} 表示一个占位替换符,在调用Populate时【可能】会被替换。

{xxx}可以支持的范围

  • {data} 表示整个数据对象的JSON序列化文本
  • {data.name} 读取数据对象的 name 属性
  • {rand} 获取一个随机数字
  • {rand.name} 获取 XRandom 支持的一个随机值,具体可参考上面的示例
  • {name} 同上,只是名称上简化了
  • {enc(...)} 先计算括号内的数据(参考上面规则),再做 UrlEncode

如果匹配失败,占位符不做替换。



数据绑定

文本模板由TextTemplate工具类来实现,可以调用 Populate 来生成结果

public sealed class TextTemplate
{
	/// <summary>
	/// 填充模板
	/// </summary>
	/// <param name="template"></param>
	/// <param name="data"></param>
	/// <returns></returns>
	public string Populate(string template, IDictionary<string, object> data)


	/// <summary>
	/// 填充模板
	/// </summary>
	/// <param name="template"></param>
	/// <param name="json"></param>
	/// <returns></returns>
	public string Populate(string template, string json)

}


关键字替换

有些场景下,仅仅需要【关键字】的替换功能,例如: 在一个SQL语句中,写上固定的 占位符

SQL示例:

select * from table1 where create_time >= '{今天}' and create_time < '{明天}' and userid = '{USERID}'

上面这个SQL语句中就定义了3个关键词:

  • 今天,例如:2023-01-01
  • 明天,例如:2023-01-02
  • USERID,例如:xxxxxxx

由于 XRandom 并不支持 {USERID} 这个【关键字】,所以需要自行实现一个,例如:

public static string GetCurrentUserId()
{
	// 例如(不能运行):return HttpPipelineContext.Get().HttpContext.User.Identity.Name;
	return "u" + DateTime.Now.Ticks.ToString();
}

在【程序启动】时注册这个【关键字】,

XRandom.RegisterValueGetter("USERID", GetCurrentUserId);

然后,在业务代码中可调用:

string sql = XRandom.FillTemplate(sqltemplate);

即可得到下面类似的SQL语句:

select * from table1 where create_time >= '2024-01-30' and create_time < '2024-01-31' and userid = 'u638422290772964811'

全局锁(分布式锁)

使用场景:在多进程之间保证资源只能由一个进程访问。

示例代码

/// <summary>
/// 多队列-多进程 自动配对订阅实现方案,
/// 假如有 10 个队列,5个进程,此时每个进程将订阅2个队列
/// </summary>
private static void Demo()
{
    // 此方案的大致思路是:
    // 1,先获取一个分布式锁,保证多个进程互斥(只有一个进程能进入执行)
    // 2,获取所有队列信息,检查将要订阅的队列是否有订阅者,如果队列没有订阅者,就订阅它


    // 创建一个分布式锁,锁将保证多个进程时,只有一个进程能进入执行
    using( GlobalLock locker = GlobalLock.Create("一个特殊的锁标识") ) {

        RabbitMonitorClient client = new RabbitMonitorClient("rabbitmq-连接名称");

        // 获取所有队列信息,并过滤【特定】
        List<QueueInfo> list = client.GetQueues(10_000).Where(x => x.Name.StartsWith("队列名称前缀")).ToList();

        // 记录当前进程已订阅的队列数量
        int count = 0;

        foreach( var q in list ) {

            // 如果某个队列没有订阅者,就表示当前进程可以订阅它
            if( q.Consumers == 0 && count < 2 ) {

                // 订阅某个队列
                RabbitSubscriberArgs args = new RabbitSubscriberArgs {
                    SettingName = "Rabbit连接配置名称",
                    QueueName = q.Name,
                    SubscriberCount = 3
                };
                RabbitSubscriber.Start<XMessage, XMessageHanlder>(args);
                count++;
            }
        }
    }
}

在上面这个示例中,受保护的资源就是RabbitMQ,

为了实现多个进程节点 均匀且不重复 订阅,就需要将RabbitMQ保护起来,

一次只允许一个进程访问RabbitMQ的队列信息,并从中选择2个没有被订阅的队列。



跨进程事件通知

跨进程事件通知 不同于简单的HTTP调用通知:它是一种一对多的通知方式。

典型场景:

  • 数据修改后通知【多个进程】及时刷新内存中缓存




发布事件通知

AppEvent.PublishGlobalEvent("insert.userservice.userrole", "usercode");

AppEvent.PublishGlobalEvent("update.userservice.userrole", "123", "usercode");

PublishGlobalEvent方法签名

/// <summary>
/// 发送【跨进程】事件,事件以异步方式交给EventHandler执行。
/// </summary>
/// <param name="name">事件名称</param>
/// <param name="param1">参数1</param>
/// <param name="param2">参数2</param>
/// <param name="param3">参数3</param>
public static void PublishGlobalEvent(string name, string param1 = null, string param2 = null, string param3 = null)

为什么是3个参数(param1/param2/param3)?

设计原因:

  • 使用简单:大多数简单场景可以直接使用简单数据类型(int/string)
    • 例如:缓存更新时,可以只指定 param1=cacheKey 参数即可
  • 方便过滤:减少反序列次数
    • 由于通知会发送给集群内所有进程,为了便于过滤,可以将过滤条件赋值给 param2/param3

说明:

  • 以上只是使用建议,你可以给param1/param2/param3赋值任意复杂的JSON文本,甚至是null
  • 这3个参数具体表达什么含意,由每个事件来决定!




订阅&处理事件

public class XxxEventHandler : BaseAppEventHandler
{
     [EventAction(EventName = "insert.userservice.userrole")]
     public void Method1(AppEventArgs e)
     {
          // 事件处理逻辑
     }


     [EventAction(EventName = "update.userservice.userrole")]
     public async Task Method2(AppEventArgs e)
     {
      // 事件处理逻辑
     }
}

注意事项:

  • 事件订阅类型必须定义成 public ,且必须从 BaseAppEventHandler 继承
  • 订阅事件的方法必须定义成 public
  • 方法的返回值类型只能是 void 或者是 Task
  • 方法只能包含一个参数,类型是 AppEventArgs
  • 方法必须用 [EventAction] 来明确指定要订阅的事件名称




如何忽略某个事件?

方法:

  • 不订阅那个事件

说明:

  • 事件处理方法是异步调用的,不会阻塞发布方,也没有返回值
  • 不论事件如何处理,对发布方是没有影响的




与消息处理相比

【跨进程事件通知】与【消息处理】其实是非常类似的设计。

跨进程事件通知 的主要设计目标是:

  • 轻量化
  • 易用使用

具体的技术差异在于:
技术特性跨进程事件通知MQ消息处理
创建队列不需要需要事先创建或者程序初始化时创建
主动订阅不需要需要程序初始化时主动订阅某个队列
作用范围所有进程由开发人员决定
消息持久化不支持由队列服务决定(绝大部分支持)
订阅者数量固定=1支持多个,可配置
异常处理非常简陋默认支持,且允许重写(自定义)
重试处理不支持支持,且允许重写重试过滤条件
日志没有
监控没有




注意事项

基于上表描述,跨进程事件通知只能用于【比较简单】的通知场景,且在使用时应该注意:

  • 事件会发送所集群内的所有进程,所以请优先考虑使用MQ的方式解决
  • 事件处理方法必须要快速完成,否则当出现大量事件时,会导致事件丢失
  • 默认的异常处理仅仅是 Console.Write,如果需要记录到异常日志,请手动处理

异步等待回调事件

虽然 .NET BCL 中提供了大量的异步API,但是有些时候,我们会遇到没有异步API的组件。

例如,某个第三方组件,它的内部执行过程比较耗时,它采用完成事件通知的方式来通知调用方。

示例代码如下:

public class Xjob {

	// 启动任务
	public void Start(string state){
		// 启动后台操作,有可能是交给硬件处理
		// 这个方法会立即返回
	}

    // 任务执行完成的事件通知
	public event EventHandler<OnCompletedArgs> OnCompleted;
}

对于这样一个组件,如果我们希望在 Web服务或者后台作业中调用,就非常麻烦,你可以自己思考下该如何实现~~~

为了解决这类问题,可以使用 ClownFish 提供的 ResultWaiter 来调用,示例代码如下:

public async Task<ActionResult> HttpAction1() {
	using ResultWaiter waiter = new ResultWaiter();

	Xjob xjob = new Xjob();
	xjob.OnCompleted += XjobOnCompleted;

	xjob.Start(waiter.ResultId);

	var result = await waiter.WaitAsync(TimeSpan.FromSeconds(30));
	// 处理 Xjob 的执行结果 ………………
}

private static void XjobOnCompleted(object sender, OnCompletedArgs e) {
     string resultId = e.State;

	 ResultWaiter waiter = ResultWaiter.GetById(resultId);
	 if( waiter != null ){
		if( e.Exception != null)
			waiter.SetException(e.Result);
		else
			waiter.SetResult(e.Result);
	 }
}









请点击目录中的节点查看具体文档

应用程序初始化

如果应用程序需要在业务处理前完成一些初始化动作,

例如:程序使用了RabbitMQ队列,我们希望在程序启动时就创建好队列,

诸如这类场景可以参考本文介绍的方法。



开发过程

  • 在项目中新建一个名为 AppInitializer 静态类
  • 增加一个名为 Init 的方法
  • 在 Init 方法中编写初始化逻辑

示例代码

namespace YourProjectNameSpace;

public static class AppInitializer
{
    public static void Init()
    {
        // 在这里编写初始化逻辑
    }
}

补充说明:

  • 示例代码中的类名和方法名是固定的,不能随意取名
  • 示例代码中的类型和方法都要定义成静态的
  • Init方法不需要在代码中调用,Nebula会在启动时调用

注意:

  • 不要直接在Program.Main方法中执行初始化操作



类库项目初始化

在类库项目中,AppInitializer类不会纳入查找范围,所以即使有定义也不会被调用。

此时可以参照以下方法:

[Init]  // 指示这个类需要在启动时初始化
public static class ComponentInitializer
{
    public static void Init()
    {
        Console.WriteLine("Demo.Data.ComponentInitializer.Init() called!");
    }
}

说明:

  • 类型的可见性必须是 public
  • 方法的签名必须是:public static void Init()

Nebula行为定制

Nebula框架的定制方式包含3类:

  • 修改参数值来实现
  • 修改启动参数
  • 重写特定的行为方法



修改参数值

Nebula包含了较多参数,请参考 Nebula参数清单 章节内容。



修改启动参数

在Program.cs的Main方法,我们需要执行一个调用

AppStartup.RunAsXXXXXX("xxxxxxx", args, startOption);

第三个参数就是启动参数,它的类型是AppStartOption,可以查看这个类型的成员获知有哪些参数可供调整。



框架行为定制

对于一些特殊场景,Nebula的默认行为可能不满足需求,那么就需要通过扩展的方式来定制框架行为。

下面将介绍如何通过扩展方式来定制Nebula的行为。



开发过程

  1. 在VS解决方案树中找到你的项目
  2. 找到Customize文件夹,如果不存在则创建
  3. 新建一个名为 MyNebulaBehavior 的类名,让它从NebulaBehavior继承
  4. 根据你的需求找到并重写NebulaBehavior定义的虚方法
  5. 找到Program.cs的Main方法
  6. 创建一个AppStartOption的实例,并设置NebulaBehavior属性

示例代码如下:

namespace Nebula.Venus.Customize;

public class VenusNebulaBehavior : NebulaBehavior
{
   // 重写一些你需要的虚方法
}

Program.cs代码如下:

public class Program
{
    public static void Main(string[] args)
    {
        AppStartOption startOption = new AppStartOption {
            NebulaBehavior = new VenusNebulaBehavior()
        };

        AppStartup.RunAsWebsite("Nebula.Venus", args, startOption);
    }
}



NebulaBehavior的可重写方法清单


public virtual bool AllowCors(NHttpContext httpContext)

public virtual void SetMvcOptions(MvcOptions x)

public virtual void SetMvcJsonOptions(MvcNewtonsoftJsonOptions x)

public virtual void ConfigureUrlRouting(WebApplication app)

public virtual async Task ShowHomePage(HttpContext httpContext)


ASP.NET 扩展支持

本文介绍将如何在Nebula中实现与ASP.NET相关的一些扩展场景:

  • 添加 MVC Filter
  • 调整 MVC UrlRouting
  • 引入新组件
  • 添加 Middleware/RequestDelegate



添加 MVC Filter

你可以开发一个符合 ASP.NET 要求的 MVC Filter,例如:

public class MyActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        // 过程省略
    }

然后,在NebulaBehavior的继承类中注册它,例如:

public class MyNebulaBehavior : NebulaBehavior
{
    public override void SetMvcOptions(MvcOptions opt)
    {
        opt.Filters.Add(typeof(MyActionFilter));
    }

最后,在 AppStartOption 中引 MyNebulaBehavior,例如:

public class Program
{
    public static void Main(string[] args)
    {
        AppStartOption startOption = new AppStartOption {
            NebulaBehavior = new MyNebulaBehavior()
        };
        
        AppStartup.RunAsWebsite("XDemo.WebSiteApp", args, startOption);
    }        
}

注意:继承NebulaBehavior的子类必须使用上面的方式来使用它,本文后面将不再重复指出。



调整 MVC UrlRouting

以下代码是 NebulaBehavior 中的 UrlRouting 默认实现:

public virtual void ConfigureUrlRouting(WebApplication app)
{
    app.UseRouting();

    app.MapControllers();

    if( AppStartup.StartOption.EnableDefaultHomePage ) {
        app.MapGet("/", ShowHomePage);
    }
}

说明:

  • 建议不要覆盖重写这个方法,可以在调用 base 方法的 before, after 中添加路由规则
  • 或者采用 RequestDelegate 的方式来拦截请求实现路由规则



引入新组件

首先要清楚,在 ASP.NET Core 的设计中,一个组件的引入被分为2个阶段:

  • 在Ioc容器中注入服务(组件),对应 Startup.ConfigureServices 方法
  • 配置ASP.NET管道(可选步骤),对应 Startup.Configure 方法

在Nebula框架中,不再需要每个项目再维护Startup类型,
只需要开发一个新类型从BaseAppStarter继承,然后选择要实现的方法即可。

BaseAppStarter 提供以下方法用于定制ASPNETCORE的启动过程:

  • PreNebulaInit:默认行为:什么也不做(等着你来填空)
  • PostNebulaInit:默认行为:什么也不做(等着你来填空)
  • PreConfigureServices:默认行为:什么也不做(等着你来填空)
  • PostConfigureServices:默认行为:什么也不做(等着你来填空)
  • PreConfigureWeb:默认行为:什么也不做(等着你来填空)
  • PostConfigureWeb:默认行为:什么也不做(等着你来填空)
  • PreBuildWebHost:默认行为:什么也不做(等着你来填空)
  • PostBuildWebHost:默认行为:什么也不做(等着你来填空)
  • PreApplicationInit:默认行为:什么也不做(等着你来填空)
  • PostApplicationInit:默认行为:什么也不做(等着你来填空)

这里以引入 SignalR 为例演示整个开发过程,完整示例如下:

public class ImportSignalrStarter : BaseAppStarter
{
    public override void ConfigureServices(IServiceCollection services)
    {
        string conn = ConfigClient.Instance.GetSetting("Redis.Connection");

        services.AddSignalR(option => {
            option.EnableDetailedErrors = true;
            option.MaximumReceiveMessageSize = 1024 * 1024;
            option.HandshakeTimeout = TimeSpan.FromSeconds(60);
            option.StreamBufferCapacity = 5000;
        }).AddNewtonsoftJsonProtocol()
            .AddStackExchangeRedis(conn);
    }

    public override void PostConfigureWeb(WebApplication app)
    {
        app.UseEndpoints(endpoints => {
            endpoints.MapHub<NotifyHub>("/v20/api/ws/notify", option => {
                option.Transports = HttpTransportType.WebSockets;
            });
        });
    }
}

说明:

  • 一个项目中,可以有多个 BaseAppStarter 的继承类
  • BaseAppStarter 的继承类必须是 public 的可见性,框架会自动识别并调用。
  • BaseAppStarter 的继承类可以出现在类库中,用来构造完整的业务模块(支持所谓的模块化开发)



添加 Middleware/RequestDelegate

开发 MyRequestDelegate,示例代码如下:

public class MyRequestDelegate
{
    private readonly Microsoft.AspNetCore.Http.RequestDelegate _next;

    private long _requestCount = 0;

    public MyRequestDelegate(Microsoft.AspNetCore.Http.RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(Microsoft.AspNetCore.Http.HttpContext httpContext)
    {
        long count = Interlocked.Increment(ref _requestCount);

        httpContext.Response.Headers.Add("x-ReuestCount", count.ToString());

        await _next(httpContext);
    }
}

开发一个新类型,从 BaseAppStarter 继承,

public class XxxAppStarter : BaseAppStarter
{
    public override void PostConfigureWeb(WebApplication app)
    {
        app.UseMiddleware<MyRequestDelegate>();
    }
}

这样就可以了,框架会自动识别!



HTTP管道扩展

在ClownFish框架中,允许采用经典的 HttpModule/HttpHandler 模型来实现HTTP管道的扩展。



开发HttpModule

示例代码如下:

public class QueryModule : NHttpModule
{
    public override void ResolveRequestCache(NHttpContext httpContext)
    {
        // 检查当前请求是否映射到一个已定义的“服务接口”
        ApiAction action = ActionResolver.GetAction(httpContext);
        if( action == null )
            return;


        QueryHandler handler = new QueryHandler(action);
        httpContext.PipelineContext.SetHttpHandler(handler);
    }        
}

小结:

  • 开发过程和经典的 ASP.NET HttpModule类似,
  • 注意这里是 NHttpModule 基类,
  • 然后是重写基类的虚方法(而不是订阅事件)
  • 由HttpModule负责HttpHandler的实例化
  • 调用httpContext.PipelineContext.SetHttpHandler(handler);来启用HttpHandler实例

开发HttpHandler

(部分)示例代码如下:

internal class QueryHandler : IAsyncNHttpHandler
{
    private NHttpContext _httpContext;
    private ApiAction _action;
    private IDictionary<string, object> _args;


    public QueryHandler(ApiAction action)
    {
        _action = action;
    }

    public async Task ProcessRequestAsync(NHttpContext httpContext)
    {
        _httpContext = httpContext;

        if( await ValidateRequest() == false )
            return;

        _args = GetArgs();

        switch( _action.SourceType ) {
            case 1:
                await new SqlQueryExecutor(_httpContext, _action, _args).ExecuteAsync();
                break;

            case 2:
                await new HttpExecutor(_httpContext, _action, _args).ExecuteAsync();
                break;

            default:
                throw new InvalidOperationException($"无效的 SourceType = {_action.SourceType} 取值。");
        }
    }
}

小结:

  • HttpHandler要实现IAsyncNHttpHandler接口
  • 然后实现 ProcessRequestAsync 方法




检查 HttpModule 是否已加载

可以访问 Venus 查看进程的 【查看系统信息】,如下下图:

xx

xx

这里显示了所有已加载的HttpModule

框架事件

以下这些框架事件,可用于一些特殊的扩展需求。



HttpClientEvent

public static class HttpClientEvent
{
    /// <summary>
    /// 创建Request对象前将会引发此事件
    /// </summary>
    public static event EventHandler<BeforeCreateRequestEventArgs> OnCreateRequest;
    
    /// <summary>
    /// 创建HttpWebRequest前将会引发此事件,提供最后一个修改请求参数的机会。
    /// </summary>
    public static event EventHandler<BeforeSendEventArgs> OnBeforeSendRequest;

    /// <summary>
    /// 表示请求完成时触发的事件
    /// </summary>
    public static event EventHandler<RequestFinishedEventArgs> OnRequestFinished;

等价的DiagnosticListener接口:

  • DiagnosticListener("ClownFish.HttpClientEvent")
    • OnBeforeSendRequest
    • OnRequestFinished



DbContextEvent

namespace ClownFish.Data;

public static class DbContextEvent
{
    /// <summary>
    /// 连接打开事件
    /// </summary>
    public static event EventHandler<OpenConnEventArgs> OnConnectionOpened;

    /// <summary>
    /// 命令执行之前事件
    /// </summary>
    public static event EventHandler<ExecuteCommandEventArgs> OnBeforeExecute;

    /// <summary>
    /// 命令执行之后事件
    /// </summary>
    public static event EventHandler<ExecuteCommandEventArgs> OnAfterExecute;

    /// <summary>
    /// 提交事务事件
    /// </summary>
    public static event EventHandler<CommitTransEventArgs> OnCommited;

等价的DiagnosticListener接口:

  • DiagnosticListener("ClownFish.DALEvent")
    • ConnectionOpened
    • BeforeExecute
    • AfterExecute
    • OnCommit



ClownFish.Log

public static class LogHelper
{
    /// <summary>
    /// 写日志时出现异常不能被处理时引用的事件
    /// </summary>
    public static event EventHandler<ExceptionEventArgs> OnError;
public static class FatalErrorLoger
{
    /// <summary>
    /// 当调用 LogException 方法时引发的事件,这表示有一个异常已发生。
    /// </summary>
    public static event EventHandler<ExceptionEventArgs> OnLogException;



RedisClientEvent

public static class RedisClientEvent
{
    /// <summary>
    /// 每当执行一次Redis调用后触发的事件
    /// </summary>
    public static event EventHandler<RedisClientEventArgs> OnExecuteFinished;



RedisClientEvent

public static class RabbitClientEvent
{
    /// <summary>
    /// 每次发送一条消息后触发
    /// </summary>
    public static event EventHandler<SendMessageEventArgs> OnSendMessage;

多数据库支持

ClownFish 提供了【多数据库支持】的特性,直接可支持以下数据库(已适配):

  • SQLSERVER
  • MySQL
  • PostgreSQL
  • SQLite
  • 达梦

为了更好的支持MySQL,还同时支持MySql.Data和MySqlConnector 2种不同的客户端,可参考:MySQL异步



开发层面的差异

在ADO.NET基础上,不同的数据库在开发层面主要有以下方面的差异:

  • 获取一个标识符的完整形式。拿SQLSERVER来说,传入 xxx 则应该返回 [xxx]
  • 判断异常是不是由于 【重复INSERT】 导致的异常
  • 获取与数据库类型匹配的SQL语句中的参数名称,一般情况:@name
  • 获取与数据库类型匹配的命令集合中参数名称,一般情况:@name
  • 根据一条查询语句生成对应的2个分页查询 (List+Count)
  • 切换数据库,一般情况:use db1
  • 获取新产生的自增列的值

在ClownFish中定义了一个抽象类来隔离不同的数据库差异:

public abstract class BaseClientProvider

为了支持本文开头列出的4种数据库,ClownFish 内置了7个实现类:

  • MsSqlClientProvider 支持 SQLSERVER,基于 System.Data.SqlClient
  • MsSqlClientProvider2 支持 SQLSERVER,基于 Microsoft.Data.SqlClient
  • MySqlDataClientProvider 支持 MySQL,基于 MySql.Data
  • MySqlConnectorClientProvider 支持 MySQL,基于 MySqlConnector
  • PostgreSqlClientProvider 支持 PostgreSQL,基于 Npgsql
  • SQLiteClientProvider 支持 SQLite,基于 System.Data.SQLite
  • DaMengClientProvider 支持 达梦,基于 DmProvider

而且还内置了2个"过时的"实现类供参考:

  • OdbcClientProvider
  • OledbClientProvider

如果遇到不能支持的数据库类型(例如:Oracle),请自行实现ClientProvider



特别注意事项
ClownFish虽然支持多种数据库客户端,但ClownFish本身没有引用那些客户端!
全部采用的是反射方式实现。
如果程序启动时出现异常且有XXX程序集找不到时,请在项目中自行引用,然后还要在代码中访问某个类型。



客户端注册方法

可调用以下方法:

/// <summary>
/// DbProviderFactory的辅助工具类
/// </summary>
public static class DbClientFactory
{
    /// <summary>
    /// 注册数据客户端提供者实例
    /// </summary>
    /// <param name="providerName">客户端提供者名称</param>
    /// <param name="provider">提供者实例</param>
    public static void RegisterProvider(string providerName, BaseClientProvider provider)

定制用户身份

默认情况下,ClownFish会在请求进入时识别用户身份,并构造相关一系列身份对象。

对于开发者来说,只需要一个调用就可以获取到用户身份信息:

// 下面代码可在 Controller 代码中使用
IUserInfo userinfo = this.NHttpContext.GetUserInfo();

再或者可以使用下面方式来做授权检查

[Authorize(Roles = "Admin,AppClient")]



在这些代码的背后,ClownFish需要做2件事情:

  • 识别用户身份并构造LoginTicket对象,默认来源有2个(名称可配置):请求头 或者 Cookie
  • 根据LoginTicket对象设置 httpContext.User 属性

默认情况下,httpContext.User 属性的值是一个 NbPrincipal 对象。



数据来源扩展

如果你需要使用其它的身份标识来源,例如:

  • 从 Redis 中加载已登录的用户身份 (其实是一种Session实现方式)
  • 从其它格式的Cookie或者Header中加载用户身份

那么可以参考下面示例。

public sealed class DemoAuthenticateModule : NHttpModule
{
    public override void AuthenticateRequest(NHttpContext httpContext)
    {
        // 从其它来源获取用户身份
        IUserInfo userinfo = GetUserInfo(...);

        // 构造登录身份凭证对象
        LoginTicket ticket = new LoginTicket{ User = userinfo };

        // 设置 httpContext.User
        httpContext.User = new NbPrincipal(ticket, LoginTicketSource.Others);
    }
}



身份类型扩展

ClownFish在内部会使用 NbPrincipal和 NbIdentity 来封装用户身份相关数据。

如果你需要在请求中维护更多信息,也可以自行实现下面2个接口:

  • IPrincipal,这个接口是 httpContext.User 的类型要求
  • INbIdentity,这个接口是包含用户身份的数据对象

示例代码

public sealed class MyIdentity : ClaimsIdentity, INbIdentity
{
     public IUserInfo UserInfo { get; }

     public Xxxxx OtherData { get; }  // 其它身份数据

     // 这里省略构造方法相关代码
}

public sealed class MyPrincipal : ClaimsPrincipal
{
    // 省略数据成员定义相关代码

    public MyPrincipal(LoginTicket ticket)
    {
        this.AddIdentity(new MyIdentity(ticket.User, ....otherData));
    }
}


public sealed class MyAuthenticateModule : NHttpModule
{
    public override void AuthenticateRequest(NHttpContext httpContext)
    {
        // 从其它来源获取用户身份
        IUserInfo userinfo = GetUserInfo(...);

        // 构造登录身份凭证对象
        LoginTicket ticket = new LoginTicket{ User = userinfo, ....};

        // 设置 httpContext.User
        httpContext.User = new MyPrincipal(ticket);        
    }
}


使用Nebula开发独立应用程序

虽然Nebula开发框架的设计目标是支持 SaaS + 微服务架构,但它也可以开发一些独立的应用程序,例如:

  • 独立的小网站
  • 独立的WebApi服务

这些独立的应用程序通常有以下特点:

  • 只需要 一个应用程序进程 就能满足业务需求,就像小工具一样,运行起来就可以做活了!
  • 不依赖配置服务之类的基础服务
  • 也不需要多租户的支持

本文将介绍这种应用程序的开发过程。




开发过程

在这个过程,主要是设置一个启动参数(EnableConfigService = false),例如:

public class Program
{
    public static void Main(string[] args)
    {
        AppStartOption startOption = new AppStartOption {

            // 独立运行,不使用配置服务
            EnableConfigService = false,
        };
        
        AppStartup.RunAsWebsite("MyTest.WebSiteApp", args, startOption);
    }        
}

此时,这个项目就可以独立运行了!




参数配置

应用程序的配置参数有以下类别:

  • 本地配置参数
    • 环境变量(系统级/用户级/进程级)
    • AppConfig(ClownFish.App.config)
  • 远程配置参数
    • 全局配置参数
      • 连接类参数:RabbbitMQ/Redis/es/oss/...
      • 帐号类参数:邮箱帐号,IM应用帐号
      • 密码密钥类:登录密码,JWT密钥
      • URL地址:内部服务,外部服务
    • 数据库连接参数
    • 独立配置文件
    • 远程配置文件(xxx.App.config, xxx.Log.config)

经过前面的步骤,程序是可以独立运行了,但是由于不依赖配置服务,那么"远程配置参数"在哪里配置和读取呢?

答案是:

  • 全局配置参数:从 ClownFish.App.config 中获取
  • 数据库连接参数:从 ClownFish.App.config 中获取
  • 独立配置文件:放在 {app}/_config/ 目录下
  • 远程配置文件:此场景下不适用!

例如:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>       
        <add key="MySqlClientProviderSupport" value="2" />
        
        <!-- 必要的3个配置参数 -->
        <add key="CLUSTER_ENVIRONMENT" value="BPM-OP"/>
        <add key="Nebula_Environment_Key" value="4fa5e8a12eb04a0b9d8f4330874cfc5a"/>
        <add key="Nebula_Authentication_SecretKey" value="892091a889304639bd42c7ebf61cbf17"/>

        <!-- 一些普通的配置参数 -->
        <add key="key1" value="abcd"/>
        <add key="key2" value="123"/>

        <!-- 连接类参数 -->
        <add key="my_Rabbit" value="server=RabbitHost;username=fishli;password=1qaz7410;vhost=nbtest" />
        <add key="Redis_Connection" value="RedisHost,password=1qazxsw2"/>

        <!-- 非关系型数据库连接(ADO.NET不支持) -->
        <add key="my_Elasticsearch" value="Server=ElasticHost:9200;"/>

        <!-- 帐号类参数 -->
        <add key="mail_config" value="Host=smtp.xxx.com;Port=587;UserName=xxxxx@abc.com;Password=xxx;IsSSL=1"/>
        <add key="WxWork_AppAuth_Config" value="ImType=WxWork;AgentId=999;AppId=xxxxx;AppSecret=xxxx"/>
        <add key="DingDing_AppAuth_Config" value="ImType=DingDing;AgentId=999;AppId=xxxxx;AppSecret=xxxx"/>
        <add key="FeiShu_AppAuth_Config" value="ImType=FeiShu;AgentId=0;AppId=xxxxx;AppSecret=xxxx"/>

        <!-- URL地址 -->
        <add key="UserService_Url" value="http://LinuxTest:8220"/>
     </appSettings>


    <!-- 数据库连接配置 -->
    <connectionStrings>
        <add name="db1" providerName="System.Data.SqlClient"
			 connectionString="server=MsSqlHost;database=MyNorthwind;uid=user1;pwd=xxxx"/>

        <add name="db2" providerName="MySql.Data.MySqlClient"
			 connectionString="server=MySqlHost;database=MyNorthwind;uid=user1;pwd=xxxx"/>

        <add name="db3" providerName="Npgsql"
            connectionString="Host=PgSqlHost;database=mynorthwind;Username=postgres;Password=xxxx"/>
    </connectionStrings>

    <!-- 或者把数据库连接配置在这里 -->
    <dbConfigs>
        <add name="s1" dbType="SQLSERVER" server="MsSqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="" />
        <add name="s2" dbType="SQLSERVER" server="MsSqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="" />
        <add name="m1" dbType="MySQL" server="MySqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="" />
        <add name="m2" dbType="MySQL" server="MySqlHost" database="MyNorthwind" uid="user1" pwd="xxxxxx" args="Allow Zero Datetime=True;Convert Zero Datetime=True;" />
        <add name="pg1" dbType="PostgreSQL" server="PgSqlHost" database="mynorthwind" uid="postgres" pwd="xxxxxx" args="" />
        <add name="dm1" dbType="DaMeng" server="DamengHost" port="5237" database="MyNorthwind" uid="SYSDBA" pwd="xxxxxx" args="" />
    </dbConfigs>
</configuration>

读取参数的示例代码:

string value1 = LocalSettings.GetSetting("key1");

using RabbitClient rabbit = new RabbitClient("my_Rabbit", "default_conn");

IDatabase db = Redis.GetDatabase(2);  // 使用的默认参数名称:Redis.Connection

static readonly NbEsClient esclient = new NbEsClient("my_Elasticsearch");

MailClient client = new MailClient("mail_config");

ImAppMsgClient client = new ImAppMsgClient("WxWork_AppAuth_Config");
ImAppMsgClient client = new ImAppMsgClient("DingDing_AppAuth_Config");
ImAppMsgClient client = new ImAppMsgClient("FeiShu_AppAuth_Config");


using DbContext db1 = DbContext.Create("db1");
using DbContext db2 = DbContext.Create("db2");
using DbContext db3 = DbContext.Create("db3");

using DbContext s1 = DbContext.Create("s1");
using DbContext m1 = DbContext.Create("m1");




不启用配置服务的影响

当设置 EnableConfigService = false 之后,会带来以下影响:

使用ClownFish开发独立应用程序

虽然可以 使用 Nebula 开发独立应用程序,但是 使用ClownFish开发独立应用程序 会有以下优点:

  • 发布目录更干净,因为ClownFish的第三方依赖更少
  • 可定制性更强

ClownFish和Nebula的设计理念不一样:

  • Nebula:All in One,大而全,开箱即用。
  • ClownFish:模块化,小积木,自行组装。

ClownFish 有以下几个 Nuget 包,可根据需求自行引用:

  • ClownFish.net:最基本单元,包含开发单个应用程序的所有最基本功能。
  • ClownFish.Web:基于ClownFish.net,支持 Asp.net Core
  • ClownFish.Redis:基于ClownFish.net的Redis工具类库
  • ClownFish.Rabbit:基于ClownFish.net的RabbitMQ工具类库
  • ClownFish.ImClients:基于ClownFish.net的IM客户端工具类库,支持:企业微信,钉钉,飞书
  • ClownFish.Email:基于ClownFish.net的Email简单封装工具类库
  • ClownFish.Office:对个别典型场景的Office文档操作做了一点简单的封装




1,创建新项目

打开 Visual Studio (最新版本) 创建一个 你需要的 应用程序…………




2,添加包引用

例如:

<ItemGroup>
    <PackageReference Include="MySqlConnector" Version="2.2.7" />
    <PackageReference Include="ClownFish.Web" Version="9.24.1121.1" />
</ItemGroup>

说明:

  • ClownFish.Web:它包含了 ClownFish.net
  • MySqlConnector:因为程序需要访问 MySQL 数据库,如果还需要访问其它类别数据库,请自行引用




3,修改 Program.cs

一个典型的WebApi项目的启动代码可参考:

public class Program
{
    public static void Main(string[] args)
    {
        AspnetCoreStarter.Run(new MyStartup());
    }
}

// 这个类用于定制启动过程
public class MyStartup : WebApplicationStartup
{
    public override bool AutoInitDAL => true;   // 初始化数据访问层

    public override bool AutoInitTracing => true;   // 开启日志与监控

    public override bool AutoInitAuth => true;  // 初始化身份认证模块

    public override void AppInit()
    {
        // 你的初始化代码写在这里…………
    }

    public override void ConfigureServices(IServiceCollection services)
    {
        // 加载所有 Controller
        services.AddControllers(this.RegisterInnerMvcFilters);

        base.ConfigureServices(services);
    }

    public override void ConfigureWeb(WebApplication app)
    {
        app.MapControllers();

        app.MapGet("/", ClownFish.Web.Utils.HttpContextUtils.ShowHomePage);
    }
}




后台任务示例

如果你要开发的是一个后台服务(不需要ASP.NET),那就更简单:

以下是包引用的示例代码:

<ItemGroup>
    <PackageReference Include="MySqlConnector" Version="2.2.7" />
    <PackageReference Include="ClownFish.net" Version="9.24.1121.1" />
</ItemGroup>

启动代码如下:

public class Program
{
    public static void Main(string[] args)
    {
        ClownFishInit.InitBase();
        ClownFishInit.InitDAL();
        ClownFishInit.InitLogAsDefault();

        // 你的初始化代码,你可以开启后台任务,订阅消息队列,等等……
        YourAppInitCode();

        Console2.WriteLine("Application started. Press Ctrl+C to shut down.");
        using( ConsoleEndWaiter waiter = new ConsoleEndWaiter() ) {
            waiter.Wait();
        }
    }
}

开发模块化的应用程序

Nebula支持多种开发模式:


本文将主要介绍第3类:模块化的-应用程序



模块化开发

对于复杂的业务范围,我们可以将其拆分为多个业务单元(模块),例如:

  • 用户模块
  • 订单模块
  • 商品模块
  • 仓库模块
  • 运营模块

我们可以:

  • 在一个源代码项目中实现所有的这些业务需求,即:单体应用程序
  • 也可以将它们拆分成多个源代码项目,每个业务模块对应一个项目,即:模块化应用程序

根据 代码执行主体 中的介绍,几乎所有业务需求最后会以3类技术方式实现:

xx

以上3类实现方式,ClownFish将它们抽象为3类代码执行主体:

  • HttpAction (由ASP.NET支持)
  • MessageHandler
  • BackgroundTask

这3类代码执行主体可以出现在任何类型的应用程序项目中,因此,我们可以按业务模块来组织源代码项目:

  • 每个业务模块对应一个源代码项目
  • 每个源代码项目中,根据业务需求来实现 Controller, MessageHandler, BackgroundTask
  • 然后在一个HOST项目中引用这些(或者全部)源代码项目(或者nuget包)



最终部署目标

一套代码,满足2种部署需求:

  • 单体应用部署
    • 只部署一个应用(包含所有业务功能)
    • 通常用于 私有化部署
  • 微服务部署
    • 一个模块对应一个微服务,所有微服务的共同支撑整个业务功能
    • 通常用于 云上部署

xx

说明:

  • 每个业务模块可采用微服务架构开发,对应一个Web项目,可独立运行
  • AllInOneWeb项目,本身不需要实现任何业务逻辑,只需要引用其它业务模块项目引可




模块化开发过程

模块化开发过程中可能会遇到的问题是:

  • 个别模块的源代码项目有特殊的初始化需求,例如用户模块需要注册 IdentityServer

这时就需要执行 ASP.NET 的 ConfigureServices 方法,解决方法可参考下面代码示例。


为了让每个模块的源代码项目能做到开箱即用:HOST项目只需要引用它们就可以了。

只需要2个步骤:

  • 在程序集中标记 [assembly: ClownFish.Base.ApplicationPartAssembly]
  • 在源代码项目中实现一个从BaseAppStarter继承的 公开 类型,例如:
public class DemoAppStarter : BaseAppStarter
{
    public override void PostConfigureServices(IServiceCollection services)
    {
        Console2.Info("DemoAppStarter.PostConfigureServices -----------");
    }

    public override void PostConfigureWeb(WebApplication app)
    {
        Console2.Info("DemoAppStarter.PostConfigureWeb -----------");
    }

    // 还有一些虚方法就不一一列举了
}

支持SaaS私有化部署

由于种种原因,SaaS应用程序就是需要在客户环境中私有化部署!!

那么如何让一套代码既支持云上的标准SaaS模式,又支持客户的私有部署模式呢?

本文将介绍如何这现这个需求。

首先请阅读以下二篇文档



准备配置参数

为了保证一套代码能同时支持SaaS和OP二种部署模式,且部署过程简单,请先设计好2个配置参数:

  • Default_Tenant_Id=xxxxxxxxxxxxxxxxxxx:默认租户ID
  • Default_Tenant_ConnName=tenant_db:默认的租户库连接名称



用户登录

租户的切换其实是根据用户登录身份来实现的,具体可参考IUserInfo的定义

public interface IUserInfo
{
    string TenantId { get; }
    string UserId { get; }

因此,可以按以下方式来实现:

  • 为这个客户分配一个明确的 【租户ID】,例如:TenantId=fc6e4bc7a3484304be1afb65b999938d
  • 私有部署环境中,给程序添加环境变量
docker run .....
  -e Default_Tenant_Id=fc6e4bc7a3484304be1afb65b999938d \
  ........
  • 定义静态变量保存这个默认租户ID
public static readonly string DefaultTenantId = LocalSetting.GetSetting("Default_Tenant_Id");
  • 应用程序的显示登录表单时,如果SaasOpTenantId有值,就不显示 "租户CODE" 这种输入控件
  • 用户登录时,设置 UserInfo.TenantId
WebUserInfo userinfo = new WebUserInfo{
    TenantId = DefaultTenantId // 从静态变量中取值
    UserId = "U0001",
    UserName = "张三",
    UserRole = "NormalUser"
}
AuthenticationManager.Login(userInfo, seconds);




Controller中获取租户Id

string tenantId = this.GetTenantId();



数据库操作

这块的代码实现与标准的SaaS没有任何区别,也是:

// Controller
using DbContext dbContext = this.CreateMasterConnection();
using DbContext dbContext = this.CreateAppDbConnection("logging");
using DbContext dbContext = this.CreateTenantConnection(tenantId);
using DbContext dbContext = this.CreateTenantConnection();

// MessageHandler, BackgroundTask
using DbContext dbContext = DbConnManager.CreateMaster();
using DbContext dbContext = DbConnManager.CreateAppDb("logging");
using DbContext dbContext = DbConnManager.CreateTenant(tenantId);

说明:

  • 如果在程序启动时设置了 EnableConfigService=false,租户库的连接名称将是 Default_Tenant_ConnName 参数指定的名称,不再按SaaS的规则去做映射查找。
  • 主库的连接名称依然是 master
  • 应用库根据使用情况来配置

数据库连接配置如下:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>        

     </appSettings>

    <connectionStrings>
        <add name="master" providerName="System.Data.SqlClient"
			 connectionString="server=MsSqlHost;database=master;uid=user1;pwd=xxxx"/>

        <add name="logging" providerName="MySql.Data.MySqlClient"
			 connectionString="server=MySqlHost;database=logging;uid=user1;pwd=xxxx"/>

        <add name="tenant_db" providerName="System.Data.SqlClient"
			 connectionString="server=MsSqlHost;database=tenant_db;uid=user1;pwd=xxxx"/>

    </connectionStrings>
</configuration>




以【单体应用】方式运行

如果私有化部署时希望采用单体应用的方式运行,可以参考下面二篇文档:

最后可以实现的效果是:

  • 一套代码支持 SaaS 和 OP 二种部署模式
  • 一套代码支持微服务开发架构 和 单体应用程序架构




部署过程

与云端的部署方式一样,部署所有服务及数据库。

唯独:租户总表只有一条记录。

使用其它的配置服务

Nebula是一个基于 .NET 技术的开发框架体系,核心特色是【支持SaaS多租户】、【支持微服务开发】和【实时可观测能力】。

这是Nebula的设计目标。

为了实现这些能力,Nebula的许多功能都依赖于 自带的 配置服务,例如下面这些API:

  • ConfigClient.Instance.GetSetting(string name)
  • Settings.GetSetting(string name)
  • ConfigFile.GetFile(string name)
  • DbConnManager.CreateMaster()
  • DbConnManager.CreateAppDb(string name)
  • DbConnManager.CreateTenant(string tenantId)
  • 各种Client:RabbitMQ/Redis/InfluxDB/Mail/IM/.....

考虑到以下二种场景:

它们可能并不会启用Nebula自带的 配置服务,甚至使用第三方的配置服务,例如:Nacos

本文将介绍如何解决这个问题:使用其它的配置服务之后,以上API仍能正常工作。



理解配置服务

配置服务只有一个功能:统一管理的各种远程配置参数:

  • 全局配置参数
    • 读取方式:Settings.GetSetting(string name)
  • 数据库连接参数
    • 读取方式:DbConnManager.CreateAppDb(string name)
  • 远程独立配置文件
    • 读取方式:ConfigFile.GetFile(string name)
  • 远程配置文件(xxx.App.config, xxx.Log.config)
    • 读取方式:Nebula内部调用

为了实现配置服务的替换,必须将以上参数转成 第三方配置服务能接受的方式:

  • 所有 全局配置参数:变成一个配置文件,即 key=value 的数组形式,再做个JSON/XML 序列化即可。
  • 所有 数据库连接参数:同上处理
  • 远程独立配置文件:保持不变
  • 远程配置文件:保持不变

然后将这些文件内容存储在 第三方配置服务。



实现思路

ConfigClient是所有访问配置服务的入口类,只要把它的行为替换就可以了。

Nebula将各配置种读取操作抽象了一个接口:IConfigClient

为了能替代Nebula自带的配置服务,我们有2种方法

  • 使用内置的工具类:MemoryConfig,因为内存中的数据总是优先查找的。
  • 实现IConfigClient接口,然后调用 ConfigClient.Instance.SetClient 来注册它

1,实现IMoonClient接口,这是一种最更灵活通用的方案。

  • 优点:允许你来决定所有的替换细节
  • 缺点:需要时间开发

2,MemoryConfig 是一种简单且内置的实现,可以理解为:

  • 将程序所需要的配置参数直接放在内存中
  • 当需要访问配置服务时,Nebula会自动切换到内存中获取它们

它提供以下方法来接收数据:

public static void AddSetting(string name, string value)
public static void AddDbConfig(string name, DbConfig config)
public static void AddFile(string name, string fileText)
public static void SetAppConfig(string xml)
public static void SetLogConfig(string xml)

本文将介绍MemoryConfig这种方式来实现。



加载配置参数

有了前面的步骤,所有配置参数都存储在第三方配置服务中,程序就可以读取它们。

如何从第三方配置服务中读取参数,请参考相关文档,此处忽略!


在程序启动时,先将本程序所需的各种参数读取到,然后调用MemoryConfig相关方法即可,例如:

public override void PreNebulaInit()
{
    NacosConfig config = LocalSetting.GetSetting<NacosConfig>("NacosConfig");
    NacosClient client = new NacosClient(config);

    // 假设用应用程序的名称做命名空间
    string @namespace = AppStartup.RuntimeStatus.ApplicationName;


    // 加载 全局配置参数
    string settings = client.GetValue(@namespace, "settings.json");
    settings.FromJson<List<NameValue>>().ForEach(x => MemoryConfig.AddSetting(x.Name, x.Value));

    // 加载 数据库连接参数
    string conns = client.GetValue(@namespace, "db_conns.json");
    conns.FromJson<List<DbConfig>>().ForEach(x => MemoryConfig.AddDbConfig(x.Name, x));

    // 加载 远程独立配置文件
    string file1 = client.GetValue(@namespace, "file1.xml");
    MemoryConfig.AddFile("file1", file1);

    // 加载 远程配置文件
    string appConfig = client.GetValue(@namespace, "App.Config");
    MemoryConfig.SetAppConfig(appConfig);
}

包装Action返回结果

许多开发人员喜欢给Action的返回结果再做一层包装,例如:

{
  "code": 1,
  "message": "xxxxxxxx",
  "data": {这里是原本Action的返回结果}
}




效果演示

Nebula对这种需求提供了内置支持,不用考虑这个包装对象的存在,Action只返回该返回的数据就可以了,例如:

[Route("return/namevalue")]
public NameValue ReturnObject2()
{
    return new NameValue("abc", "12345");
}

服务端的返回结果:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
    "code": 1,
    "message": "",
    "detailMessage": "",
    "data": {
        "Name": "abc",
        "Value": "12345"
    }
}

如果遇到异常

[Route("return/error")]
public int ReturnError()
{
    if( DateTime.Now.Year > 2000 )
        throw new MessageException("一个测试消息异常") { StatusCode = 555 };
    else
        return 111;
}

服务端的返回结果:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
    "code": 555,
    "message": "一个测试消息异常",
    "detailMessage": "ex.ToString()",
    "data": ""
}




如何实现?

1,开启一个开头(默认是关闭状态)

在ClownFish.App.config中添加:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="Nebula_WrapDataResultFilter_Enable" value="1" />
    </appSettings>    
</configuration>

或者用环境变量也可以,例如:

docker run -d --name xxxxx  \
  -e Nebula_WrapDataResultFilter_Enable=1 \



2,对返回结果做打包:

先定义包装类型:

public record WrapData(int code, string message, string detailMessage, object data);

再重写 NebulaBehavior.WrapActionResult 方法。

public override object WrapActionResult(object resultOrException)
{
    // Action可能会返回NULL
    if( resultOrException == null) {
        return new WrapData(1, "", "", "");
    }

    // Action可能返回了一个简单的:数字/字符串
    if( resultOrException is string || resultOrException.GetType().IsPrimitive ) {
        return new WrapData(1, "", "", resultOrException.ToString());
    }

    // Action中出现了异常
    if(  resultOrException is Exception ex ) {
        return new WrapData(ex.GetErrorCode(), 
                    ex.Message,
                    "ex.ToString()",   // 实际使用时,不需要用双引号
                    "");
    }

    // Action的“正常”返回数据
    return new WrapData(1, "", "", resultOrException);
}



3,调用API

GET http://localhost:8206/v20/api/WebSiteApp/wrapdata/return/namevalue HTTP/1.1
x-wrap-result: 1

注意上面的调用中,有一个 x-wrap-result 的请求头。

如果没有这个请求头,结果将不会做包装!




谈谈这种API的设计方式

"对Action返回结果再做包装",最初的设计者可能认为(后来人应该是无脑抄):

这种API对调用者会比较友好,因为可以不用考虑异常情况,调用代码会比较简单。

他想像中的调用代码:

var result = call_httpapi(...);
if( errorcode == 1){
   // 调用成功
   // 继续执行其它业务操作
}
else{
   // 调用失败
   // ################ 在这里处理异常 111111111111111
}

国内大厂的许多外部API也确实都采用了这种设计方式,但这种做法其实是 非常差劲的!

因为有些异常情况是不受业务代码控制的,例如:

  • 网络不通
  • 服务不可用
  • 服务端出现不可控异常,例如:OOM

在这些场景下,前面所说的包装方式就无效了,最后调用代码还是必须要增加 try...catch 来捕获异常,例如:

try{
  var result = call_httpapi(...);
  if( errorcode == 1){
    // 调用成功
    // 继续执行其它业务操作
  }
  else{
    // 调用失败
    // ################ 在这里处理异常 111111111111111
  }
}
catch(Exception ex){
    // ################ 这里处理其它类型的异常 222222222222222
}

如果业务项目有全局异常处理机制,errorcode != 1 的场景反而会更麻烦,它需要主动抛出一个异常,来结束当前代码块!

综上所述,这种做法虽然在国内很流行,但是设计想法即是非常愚蠢的!
调用代码不仅得不到简化,反而会更复杂,除非调用方意识不到要做异常处理!

响应退出事件/应用程序优雅退出

基于 ClownFish/Nebula 的应用程序,都会响应 Ctrl+C 事件来退出应用程序。

响应 Ctrl+C 事件需要2方面来共同完成:

  • 框架层面,包含基础组件,例如:HttpClient, MessageSubscriber, BackgroundTask
  • 业务代码

目前框架层面已全面支持,本文主要介绍在业务代码中如何 响应 Ctrl+C 事件



调用阻塞等待API

在.NET的框架设计中,绝大多数的阻塞等待API都可以授受一个CancellationToken参数, 它可以 (在程序退出时)提前结束等待。

因此我们可以在调用阻塞方法时,使用这个参数,可供传递的对象有2个(选择其一):

  • ClownFishInit.AppExitToken ,由ClownFish提供
  • AppStartup.AppExitToken ,由Nebula提供

使用示例:

await Task.Delay(waitSeconds * 1000, AppStartup.AppExitToken);
// or
await _channel.Reader.ReadAsync(AppStartup.AppExitToken);

超时时间一起使用:

using CancellationTokenSource timeoutCancelSource = new CancellationTokenSource(timeout);
using CancellationTokenSource tokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancelSource.Token, AppStartup.AppExitToken);

var data = await item.Channel.Reader.ReadAsync(tokenSource.Token);



主动判断退出事件

在一个 大循环 中,我们也可以主动去判断有没有触发退出事件,进而提前结束。

示例代码:

if( AppStartup.AppExitToken.IsCancellationRequested ) {
    Console2.WriteLine("Application exit, VenusCommandClient exit.");
    break;
}



注意事项

以下这些设计不会及时响应退出事件

  • Thread.Sleep( 一个长时间 )
  • for/while 中长时间运行,且不检查是否已发生退出事件

如果程序不能及时响应退出事件,那么在超过一段时间后,会被强制中止线程,最后有可能会出现数据损坏。

2个默认的超时时间:

  • asp.net: 30s
  • docker: 10s



检验是否正确响应退出事件

  • 启动程序
  • 稍许等待,让程序所有功能都正常运行 ......
  • 按下 Ctrl+C

观察程序是否立即结束,如果不能,则需要结合具体代码添加埋点处理。









请点击目录中的节点查看具体文档

日志组件

本文主要介绍ClownFish内置日志,日志组件,及日志配置相关内容。



执行主体

以下3类场景被ClownFish视做执行主体,为自动为它们产生OprLog日志

  • HTTP响应 - HttpAction (Controller)
  • 消息处理 - MessageHandler
  • 后台任务 - BackgroundTask



内置日志

ClownFish/Nebula内置5类日志

  1. 操作日志:每个【执行主体】每次执行都会产生一条操作日志OprLog
  2. 异常日志:【执行主体】在发生异常时出现,异常信息会合并到当次OprLog中
  3. 性能日志:【执行主体】都会有性能监控,如果超过性能阀值,将产生性能日志,合并到OprLog中
  4. 框架性能日志:记录ASP.NET执行过程中耗费的性能,如果超过性能阀值,将产生性能日志 NebulaLog
  5. 致命异常日志:会记录导致程序异常退出的异常,错误消息固定写入到系统临时目录下,文件名: {sys-temp-directory}_ClownFish_net_error_{date}.txt



日志组件

日志组件由ClownFish提供,主要特性有:

  • 支持结构化日志(非随意性文本)
  • 区分日志数据类型和写入器概念
    • 日志数据类型就是一个.NET类型,包含日志的数据成员定义
    • 写入器是指将日志数据对象写入到哪里(实现持久化)
  • 一个日志类型可以对应多个写入器(同时写到多个地方)
  • 日志采用异步设计,不阻塞调用线程



写日志

可以在代码中调用以下API

/// <summary>
/// 日志记录的工具类
/// </summary>
public static class LogHelper
{
    /// <summary>
    /// 记录指定的日志信息
    /// 说明:此方法是一个异步版本,内部维护一个缓冲队列,每 XXms 执行一次写入动作
    /// </summary>
    /// <typeparam name="T">日志信息的类型参数</typeparam>
    /// <param name="info">要写入的日志信息</param>
    public static void Write<T>(T info) where T: class, ITime

    /// <summary>
    /// 直接将一个异常对象写入到异常日志
    /// </summary>
    /// <param name="ex"></param>
    /// <param name="addition"></param>
    public static void Write(Exception ex, string addition = null)
}



注意事项

虽然LogHelper提供了一个记录异常对象的方法,但是不建议在以下场景中调用

  • HTTP请求过程中(Controller,BLL)
  • 消息处理过程中(MessageHandler)
  • 后台任务中(BackgroundTask)

因为这些执行环境中ClownFish有异常处理机制,会统一记录异常,而且异常日志会有上下文关联信息。



日志配置

在每个项目中,日志的配置文件是ClownFish.Log.config,以下是Nebula类库中的默认配置

<?xml version="1.0" encoding="utf-8"?>
<LogConfig>
    <!--是否启用日志写入功能,总开关-->
    <Enable>true</Enable>
    <!--日志从缓存区写入持久化存储的间隔时间,单位:毫秒-->
    <TimerPeriod>500</TimerPeriod>
    <!--性能日志的阀值参数,超过这个阀值将会认为是“慢”操作,此时会标记 IsSlow=1,并记录请求,单位:毫秒-->
    <Performance HttpExecute="100" HandleMessage="200"  />
    <!-- 【本地】日志文件相关配置 -->
    <File RootPath="Logs" MaxLength="500M" MaxCount="100" />

    <Writers>
        <!--定义几个内置的写入器 -->
        <Writer Name="Xml" Type="ClownFish.Log.Writers.XmlWriter, ClownFish.net" />
        <Writer Name="Json" Type="ClownFish.Log.Writers.JsonWriter, ClownFish.net" />
        <Writer Name="Json2" Type="ClownFish.Log.Writers.Json2Writer, ClownFish.net" />
        <Writer Name="Http"  Type="ClownFish.Log.Writers.HttpJsonWriter, ClownFish.net" />
        <Writer Name="ES"    Type="ClownFish.Log.Writers.ElasticsearchWriter, ClownFish.net" />
        <Writer Name="Rabbit2" Type="ClownFish.Log.Writers.RabbitHttpWriter, ClownFish.net" />
        <Writer Name="Rabbit" Type="ClownFish.Rabbit.Log.RabbitWriter, ClownFish.Rabbit" />
        
        <!--如果项目中有自定义的写入器,请自行在项目中创建 ClownFish.Log.config 并添加 【你新增的】写入器-->
    </Writers>
    
    <Types>
        <!--定义支持的数据类型,并为每种数据类型配置使用哪种写入器,
            如果某个数据类型同时指定了多个写入器,将会以不同的持久化方式同时记录多次。-->
        <Type DataType="ClownFish.Log.Logging.OprLog, ClownFish.net" Writers="NULL" />
        <Type DataType="ClownFish.Log.Logging.InvokeLog, ClownFish.net" Writers="NULL" />
        <Type DataType="Nebula.Log.NebulaLog, Nebula.net" Writers="NULL" />

        <!--### 注意:1,为能让配置文件通用,这里并没有为每个日志数据类型指定关联的写入器,
                      2,写入器的关联设置在配置参数 ClownFish_Log_WritersMap 中一次性指定  -->
    </Types>
</LogConfig>

以上默认配置中,所有日志数据类型对应的写入器是NULL,即不执行任何写入。

实际使用时,可以在配置服务中增加一个参数项来全局指定:
xx

name: ClownFish_Log_WritersMap
value: InvokeLog=Rabbit;OprLog=es;NebulaLog=es;*=NULL

value的含意是:

  • InvokeLog=Rabbit    表示 ClownFish.Log.Logging.InvokeLog 类型将使用 Rabbit 写入器
  • OprLog=es;NebulaLog=es    表示 OprLog和NebulaLog,这2类日志使用 ES写入器
  • *=NULL     *星号表示其它(未列出的)日志类型,它们将使用 NULL写入器(即丢弃)

举例:如果需要将OprLog日志同时写入ES和本地JSON文件,可以这样设置:
InvokeLog=Rabbit;OprLog=es,Json;*=NULL

日志存储与 Elasticsearch 配置

配置日志组件


可以通过配置的方式将所有日志写入ES,这部分请参考:日志组件

最简单的设置就是:

name: ClownFish_Log_WritersMap
value: InvokeLog=Rabbit;*=es



日志存储策略及索引模板

请确保 Elasticsearch 和 Kibana 已正确部署,处于运行状态,
此时可启动 Venus,它会在自动创建所需的 日志存储策略,索引模板,和索引模式。

在Kibana中可以看到

xx

如果这时运行程序,就能产生日志了,最后可以看到这个样子:

xx




Kibana索引模式

为了能在Kibana中查看日志,需要新增索引模式,例如:

xx



查看日志数据

现在可以打开Discover查看日志数据了:

xx

这是比较原始的方式,我们可按下图来定制显示方式

xx

下图是定制后的显示方式:

xx

异常日志

ClownFish会在以下场景中自动记录异常日志

  • 处理HTTP请求过程中(HttpAction)
  • 处理消息过程中(MessageHandler)
  • 后台任务中(BackgroundTask)

最后可以在日志中找到它们,假设我们将日志已写入Elasticsearch



从Venus跳转到日志列表

查看当天的异常日志,可以直接从Venus中跳转,例如:

xx


点击每个服务卡片中的“异常数”(下面的数字)即可跳转到Kibana的具体页面中。

xx


展开后可查看日志详情,其中就包含了详细的异常描述以及完整的请求记录。

xx





致命异常日志

如果程序在启动时出现异常,那么这类异常会导致程序退出,此时就会记录一条【致命异常日志】

这类日志固定写入到系统临时目录下,文件名: {sys-temp-directory}_ClownFish_net_error_{date}.txt

如下图:
xx



而且还会有企业微信通知:
xx

异常处理

ClownFish会在以下场景中自动记录异常日志

  • 处理HTTP请求过程中(HttpAction)
  • 处理消息过程中(MessageHandler)
  • 后台任务中(BackgroundTask)

所以,绝大多数情况下是不需要捕获异常的。





将异常输出到控制台

在某些场景下如果需要小范围的进行异常处理,尤其是涉及 将异常输出到控制台时
那么建议使用下面的做法:

try{
    //.....
}
catch(Exception ex){
    Console2.Error(ex);
}

这样做的好处是可以在Venus中看到异常的累计次数,也可以增加告警规则,例如:

xx

当次数超过一个阀值时,将会以红底色显示。



我们还可以定义一个告警规则,例如:

<Monitor AppName="XDemo.TestApp" MetricName="ERR" 
        Statistic="Incremental" DueTime="40" 
        Period="60" Threshold="100" Compare="MoreThan"/>

规则的含意是:

  • 针对 "XDemo.TestApp" 这个应用程序,
  • 在程序启动40秒后可始监控,
  • 每60秒获取 ERR 这个指标值(异常次数)
  • 如果增量超过 100,即产生一条告警通知。





不推荐的做法

try {
    //.............
} catch ( Exception e ) {
    Console2.Info($"刷新告警规则缓存失败 3分钟后重试 失败原因{e.Message}");
}
catch ( Exception ex ) {
    Console2.Info($"队列异常:{ex.Message}-{ex.StackTrace}");
}
catch ( Exception ex ) {
    Console2.Info($"Alert:{ex.Message}-{ex.StackTrace}");
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message + ex.StackTrace);
}
catch (Exception iex)
{
    Console2.Warnning(iex.ToString());
}
catch (Exception ex)
{
    Console.WriteLine(ex);
}
catch (Exception e)
{
    Console.WriteLine($"发送打开消息到rabbitmq异常{e.Message}");
}
catch (Exception e)
{
    Console.WriteLine("快照获取失败" + e.ToJson());
    return null;
}

性能日志

ClownFish会在以下场景中自动监控调用性能并记录日志

  • 处理HTTP请求过程中(HttpAction)
  • 处理消息过程中(MessageHandler)
  • 后台任务中(BackgroundTask)

性能监控范围主要涵盖以下范围:

  • 基于HTTP协议的远程调用
    • 微服务之间调用
    • ElasticSearch调用
    • InfluxDB调用
    • VictoriaMetrics调用
    • 3种IM调用(企业微信,钉钉,飞书)
    • 阿里云日志服务调用
    • 阿里云OSS调用
    • 阿里云短信
  • 基于TCP协议的远程调用
    • SQL数据库调用(各种RMDBS)
    • Redis调用
    • 发送RabbitMQ消息
    • 发送Plusar消息
    • 发送邮件
  • 非 远程调用
    • 读请求流(Request Body)
    • 用户代码(HttpAction)执行前的框架耗时





与APM的比较

ClownFish/Nebula的性能监控日志与其它APM性能监控日志对比,主要有以下优势:

  • 覆盖面更完整(3种开发模型)
  • 日志量精准:仅当任务的执行时间超过性能阀值时才会记录(不是全量,也不是采样)
    • 日志量较小,不多不少,刚刚好(只要发生一次慢任务,一定会记录)
    • 性能损耗较小:充分利于框架特性 + 延迟技术 + 日志量小
  • 可监控普通方法(非远程调用)
  • 内容更全,例如:可监控【读请求流】,可包含租户用户等等内部数据
  • 整合了最优秀的日志查看工具:Kibana, JeagerUI





利用性能日志排查问题


可参考以下链接:





查看调用链路

当我们从日志列表界面Kibana进入日志详情页面JeagerUI时,

xx

xx

此时是按【当前日志记录】为 顶层节点 来展示的。

在微服务架构中,服务之间相互调用可能很常见。很有可能这个【当前日志记录】只是 整个调用链路 中的一个环节而已, 如果此时需要查看 完整的调用链路,可以在 URL 后面添加 ?tree 来实现,例如:

xx





监控普通方法调用

这里所说的【普通方法】是指没有远程调用的方法,全是一些内存操作,也可以称为【CPU密集型操作】。

要监控这些方法的执行性能就是给这些方法增加 [MonitorPerformance] 标记,例如:

xx

这个HttpAction调用了5个 “普通方法”,它们的定义如下:

xx

最后可以在 JeagerUI 看到如下效果:

xx

框架性能日志

Nebula会针对HTTP请求处理生成另外一份日志:框架性能日志

这里所说的框架包含3部分:

  • ASP.NET Core
  • Nebula
  • ClownFish

日志产生条件:框架代码执行时间大于 性能阀值

性能阀值:

  • 参数名:Nebula_Performance_ThresholdMs
  • 默认值:0
  • 数量单位:毫秒
  • 配置方式:环境变量,配置服务,本地配置文件
  • ES中的索引名称:nebulalog-yyyyMMdd
  • 说明:如果参数值小于等于零,则表示不启用。



日志样例:

xx

xx

说明:

  • 此处的 OprId 和 OprLog中的OprId相同,目的就是为了方便相互查找。
    比如:在浏览 NebulaLog时,可以根据某个OprId找到对应的HTTP操作日志OprLog,反之也成立。
  • UserCode是相对框架代码来说的,一般是指Action的代码
  • PreFindAction和PostFindAction之间包含了所有Middleware和Filter在Action【之前】的执行时间
  • PostRequestExecute和UpdateRequestCache之间包含了所有Middleware和Filter在Action【之后】的执行时间
  • 默认情况下Nebula并没有引入其它Middleware和Filter,如果上面2个阶段产生了耗时,那么都是ASP.NET本身的消耗




ES配置说明

为了能看到上面的截图,需要在ES中配置以下地方:

  • 创建一个名为 nebulalog 的 index template,并引入合适的生命周期策略。
  • 创建一个名为 nebulalog-* 的 Index pattern

HTTP日志

HTTP日志分为2大块:

  • 当次HTTP请求的 Request/Response 日志
  • 当次HTTP请求执行过程中,由 HttpClient 发起的HTTP请求及响应的日志

注意:记录日志都是需要耗费性能以及增加内存占用的
所以 ClownFish 设计了8个参数来控制日志记录哪些内容

参数名默认值参数含意
ClownFish_Log_Http_LogRequest0是否必须记录 Request 到日志中
ClownFish_Log_Http_LogRequestBody1是否必须记录 Request-Body 到日志中
ClownFish_Log_Http_LogResponse0是否必须记录 Response 到日志中
ClownFish_Log_Http_LogResponseBody1是否必须记录 Response-Body 到日志中
ClownFish_Log_HttpClient_LogRequest1是否必须记录 HttpClient-HttpRequestMessage 到日志中
ClownFish_Log_HttpClient_LogRequestBody0是否必须记录 HttpClient-HttpRequestMessage-Body 到日志中
ClownFish_Log_HttpClient_LogResponse1是否必须记录 HttpClient-HttpResponseMessage 到日志中
ClownFish_Log_HttpClient_LogResponseBody0是否必须记录 HttpClient-HttpResponseMessage-Body 到日志中

重要说明:

  • 以上8个参数,可分为4组
    • LogXxx: 是否记录 Request 或者 Request 的 “开始行”和“请求头/响应头”
    • LogXxxBody: 是否记录 请求体 或者 响应体
    • 当 LogXxx = 0 时,会忽略 LogXxxBody 的设置。
  • 如果当前HTTP请求执行时间超出性能阀值,则 强制记录请求,
    • 可认为此时(当前请求) ClownFish_Log_Http_LogRequest=1
  • HttpClient相关日志仅在 当前HTTP请求执行时间超出性能阀值时有效
    • 此处没有参数可控制,因为没有性能问题的执行过程是不记录性能日志的
    • 如果确实需要记录执行过程,可修改 性能阀值
  • 记录 “请求体或者响应体” 有许多限制,例如:
    • 必须有 Body
    • Content-Type 必须为常见的文本类型
    • Body 不能过大,必须小于 RequestBodyBufferSize 的设置
    • Body stream 可重复读取



Body stream 可重复读取

.NET 出于性能以及内存占用的原因,默认的行为是不允许 多次 读取 Request.Body
请求体的第一次读取通常发生在Action方法的参数赋值的时候,
请求执行过程中,就不能再次读取了,因此无法在日志中记录请求体内容,

如果需要让ClownFish在日志中记录请求体内容,则需要按以下步骤来操作。



开启请求缓冲区

可以在 ClownFish.App.config 或者环境变量在设置参数 ClownFish_Aspnet_RequestBufferSize

例如:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="ClownFish_Aspnet_RequestBufferSize" value="2048"/>
    </appSettings>    
</configuration>

2048 这个数字就是缓冲区的大小,单位: byte

注意: 这个数字不能过大,否则会占用大量内存,甚至影响程序稳定!

建议值: 2048



请求缓冲区启用条件

请求缓冲区的开启需要几个条件:

  • ClownFish_Aspnet_RequestBufferSize 的设置值 大于 0
  • 存在请求体,例如: POST 是支持的,GET请求就不支持
  • 请求体内容是文本格式,这里主要分析 Content-Type
  • 请求体长度小于 ClownFish_Aspnet_RequestBufferSize 的设置值

以上4个条件必须全部满足才会启用。



确认请求缓冲区已开启

可以查看响应头,例如:

HTTP/1.1 200 OK
x-EnableBuffering: 2048

当存在 x-EnableBuffering 这个响应头时,就表示请求缓冲区已开启。



查看日志

例如:

xx


xx

OprLog日志中添加自定义内容

OprLog中的绝大部分数据成员都由框架负责填充,不需要应用程序自己来做日志埋点。

但是也允许应用添加一些自定义的内容:

  • 性能日志中的执行过程(可在JaegerUI中展示)
  • 普通的 time:message 文本日志消息
  • 预留字段(BizId,BizName,Text1-5)



添加自定义的执行过程

如果你需要记录某一段代码的执行时间,可参考下面的代码:

[Route("demo1.aspx")]
public string DemoCode1()
{
    DateTime startTime = DateTime.Now;   // 记录开始时间
    lock( s_lockObject1 ){

        // 记录上面 lock 的等待时间
        this.NHttpContext.PipelineContext.OprLogScope.AddStep(startTime, "wait_lock_1", "其它描述信息");

         Method1();
    }
    

    // 省略不相关代码。。。。。
}

要点提示:

  • 在执行前记录一个时间点, startTime
  • 调用 OprLogScope.AddStep(...) 方法

日志中的展示效果:

xx


xx




添加自定义的执行过程日志

有些时候,我们会直接使用 Console.WriteLine(..)输出一些日志,例如:

xx

这样做虽然不影响业务结果的正确性,但是极不建议,
它有一个很大的缺点:它们会淹没一些警告和异常消息!!



另一方面,这些消息输出后,再查看时,并不知道当时那个业务流程的其它关系信息,所以这些消息的价值非常有限!
所以,如果确实需要以后查看这些消息,可以将它们与OprLog保存在一起。

实现方法可参考代码:

[Route("demo2.aspx")]
public async Task<int> DemoCode2()
{
    OprLogScope logScope = this.NHttpContext.PipelineContext.OprLogScope;
 
    logScope.Log("正在生成报告第 1 页");
    // 省略一些业务代码块,,,,
    
    logScope.Log("正在生成报告第 2 页");
    // 省略一些业务代码块,,,,

    logScope.Log("正在生成报告第 3 页");
    // 省略一些业务代码块,,,,

    logScope.Log("正在生成报告第 4 页");
    // 省略一些业务代码块,,,,

    logScope.Log("正在生成报告第 5 页");
    // 省略一些业务代码块,,,,

    logScope.Log("正在发送报告");
    // 省略一些代码,,,,,,

    return 200;
}


要点提示:

  • 调用 OprLogScope.Log(...) 方法

日志中的展示效果:

xx




修改默认的日志字段

默认情况下,ClownFish会自动产生日志内容。如果觉得默认的日志内容不利于分析问题,应用程序可以修改它。

例如:默认情况下,消息处理的日志一般是这种形式:

xx

OprName, Url 基本上都是这样格式的。对于某些特定场景,这样的数据并不友好。

下面的代码将会对日志字段做出修改:

OprLogScope scope = context.OprLogScope;

// 修改日志中的2个成员,因为默认的取值不方便查看和统计:
// oprName=HttpRequestMessageHandler/Execute
// url=msg://RabbitMQ/async/HttpRequestMessageHandler/61dafeb52f06453c978bcf0cf879ede7/0

scope.OprLog.Url = httpOption.Url;
scope.OprLog.OprName = GetUrlActionName(httpOption.Url) ?? scope.OprLog.OprName;

// 额外增加2个字段
scope.OprLog.TenantId = tenantId;
scope.OprLog.Text1 = "serverId=" + serverId;




private static string GetUrlActionName(string url)
{
    if( url.IsNullOrEmpty() == false ) {

        // 取URL的最后一段,
        // 例如:"http://databus.com/v20/api/datacenter/agent/win/server/save-process-sample.svc"   得到  "save-process-sample.svc"
        int p = url.LastIndexOf('/');
        if( p > 0 && p != url.Length - 1 ) {
            return url.Substring(p + 1, url.Length - p - 1);
        }
    }

    return null;
}

最后的日志内容如下图:

xx

此时的日志内容更能反映业务处理场景。




日志中的预留字段

OprLog日志结构预留了7个字段可由应用程序来填充,方便记录一些与业务相关的数据到日志中。

/// <summary>
/// 业务ID,例如:工作流的流程ID
/// </summary>
public string BizId { get; set; }
/// <summary>
/// 业务操作名称,例如:某个工作流的节点名称
/// </summary>
public string BizName { get; set; }

/// <summary>
/// Text1,预留字段,具体含意由应用程序决定
/// </summary>
public string Text1 { get; set; }
public string Text2 { get; set; }
public string Text3 { get; set; }
public string Text4 { get; set; }
public string Text5 { get; set; }

其它未列出的字段,可根据观察实际运行后产生的日志,
如果没有赋值或者字段内容没有价值,也可以由应用程序来指定。

Console使用建议

不要调用 Console.Write(...) Console.WriteLine(...)

有些时候,我们会直接使用 Console.WriteLine(..)输出一些日志,例如:

xx

这样做虽然不影响业务结果的正确性,但是极不建议,
它有一个很大的缺点:它们会淹没一些警告和异常消息!!



替代方式是写入 OprLog 中,例如:

xx

或者
xx





如果仅需要输出调试消息,可以调用 Console2.Debug(...)

Console2.Debug()会识别部署环境,它仅对【开发环境】有效。

但是,请注意:

Console2.Debug(xx.ToJson());

仍然会在生产环境带来一些性能和内存的消耗!
因为 xx.ToJson() 一定会执行!!





appsettings.json

确保项目中只有一个 appsettings.json 文件。
可删除多余的 appsettings.Development.json



并确保 appsettings.json 的文件内容是:

{
    "Logging": {
        "LogLevel": {
            "Default": "Warning",
            "Microsoft.AspNetCore.DataProtection": "Error"
        }
    },
    "AllowedHosts": "*"
}



一定要避免在生产环境出现这样问题:

xx

它们会淹没一些警告和异常消息!!

一些重要的日志开关

ClownFish_ShowAuthFailedMsg=1

用途:可在日志中输出【身份验证】失败的原因。

例如:下面这个请求:

GET http://localhost:51900/v20/api/server/heart-beat HTTP/1.1
x-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VyIjp7...token内容较长,示例中故意截断

看起来在请求头中存在一个用户身份token,然而服务端就是不识别,。

那么可以添加一个开关:ClownFish_ShowAuthFailedMsg=1

然后在 oprlog 中就可以看到失败的原因了:

xx









请点击目录中的节点查看具体文档

服务健康状况识别(运行状态)

在服务治理方面,Nebula.Venus 给开发人员提供了一个简单又强大的大屏界面,可辅助他们快速治理服务的稳定性问题。

在这个界面中(下图),

  • 每个服务以一个卡片的形式显示它的运行状态,
  • 并根据一定的健康评价规则给每个服务打分,
  • 然后使用直观的颜色来区分它们的健康状况

例如:

xx
在这个页面中,5种底色(0-4)分别代表不同的含意:

  • 0-白底色:表示程序运行状态【健康】
  • 1-4 颜色:颜色越深,表示越【不健康】

健康度打分参考了2个指标:

  • 异常数
  • 延迟数



你是不是可以很容易地发现哪些服务不健康?

治理服务稳定问题3步骤:





进程健康状况识别

xx
这个页面会针对2个指标做出颜色高亮提醒:

  • 错误数(ERR)
  • 内存占用(MEM)

内存占用,以MB为单位=x,

  • 白底色: x <= 200
  • 淡红底色: 200 < x <= 500
  • 亮红底色:x > 500

业务指标实时汇总展示

xx

这个页面的数据有以下特点:

  • 所有指标由应用程序【自行定义】
  • Nebula框架会实时采集每个进程的实时业务指标数据,
  • Venus会实时汇总每个服务所有节点的数据,最终展示在这个页面上

业务指标的自定义方法 可参考

技术指标实时展示

xx

这个页面的数据有以下特点:

  • 所有指标由Nebula框架定义,应用程序 无需任何开发步骤
  • Nebula框架会实时采集每个进程的实时指标数据,
  • Venus会以卡片形式实时展示每个进程的指标数据

可用性监控

xx

这个页面会展示所有已识别出的可监控目标,有以下特点:

  • 所有监控目标由Venus自行识别, 无需任何开发步骤
  • Venus会以一定频率探测它们来检查是否可用
  • 探测过程中会尽量获取一些重要技术数据以供人工识别




自动发现监控对象

Venus会根据配置服务中注册的各类配置,自动识别出可监控的对象,实施可用性探测。

监控对象的范围:

  • 各类中间件服务,例如:RabbitMQ, Redis
  • 集群中的各个服务
  • 所有已注册的数据库连接

具体内容可参考:监控相关枚举值说明

内部诊断URL

Nebula提供以下URL用于线上运行状态的诊断

  • /nebula/app/now
    显示服务端当前时间

  • /nebula/app/whoami
    已登录:HTTP200/显示当前用户信息
    未登录:HTTP204

  • /nebula/app/heartbeat
    显示心跳数据包

  • /nebula/test/error
    故意引发一个异常,用于检验异常日志是否能正常写入
    说明:每次程序启动后,只有前5次调用有效(能抛出异常)

  • /nebula/test/slow
    故意制造一个慢请求,用于检验性能日志是否能正常写入
    说明:每次程序启动后,只有前5次调用有效(慢请求)

  • /swagger/index.html
    显示 Swagger UI
    如果界面不能显示,可访问 /swagger/v1/swagger.json

查看内部状态

Nebula提供了一些 内部诊断URL 可以查看服务的运行状态, 但有2个限制:

  • 多节点部署时,不能指定查看具体的节点
  • 非Web服务项目由于没有HTTP监听功能,就无法支持。

解决方法:在Venus中查看!



在Venus中查看应用程序内部状态

先打开Venus界面,然后登录,如下图:
xx

选择你要查看的那个 应用程序进程 ,点击 内部状态,此时进入以下界面:

xx

在此页面中,点击你需要查看的方块即可!




心跳数据

xx




状态数据

xx




系统信息

xx




程序集信息

xx




线程列表

xx




后台作业

xx




Venus 指标监控

Venus可针对3大类指标进行监控

  • 运行状态
  • 业务指标
  • 技术指标

只需要在启动目录中添加一个名为 Nebula.Venus.MonitorConfig.xml 的配置文件即可。
或者在配置服务中添加这个文件。



运行状态-监控

配置示例:

<!--针对【运行状态】汇总数据的监控配置-->
<Summaries>
    <!--【DataBusService】启动后【60】秒开始监控,每【60】秒采集一次【延迟数】指标,如果【增量】【大于等于】【300】,就发送告警-->
    
    <Monitor AppName="Uranus.DataBusService" MetricName="延迟数" Statistic="Incremental" 
             DueTime="60" Period="60" Threshold="300" Compare="MoreThan"/>
</Summaries>

指标在界面中的位置:
xx



业务指标-监控

配置示例:

<!--针对【业务指标】的监控配置-->
<Metrics>
    <!--【Mercury】启动后【60】秒开始监控,每【20】秒采集一次【消息积压数量】指标,如果【增量】【大于等于】【1000】,就发送告警-->
    <Monitor AppName="Nebula.Mercury" MetricName="消息积压数量" Statistic="Incremental" 
                DueTime="60" Period="20" Threshold="1000" Compare="MoreThan" />
</Metrics>

指标在界面中的位置:
xx



技术指标-监控

配置示例:

<!-- 针对【技术指标】的监控配置 -->
<Heartbeats>
<!-- 【Juno】启动后【60】秒开始监控,每【60】秒采集一次【Error】指标,如果【增量】【大于等于】【10】,就发送告警 -->
<Monitor AppName="Nebula.Juno" MetricName="Error" Statistic="Incremental" 
         DueTime="60" Period="60" Threshold="10" Compare="MoreThan"/>
</Heartbeats>

指标在界面中的位置:
xx

配置文件

完整配置文件的结构如下

<?xml version="1.0" encoding="utf-8"?>
<MonitorConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    
    <Summaries>
        <Monitor AppName="XDemo.WebSiteApp" MetricName="延迟数" Statistic="Incremental" 
                 DueTime="60" Period="20" Threshold="100" Compare="MoreThan" />
    </Summaries>

    <Metrics>
        <Monitor AppName="Nebula.Mercury" MetricName="消息积压数量" Statistic="Incremental" 
                 DueTime="60" Period="20" Threshold="10000" Compare="MoreThan" />
    </Metrics>

    <Heartbeats>
        <Monitor AppName="XDemo.WebSiteApp" MetricName="Error" Statistic="Incremental" 
                 DueTime="30" Period="20" Threshold="10" Compare="MoreThan" />

    </Heartbeats>    
</MonitorConfig>

说明:

  • AppName="*" 表示规则用于所有应用程序
  • 当 AppName="*" 存在时,再指定 AppName="xxx" 时,后者优先

对应的类型定义:

public sealed class MonitorConfig
{
    /// <summary>
    /// 针对【运行状态】汇总数据的监控配置
    /// </summary>
    [XmlArrayItem("Monitor")]
    public List<MonitorItem> Summaries { get; set; }

    /// <summary>
    /// 针对【业务指标】的监控配置
    /// </summary>
    [XmlArrayItem("Monitor")]
    public List<MonitorItem> Metrics { get; set; }

    /// <summary>
    /// 针对【技术指标】的监控配置
    /// </summary>
    [XmlArrayItem("Monitor")]
    public List<MonitorItem> Heartbeats { get; set; }

    
}


public sealed class MonitorItem
{
    /// <summary>
    /// 应用程序的名称,在Venus界面上可查到。
    /// </summary>
    [XmlAttribute]
    public string AppName { get; set; }

    /// <summary>
    /// 需要监控的指标名称,在Venus界面上可查到。
    /// </summary>
    [XmlAttribute]
    public string MetricName { get; set; }

    /// <summary>
    /// 获取数据的计算方式, (最新值,增量)
    /// </summary>
    [XmlAttribute]
    public StatisticMode Statistic { get; set; } = StatisticMode.Direct;

    /// <summary>
    /// 程序启动后延迟多久开始监控,单位:秒
    /// </summary>
    [XmlAttribute]
    public int DueTime { get; set; } = 60;

    /// <summary>
    /// 采集周期,单位:秒
    /// </summary>
    [XmlAttribute]
    public int Period { get; set; } = 60;

    /// <summary>
    /// 告警阀值
    /// </summary>
    [XmlAttribute]
    public long Threshold { get; set; }

    /// <summary>
    /// 指标值与阀值的比较方式
    /// </summary>
    [XmlAttribute]
    public CompareMode Compare { get; set; } = CompareMode.MoreThan;
}

public enum StatisticMode
{
    /// <summary>
    /// 直接取值(最新版本)
    /// </summary>
    Direct,

    /// <summary>
    /// 取增量
    /// </summary>
    Incremental
}


public enum CompareMode
{
    /// <summary>
    /// 大于等于
    /// </summary>
    MoreThan,

    /// <summary>
    /// 小于等于
    /// </summary>
    LessThan
}

Venus 告警通知

Nebula内置4种类别的告警通知

  • 服务/资源的【可用性】告警
  • 服务节点的 心跳/打卡 告警
  • 基于 指标规则 的监控告警
  • 应用程序启动失败的异常日志告警



服务/资源的【可用性】告警

Venus会根据配置服务中定义的资源进行【可用性】探测,如果测试失败,就会发送告警(如下图)

xx

Venus对应的监控界面

xx

这些资源定义在配置服务的settings表中,由restype表示资源类型。 Venus支持的【可用性】探测 类型如下表

restype列值资源类别
1RabbitMQ
2Kafka
3Redis
4站点Url
10Website/PublicService/InternalService

除了资源,还包含在配置服务中维护的数据库连接,Venus支持的数据库类型如下:

dbType列值数据库类别
0SQLSERVER
1MySQL
2PostgreSQL
4MongoDB
6HBase
7InfluxDB



服务节点的 心跳/打卡 告警

xx

Venus会根据最近一次的心跳时间来检查应用程序有没有按时打卡。

xx

如果某个应用有多个节点,只要有一个按时打卡都认为是正常,反之则产生告警。



基于 指标规则 的监控告警

xx

根据指定的配置来产生的告警,可参考:Venus 指标监控 章节介绍。



应用程序启动失败的异常日志告警

xx

应用程序如果启动失败,会将完整的代码调用堆栈以文件形式发送出来。



本文不介绍这些告警是如何产生的,只介绍这些告警是如何发出来的。



告警配置

从以上截图可以看出,告警消息被发送到了一个【企业微信群】中。
要实现这个告警通知需要指定4个参数:

参数名称参数含意
ImApp.Auth.Config企业微信【登录参数】
Nebula.FatalError.ImChatId企业微信【群ID】
Nebula.Venus.UrlVenus地址
Nebula.MonitorOption监控频率(可选)

ImApp.Auth.Config 参数格式
ImType=WxWork;AgentId=xxxx;AppId=xxxxxxxx;AppSecret=xxxxxxxxx

说明:

  • 企业微信群ID: 这个群必须是使用 AgentId=xxxx 这个企业微信应用创建的。

业务指标监控开发

本文将介绍下图中的业务指标如何显示在Venus页面中
xx



定义指标类 RuntimeData

首先,请在项目中新增一个名为 Monitor 的文件夹。
然后创建一个名为 RuntimeData 的类型,并定义一些指标项,例如:

xx

在这个类型中,使用了三种数据类型:

  • SumValue:表示一个全局的汇总数据。 全局有二层含义:
    1. 与应用无关(通常是查询汇总进程外的数据)
    2. 与部署多少个节点无关(取最近接收到的某个节点发来的数据)。
  • TotalValue:表示一个应用程序的【当天合计】数据, 如果应用部署了多个节点,它们的数据会合并在一起。
  • RealtimeValue:表示一个应用节点内的实时累计数据(XXX/秒),最终展示时会按多节点合并,合并时取各节点的最新值。

除此之外,还可以使用:

  • StateValue:表示一个应用程序的状态数据。如果某个应用程序有多个节点时,数据会做合并处理。



更新汇总数据-SumValue

在 Monitor 的文件夹中再创建一个名为 MonitorWorker 的类型,
并继承于 BaseMonitorWorker<RuntimeData> 用于更新汇总数据,例如:

xx

示例代码中的 return 3; 不要无脑抄!!!


二个方法的解释:
/// <summary>
/// 刷新【汇总】数据,准备上传。
/// </summary>
/// <returns>返回一个数字,表示多少秒之后再执行当前方法。如果不需要再次执行,可以返回 -1</returns>
public abstract int RefreshSumData();

/// <summary>
/// 刷新【状态】数据,准备上传。
/// </summary>
/// <returns>返回一个数字,表示多少秒之后再执行当前方法。如果不需要再次执行,可以返回 -1</returns>
public abstract int RefreshStatusData();

这二个方法都要求返回一个数字,表示下次调用要等待多久。
如果某个方法没有【内容】,可返回 -1 表示不再调用。

注意:如果数据变化很慢,返回值可以选择较大的数字。
例如:如果统计【租户数量】或者【数据库规模】这类数据,大可以30分钟做一次,返回 1800 是可以的。



这个步骤完成后,项目目录会是这样的:

xx



更新当天合计-TotalValue

这类指标一般会在业务代码中埋点,例如:

xx

原理:每次执行一个业务操作,就将计数器加 1,最后就能得到一天内的操作次数。



TotalValue 提供了3种修改计数器的方法:

/// <summary>
/// 递增计数器
/// </summary>
/// <returns></returns>
public long Increment()

/// <summary>
/// 增加计数器的值
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public long Add(long value)

/// <summary>
/// 指定计数器的值
/// </summary>
/// <param name="value"></param>
public void Set(long value)



更新实时数据-RealtimeValue

这类指标一般会在业务代码中埋点,例如:

xx

原理:Nebula会按时间窗口来计算一段时间内的调用次数,并平均到每秒。



RealtimeValue 也提供了3种修改计数器的方法:

/// <summary>
/// 递增计数器
/// </summary>
/// <returns></returns>
public long Increment()

/// <summary>
/// 增加计数器的值
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public long Add(long value)

/// <summary>
/// 指定计数器的值
/// </summary>
/// <param name="value"></param>
public void Set(long value)



指标命名约定

  • xxx/s 表示实时数据, 例如: "消息吞吐量/s"
  • xxx/d 表示当天合计数据, 例如: "消息处理量/d"
  • xxx 表示汇总数据,例如: "租户数量"

简单来说就是用【后缀】来区分不同类别的指标。
xxx/d,虽然表示 【平均每天】的意思,但是这里就表示 【当天合计】,不必纠结!



针对数据容量的界面显示约定

  • 汇总数据以 MB, GB 结尾,会自动按单位做格式化
  • 当天数据以 MB/d, GB/d 结尾,会自动按单位做格式化
  • 实时数据以 KB/s, MB/s 结尾,会自动按单位做格式化

注意以上单位中的 【大小写】









请点击目录中的节点查看具体文档

安装部署

安装依赖服务

Nebula依赖以下服务,需要提前安装:

  • 必选
    • MySQL (建议版本 8.0+)
    • RabbitMQ (建议版本 3.13.4)
    • Elasticsearch (建议版本 7.9.3)
    • Kibana (建议版本 7.9.3)
    • JaegerUI (建议版本 1.21.0)
  • 可选(视功能而定)
    • Redis (建议版本 6.0) 或者 Garnet 1.0.14
    • PostgreSQL (建议版本 13.1)



部署--第三方依赖服务

RabbitMQ

docker run -d  --restart=always --name rabbit  -m1024m \
  --hostname my-rabbit \
  -p 5672:5672 \
  -p 15672:15672 \
  -e RABBITMQ_DEFAULT_USER=fishli \
  -e RABBITMQ_DEFAULT_PASS=xxxxxxxxxxxxx \
  -e RABBITMQ_VM_MEMORY_HIGH_WATERMARK=90% \
  -v /data/rabbit:/var/lib/rabbitmq \
  rabbitmq:3.8.9-management-alpine

日志相关服务

docker run -d --restart=always --name es  \
  -p 19200:9200 \
  -p 9300:9300 \
  -e ES_JAVA_OPTS="-Xms2g -Xmx2g" \
  -e ES_HOME=/data/es \
  -e "discovery.type=single-node" \
  -v /data/es:/data/es \
  elasticsearch:7.9.3

docker run -d --restart=always --name kibana -m1024m \
  -p 15601:5601 \
  -e ELASTICSEARCH_HOSTS=http://xxxxxxxxxxxxxxxx:19200 \
  -v /data/kibana:/data \
  kibana:7.9.3

docker run -d  --restart=always  --name jaeger  -m100m \
  -p 16686:16686 \
  jaegertracing/all-in-one:1.21.0

注意:

  • 请根据实际情况修改上面的 参数xxxxxxxxxxxxxxx

安装完成后可访问,确认安装是否成功:

  • http://hostxxxxxxxxxx:15672/
  • http://hostxxxxxxxxxx:19200/
  • http://hostxxxxxxxxxx:15601/
  • http://hostxxxxxxxxxx:16686/



创建数据库

在此忽略 mysql 的安装过程~~~~~

然后,请下载并执行下面的脚本文件(MySQL格式):

说明:

  • 如果访问最比较小,例如开发或者测试环境,可以将以上2个脚本在一个 database 实例上执行,最终只使用一个database
  • ConfigDb/NebulaDb 这2个名字不是固定的,可以根据实际情况下来取名
  • 数据库也能使用 SQLSERVER 或者 PostgreSQL,但是需要自行转换上面的2个SQL脚本的语法



配置全局参数

在ConfigDb的settings表添加以下配置

  • name=Nebula_Environment_Name

    • 含意:集群环境名称
    • 示例值:XXXX-DEV or XXXX
    • 注意:不要使用特殊字符,包括“空格”
    • 默认值:Nebula.DEMO
    • 要求:此参数必填
    • restype: 1000
  • name=Nebula_Environment_Key

    • 含意:标识当前环境,用于区分一套环境中的应用
    • 取值范围:一个随机字符串,不同环境的值【必须】不一样
    • 示例值:a543162600df44bdbb35f8f108f9ccfa
    • 注意:此参数在使用后不得修改!
    • 要求:此参数必填
    • restype: 1000
  • name=Nebula_Authentication_SecretKey

    • 含意:JWT密钥
    • 取值范围:一个随机字符串,不同环境的值【必须】不一样
    • 示例值:475800cd45bb47a38cf0805a82b49fb7e7cc31b624c7440c99ebe83628f0af45
    • 注意:此参数在使用后不建议修改!
    • 要求:此参数必填
    • restype: 1000

  • name=ClownFish_Log_WritersMap

    • 含意:指示不同的日志数据类型使用哪个写入器
    • 示例值/建议值:InvokeLog=Rabbit;*=ES
    • 示例解释:InvokeLog写入RabbitMQ,其它日志写入ES
    • 要求:此参数必填
    • restype: 1000
  • name=ClownFish_Log_Rabbit

    • 含意:启用 Rabbit 写入器后,日志写入时使用的RabbitMQ连接参数
    • 示例值:server=RabbitHost;username=fishli;password=aaaaaaaaaaaa;vhost=nbtest
    • 要求:如果日志不使用RabbitMQ可不填写,否则此参数必填
    • restype: 1
  • name=ClownFish_Log_ES_IndexNameFormat

    • 含意:Nebula在将日志写入ES时使用的索引名称后缀
    • 默认值:-yyyyMMdd
    • 说明:最终生成的索引名称是:"typeName-yyyyMMdd"
    • 说明:如果需要每小时生成一个索引可设置:"typeName-yyyyMMdd-HH"
    • 要求:如果日志不使用ES可不填写,否则此参数必填
    • restype: 1000

  • name=Nebula_Venus_Url

    • 含意:Venus站点的访问地址
    • 示例值:http://localhost2:8208
    • 要求:如果不需要监控告警功能,否则可以不配置
    • restype: 1000
  • name=ImApp_Auth_Config

    • 含意:IM应用的连接认证参数
    • 示例值:ImType=WxWork;AgentId=111111;AppId=xxxxxxxxxxxx;AppSecret=xxxxxxxxxxxxxxxx
    • 说明:可以从各IM的管理后台界面中获取
    • 要求:如果不需要发送通知,可以不配置
    • restype: 1000
  • name=Nebula_FatalError_ImChatId

    • 含意:告警通知群ID
    • 要求:如果不配置将不发送告警通知
    • restype: 1000

  • name=Nebula_Neptune_Url

    • 含意:Neptune服务的访问地址
    • 要求:如果不使用Neptune服务可以不配置,否则此参数必填
    • restype: 10
  • name=Redis_Connection

    • 含意:Redis连接参数
    • 要求:如果不使用Redis可以不配置,否则此参数必填
    • restype: 3
  • name=Kibana_Url

    • 含意:Kibana的访问地址
    • 示例:http://192.33.206.176:2222
    • restype: 1000
  • name=JaegerUI_Url

    • 含意:JaegerUI的访问地址
    • 示例:http://192.33.206.176:3333
    • restype: 1000

  • name=Nebula_Ceres_Url

    • 含意:Ceres服务(WebHook)的访问地址
    • 要求:如果不使用Ceres服务可以不配置,否则此参数必填
    • restype: 10
  • name=Nebula_Ceres_Rabbit

    • 含意:Ceres服务使用的RabbitMQ连接参数
    • 格式:server=RabbitHost;username=fishli;password=aaaaaaaaaaaa;vhost=nbtest
    • 要求:如果不使用Ceres服务可以不配置,否则此参数必填
    • restype: 1

  • name=Nebula_Metis_Url

    • 含意:通知服务的访问地址
    • 示例值:http://localhost2:8220
    • restype: 10
  • name=Nebula_Metis_Rabbit

    • 含意:Metis服务使用的RabbitMQ连接参数
    • 格式:server=RabbitHost;username=fishli;password=aaaaaaaaaaaa;vhost=nbtest
    • 要求:如果不使用Metis服务可以不配置,否则此参数必填
    • restype: 1
  • name=Nebula_Metis_Email

    • 含意:发送邮件的SMTP连接及帐号
    • 示例值:Host=smtp.exmail.qq.com;Port=587;UserName=xx@abc.com;Password=xxxxx;IsSSL=1
    • 说明:如果不使用邮件功能,可以不配置
    • restype: 6
  • name=AliCloud_SmsConfig

    • 含意:阿里云短信服务认证参数
    • 示例值:AccessKeyId=xxxxxxxxxxxxx;AccessKeySecret=xxxxxxxxxxxx
    • 说明:如果不使用短信功能,可以不配置
    • restype: 5



示例脚本(在ConfigDb数据库中执行)

-- 基础参数
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Environment_Name', 'XXXXXX_DEMO', 1000, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Environment_Key', 'a543162600df44bdbb35f8f108f9ccfa', 1000, '', ' ');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Authentication_SecretKey', '475800cd45bb47a38cf0805a82bcc31b624c7440c99ebe83628f0af45', 1000, '', '');

-- 日志--相关参数
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('ClownFish_Log_WritersMap', 'InvokeLog=Rabbit;*=es', 1000, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('ClownFish_Log_Rabbit', 'server=xxx;username=fishli;password=xxx', 1, '', '');


-- 监控&通知--相关参数
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Venus_Url', 'http://LinuxTest:8208', 1000, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('ImApp_Auth_Config', 'ImType=WxWork;AgentId=9999;AppId=xxxxxxxx;AppSecret=xxxxxxxxx', 1000, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_FatalError_ImChatId', 'xxxxxxxxx', 1000, '', '');

-- 各种服务地址
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Neptune_Url', 'http://LinuxTest:8211', 10, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('redis_Connection', '192.168.1.1,password=xxxxx,connectTimeout=3000', 3, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Kibana_Url', 'http://192.33.206.176:2222', 1000, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('JaegerUI_Url', 'http://192.33.206.176:3333', 1000, '', '');

-- webhook服务--相关参数-----如果不安装此服务可以不添加这些参数
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Ceres_Url', 'http://LinuxTest:8504', 10, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Ceres_Rabbit', 'server=xxx;username=fishli;password=xxxx', 1, '', '');


-- 通知服务--相关参数-----如果不安装此服务可以不添加这些参数
INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Metis_Url', 'http://LinuxTest:8220', 10, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Metis_Rabbit', 'server=xxx;username=fishli;password=xxxx', 1, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('Nebula_Metis_Email', 'Host=smtp.exmail.qq.com;Port=587;UserName=xx@abc.com;Password=xxxxx;IsSSL=1', 6, '', '');

INSERT INTO settings(name, value, restype, extra, remark) 
VALUES ('AliCloud_SmsConfig', 'AccessKeyId=xxxxxxxxxxxxx;AccessKeySecret=xxxxxxxxxxxx', 5, '', '');

restype的取值可参考下表: 监控枚举值说明




数据库连接配置

在ConfigDb的dbconfig表添加以下配置

  • name=master

    • 用途:主库连接址
  • name=Nebula_ConfigDb

    • 用途:指向配置服务对应数据库的连接,此参数可选。
  • name=Nebula_Db

    • 用途:Nebula所需要其它数据表
  • name=ClownFish_Log_Elasticsearch

    • 用途:启用 ElasticsearchWriter 写入器后,指向Elasticsearch的地址
    • dbType=8
    • server=192.168.1.100:9200



示例脚本

INSERT INTO dbconfig(name, dbType, server, database, username, password, port, args) 
VALUES ('master', 1, 'MySqlHost', 'master_db', 'user1', 'xxxxx', 0, '');

INSERT INTO dbconfig(name, dbType, server, database, username, password, port, args) 
VALUES ('Nebula_Db', 1, 'MySqlHost', 'nebuladb', 'user1', 'xxxx', 0, '');

INSERT INTO dbconfig(name, dbType, server, database, username, password, port, args) 
VALUES ('Nebula_ConfigDb', 1, 'MySqlHost', 'configdb', 'user1', 'xxxx', 0, '');

INSERT INTO dbconfig(name, dbType, server, database, username, password, port, args) 
VALUES ('ClownFish_Log_Elasticsearch', 8, 'ElasticHost:9200', 'xx', '', '', 0, '');

dbtype的取值可参考:监控枚举值说明




部署-开发/测试环境

为了简单部署,开发/测试环境 可以使用 Nebula.AllInOne 镜像

docker run -d  --name nebula_dev  -m1024m \
  -p 8503:80  -p 8208:80 \
 --privileged=true \
 --restart=always \
  -e ASPNETCORE_ENVIRONMENT=DEV \
  -e ClownFish_AutoTest_Url=xx \
  -e dbConnectionString="server=xxxxxxxxxxxx;database=configdb_dev;username=dev;password=xxxxx" \
  -e ClownFish_EnableAppParts="Nebula.Moon; Nebula.Mercury; Nebula.Venus;" \
   yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/allinone:202411191116_net9




部署必需服务-生产环境

以下服务为可选


服务的docker镜像地址:

yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/moon:202411191116_net9

yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/mercury:202411191116_net9
yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/venus:202411191116_net9
yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/adminui:202411191116_net9

yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/metis:202411191116_net9
yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/ceres:202411191116_net9
yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/neptune:202411191116_net9
yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/juno:202411191116_net9




部署-生产环境--配置服务

其它服务可以通过调用配置服务获取到数据库连接,

但是,配置服务只能使用【本地参数】方式得到数据库连接

所以,运行配置服务的docker时,通过环境变量传递必要的参数项,例如:

docker run -d  --restart=always --name moon  -p 8503:80 -m300m \
  -e dbConnectionString="server=hostxxxxxxxx;database=configdb;uid=user1;pwd=xxxx" \
  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/moon:202411191116_net9

配置服务可以接收2个环境变量参数:

  • dbConnectionString: 数据库连接字符串,必填
  • dbProviderName: 数据提供者名称,默认值:MySql.Data.MySqlClient




部署-生产环境--Venus/AdminUI/Mercury

Venus/AdminUI 是站点,允许用户登录,并提供了操作页面。

由于允许用户登录,因此会遇到登录帐号及验证相关问题,相关的要求如下:

  • 所有登录帐号必须使用AdminUI的界面来创建,并设置用户的相关权限
  • 登录时,不使用密码,而是一种随机的验证码,此验证码通过IM工具来发送
  • 需要确保登录帐号与IM工具实现用户绑定,例如使用企业微信
  • docker启动时,可以使用一个虚拟管理员的帐号来登录,可参考下面的脚本
docker run -d  --restart=always --name venus  -p 8208:80 -m500m \
  -e ConfigServiceUrl="http://hostxxxxxxxx:8503"  \
  -e Nebula_Admin_LoginName=fishli \
  -e Nebula_Admin_Password=123 \
  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/venus:202411191116_net9


docker run -d  --restart=always --name adminui  -p 8210:80 -m300m \
  -e ConfigServiceUrl="http://hostxxxxxxxx:8503"  \
  -e Nebula_Admin_LoginName=fishli \
  -e Nebula_Admin_Password=123 \
  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/adminui:202411191116_net9


docker run -d  --restart=always --name mercury  -m300m \
  -e ConfigServiceUrl="http://hostxxxxxxxx:8503"  \
  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/mercury:202411191116_net9

此时可使用 fishli/123 这个帐号来登录,并且不经过IM发送登录验证码。




身份认证相关配置

身份认证有几个重要参数建议了解:

  • Nebula_JwtToken_AlgorithmName
    • 含意:JWT签名算法名称
    • 默认值:HS512
    • 取值范围:HS256/HS512/RS256/RS512/ES256/ES512
  • Nebula_Authentication_SecretKey
    • 含意:当使用 HS256/HS512 算法时的密钥
    • 默认值:框架内定
  • Nebula_Authentication_CookieName
    • 含意:JWT-TOKEN采用 Cookie 方式发送到服务时的键名
    • 默认值:xtoken
  • Nebula_Authentication_HeaderName
    • 含意:JWT-TOKEN采用 请求头 方式发送到服务时的键名
    • 默认值:x-token




使用非对称签名算法

框架支持4种非对称签名算法:RS256/RS512/ES256/ES512
如果需要使用这些算法,请先创建一个X509证书,
为了简单,建议创建一个 pfx 的自签名证书。


本文假设你已经创建好了一个pfx证书,下面仅介绍如何配置。
你需要再创建一个配置文件(文本格式), 假设命名为:ClownFishTest.conf

然后把它放在配置服务中,或者程序的启动目录中。

它的内容格式为:

base64(证书加载密码)\nbase64(证书文件二进制内容)

例如:

xx

然后再指定一个配置参数:

ClownFish_Authentication_X509Conf_FileName=ClownFishTest.conf




直接使用ClownFish框架

如果不使用Nebula框架,而是直接使用ClownFish框架,只需要将前面介绍的参数名称前缀从 Nebula 修改为 ClownFish 即可。

例如:

  • ClownFish_JwtToken_AlgorithmName
  • ClownFish_Authentication_SecretKey
  • ClownFish_Authentication_CookieName
  • ClownFish_Authentication_HeaderName




登录凭证自动续期

当用户登录时,可调用以下2个方法(其中1个)得到一个登录凭证(TOKEN)

  • AuthenticationManager.Login(IUserInfo userInfo, int expirationSeconds)
  • AuthenticationManager.GetLoginToken(IUserInfo userInfo, int expirationSeconds)

由于调用方法时指定了过期时间,因此得到的 登录凭证(TOKEN) 会在一定时间后过期。

为了提升用户体验,避免让用户频繁登录,框架 默认 会对登录凭证做续期处理。

如果 不希望 框架做自动续期,可以指定以下参数:

ClownFish_JwtToken_ExpirationRenewal=0

ClownFish/Nebula参数清单

说明:

  • Nebula安装部署 介绍过部分参数,本文将忽略它们。
  • 本文介绍的参数默认全是【本地参数】,它们仅在 ClownFish.App.config 或者 环境变量中有效。



通用参数

以下参数可控制 所有基于Nebula类库开发的应用程序:

  • name=ConfigServiceUrl

    • 解释:配置服务的访问地址
    • 默认值:无,必填写
  • name=ClownFish_AppCache_CacheSeconds

    • 解释:AppCache类型的默认缓存时间,单位:秒
    • 默认值:180
  • name=ClownFish_CacheDictionary_ExpirationScanFrequency

    • 解释:CacheDictionary<T>类型的主动过期清理周期,单位:秒
    • 默认值:1800
  • name=ClownFish_HttpClient_HttpTimeout

    • 解释:HTTP客户端的调用超时时间(单位:毫秒)
    • 默认值:10_000
  • name=ClownFish_Log_MaxCacheQueueLength

    • 解释:日志内存缓冲队列的最大长度
    • 默认值:5000
  • name=AspNetCore_Kestrel_MaxRequestBodySize

    • 解释:Kestrel允许的HTTP最大请求长度(单位:byte)
    • 默认值:1080 * 1024
  • name=Nebula_Auth_UserCache_UserCacheSecond

    • 解释:UserCache类型的User对象缓存时间,单位:秒
    • 默认值:180
  • name=Nebula_Auth_UserCache_RoleCacheSecond

    • 解释:UserCache类型的Role对象缓存时间,单位:秒
    • 默认值:60
  • name=Nebula_Authentication_HeaderName

    • 解释:登录Token的请求头名称
    • 默认值:"x-token"
  • name=Nebula_Authentication_CookieName

    • 解释:登录Token的Cookie名称
    • 默认值:"xtoken"
  • name=Nebula_ConfigClient_HttpTimeout

    • 解释:配置服务客户端的【HTTP调用超时】时间,单位:毫秒
    • 默认值:15_000
  • name=Nebula_ConfigClient_SettingsCacheSeconds

    • 解释:配置服务客户端的【配置结果】时间,单位:秒
    • 默认值:60
  • name=Nebula_ConfigClient_DbConfigCacheSeconds

    • 解释:配置服务客户端的【数据库连接结果】时间,单位:秒
    • 默认值:120
  • name=Nebula_ConfigClient_TenantCacheSeconds

    • 解释:配置服务客户端的【租户库连接结果】时间,单位:秒
    • 默认值:120
  • name=ThreadPool_MinWorker

    • 解释:线程池工作线程的最小数量
    • 默认值:CPU逻辑处理器数量
  • name=ThreadPool_MaxWorker

    • 解释:线程池工作线程的最大数量
    • 默认值:2000
  • name=ThreadPool_MinIOCP

    • 解释:线程池IOCP线程的最小数量
    • 默认值:256
  • name=ThreadPool_MaxIOCP

    • 解释:线程池IOCP线程的最大数量
    • 默认值:3000
  • name=ClownFish_Log_ES_TimeoutMs

    • 解释:ESWriter调用ES的请求超时时间,单位:毫秒
    • 默认值:5000
  • name=ClownFish_Log_ES_IndexNameFormat

    • 解释:ESWriter写ES数据时,索引的后缀名称
    • 默认值:"-yyyyMMdd"
  • name=ClownFish_Aspnet_RequestBufferSize

    • 解释:请求体缓冲区大小,具体用法可参考:日志记录请求体
    • 默认值:0(表示不启用请求体缓冲,只允许读取一次)




Moon专用配置

  • name=Nebula_Moon_CacheSeconds

    • 解释:数据缓存时间,单位:秒
    • 默认值:120
  • name=dbConnectionString

    • 解释:连接字符串参数,必填。
  • name=dbProviderName

    • 解释:数据驱动名称。
    • 默认值:MySql.Data.MySqlClient




Venus专用配置

  • name=Venus_MonitorData_RabbitSubscriber_Count

    • 默认值:2
  • name=Venus_StatisticsInfo_RabbitSubscriber_Count

    • 默认值:2




Mercury专用配置

  • name=Mercury_InvokeLog_RabbitSubscriber_Count
    • 默认值:10




Ceres专用配置

  • name=Ceres_HookEventHandler_RabbitSubscriber_Count

    • 默认值:1
  • name=Ceres_HookEventHandler_Retry_RabbitSubscriber_Count

    • 默认值:1




Metis专用配置

  • name=Metis_NotifyMessageHandler_RabbitSubscriber_Count

    • 默认值:1
  • name=Metis_NotifyMessageHandler_Retry_RabbitSubscriber_Count

    • 默认值:1




监控相关枚举值说明


ResourceType

应用目标:settings表的restype列

restype列值资源类别
0普通配置参数
1RabbitMQ
3Redis
4外部站点Url
5密码及敏感文本
6邮箱参数
10基于Nebula开发的Web服务
11Pulsar
12Kafka
1000Nebula专用




DatabaseType

应用目标:dbconfig表的dbtype列

dbType列值数据库类别
0SQLSERVER
1MySQL
2PostgreSQL
3Oracle
4MongoDB
5SQLite
6HBase
7InfluxDB
8Elasticsearch
9VictoriaMetrics

Venus-K8S配置

Venus的下图界面中:

xx

有2个功能需要调用K8S-API:

  • 获取容器的内存大小
  • 查看 pod-logs

所以在部署Venus时,需要配置相关的K8S-API的访问权限。

有2种方法可以配置访问权限:

  • 在配置服务中添加一个 "Nebula.Venus.kubeconfig" 的文件。
    如果你使用阿里的K8S服务,可以从以下界面中获取:
    xx
    注意:这种方式得到的配置文件有过期问题,请定时续期。

  • 在K8S中配置访问权限,请继续阅读本文剩余部分。





1,创建 Service Account

建议在 Lens 中创建,可参考下图:

xx





2,创建 Cluster Role

建议在 Lens 中创建,可参考下图:

xx

然后修改YAML配置,可参考:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: nebula-venus-monitor-rule
  selfLink: /apis/rbac.authorization.k8s.io/v1/clusterroles/nebula-venus-monitor-rule
rules:
  - verbs:
      - get
      - watch
      - list
    apiGroups:
      - '*'
    resources:
      - '*'





3,创建 Cluster Role Binding

建议在 Lens 中创建,可参考下图:

xx





4,修改 Venus 的部署YAML,引用serviceAccount

可参考

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    metadata:
      labels:
        app: nebulavenus
    spec:
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      serviceAccount: nebula-venus-monitor
      serviceAccountName: nebula-venus-monitor

5,开启配置开关

Venus_K8sApi_Enable=1

服务注册和服务发现

本文不详细解释名词 “服务注册和服务发现” ,如果不了解,可以在先在网上搜索相关资料文档。

这里先从一个简单例子来开始介绍:
在微服务架构中,服务之间相互调用 是很常见的操作。
例如:ServceA 需要调用 ServiceB
那么就需要解决一个问题:ServiceA 怎么知道 ServiceB 的访问地址?




K8S集群环境

如果应用程序部署在K8S集群中,那么可以直接使用 K8S 内置的 “服务注册和服务发现” 能力。

简单来说,K8S体系中,

  • 它内置实现了一个DNS服务
  • 服务创建时,会指定一个 服务名称,例如: serviceb
  • “部署”(POD)启动时会自动将自己分配到的IP和端口注册到DNS服务中,假设:172.20.1.161:80
  • 服务会根据“标签”和“部署”关联在一起
  • 应用程序通过 服务名称 就可以访问到 具体的服务,例如:http://serviceb 可到达:http://172.20.1.161:80

说明:这和 JAVA Spring Cloud 的实现差异较大,不要被那种方式【洗脑】了。

到这里,还有一个小问题需要解决:

  • 服务名称,它是在部署时指定的,通常是由运维人员指定
  • 在开发代码阶段,开发人员不知道 最终的 服务名称 是什么,所以不能在代码中硬编码 http://ServiceB/aa/bb.aspx 这样的调用地址。

因此,在使用Nebula开发框架时,需要在配置服务中添加一个全局配置参数:

ServiceB_url = http://xxxxxxxxxxxxxxxxxxxx

然后这样编写代码:

string url = Setting.GetSetting("ServiceB_url") + "/aa/bb.aspx";

实际部署时,即使运维人员将 ServiceB 取名为 serviceXX 也无妨,他只要在配置服务中添加一个全局配置参数就可以了:

ServiceB_url = http://serviceb

简单理解就是:使用参数名称来找到服务的调用地址。




开发环境

开发环境可以很简单,就是一台开发机器(本地电脑),没有K8S,也不需要docker。

此时各个服务直接运行在操作系统中,它们会监听各自的端口号,所以它们有一个 IP+PORT 的访问方式。

在这个环境中,直接将这些服务的访问方式指定在配置中即可,例如(假设本机地址是 192.168.1.100):

ServiceA_url = http://192.168.1.100:3008
ServiceB_url = http://192.168.1.100:4008

回头再看下前面的代码

string url = Setting.GetSetting("ServiceB_url") + "/aa/bb.aspx";

此时就得到一个在开发环境中可以使用的具体调用地址:

http://192.168.1.100:4008/aa/bb.aspx

Nebula框架的队列使用清单

Nebula日志与监控

Rabbit连接配置名称:ClownFish_Log_Rabbit

ClownFish.Log.Logging.InvokeLog 队列

  • 发送方:所有使用Nebula开发的应用程序
  • 消费方:Nebula.Mercury
  • 消费者数量参数:InvokeLog_RabbitSubscriber_Count,默认值:10
  • 创建时机:Mercury启动时
  • 队列用途:由日志组件写入,消息数据表示一条调用日志

Nebula.StatisticsInfo

  • 发送方:Nebula.Mercury
  • 消费方:Nebula.Venus
  • 消费者数量参数:StatisticsInfo_RabbitSubscriber_Count,默认值:2
  • 创建时机:Mercury启动时
  • 队列用途:异步持久化(同步转异步)

Nebula.MonitorData

  • 发送方:所有使用Nebula开发的服务
  • 消费方:Nebula.Venus
  • 消费者数量参数:MonitorData_RabbitSubscriber_Count,默认值:3
  • 创建时机:所有服务启动时
  • 队列用途:各服务向Venus发送监控统计数据




Nebula.Ceres

Rabbit连接配置名称:Nebula_Ceres_Rabbit

Ceres_WebHook_Events

  • 发送方:Nebula.Ceres
  • 消费方:Nebula.Ceres
  • 消费者数量参数:HookEventHandler_RabbitSubscriber_Count,默认值:1
  • 创建时机:Ceres启动时
  • 队列用途:接收外部请求,同步转异步;并按订阅者做消息拆分

Ceres_WebHook_WaitRetry

  • 发送方:Nebula.Ceres
  • 消费方:无消费者
  • 消费者数量参数:无
  • 创建时机:Ceres启动时
  • 队列用途:用于实现重试等待

Ceres_WebHook_Retry

  • 发送方:Rabbit内部的死信机制,由Ceres_WebHook_WaitRetry队列转入
  • 消费方:Nebula.Ceres
  • 消费者数量参数:HookEventHandler_Retry_RabbitSubscriber_Count,默认值:1
  • 创建时机:Ceres启动时
  • 队列用途:消息重试

Ceres_WebHook_DeadMsg

  • 发送方:Nebula.Ceres
  • 消费方:无消费者
  • 消费者数量参数:无,供外部程序订阅
  • 创建时机:Ceres启动时
  • 队列用途:用于消息处理失败时(包含全部重试失败),通知外部程序




Nebula.Metis

Rabbit连接配置名称:Nebula_Metis_Rabbit

Metis_MsgData

  • 发送方:Nebula.Metis
  • 消费方:Nebula.Metis
  • 消费者数量参数:NotifyMessageHandler_RabbitSubscriber_Count,默认值:1
  • 创建时机:Metis启动时
  • 队列用途:接收外部请求,同步转异步;并按通知的推送目标做消息拆分

Metis_MsgData_WaitRetry

  • 发送方:Nebula.Metis
  • 消费方:无消费者
  • 消费者数量参数:无
  • 创建时机:Metis启动时
  • 队列用途:用于实现重试等待

Metis_MsgData_Retry

  • 发送方:Rabbit内部的死信机制,由Metis_MsgData_WaitRetry队列转入
  • 消费方:Nebula.Metis
  • 消费者数量参数:NotifyMessageHandler_Retry_RabbitSubscriber_Count,默认值:1
  • 创建时机:Metis启动时
  • 队列用途:消息重试

Metis_MsgData_DeadMsg

  • 发送方:Nebula.Metis
  • 消费方:无消费者
  • 消费者数量参数:无,供外部程序订阅
  • 创建时机:Metis启动时
  • 队列用途:用于消息处理失败时(包含全部重试失败),通知外部程序

并发参数&优化

影响程序支撑并发量的几个因素

  1. 线程池的线程数
  2. 程序代码本身的性能
  3. IO密集操作是否使用异步
  4. 数据库连接池的大小
  5. 数据库MySQL的最大连接数
  6. SQL语句的性能,以及锁的使用
  7. 硬件&网络本身的性能



线程池参数

有4个本地参数可以调整

  • ThreadPool_MinWorker
  • ThreadPool_MaxWorker
  • ThreadPool_MinIOCP
  • ThreadPool_MaxIOCP

可参考: Nebula参数清单

具体代码可参考:

private static void SetThreadPool()
{
    // .net 默认值:
    // Min Worker Threads: {ProcessorCount}
    // Max Worker Threads: 32767
    //------------------ -
    // Min CompletionPort Threads: {ProcessorCount}
    // Max CompletionPort Threads: 1000

    int coreCount = System.Environment.ProcessorCount.Min(32);  // 最少32个线程

    int minWorker = LocalSettings.GetUInt("ThreadPool_MinWorker", coreCount);
    int maxWorker = LocalSettings.GetUInt("ThreadPool_MaxWorker", 2000);

    int minIOCP = LocalSettings.GetUInt("ThreadPool_MinIOCP", 256);
    int maxIOCP = LocalSettings.GetUInt("ThreadPool_MaxIOCP", 3000);

    if( ThreadPool.SetMaxThreads(maxWorker, maxIOCP) == false )
        Console2.Warnning($"SetMaxThreads({maxWorker}, {maxIOCP}) failed.");

    if( ThreadPool.SetMinThreads(minWorker, minIOCP) == false )
        Console2.Warnning($"SetMinThreads({minWorker}, {minIOCP}) failed.");
}

查看进程当前的线程状态,可以在Venus中查看:

xx



数据库连接池参数

与并发性能有关的参数有二个:

  1. 连接池最大值
  2. 连接超时时间

这二个参数都可以在连接字符串上指定,例如:Maximum Pool Size=300;Connect Timeout=5

  • 对于配置服务来说,它的连接字符串是通过环境变量来指定的,因此直接在环境变量中指定即可。
  • 对于应用库连接来说,可在 dbconfig表的args列中指定

例如:

xx



MySQL的并发控制参数

影响MySQL并发的参数有二个:

[mysqld]
# The maximum amount of concurrent sessions the MySQL server will
# allow. One of these connections will be reserved for a user with
# SUPER privileges to allow the administrator to login even if the
# connection limit has been reached.
max_connections=3000

# Number of threads allowed inside the InnoDB kernel. The optimal value
# depends highly on the application, hardware as well as the OS
# scheduler properties. A too high value may lead to thread thrashing.
innodb_thread_concurrency=128



SQL语句的性能,程序代码本身的性能

ClownFish会记录一些慢SQL,慢请求,可以在性能日志中找到它们。

下图是默认的性能阀值:

xx


可以在配置参数中重新指定它们:

ClownFish_Log_Performance_HttpExecute=1000
ClownFish_Log_Performance_HandleMessage=2000

关于性能日志的更多介绍,可以点击此处

设置运行环境

运行环境差异

Nebula针对3类环境会有不同的运行行为差异:

  • 开发环境
    • Console2.Debug(...)方法有效
    • 支持 Swagger
    • 支持 服务接口HTTP调用覆盖率统计
  • 测试环境
    • 支持 Swagger
    • 支持 服务接口HTTP调用覆盖率统计
  • 生产环境
    • 以上列出的特性都不支持



运行环境设置

应用程序在启动时,可以通过环境变量 ASPNETCORE_ENVIRONMENT 来指定。

  • 开发环境
    • ASPNETCORE_ENVIRONMENT=Development
    • ASPNETCORE_ENVIRONMENT=DEV
  • 测试环境
    • ASPNETCORE_ENVIRONMENT=TEST
  • 生产环境
    • ASPNETCORE_ENVIRONMENT=Product
    • ASPNETCORE_ENVIRONMENT=PROD
    • 不指定ASPNETCORE_ENVIRONMENT
    • 所有不能被识别的标识都认为是生产环境!



运行环境判断方法

可以访问以下属性来判断当前的运行环境:

// 当前运行环境是否为 开发 环境
EnvUtils.IsDevEnv

// 当前运行环境是否为 测试 环境
EnvUtils.IsTestEnv

// 当前运行环境是否为 生产 环境
EnvUtils.IsProdEnv

灰度部署

业务背景

  • 支持SaaS租户级&用户级的功能灰度发布
  • 支持微服务(节点多,域名多)的部分应用灰度发布
  • 多应用共用域名(一个域名供多个服务和前端站点使用)

在阅读本文前,请先浏览: 灰度部署方案设计

实现步骤:

  1. 在配置服务中新增:1个全局参数
  2. 在配置服务中新增:2个配置文件
  3. 部署 Nebula.GateWay 服务
  4. 登录时指定用户的灰度标记



1,配置参数

在配置服务中新增全局参数:

Nebula_GrayDeploy_Enable = 1



2,配置文件

在配置服务中,新增2个配置文件。



1)Nebula.GateWay.ProxyMapRule.xml

配置示例

<ProxyMapRule>
    <Rules>
        <rule src="http://app1.com/v20/api/[any]" dest="http://app1/v20/api/[any]" />  <!-- 后端服务 -->
        <rule src="http://app1.com/[any]"         dest="http://app1-ui/[any]" /> <!-- 前端站点 -->
        <rule src="http://*.app1.com/[any]"       dest="http://app1-ui/[any]" /> <!-- 前端站点 -->
        
        <rule src="http://app2.com/v20/api/[any]" dest="http://app2/v20/api/[any]" />    <!-- 后端服务 -->
        <rule src="http://app2.com/[any]"         dest="http://app2-ui/[any]" /> <!-- 前端站点 -->
    </Rules>
</ProxyMapRule>

说明:

  • src: 是指请求的传入地址,通常是外网地址
  • dest: 是指转发到内网的地址
  • [any] 表示可匹配任何字符,它可同时用于 src,dest中
  • * 字符只用于 src,可匹配任何字符,一般用于SaaS模式下的租户名匹配
  • 上面示例中,"/v20/api/" 是后端服务的URL路径前缀
  • 上面示例中,没有前缀的URL都认为是前端路径
  • 规则匹配采用【自上而下】的次序执行,特殊规则一定要放在靠前的位置



2)Nebula.Public.NodeGrayRule.xml

配置示例

<NodeGrayRule>
    <Rules>
        <rule normal="http://app1/"      gray="http://app1-gray/" />
        <rule normal="http://app2/"      gray="http://app2-gray/" />
        <rule normal="http://app1-ui/"   gray="http://app1-ui-gray/" />
        <rule normal="http://app2-ui/"   gray="http://app2-ui-gray/" />
    </Rules>
</NodeGrayRule>

注意:不需要灰度发布的应用或服务,不用在上面配置!


示例解释:

http://app1/           是服务X的 正常 访问地址
http://app1-gray/      是服务X的 灰度 访问地址
http://app1-ui/        是前端站点X的 正常 访问地址
http://app1-ui-gray/   是前端站点X的 灰度 访问地址





3,部署服务

需要部署以下服务:

  • Nebula.GateWay
    • 它将接受所有的外网HTTP请求
    • 然后根据【用户身份】和【上面2个配置文件】转发到内部的服务节点





4,登录时指定用户的灰度标记

参考以下代码:主要是设置:userInfo.GrayFlag = 1

public int Login(string usercode, string password, string tenantId)
{
    // 示例代码中忽略用户名和密码的校验逻辑………………

    // 构造用户身份对象
    WebUserInfo userInfo = new WebUserInfo {
        TenantId = tenantId,
        UserId = userId,
        UserName = "某某某",
        UserRole = "Admin",
        GrayFlag = 1 // 标记当前用户访问灰度功能
    };

    int seconds = 1200;   // 设置身份凭证的有效期(用户持续操作时会自动续期)
    AuthenticationManager.Login(userInfo, seconds);
    return 1;
}

ASP.NET Core 项目部署到 IIS

1,新建一个发布,选择IIS,然后在对话框中随便填写,例如:

xx




2,执行发布操作,此时肯定会出现错误,例如:

xx




3,打开图片箭头所指目录

xx




4,部署站点

方法有2种:

  1. 直接用这个目录部署到IIS
  2. 把 web.config 复制到程序的编译输出目录,然后把编译输出目录部署到 IIS

建议使用后者,因为不需要反复使用【发布】操作

Web.config文件内容可参考:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet" arguments=".\TucaoWebApp.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
    </system.webServer>
  </location>
</configuration>




5,配置参考:

xx

xx




6,运行效果截图

xx









请点击目录中的节点查看具体文档

配置服务 - Nebula.Moon

集中管理各种参数的服务。

主要管理以下数据:

  • 各服务的调用地址(服务注册)
  • 多应用之间共享的全局参数
  • 敏感信息数据
    • 连接类参数:RabbbitMQ/Redis/es/oss/...
    • 帐号类参数:邮箱帐号,IM应用帐号
    • 密码密钥类:登录密码,JWT密钥
  • 数据库连接:主库,租户库,应用库
  • 应用程序的配置文件

设计背景

  • 微服务相互调用所必需的服务注册机制
  • 配置安全:避免将各种敏感信息(密码,帐号)写在代码文件中(配置文件也算)
  • 可用性监控:利用管理的各种连接参数(MySQL/PostgreSQL/Rabbit/Redis)实现对资源的可用性监控



服务注册

xxx

代码中使用:

private static readonly string s_urlRoot = Settings.GetSetting("url.tenantService");

var httpOption = new HttpOption
{
    Url = $"{s_urlRoot}/v20/api/tenantservice/tenant/detail/{param.EntCode}.svc",
    Data = new { param.EntCode },
    Method = "POST"
};

var pTenantDetail = await this.SendRequestAsync<PTenantDetail>(httpOption);



管理数据库连接

在连接数据库前需要先配置连接,可以使用以下界面:

xxx


打开连接请参考: 数据库连接

建议阅读: 远程配置参数



管理/获取全局参数或帐号参数

可以使用以下界面来管理全局参数:

xxx

示例代码:

string value = Settings.GetSetting("name");
// or
XOption option = Settings.GetSetting<XOption>("name");

Settings.GetSetting查找次序

  • 环境变量
  • 配置服务
  • AppConfig

查找次序解释

  • 对于开发环境,用App.Config就可以了
  • 对于线上环境,可由配置服务统一覆盖
  • 对于线上个别应用,可以在docker环境变量中指定,优先级最高。



配置文件

每个基于Neblua.net的应用可以在项目中包含2个配置文件:

  • ClownFish.App.config
  • ClownFish.Log.config

以上这2个文件都不是必需的,它们都有默认配置,如果存在则优先使用它们。

但是这种本地文件对于线上部署时不方便及时调整,所以可以将它们放在配置服务中管理。
它们对应在配置服务中的名称为:

  • appname.App.config
  • appname.Log.config



独立配置文件

对于参数数量较多的应用程序,可以创建独立的配置文件,而不是继续使用ClownFish.App.config, 此时可以在配置服务中新增一个配置文件来实现,具体可操作配置服务界面。

建议阅读: 远程配置参数



示例代码

public static MonitorConfig LoadConfig()
{
    string xmlBody = null;
    string filename = "VenusMonitorConfig.xml";

    // 先读取本地文件
    if( File.Exists(filename) ) {
        xmlBody = File.ReadAllText(filename, Encoding.UTF8);
        Console2.WriteLine("VenusMonitorConfig.xml 已从本地文件中加载。");
    }

    // 再尝试从配置服务中获取
    if(xmlBody.IsNullOrEmpty() ) {
        xmlBody = ConfigFile.GetFile(filename);
        Console2.WriteLine("VenusMonitorConfig.xml 已从配置服务中获取。");
    }

    if( xmlBody.IsNullOrEmpty() )
        return null;


    // 反序列化,获取配置对象
    MonitorConfig config = ClownFish.Base.Xml.XmlHelper.XmlDeserialize<MonitorConfig>(xmlBody);

    // 验证配置参数
    ValidateMonitorConfig(config);

    return config;
}

可以使用以下界面来管理配置文件:

xxx



参数存储

配置服务的参数存储可参考: 点击这里

测试配置服务

本文将给出一些脚本用于测试服务是否正确响应。



获取一个配置参数

GET http://xxxxxxxx/v20/api/moon/setting/get?name={settingName} HTTP/1.1

获取一个配置文件

GET http://xxxxxxxx/v20/api/moon/file/get?name={filename} HTTP/1.1

获取一个数据库连接信息

GET http://xxxxxxxx/v20/api/moon/database/appdb?name={dbname} HTTP/1.1

获取一个 MySQL【租户库】连接信息

GET http://xxxxxxxx/v20/api/moon/database/tntdb/xsql/{tenantId} HTTP/1.1

获取一个 MySQL【租户库】的【只读库】

GET http://xxxxxxxx/v20/api/moon/database/tntdb/xsql/{tenantId}?flag=_readonly HTTP/1.1

获取一个 InfluxDB【租户库】连接信息

GET http://xxxxxxxx/v20/api/moon/database/tntdb/influx/{tenantId} HTTP/1.1

获取一个 InfluxDB【租户库】的【只读库】

GET http://xxxxxxxx/v20/api/moon/database/tntdb/influx/{tenantId}?flag=_readonly HTTP/1.1

配置服务存储说明

创建 configdb 数据库

脚本位置:Nebula.net\script\sql\config_db.sql

执行脚本后,会创建4张表:

  • configfiles: 用于存储独立配置文件
  • dbconfig: 用于存储数据库连接配置
  • settings: 用于存储全局配置/帐号参数
  • tenantconn: 用于存储租户的数据库连接映射



configfiles 配置文件表

configfiles表存储了所有的独立配置文件:

xx

字段说明:

字段名含意
FileName配置文件的名称,此名称必须全局唯一,建议包含应用的名称
FileBody配置文件的内容。



settings 全局参数配置

settings表用于管理所有全局的配置参数,可供多个服务共享访问。
所有参数保存在一张数据表中,可参考下图:

xx

字段说明:

字段名含意
name表示配置的名称,在代码中使用
value表示配置的值
restype资源类别,用于界面过滤以及监控发现
extra额外数据,可忽略
remark备注信息



restype的取值可参考下表: 监控枚举值说明



dbconfig 数据库路由表

dbconfig用于管理数据库的路由信息,可参考下图:

xx

这张表定义了【数据库连接名称】以及对应的连接参数,
所以在我们的代码中,只需要使用【数据库连接名称】就可以了。

特别说明

  • 对于SaaS应用程序来说,master连接 必须提前创建好,可参考上图中的第一行。

字段说明:

字段名含意
name数据库连接名称,供代码中引用
dbType数据库类型,0:SQLSERVER, 1:MYSQL
server数据库实例的服务器地址,IP或者DNS名称
database数据库名称
username登录用的用户名
password登录用的密码
port连接端口。 上以5个字段就是数据库的具体连接参数,有些列可不填(视具体数据库而定)
args连接字符串额外参数

dbtype的取值可参考:监控枚举值说明



tenantconn 租户&连接映射表

xx

字段说明:

字段名含意
tenant_id租户ID
db_name租户对应的数据库名称(在任何数据库都使用这个名称)
xsql_nameSQLSERVER/MySQL/PostgreSQL 实例的连接名称
influx_nameInfluxDb实例的连接名称

举例说明:

  • 假设租户ID=my561f78c61816f
  • 此租户的MySQL数据库【连接名】为 rds_1
  • 此租户的InfluxDB数据库【连接名】为 influx_1
  • 此租户的MySQL/InfluxDB的【数据库名称】:tianxiang_my561f78c61816f

当调用下面方法时:

public abstract class BaseController : ControllerBase, IDisposable
{
        /// <summary>
        /// 创建指定租户ID的数据库连接
        /// </summary>
        /// <param name="tenantId">租户ID,可以为空。如果是空,就从当前用户身份中获取。</param>
        /// <param name="readonlyDB">是否连接【只读库】</param>
        /// <returns></returns>
        public virtual DbContext CreateTenantConnection(string tenantId = null, bool readonlyDB = false)
}
public  class InfluxClient
{
    /// <summary>
    /// 根据租户ID创建对应的连接,再构造InfluxClient实例
    /// </summary>
    /// <param name="tenantId">租户ID</param>
    /// <param name="readonlyDB">是否连接【只读库】</param>
    /// <returns></returns>
    public static InfluxClient CreateTenant(string tenantId, bool readonlyDB = false)
}

执行过程:

  1. 先获取租户ID,假设租户ID=my561f78c61816f
  2. 查找 tenantconn 表,WHERE tenant_id='my561f78c61816f'
  3. 如果是连接MySQL则读取 xsql_name 列, 此时取到连接名称 rds_1
  4. 如果是连接InfluxDB则读取 influx_name 列, 此时取到连接名称 influx_1
  5. 如果指定了 readonlyDB=true,则连接名称后面再拼接 "_readonly",例如:rds_1_readonly
  6. (接第3步),再查找 dbconfig 表,WHERE name='rds_1',获取到 server, username, password
  7. 根据 tenantconn表的 db_name 以及获取的 server, username, password 生成一个连接字符串
  8. 连接到具体的租户库

监控&日志展示站点 - Nebula.Venus

Venus是一个站点,主要展示 2大类 数据



部分界面示例:

xxx


xxx


xxx


xxx

监控数据汇总服务 - Nebula.Mercury

这个服务的用途实时计算各个服务的调用情况,给Venus提供 “服务状态数据”,

例如下图:

xxx

它的所有数据就是来源于 Mercury 的实时汇总。

Mercury 的运行状态可参考:


xxx

后台配置站点 - Nebula.AdminUI

它是一个web站点,提供以下功能:



界面示例:

xxx

xx

数据查询服务 - Nebula.Neptune

数据查询服务有以下特点:

  • 通用性:它不与具体的数据查询逻辑绑定,因此比较通用。
  • 可配置性:服务接口的开发过程在界面中配置完成,不需要事先开发。
  • 低门槛:服务接口采用SQL方式配置,会SQL就能开发服务接口。

具体操作过程请继续阅读本文。



定义接口分类

为了便于维护,请在开发服务接口前,定义接口分类:

xx

点击图片下方的【添加】按钮,然后输入一个名称,

xx

再点击【保存】即可。

xx








开发服务接口

首先,选择接口分类,步骤接上图:点击左导航中的 TEST 链接

xx

参考下图,输入一些基本参数,

xx

点击 【根据查询脚本自动生成参数】,

xx

它会解析出2个参数:CategoryID 和 UnitPrice
此时你需要:

  • 调整 参数类型
  • 填写 参数描述
  • 填写 功能说明

就可以点击【保存】按钮了。

最终如果如下图所示:

xx








测试服务接口

xx

点击【测试调用】链接,然后给参数赋值,

xx

点击【发起测试调用】按钮,可查看调用结果

xx

到这里,一个服务接口就开发完成了!



点击调用对话框中的 【获取正式请求地址】就可以发给开发人员调用了,参数默认支持URL查询字符串传递。



数据查询服务 - Nebula.Neptune

在后台代码中发起调用

下面以 C# 举例

xx








应用程序登录并调用接口

1,应用注册

有些查询接口出于安全考虑,会限制访问范围,例如:某个查询接口仅允许“运维报告”程序访问,此时需要先注册应用程序。

xx

xx

2,查询接口限制调用

xx

3,应用程序登录及执行查询

可参考以下C#代码

xx

4,应用程序登录及执行查询(HTTP示例)

登录请求及响应

POST https://xxxxxxxxx.com/v20/api/neptune/client/login.svc HTTP/1.1
Content-Type: application/x-www-form-urlencoded; chartset=utf-8

appId=ea8841xxxxx&loginKey=OTYzMzM3NjAtMjlxxxxxxxxx

xx

在响应结果中,

  • 响应体就是供后续使用的 token
  • 同样的 token 也出现在 cookie中,因此可以用 CookieContainer 来维持会话



调用查询接口

注意:请求头中的 x-token (图片是测试环境,用的是 np-token)

GET https://xxxxxxxxx.com/data/query/aaaa/bbbbbbbb/ccccc/only-report?tenantId=my57972739adc90&currenDateStart=2020-08-31&currentDateEnd=2020-09-23 HTTP/1.1
x-result-format: json
x-token: eyJ0eXAiOiJxxxxxxxxxxxxxxxx

xx

如果没有这个请求头,则结果如下:

xx

数据查询服务 - Nebula.Neptune

选择数据源

数据查询服务支持以下数据源类别:
xx


说明:

  • RMDB-SQL,是指关系型数据库,例如:SQLSERVER, MySQL, PostgreSQL
  • 除 REST-API 之外,其它数据源类别都需要在后面的文本框中指定 “数据库连接名称”,且这个名称需要事先在配置服务中注册。








查询租户数据库

xx

界面中的连接名称固定为: [tenants]

客户端调用示例:

xx

租户ID通过请求头 x-tenantId 指定。



以下数据源类别支持租户库查询:

  • RMDB-SQL
  • InfluxDB
  • VictoriaMetrics

注意:数据查询服务访问 RMDB 租户库时固定使用【只读库】连接。



查询 某个特定的租户库

  • 数据源名称格式:tenantid=xxxxxxxxxxx
  • 不需要再用请求头指定 tenantid



参考链接:








数据输出格式

数据查询服务支持多种结果返回格式,在调用时可以使用请求头(或者QueryString) x-result-format 来指定,可选范围:
个别数据源可能不支持某个格式,如果不支持会有异常出现。

  • json (默认值)
  • json2
  • xml
  • xml2
  • csv
  • excel
  • raw

请求示例:

GET http://linuxtest:8211/data/query/test/vm/RequestCount?starttime=2022-04-07+17%3a00%3a00&endtime=2022-04-07+18%3a00%3a00&step=10 HTTP/1.1
db: mx22341235t3
x-result-format: raw

在查询服务中使用 ElasticSearch

查询 Elasticsearch / JSON查询风格

xx

在示例图片中,查询脚本就是符合 Elasticsearch 要求的HTTP查询请求,具体的ES访问地址不需要填写。 实际运行时,会根据数据源的参数来计算ES的访问地址。

有2种方法可以获得查询脚本:

注意事项:

  • 查询参数建议全部定义成 string 类型

示例图片中的查询脚本:

POST /oprlog-*/_search HTTP/1.1
Content-Type: application/json

{  
  "_source": {
    "excludes": ["exAll", "oprDetails", "ctxData", "addition"]
  },
  "size": 100,
  "sort": [
    { "startTime": {   "order": "desc"  }  }
  ],
  "query": {
    "bool": {
      "must": [],
      "filter": [
        {
          "bool": {
            "filter": [{
                "bool": {
                  "filter": [
                    { "bool": { "should": [ { "match_phrase": { "appName": "{appname}"} }], "minimum_should_match": 1} },
                    { "bool": { "should": [ { "match": {  "isSlow": 1 } }], "minimum_should_match": 1} }
                  ]
                }
              }]
          }
        },
        {
          "range": {
            "startTime": {
              "gte": "{start}",
              "lte": "{end}",
              "format": "strict_date_optional_time"
            }
          }
        }
      ]
    }
  }
}



说明:

  • JSON查询的参数采用 {xxx} 的占位形式
  • 如果需要查看 Elasticsearch 的原始响应,调用时请指定:x-result-format=raw





查询 Elasticsearch / SQL查询风格

也可以使用 SQL 语句来查询 Elasticsearch,例如:
xx


示例图片中的查询脚本:
select startTime,appName,oprId,isSlow,hasError 
from "oprlog-*" 
where appName = @appName  order by startTime desc limit 10



说明:

  • ES的SQL查询是有限制的,可参考: https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-limitations.html
  • 如果需要查看 Elasticsearch 的原始响应,调用时请指定:x-result-format=raw

日志数据清理服务 - Nebula.Juno

Nebula.Juno 是一个通用的数据表清理服务,它主要解决了以下问题:

  1. 日志表越来越大,需要清理【较老】的数据。
  2. 有可能一天就产生大量数据,所以需要及时清理。
  3. 避免人工执行大量DELETE语句产生大量数据库锁,影响业务系统稳定运行,甚至拖死数据库。
  4. 支持SaaS的多租户模式

针对第1个问题,Juno的解决方法是:

  • 可以为要清理的数据表指定一个时间(多少小时前),在这个时间之前的数据将会被清理和归档。
  • 不同的数据表可以指定不同的时间
  • 也可以设置一个全局默认设置的时间,方便多表时简化配置

针对第2个问题,Juno的解决方法是:

  • 一个小时检查一次有没有数据要清理
  • 可以为每个表指定一个最小的清理行数,避免频繁执行这个复杂的操作,也可以减少归档文件数量

针对第3个问题,Juno的解决方法是:

  • 整个清理操作分批次进行,进一步保证锁能快速释放,不影响数据库及业务系统运行。
  • 批次的行数可配置,可具体到表,也可指定一个全局默认值。

针对第4个问题,Juno的解决方法是:

  • 允许在一个配置文件指定多个数据库,以及它们包含的多张数据表
  • 引入一个特殊的名称 [tenants] 表示所有租户数据库



如何配置?

以下是一个配置示例

{
    // 清理多少小时之前的数据
    "HoursAgo": 48,

    // 每次清理记录的最大条数,如果超过这个数量,将拆分成多次执行清理和归档。
    "BatchRows": 1000,

    // 要清理的数据库:指定要处理哪些数据库以及它们的表
    "Databases": [
        {
            // 数据库连接名,需要事先在配置服务中注册。
            "DbName": "Nebula.Db",

            // 在这个数据库中要清理的表定义集合
            "Tables": [
                {
                    // 要清理的数据表名
                    "TableName": "TableX",

                    // 日期字段名,用于判断记录是否要清理
                    "TimeFieldName": "CreateTime"
                }
            ]
        },
        {
            // 特殊值:[tenants] = 所有租户库
            "DbName": "[tenants]",
            "Tables": [
                {
                    "TableName": "table1",
                    "TimeFieldName": "CreateTime"
                },
                {
                    "TableName": "table2",
                    "TimeFieldName": "CreateTime",

                    // 下面这2个字段不使用顶层的默认值
                    "HoursAgo": 24,
                    "BatchRows": 5000
                }
            ]
        }
    ]
}




使用限制

  • 数据表必须有一个日期字段,并创建索引

日志同步服务 - Nebula.Log2DB

使用背景:

  • 应用程序将OprLog日志写入到ES中,这些日志包含了用户的访问情况
  • 运营从员希望从SQL数据库中统计应用程序的访问情况

服务用途:

  • 从ES中 持续获取 OprLog,并写入MySQL

实现方法:

  • 先从Redis获取 "上次处理时间",第一次运行取上一个周期时间
  • 根据上次处理时间,查询只到最近 N 分钟前的数据
  • 将查询出来的数据写入 MySQL数据库
  • 将最后的处理时间写回Redis供下次使用
  • 持续执行以上4个步骤



配置文件(ClownFish.App.config)

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <add key="configServiceUrl" value="http://LinuxTest:8503" />
        <add key="MySqlClientProviderSupport" value="2" />

        <!--后台任务的执行间隔时间,单位:秒-->
        <add key="MainWorker_SleepSeconds" value="3600" />

        <!--日志查询过滤条件,多个名称用分号隔开,可以不指定(表示不做过滤条件) -->
        <add key="QueryFilter_AppNames" value="Uranus.TxApp;Aries.AIReportApp"/>
        
        <!--日志查询过滤条件,多个名称用分号隔开,可以不指定(表示不做过滤条件) -->
        <add key="QueryFilter_OprKinds" value="httpin"/>

        <!--日志查询过滤条件,只允许指定一个,可以不指定(表示不做过滤条件) -->
        <add key="QueryFilter_EnvName" value="MilkyWay"/>

        <!--查询过滤条件,查询几分钟前的数据-->
        <add key="QueryFilter_SecondsAgo" value="180"/>

        <!--数据保存目标-->
        <add key="SaveToDb_ConnName" value="logging-db"/>

    </appSettings>
    
</configuration>



输出表结构

CREATE TABLE `website_oprlog`  (
  `RowId` bigint NOT NULL AUTO_INCREMENT,
  `OprKind` varchar(255)  NOT NULL,
  `OprId` varchar(64)  NOT NULL,
  `OprName` varchar(255)  NOT NULL,
  `ParentId` varchar(64)  NULL,
  `RootId` varchar(64)  NULL,
  `StartTime` datetime(0) NOT NULL,
  `Duration` bigint NOT NULL,
  `Status` int NOT NULL,
  `IsSlow` int NOT NULL,
  `HasError` int NOT NULL,
  `TenantId` varchar(255)  NULL,
  `UserId` varchar(255)  NULL,
  `UserCode` varchar(255)  NULL,
  `UserName` varchar(255)  NULL,
  `UserRole` varchar(255)  NULL,
  `BizId` varchar(255)  NULL,
  `BizName` varchar(255)  NULL,
  `HttpMethod` varchar(255)  NULL,
  `Url` varchar(2048)  NOT NULL,
  `UserAgent` varchar(255)  NULL,
  `HttpRef` varchar(2048)  NULL,
  `Module` varchar(255)  NULL,
  `Controller` varchar(255)  NULL,
  `Action` varchar(255)  NULL,
  `ExType` varchar(255)  NULL,
  `ExMessage` text  NULL,
  `ExAll` longtext  NULL,
  `OprDetails` longtext NULL,
  `CtxData` longtext  NULL,
  `Addition` longtext  NULL,
  `AppName` varchar(255)  NOT NULL,
  `HostName` varchar(255)  NOT NULL,
  `EnvName` varchar(255)  NOT NULL,
  PRIMARY KEY (`RowId`),
  UNIQUE INDEX `OprId_idx`(`OprId`) ,
  INDEX `StartTime_idx`(`StartTime`) ,
  INDEX `AppName_idx`(`AppName`) 
) ENGINE = InnoDB CHARACTER SET = utf8mb4 ;



部署过程

  • 创建一个用于存储日志的数据库,并执行上面的SQL脚本(website_oprlog)
  • 在配置服务中增加一个 “数据库连接” name=logging-db ,指向包含website_oprlog表的数据库
  • 在配置服务中增加一个 "配置文件" name=Nebula.Log2DB.App.Config,内容参考前面的ClownFish.App.config
  • 修改配置服务的 "配置文件" name=Nebula.Juno.ArchiveOption.json,增加对website_oprlog表的定时清理,可参考下面配置
  "Databases": [
   {....现有配置....},
	{
      "DbName": "logging-db",
      "Tables": [
        {
          "TableName": "website_oprlog",
          "IdFieldName": "RowId",
          "TimeFieldName": "StartTime"
        }
      ]
    }
  ]

命令中心服务 - Nebula.Fides

使用场景

xx

这里涉及3个对象:

  • 命令的发布者:【云端】的各个应用程序
  • 命令的执行者:【云下】的客户端程序
  • 二端的协调者:用于连接各个应用程序和云下客户端。

执行流程介绍:

  • 客户端,以死循环的方式执行下面步骤:
    • (1)使用HTTP协议调用Fides拉取命令
    • (4)在获取到命令后,交给后台线程来执行,然后上传命令的执行结果(5)
    • (_)重新执行 (1) 步骤
  • Fides,监听HTTP端口,接收命令的发布和上传结果
    • (3)当接收到命令发布时,Fides会立即将命令返回给客户端
    • 如果没有收到命令,Fides则将客户端的请求挂起,直到命令出现或者达到超时
  • 应用程序
    • (2)根据业务需要,使用HTTP协议给Fides发布命令,并等待执行结果(6)

Fides的价值:

  • 简化【云端应用程序】调用【云下客户端】的过程
  • 对于【云端应用程序】来说,只需要对Fides发起一次HTTP调用即可得到结果





API调用

应用程序--发布命令--并等待--执行结果

POST http://hostxxxxxx/v20/api/fides/cmd/publish HTTP/1.1
Content-Type: application/json
x-token: .......jwt-token.................

{
  "ActionName": "actionx",
  "ClientId": "Client-3",
  "Caller": "Caller-111"
}

响应示例:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
    "ServerId": "e0e194b0bbc84db98f8eac6dff15e126",
    "CallbackId": "5459a6fe9b7541d59b9af53358044da8",
    "TenantId": "FISHDEV-WIN10",
    "ClientId": "Client-3",
    "ClientReceiveTime": "2022-07-13T18:51:34.9975544+08:00",
    "ClientStartTime": "2022-07-13T18:51:34.9975544+08:00",
    "ClientEndTime": "2022-07-13T18:51:34.9975544+08:00",
    "ServerReceiveTime": "2022-07-13T18:51:35.0918622+08:00",
    "ResponseResult": "FISHDEV-WIN10#Client-3 execute actionx OK!",
    "ClientAddition": "addition is ..xxx..",
    "RequestId": "92eeaf0008e543a6846748d6a0615892"
}

请求体的数据结构如下:

public sealed class PublishArgs
{
    /// 要执行命令的客户端Id
    [Required]
    public string ClientId { get; set; }

    /// 命令名称
    [Required]
    public string ActionName { get; set; }

    /// 命令的调用参数
    public string Args { get; set; }

    /// 命令的发布者
    public string Sender { get; set; }

    /// 发布命令的功能场景
    public string Caller { get; set; }

    /// 命令是单向的,不需要等待执行结果
    public int IsOneWay { get; set; }

    /// 服务端的最大等待时间
    public int ServerWaitSecond { get; set; }

    /// 客户端的最大等待时间
    public int ClientWaitSecond { get; set; }

    /// 客户端执行命令时,如果发生异常,最大的允许多少次重试
    public int MaxExecuteRetryCount { get; set; }

    /// 客户端上传结果时,如果发生异常,最大的允许多少次重试
    public int MaxUploadRetryCount { get; set; }

    /// 客户端遇到异常做重试时,中间间隔多少秒
    public int ErrorWaitSecond { get; set; }
}

响应体的数据结构如下:

public sealed class CommandResult
{
    /// 服务端节点Id
    public string ServerId { get; set; }

    /// 服务端当前请求ID
    public string RequestId { get; set; }

    /// 引用 ClientCommand.CallbackId
    public string CallbackId { get; set; }

    public string TenantId { get; set; }
    public string ClientId { get; set; }
    public DateTime ClientReceiveTime { get; set; }
    public DateTime ClientStartTime { get; set; }
    public DateTime ClientEndTime { get; set; }
    public DateTime ServerReceiveTime { get; set; }

    /// 客户端的响应结果
    public string ResponseResult { get; set; }

    /// 客户端的结果响应类型,,可参考HTTP的 Content-Type 响应头
    public string ResponseContentType { get; set; }

    /// 客户端的结果响应的编码方式,可参考HTTP的 Content-Encoding 响应头
    public string ResponseContentEncoding { get; set; }

    /// 客户端的一些附加描述信息
    public string ClientAddition { get; set; }

    /// 客户端引发的异常类型
    public string ExecptionType { get; set; }

    /// 客户端引发的异常描述
    public string ExecptionText { get; set; }    
}





客户端--拉取命令

GET http://hostxxxxxx/v20/api/fides/cmd/pull HTTP/1.1
x-token: .......jwt-token.................
x-expect-count: 1
x-client-timeoutms: 110000

请求头说明:

  • x-expect-count 明确告诉服务端:一次只返回一个ClientCommand对象。
    如果不指定,则以数组形式返回(也只有一个对象)
    这个参数主要是为了解决兼容性问题,建议指定,且 value 只能设置为 1
  • x-client-timeoutms 告诉服务端:客户端的超时时间
    如果不指定此参数,请求在服务端挂起的时间由服务端决定,此时客户端的超时时间必须大于服务端的挂起时间。

响应示例:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
    "CommandGuid": "ddcd19465244410cb8030701e029bf97",
    "CreateTime": "2022-07-13T19:06:55.8980434+08:00",
    "ServerId": "e0e194b0bbc84db98f8eac6dff15e126",
    "CallbackId": "f5e12ca3a8f642f8a2945009461e6769",
    "ActionName": "actionx",
    "Caller": "Caller-111",
    "IsOneWay": 0,
    "UserId": "11111",
    "UserType": "admin",
    "TenantId": "FISHDEV-WIN10",
    "ClientId": "Client-3",
    "ClientWaitSecond": 0,
    "ServerWaitSecond": 20,
    "MaxExecuteRetryCount": 0,
    "MaxUploadRetryCount": 0,
    "ErrorWaitSecond": 0
}

响应体的数据结构如下:

public sealed class ClientCommand
{
    /// 服务端创建时间
    public DateTime CreateTime { get; set; }

    /// 服务端节点Id
    public string ServerId { get; set; }

    /// 客户端上传结果后的回调通知ID,【始终应该使用这个字段来做唯一ID】
    public string CallbackId { get; set; }

    public string ActionName { get; set; }
    public string Args { get; set; }
    public string Caller { get; set; }

    /// 命令是单向的,不需要等待执行结果
    public int IsOneWay { get; set; }

    public string UserId { get; set; }
    public string UserType { get; set; }
    public string TenantId { get; set; }
    public string ClientId { get; set; }

    public int ClientWaitSecond { get; set; }
    public int ServerWaitSecond { get; set; }
    public int MaxExecuteRetryCount { get; set; }
    public int MaxUploadRetryCount { get; set; }
    public int ErrorWaitSecond { get; set; }
}





客户端--上传--命令执行结果

POST http://hostxxxxxx/v20/api/fides/cmd/upload HTTP/1.1
Content-Type: application/json
x-token: .......jwt-token.................

{
    "CallbackId": "..............",
    "ClientStatus": 200,
    "ResponseResult": ".............."
}

响应示例:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
    "CallbackId": "5459a6fe9b7541d59b9af53358044da8"
}

请求体的数据结构如下:

public sealed class UploadArgs
{
    /// 引用 ClientCommand.CallbackId
    [Required]
    public string CallbackId { get; set; }

    public DateTime ClientReceiveTime { get; set; }
    public DateTime ClientStartTime { get; set; }
    public DateTime ClientEndTime { get; set; }

    /// 客户端对于执行结果的状态码表示
    public int ClientStatus { get; set; }

    /// 客户端的响应结果
    public string ResponseResult { get; set; }

    /// 客户端的结果响应类型
    public string ResponseContentType { get; set; }

    /// 客户端的结果响应的编码方式,可参考HTTP的 Content-Encoding 响应头
    public string ResponseContentEncoding { get; set; }

    /// 客户端的一些附加描述信息
    public string ClientAddition { get; set; }

    /// 客户端引发的异常类型
    public string ExecptionType { get; set; }

    /// 客户端引发的异常描述
    public string ExecptionText { get; set; }
}





日志&问题排查

发布命令时,Fides会记录一些重要数据到Oprlog日志中,可以在Venus/Kibana中查看:

xx

日志字段含意

  • bizId: CallbackId
  • bizName: ActionName
  • text2: TenantId
  • text3: ClientId

其中 CallbackId 贯穿了 publish/pull/upload 3个阶段,可以根据此数据来查询对应的日志:

xx

WebHook服务 - Nebula.Ceres

设计背景:增强产品开放性。
例如:工单创建或者状态改变后及时通知第三方程序实现数据同步。



通常来说,如果希望应用程序具备较好的开放性(易于集成),应该做到:

  • 提供丰富&开放的WebAPI,供第三方程序主动获取数据以及推送数据。
  • 提供WebHook事件机制,供第三方订阅关键事件,实现业务联动。

本文所说的WebHook服务,就是为了解决第2点而设计,
它是一个通用服务,有以下优点:

  • 解耦合:应用程序只管发布事件,不用关注事件如何发送给第三方,以及有多少个第三方
  • 易用性:应用程序只负责发布事件,所有注册/订阅过程全都不需要关注
  • 通用性:Ceres接受多个应用程序的事件,并将事件发送到匹配的第三方程序订阅者
  • 可靠性:内置复杂且可靠的重试机制,尽量保证事件能成功发送给第三方

对于WebHook架构而言,会有3个参与对象

  • 事件发布者: 发布Web事件的应用程序,通常是【内部程序】
  • 事件订阅者: 订阅事件的应用程序,通用是【外部程序】,并且一个事件可能有多个不同的订阅者
  • 服务本身: WebHook服务 - Nebula.Ceres

如下图:

xx



事件发布者

事件发布者在使用WebHook服务过程中需要有2个动作:

  • 事件注册
    • 应用程序在发送事件前,需要先注册事件(可在 AdminUI 中配置)
  • 事件发布
    • 应用程序代码发布事件,例如:创建工单之后



事件发布者-发布事件

使用客户端的示例代码

// 准备事件相关的参数
string eventName = "TaskOrder.Create";
object eventData = new { name1, name2 };   // 也可以是一个DTO或者实体对象

// 调用WebHook服务端
await new CeresClient(eventName, eventData)
            .SetRetry(2, 3600)  // 设置重试参数,这个调用是可选的
            .GetRequest()
            .SendAsync();

使用HTTP协议的调用示例:

POST http://localhost:8504/v20/api/ceres/event/publish?sender=xxxx&eventName=xxxx&tenantId=xxxx HTTP/1.1
Content-Type: application/json; chartset=utf-8
x-Retry-MaxCount: 2
x-Retry-Expiration: 60

{
   "name1": "aaa"
   "name2": "bbb"
}



事件订阅者(第三方程序)

事件订阅者在使用WebHook服务过程中需要有2个动作:

  • 订阅事件
    • 在接收WebHook事件前,必须先订阅事件(可在 AdminUI 中配置)
      订阅事件时,必须提供【事件回调模板】来接收事件数据,示例如下。
  • 处理回调事件
    • WebHook服务根据订阅时提供的【事件回调模板】,调用第三方应用

事件回调模板示例:
POST http://www.3rd-app.com/webhook/callback?v={rand} HTTP/1.1
x-header1: aaaaaaaaaa
x-header2: bbbbbbbbb
x-event-name: TaskOrder.Create
x-event-src: Nebula.TestApp
x-xx1: {data.name1}
x-xx2: {data.name2}
Content-Type: application/json; charset=utf-8

{data}

事件回调模板的用法可参考:文本模板



WebHook服务内部实现


事件处理流程

  • 接收事件
    • 根据 sender+eventname 查找事件定义
    • 根据 evnetId+tenantId 匹配第三方订阅者
    • 构造事件消息 HookEvent,每个订阅都有一个消息对象
    • 将消息对象存入MQ -- Ceres_WebHook_Events
  • 执行推送
    • 根据回调模板和事件数据,构造回调的HTTP请求
    • 发起回调请求
    • 记录成功日志
  • 执行推送-错误处理
    • 检查消息是否需要重试
    • 如果需要则
      • 累加重试次数
      • 将消息发到MQ -- Ceres_WebHook_WaitRetry
      • 没有订阅者,一直会等到 “到期” 被移到 “Ceres_WebHook_Retry”
    • 如果不需要重试,就结束处理
    • 如果所有重试全部失败:将消息写入MQ -- Ceres_WebHook_DeadMsg
  • 执行失败重试
    • 记录【重试操作】日志
    • 执行重试,与 执行推送 过程一致





重试处理

  • 当出现消息处理失败时,将启动消息重试机制
  • MaxRetryCount: 如果超过这个次数,将不再重试
  • Expiration: 过期时间,单位:秒。从服务端接收到调用算起,如果超过这个时间,将不再重试
  • 每次消息处理过程,不论是第一次还是后续重试,都产生一条执行日志-HookEventLog
  • 重试失败的消息将写入一个死信队列-Ceres_WebHook_DeadMsg



重试间隔时间:

  • 固定间隔:3 秒
  • 变长间隔:请参考下图
  • 控制参数:本地参数 Nebula_LongRetryUtils_Mode = 1,启用【变长间隔】,否则使用 【固定间隔】

xx







执行日志

在回调第三方的WebHook时,

  • 不论成功还是失败,都是会记录一条日志
  • 如果是重试执行,会单独再记录一条日志,RetryCount 会不同

所有日志会写入 ElasticSearch,索引文件名称:hookeventlog-yyyyMMdd

建议在部署Ceres时在Kibana中执行以下配置

  • 新建 "Index Lifecycle Policie",假设名为:applog_policy
  • 新建 "Index Management", Index pattern: hookeventlog-* ,并按下面的示例来配置Settings
  • 新建 "Index pattern",时间字段选 serverTime
{
  "index": {
    "format": "1",
    "lifecycle": {
      "name": "applog_policy"
    }
  }
}

通知服务 - Nebula.Metis

通知服务主要解决以下问题:

  • 应用程序与消息发送过程解耦:程序不需要关注消息发给谁,以及用什么方式发送
  • 应用程序与消息内容编排解耦:程序只管提交消息数据,最终发出的消息格式由模板决定
  • 消息的发送方式灵活可配置:消息可以用短信发出,或者邮件,或者聊天工具发出
  • 发送方式多样性支持:目前已支持 12 种发送渠道,3类消息格式



3类消息格式:

  • 文本消息(含模板,Markdown)
  • 文件消息
  • 图片消息



发送渠道:
  可参考本文示例中的SendConfig参数,

  • 它是一个数组,表示可以包含多个发送目标
  • Mode:发送方式
    1. Mode=1: 邮件
    2. Mode=101: 企业微信-群-webhook
    3. Mode=102: 企业微信-个人
    4. Mode=103: 企业微信-群
    5. Mode=201: 钉钉-群-webhook
    6. Mode=202: 钉钉-个人
    7. Mode=203: 钉钉-群
    8. Mode=301: 飞书-群-webhook
    9. Mode=302: 飞书-个人
    10. Mode=303: 飞书-群
    11. Mode=401: POST URL
    12. Mode=501: 短信(阿里云短信)
  • Target:发送目标,数组类型,范围:
    • 邮箱
    • Im用户名
    • Im群ID
    • ImHook配置,格式:ImType=xxx;WebHookUrl=xxxxxxxx;SignKey=xxxx
    • URL
    • 手机号
  • AuthConf: 登录认证配置名称,【可选参数】
    • 连接参数需要事先在配置服务中注册
      • 格式:ImType=WxWork;AppId=xxxxxxxxxxxx;AppSecret=xxxxxxxxxxxxxxxx;AgentId=xxxxxx
      • 格式:ImType=DingDing;AppId=xxxxxxxxxxxxxx;AppSecret=xxxxxxxxxxxxxxxx;AgentId=xxxxx
      • 格式:ImType=FeiShu;AppId=xxxxxxxxx;AppSecret=xxxxxxxxxxxxxxxxxxx;AgentId=0
    • 仅在部分需要认证的场景中使用
    • 如果不指定,使用以下默认值:
      • IM场景:"ImApp.Auth.Config"
      • 邮件:"Nebula_Metis_Email"





发送【文本】消息

POST http://xxxxxxxx/v20/api/metis/notify2/text.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "Text": "xxxxxxxxxx消息正文内容xxxxxxxxxxx",
    "IsMarkdown": 0,    
	"MaxRetryCount": 2,
    "Expiration": 60,
    "Subject": "message title",
    "SendConfig": [
        {
            "Mode": 102,  "Target": [ "liqf01", "yangmc", "fangw" ], "AuthConf": "WxWork.AppAuth.Config_124"
        },
        {
            "Mode": 103, "Target": [ "7f0f8881a37f4be49be51f3f8b4e42c4"  ], "AuthConf": "WxWork.AppAuth.Config_124"
        }
    ]
}

参数说明:

  • TenantId: 租户ID,如果消息不涉及租户可指定为 "NULL"
  • AppName:应用名称
  • SendId:一个业务ID号

  • Text: 消息正文内容,可以是一个普通的字符串,或者是 markdown
  • IsMarkdown: 文本内容是否为 markdown,目前仅供一些IM渠道使用
  • MaxRetryCount: 最大重试次数,【可选参数】
  • Expiration: 过期时间,单位:秒,【可选参数】
  • Subject:消息标题。某些场景下使用,例如:邮件
  • SendConfig: 发送渠道参数

  • 此处列出的部分参数,在下面的场景中出现时,将不再重复说明。
  • 除明确说明是 可选参数 之外,其它都是 必选参数

C#代码示例

await new MetisClient("message title")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(MetisSendConfig.Text001())
        .SetRetry(2, 600)
        .GetTextRequest("xxxxxxxxxx消息正文内容xxxxxxxxxxx")
        .SendAsync();



发送【模板】消息

POST http://xxxxxxxx/v20/api/metis/notify2/text.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "Text": "{\"tenant_name\":\"XX租户\",\"last_week_result\":99,\"slow_pages\":19,\"unreslove_bugs\":66,\"fault_ticket_rate\":0.85,\"fault_ticket_cnt\":12,\"fault_ticket_times\":7,\"update_fault_tickets\":77,\"timeout_bugs\":13,\"urgent_bug_count\":9,\"health_score\":120}",
    "Template": "# **当日拉闸指标计数更新**
> 租户名称:{{data.tenant_name}}
> 最近七天突破底线次数:{{if ((string.to_int data.last_week_result)>0)}}<font color=\"warning\">{{data.last_week_result}}</font>{{else}}<font color=\"info\">{{data.last_week_result}}</font>{{end}}次
> 最近一天拉闸指标如下:
> 关键用户近七天平均慢次数(底线小于等于5个):{{if ((string.to_float data.slow_pages)>5)}}<font color=\"warning\">{{data.slow_pages}}</font>{{else}}<font color=\"info\">{{data.slow_pages}}</font>{{end}}
> 整体BUG数(底线小于等于20个):{{if ((string.to_int data.unreslove_bugs)>20)}}<font color=\"warning\">{{data.unreslove_bugs}}</font>{{else}}<font color=\"info\">{{data.unreslove_bugs}}</font>{{end}}
> 故障类事件占比:{{if (data.fault_ticket_rate>0.10)}}<font color=\"warning\">{{ 100*(string.to_float data.fault_ticket_rate) }}%</font>{{else}}<font color=\"info\">{{ 100*(string.to_float data.fault_ticket_rate) }}%</font>{{end}},故障工单数(底线工单数大于5时,故障类事件占比小于等于10%):{{if ((string.to_int data.fault_ticket_cnt)>5)}}<font color=\"warning\">{{data.fault_ticket_cnt}}</font>{{else}}<font color=\"info\">{{data.fault_ticket_cnt}}</font>{{end}},连续突破底线天数:{{if ((string.to_int data.fault_ticket_times)>1)}}<font color=\"warning\">{{data.fault_ticket_times}}</font>{{else}}<font color=\"info\">{{data.fault_ticket_times}}</font>{{end}}
> 更新引发事件量(底线小于30个):{{if ((string.to_int data.update_fault_tickets)>30)}}<font color=\"warning\">{{data.update_fault_tickets}}</font>{{else}}<font color=\"info\">{{data.update_fault_tickets}}</font>{{end}}
> 超时未解决BUG(底线小于5个):{{if ((string.to_int data.timeout_bugs)>4)}}<font color=\"warning\">{{data.timeout_bugs}}</font>{{else}}<font color=\"info\">{{data.timeout_bugs}}</font>{{end}}
> P1级别BUG类事件单数(底线小于等于2个):{{if ((string.to_int data.urgent_bug_count)>2)}}<font color=\"warning\">{{data.urgent_bug_count}}</font>{{else}}<font color=\"info\">{{data.urgent_bug_count}}</font>{{end}}
> 最近一天三高一关键告警得分如下:
> 系统健康度得分(底线大于等于80分):{{if ((string.to_int data.health_score)<80)}}<font color=\"warning\">{{data.health_score}}</font>{{else}}<font color=\"info\">{{data.health_score}}</font>{{end}}",
    "IsMarkdown": 1,
    "MaxRetryCount": 2,
    "Expiration": 600,
    "Subject": "message title",
    "SendConfig": [
        {
            "Mode": 102,  "Target": [ "liqf01", "yangmc", "fangw" ], "AuthConf": "WxWork.AppAuth.Config_124"
        }
    ]
}

参数说明:

  • Text: 用于绑定模板所需的数据
  • Template: 消息模板,如果不使用模板,可以不指定。

C#代码示例

object data = new {
    tenant_name = "XX租户",
    last_week_result = 99,
    slow_pages = 19,
    unreslove_bugs = 66,
    fault_ticket_rate = 0.85,
    fault_ticket_cnt = 12,
    fault_ticket_times = 7,
    update_fault_tickets = 77,
    timeout_bugs = 13,
    urgent_bug_count = 9,
    health_score = 120
};

await new MetisClient("message title")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(MetisSendConfig.Xmsg001())
        .SetRetry(2, 600)
        .GetTextRequest(data.ToJson(), MetisSendConfig.Template2, true)
        .SendAsync();



发送【文件】消息

POST http://xxxxxxxx/v20/api/metis/notify2/file.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "FileBody": "......file-base64-string.....or...URL......",
    "FileName": "logs.txt",
    "MaxRetryCount": 2,
    "Expiration": 600,
    "Subject": "test send notify/file",
    "SendConfig": [
        {
            "Mode": 102,  "Target": [ "liqf01", "yangmc", "fangw" ], "AuthConf": "WxWork.AppAuth.Config_124"
        }
    ]
}

参数说明:

  • Filebody: 文件二进制内容做Base64编码后的结果,或者一个可下载的URL
  • Filename: IM工具中显示的文件名

C#代码示例

string fileBodyBase64 = "......file-base64-string.....or...URL......";
string filename = "logs.txt";

await new MetisClient("test send notify/file")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(MetisSendConfig.FileImg_001())
        .SetRetry(2, 600)
        .GetFileRequest(fileBodyBase64, filename)
        .SendAsync();



发送【图片】消息

POST http://xxxxxxxx/v20/api/metis/notify2/image.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "FileBody": "......image-base64-string.....or...URL......",
    "MaxRetryCount": 2,
    "Expiration": 600,
    "Subject": "test send notify/image",
    "SendConfig": [
        {
            "Mode": 102,  "Target": [ "liqf01", "yangmc", "fangw" ], "AuthConf": "WxWork.AppAuth.Config_124"
        }
    ]
}

C#代码示例

string imageBase64OrUrl = "......image-base64-string.....or...URL......";

await new MetisClient("test send notify/image")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(MetisSendConfig.FileImg_001())
        .SetRetry(2, 600)
        .GetImageRequest(imageBase64OrUrl)
        .SendAsync();



用短信方式,发送验证码

POST http://xxxxxxxx/v20/api/metis/notify2/text.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "Text": "{\"code\":\"c8ceb8\"}",
    "MaxRetryCount": 2,
    "Expiration": 600,
    "Subject": "test send notify/sms",
    "SendConfig": [
        {
            "Mode": 501,   "Target": [ "15871353763" ],  "Options": "SignName=明源云运维;TemplateCode=SMS_229636351"
        }
    ]
}

参数说明:

  • Options: 阿里云短信需要这个属性来指定它自己的模板参数

C#代码示例

object data = new { code = Guid.NewGuid().ToString("N").Substring(0, 6) };

await new MetisClient("test send notify/sms")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(MetisSendConfig.Sms001())
        .SetRetry(2, 600)
        .GetTextRequest(data.ToJson())
        .SendAsync();



用企业微信,发送验证码

POST http://xxxxxxxx/v20/api/metis/notify2/text.svc?tenantId={TenantId}&sender={AppName}&sendId={SendId} HTTP/1.1
Content-Type: application/json

{
    "Text": "Nebula.Venus 登录码: 7777777 ,有效期2分钟。",
    "MaxRetryCount": 2,
    "Expiration": 600,
    "Subject": "test send notify/vcode",
    "SendConfig": [
        {
            "Mode": 102,  "Target": [ "liqf01" ], "AuthConf": "WxWork.AppAuth.Config_124"
        }
    ]
}

C#代码示例

string text = "Nebula.Venus 登录码: 7777777 ,有效期2分钟。";

NotifySendConfig sendConfig = new NotifySendConfig {
    Mode = NotifySendMode.WxworkUser,
    Target = new string[] { "liqf01" },
    AuthConf = "WxWork.AppAuth.Config_124"
};

await new MetisClient("test send notify/vcode")
        .SetTenantId("my57a04574bf635")
        .SetSendConfig(sendConfig)
        .SetRetry(2, 600)                
        .GetTextRequest(text)
        .SendAsync();





发送消息的服务端响应

为分3大场景:

  • 服务端成功处理请求:HTTP 200
  • 客户端提交的数据不正确:HTTP 400
  • 服务端出现内部错误:HTTP 500

所有场景可以不关注响应体。



消息重试

  • 当出现消息发送失败时,如果不是服务端明确拒绝的错误,都将启动消息重试机制
  • MaxRetryCount: 如果超过这个次数,将不再重试
  • Expiration: 过期时间,单位:秒。从服务端接收到调用算起,如果超过这个时间,将不再重试
  • 每次发送过程,不论是第一次发送还是后续重试发送,都产生一条发送日志
  • 重试失败的消息将写入一个死信队列



重试范围

  • 当消息发送到多个目标群时,如果只是其中一个群发送失败,那么仅重试那个群的发送过程
  • 如果可以批量发送,例如邮件可同时发送给多个人,那么就是整个批操作重试
  • 如果发送目标,既有多个群又有多个用户,那么多个用户始终做为一个批来处理,每个群是独立处理,重试也是小范围的



重试间隔时间:

  • 固定间隔:3 秒
  • 变长间隔:请参考下图
  • 控制参数:本地参数 Nebula_LongRetryUtils_Mode = 1,启用【变长间隔】,否则使用 【固定间隔】

xx







发送日志

通知在发送时,

  • 不论成功还是失败,都是会记录一条日志
  • 如果是重试执行,会单独再记录一条日志,RetryCount 会不同

所有日志会写入 ElasticSearch,索引文件名称:notifysendlog-yyyyMMdd

建议在部署Metis时在Kibana中执行以下配置

  • 新建 "Index Lifecycle Policie",假设名为:applog_policy
  • 新建 "Index Management", Index pattern: notifysendlog-* ,并按下面的示例来配置Settings
  • 新建 "Index pattern",时间字段选 serverTime
{
  "index": {
    "format": "1",
    "lifecycle": {
      "name": "applog_policy"
    }
  }
}









请点击目录中的节点查看具体文档

性能问题排查-通用方法

基于Nebula的框架开发的应用程序在遇到性能问题时,可以按下面的步骤来分析性能原因:

  1. 从Venus中找到出现性能的应用程序
  2. 进入Kibana界面查看性能日志列表
  3. 展开OprName查看功能点的分布情况
  4. 从日志中进入JeagerUI查看具体的执行过程
  5. 根据JeagerUI的图形找到性能根因




从Venus中找到出现性能的应用程序

打开Venus页面,观察每个服务的【延迟数】指标,即可发现性能不佳的应用程序,例如:

xx



也可以观察【响应ms】这个指标,例如:

xx



这二个指标任何一个偏大,都表示程序有性能问题。

根据这2个指标找到线索后,可以点击【延迟数】下面的数字,即可进入Kibana界面查看性能日志列表。





进入Kibana界面查看性能日志列表

从Venus转到Kibana的日志列表界面展示如下:

xx

可以看到:

  • 索引已切换到 oprlog
  • 过滤条件已设置为当前环境的当前应用程序

在此,我们要做的操作就是按图片所示,点击【OprName】文字





展开OprName查看功能点的分布情况

此时的效果如下:

xx

图片展示了3个服务的性能统计情况。


根据Kibana的统计,我们可以直观的知道程序慢在哪些功能点上。

这是一个很重要的步骤,我们要确定分析&优化哪些较慢的业务功能。

经验&建议

  • 我们可以采用2/8原则,优先解决占比较多的业务功能即可
  • 尤其是挑选【占比数值】较大的

以图片为例:

  • 程序1:优化前2个功能
  • 程序2:优化前3个功能
  • 程序3:2个功能都要优化

确定功能范围后,可以点击后面的 + 来做进一步的过滤,例如:

xx

xx

在这个列表中,可以随机挑选几条日志,展示它们(按图片所示),例如:

xx


请点击: 【View in JeagerUI】链接





从日志中进入JeagerUI查看具体的执行过程

此时JeagerUI的展示如下:

xx





根据JeagerUI的图形找到性能根因

查看上面的图片,很明显: ToList 这个操作对应的 色块较长,表示这个操作的耗时较多。

点击它,可展示查看具体的执行过程描述:

xx

至此性能问题已定位清楚:就是一个SQL语句导致的!

性能问题排查-案例1

此案例是由一个SQL查询导致的性能问题。


1,找到性能较慢的服务

打开Venus,很明显 Satum.MysmAdminApp 这个服务比较慢:

  • 平均响应时间:127ms
  • 当天的慢请求数:7340次

xx

此时点击【7340】数字( 需要先登录),进入日志列表界面。



2,初步定位

在 Kibana 中查看 oprName 的分布情况,观察慢在哪些请求上

xx



3,分析具体的请求

请按示例图片来分析,注意图片上的文字说明。

xx

xx

xx

xx

xx



结论

通过上面的日志分析过程,性能瓶颈在一个SQL查询上。

性能问题排查-案例2

此案例是由一个数据表批量更新导致的性能问题。


1,进入性能日志列表


先打开Venus,找到要优化的服务卡片,例如:

xx

点击延迟数下面的数字链接,进入日志列表界面:

xx



2,观察&过滤日志

点击【oprName】,它会展开出现下面列表:

xx

点击上图中的 + 符号即可过滤,例如:

xx

此时可以看到 stepCount 这一列的数字都比较大。

这个列的数字含意是:一次请求过程中执行了多少次远程调用,显然这个数字应该是越少越好,

这里偏大就很不合理了!



3,查看日志细节,了解执行过程

我们进一步查看日志中的性能详情,请参照图片来操作:

xx

可以看到下图:

xx

基本上全是数据库操作(ExecuteNonQueryAsync),任意选择几条查看下到底在做什么,

xx



4,结论


性能分析:

  1. 本次分析的这个请求: 性能不达标,且执行次数占比较多,导致服务的整体性能较差。
  2. 请求性能不达标的原因是:在一次请求中执行了太多的UPDATE数据库操作

结合业务场景来分析:

  1. 这是一个采集数据的保存场景,每次采集到数据都会更新到数据表
  2. 这个场景中,每次采集都会有大量的数据(一个K8S集群中所有POD信息)
  3. 这些POD信息并不是一直变化!

优化建议:

  1. 为每次采集的数据生成一个HASH值
  2. 每次更新前,计算HASH值,与上一次比较,有变化时才更新。
  3. 更新数据后保存最近的HASH值

性能问题排查-案例3

此案例是由机器负载较高引发的性能问题。


1,找到性能较慢的服务

xx



2,进入日志界面

点击上图中的数字【5392】,进入日志界面

xx

注意看:最近1小时,OprLog有 129 条慢日志。

此时切换到 NebulaLog,

xx

注意:

  • NebulaLog比OprLog要多,此时表示性能的大部分消耗是由框架导致
  • 大部分的日志产生在同一台机器上



3,分析机器负载

查看服务的【容器组】界面,可以看到有多少个POD,以及它们运行在哪个机器节点上:

xx

根据容器名称找到对应的机器节点IP。

然后再查看机器状态:

xx



4,结论

机器负载较高,需要升级硬件配置!

死锁问题排查

初步判断

某个服务在启动时线程数量会越来越多,可以在Venus中查看,并收到通知:

xx

如果观察服务的请求处理量,发现并没有增加,因此判断 服务出现死锁了!



抓dump包


具体过程可参考:抓dump包



用VS分析dump包

用VS打开dump文件,并点击链接

xx

观察每个线程当时在做什么

xx

很明显,大多数线程卡在 SendMessage 方法的 lock 语句上。

出现这个问题,一般可以肯定:至少有一个线程已进入 lock 语句块。
此时注意看VS给出的提示:

xx

找到 141 号线程,此时发现进入 lock 的线程在这里。

xx



另一种方法确认

用 dotnet-dump 来分析 dump 文件。

dotnet-dump analyze  "E:\Temp\dump\新建文件夹\DataCenterApp.dmp"

查看进程中的所有锁对象:

syncblk

xx

很明显,有大量的线程阻塞在这个锁对象上。

再来查看持有锁的线程在做什么:

> setthread 64
> clrstack

xx

此时可以看到比VS更完整的线程堆栈信息。



这里补充说明下syncblk的结果中如何得到线程ID

xx

00007FD26104EFE0 8d  64

这3个列其实都是线程信息,只不过是3种表示方式而已。

可以执行 clrthreads 命令来查看:

xx

(十六进制)8D == (十进制)141,VS中显示的就是线程141




分析&结论

  1. 发送Rabbit消息放在Action中执行,
  2. 当第一个Action线程在打开Rabbit连接时,ASP.NET 接收了大量的HTTP请求,此时消耗了大量的线程,
  3. 打开Rabbit连接时,至少需要消耗3个线程,导致这个操作一直没有机会完成
  4. 后面越来越多的HTTP请求进入,Rabbit连接永远无法打开,造成程序卡死。









请点击目录中的节点查看具体文档

FAQ

MySQL乱码

表现现象

  • INSERT, UPDATE 时,中文成乱码(问号)
  • SELECT WHERE中包含中文时查不到结果

解决方法:http://note.youdao.com/noteshare?id=f2578f5b0c3209ebf44b7c42b43e2e10




SSL Handshake failed with OpenSSL error

如果遇到这类问题,例如:

(MySqlException) SSL Authentication Error
==>(AuthenticationException) Authentication failed, see inner exception.
==>(SslException) SSL Handshake failed with OpenSSL error - SSL_ERROR_SSL.
==>(OpenSslCryptographicException) error:1425F102:SSL routines:ssl_choose_client_version:unsupported protocol

或者

System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.Security.Authentication.AuthenticationException: Authentication failed, see inner exception.
---> Interop+OpenSsl+SslException: SSL Handshake failed with OpenSSL error - SSL_ERROR_SSL.
---> Interop+Crypto+OpenSslCryptographicException: error:141A318A:SSL routines:tls_process_ske_dhe:dh key too small
--- End of inner exception stack trace ---

这类问题和 .net 的基础镜像有关,可参考:https://github.com/dotnet/runtime/issues/30667

解决办法,修改 Dockerfile 增加以下内容:

RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /usr/lib/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /usr/lib/ssl/openssl.cnf

例如:

FROM mcr.microsoft.com/dotnet/aspnet:5.0

ENV TZ=Asia/Shanghai
ENV ASPNETCORE_URLS=http://*:80
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN    sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /etc/ssl/openssl.cnf \
    && sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /etc/ssl/openssl.cnf \
    && sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /usr/lib/ssl/openssl.cnf \
    && sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /usr/lib/ssl/openssl.cnf

WORKDIR /app
EXPOSE 80

ADD publish /app
ENTRYPOINT ["dotnet", "Nebula.Venus.dll"]




.netcore3不能连接SQLSERVER

异常提示:

Microsoft.Data.SqlClient.SqlException (0x80131904): A connection was successfully established with the server, but then an error occurred during the pre-login handshake. (provider: TCP Provider, error: 35 - An internal exception was caught) 
---> System.Security.Authentication.AuthenticationException: Authentication failed, see inner exception. 
---> Interop+OpenSsl+SslException: SSL Handshake failed with OpenSSL error - SSL_ERROR_SSL. 
---> Interop+Crypto+OpenSslCryptographicException: error:1425F102:SSL routines:ssl_choose_client_version:unsupported protocol

参考链接:https://github.com/dotnet/SqlClient/issues/222

此问题的解决办法同上,问题的根源是一样的!

调试Nebula源代码


本文介绍如何调试Nebula和ClownFish的源代码。



VS配置

先参考下图设置VS中的调试相关参数:

xx

xx



拉取Nebula源码

先查看引用的包版本,例如:

xx

然后到 gitlab 仓库拉取Nebula源码,Nebula包的每个版本号都有一个对应的 tag 版本号,

在调试前,将分支切换到那个 TAG 版本上。

xx



这里假设我们将代码保存到 D:\TEMP\TEST\Nebula.net
记住这个路径,后面会用到!!
xx





断点与调试

在调用Nebula的代码行上设置断点(F9),然后单步(F11)进入即可。

第一次 会出现查找文件对话框:

xx

此时,注意看对话框顶部的路径,不要不知所措 ~~~

以上图为例,红色框内,

  • \Nebula.net 就是你前面拉取Nebula源码的保存路径
  • \src..... 就是源代码目录内的相对路径

按照这个提示去找就可以了。

此时,在对话框中找到前面保存Nebula的目录(D:\TEMP\TEST\Nebula.net),如下图:

xx

按图片中描述双击后,VS就可以直接打开需要调试的源码文件了…………

基础镜像

目前有2个配套的基础镜像可用:

  • 运行时镜像
  • 编译工具镜像



运行时镜像(标准版)

yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/aspnet:9.0.0

镜像内容说明

  • 设置时区(东8区)
  • 添加一些小工具,例如:curl busybox



使用示例(注意第 1 行)

FROM yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/aspnet:9.0.0

WORKDIR /app
EXPOSE 80

ADD publish /app
ENTRYPOINT ["dotnet", "Demo.WebSiteApp.dll"]





编译工具镜像

yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/sdk:9.0.100

此镜像主要包含了一个代码生成工具,用于优化程序的启动速度,具体内容可参考:代理程序集



使用示例,在构建应用程序镜像前使用(注意第 3 行,使用时可直接复制)

pushd ext/Nebula.Moon/bin

# 运行命令行工具,生成代理类和加载器类
docker run --rm  -v $PWD:/xbin  -w /xbin yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/sdk:9.0.100  dotnet /clownfish/ClownFish.CodeGen.dll  /xbin/publish

# 为应用程序构建镜像,然后推送到镜像仓库
cp ../Dockerfile .
docker build -t nebula_moon  .
docker tag nebula_moon  yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/moon:$version
docker push yyw-registry.cn-hangzhou.cr.aliyuncs.com/nebula/moon:$version
popd

.NET抓包

假设要分析的docker容器名称为:websiteapp



进入 docker

docker exec -it websiteapp bash



进入 .net 目录

cd /usr/share/dotnet/shared/Microsoft.NETCore.App/6.0.5/
## 后面的 6.0.5 是 dotnet 的版本号,如果不确定是哪个版本,可以用 ls 查看



运行 createdump 抓包

createdump 命令行用法

createdump [options] pid
-f, --name - dump path and file name. The default is '/tmp/coredump.%p'. These specifiers are substituted with following values:
   %p  PID of dumped process.
   %e  The process executable filename.
   %h  Hostname return by gethostname().
   %t  Time of dump, expressed as seconds since the Epoch, 1970-01-01 00:00:00 +0000 (UTC).
-n, --normal - create minidump.
-h, --withheap - create minidump with heap (default).
-t, --triage - create triage minidump.
-u, --full - create full core dump.
-d, --diag - enable diagnostic messages.

执行命令抓包

./createdump  -f /tmp/websiteapp_dump_n -u  1

参数说明:

  • 最后面的 1 表示 pid ,一般就是 1
  • dump_n,后面的n表示执行序号,有可能需要多次抓包,所以用序号来分开

此时会在容器内生产一个临时文件 /tmp/websiteapp_dump_n



注意:如果提示没有权限,需要在docker运行时增加一个参数 --privileged=true ,以便容器以特权方式运行。
完整示例如下

docker run -d   --name websiteapp  -p 8206:80 -m512m  \
 --privileged=true \
 -e ASPNETCORE_ENVIRONMENT=Development \
 -v /etc/hosts:/etc/hosts \
 -v $PWD/publish:/app \
 -v $PWD/logs:/app/Logs \
 -v $PWD/temp:/app/temp \
 -v $PWD/temp:/app/tmp  \
nebula/websiteapp:latest



将dump文件从容器拷贝出来

在【容器外面】运行下面命令:

docker cp websiteapp:/tmp/websiteapp_dump_1  /root/dumps/

文件拷贝出来后,可以删除这个文件



如果docker运行在K8S中,可以在 LENS 中运行下面的命令将文件拷贝出来

zip  websiteapp_dump1.zip  websiteapp_dump1
kubectl.exe cp  yunwei/notifyapp-release-8478b8b47c-gszbc:/tmp/websiteapp_dump1.zip   d:/localfiles/websiteapp_dump1.zip

命令格式

kubectl.exe cp  namespace/pod-id:/file_path_in_docker   save_to_local_path

服务接口测试

推荐工具

推荐使用HttpTestUI做服务接口测试,工具界面如下:
xxx

进入工具下载介绍页面     查看使用方法





测试服务(AutoTest)

运行测试用例的工作也可以交给测试服务来做,只需要部署它即可。

部署参考链接:https://note.youdao.com/s/14DF8Ge4





查看服务接口测试的覆盖情况


在HttpTestUI中,按下图开启控制开关:
xxx
然后执行测试。



过程中,可以查看测试服务(AutoTest) 查看测试结果:

  • 测试结果汇总表
    xxx

  • 某个服务的明细情况
    xxx

Nebula配套工具下载

如果希望在使用Nebula框架开发过程中能有更好的开发体验,可以尝试使用以下工具:

点击此处下载工具 FishTools_setup_V1.24.1118.1.exe



开发辅助小工具

功能特性:

  • 非常适合【开发人员】自测服务端的REST接口(完整的Request/Response)
  • 帮助开发人员深入理解HTTP协议
  • 支持所有HTTP提交数据场景(例如:文件上传,gzip压缩)
  • 支持Cookie会话维持
  • 支持格式化响应内容(JSON/XML)
  • 支持Fiddler代理
  • 可保留历史脚本(程序退出时自动保存,启动时自动加载)

  • 极简的JSON格式化操作(Ctrl+V 即格式化)

  • 一些文本的HASH/加密/编码,计算转换

xxx


xxx



HTTP测试工具

功能特性:

  • 测试用例编写方式适合【开发人员】(全部是脚本,没有复杂的界面操作)
  • 支持所有HTTP提交数据场景(例如:文件上传,gzip压缩)
  • 支持高并发模拟(多线程/次数/延迟)
  • 测试用例支持模板&变量
  • 测试用例支持源代码版本管理
  • 测试用例支持JS脚本语言
  • JS脚本可使用.NET类型,实现非常复杂的操作(例如:读写文件,访问数据库,发起HTTP调用)
  • 测试用例支持4种级别的变量(全局/内置/会话/临时)
  • 支持各种复杂的响应断言
  • 支持一套用例适应多套环境
  • 支持【开发模式】和【无人值守模式】
  • 支持性能统计(最大/最小/平均)
  • HTTP返回结果支持格式化(JSON/XML)
  • 支持服务端的接口覆盖度统计
  • 支持Cookie会话维持
  • 支持复杂业务流程的自动化测试
  • 支持格式化响应内容(JSON/XML)
  • 支持Fiddler代理
  • 支持性能评估(单个用例/整个执行过程)

xxx

工具使用文档



InfluxDB客户端

xxx



实体代码生成工具

xxx



XmlCommand管理工具

xxx



正则表达式测试工具

xxx