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

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 8.24.1025.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:202410301153_net8

注意请根据实际情况调整 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从 提交数据 中获取,根