请点击目录中的节点查看具体文档
Nebula 介绍
Nebula是一个基于 .NET 技术的开发框架体系,核心特色是【支持SaaS多租户】、【支持微服务开发】和【实时可观测能力】。
Nebula包含2大部分:
- 开发框架类库
- 公共基础服务
整体逻辑架构如下:
TOP10技术特性
- 支持SaaS多租户架构
- 支持微服务架构开发
- 支持 Linux+docker+K8S 的部署方式
- 强大且实时的在线可观测能力,超越各种流行APM
- 监控对象自动识别,可配置可扩展的监控指标
- 强大易用的消息管道开发模型
- 简单易用的后台任务开发模型
- 统一的全局帐号管理,解决帐号泄漏问题,所有配置可在线调整
- 全异步支持
- 更少的内存占用
支持的平台与技术规范
- 支持多种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的,所以有时候并没有刻意区分它们,因为这对开发过程来说,完全是无感的!
总共包含以下模块
TOP10技术特性
本文主要介绍Nebula的10个最重要技术特性
- 支持SaaS多租户架构
- 支持微服务架构开发
- 支持 Linux+docker+K8S 的部署方式
- 强大且实时的在线可观测能力,超越各种流行APM
- 监控对象自动发现,可配置可扩展的监控指标
- 强大易用的消息管道开发模型
- 简单易用的后台任务开发模型
- 统一的全局帐号管理,解决帐号泄漏问题,所有配置可在线调整
- 全异步支持
- 更少的内存占用
SaaS多租户架构
SaaS架构,也称 “多租户” 架构。概念这里就不多说~~
数据库设计
- 主库:一个SaaS项目一个主库,存放一些全局数据。
- 租户库:为每租户创建一个租户数据库,存放与租户相关的数据,用于保证各租户之间的数据隔离。
应用站点
- 应用站点与服务的部署实例不区分租户(共享),可以接受所有租户的用户访问。
- 用户Token包含租户ID,用于区分租户
- 框架根据租户ID,在运行时动态切换到对应的租户数据库中,实现租户的数据库路由访问。
Linux+docker+K8S 的部署环境
支持以下部署技术:
微服务架构开发
微服务与单体应用程序有很大差别,主要表现在:
- 经常需要跨进程调用,而不是直接的方法调用
- 因此需要知道:调用的目标服务部署在哪里?
- 需要部署多个服务站点,又会带来新的问题:
- 参数在哪里配置? 如何避免重复问题?
- 日志怎么统一收集,查看?
- 出现故障时,怎么排查问题?
Nebula对于微服务的支持可参考:微服务架构开发
强大的在线可观测能力
重点体现在:
细节内容包括:
- 各服务的实时调用情况统计(总量/延迟/异常/平均响应时间)
- 各服务的业务指标 实时采集与实时汇总统计
- 一些技术性指标:线程池线程数量,内存占用,TPS,错误数,警告数,打开的SQL连接数量,等等。
- 完善的日志记录(操作日志/异常日志/性能日志/框架性能日志)
- 性能日志 包含详细的执行过程及耗时,以及全链路展示,可快速定位性能根因
- 各种内部状态:运行环境参数,系统信息,各种计数器,程序集,线程及调用堆栈
- 应用进程/数据库/中间件服务的 可用性监控
监控对象自动发现,可配置可扩展的监控指标
Nebula自动识别的监控对象分为4大类:
- 配置服务中注册的:服务地址
- 配置服务中注册的:连接帐号,例如:RabbitMQ的连接帐号
- 配置服务中注册的:数据库连接,例如:某个MySQL数据库的连接参数
- 集群中微服务的运行实例
可监控指标包含3大类:
所有这些数据都可以配置为监控规则,点击引处查看文档。
有了这些监控对象和监控规则,Nebula就能产生告警通知。
- 服务/中间件的【可用性】告警
- 服务节点的 心跳/打卡 告警
- 基于 指标规则 的监控告警
- 应用程序启动失败的异常日志告警
以上4类告警通知都可以发送到指定的IM聊天群。
消息管道开发模型
为了方便消息订阅和消息处理,ClownFish提供了一种称为消息管道的开发模型,如下图所示:
此模型有以下优点:
- 规范代码:将消息处理划分为多个阶段,避免代码风格迥异
- 功能增强:提供完善的日志和监控,支持重试处理,支持异常处理
- 统一模型:支持一套消息处理代码,订阅不同的消息服务(消息来源),可理解为与消息服务解耦。
- 简化代码:隐藏所有技术差异:订阅模式差异,线程模型差异,订阅者数量管理,提交方式,序列化等等细节。
消息处理只负责实现业务逻辑即可。
后台任务开发模型
后台任务也是一种很常见的业务开发模式,例如:
- 每天晚上执行一次数据统计
- 每小时执行一次XXXX检查
虽然 Quartz和Hangfire 也能完成这类任务,但是它们没有提供完善的日志和监控功能,
没法与Nebula的日志和监控整合,因此,ClownFish直接内置了周期性后台任务的开发模型。
下表是ClownFish内置的 BackgroundTask 和 Hangfire 的差别对比
技术特性 | BackgroundTask | Hangfire |
---|---|---|
依赖ASP.NET | 否 | 是 |
依赖数据库持久化 | 否 | 是 |
同一任务重叠执行 | 否 | 是 |
支持异步任务 | 是 | 否 |
支持秒级触发 | 是 | 否 |
支持性能日志 | 是 | 否 |
支持Venus监控 | 是 | 否 |
支持立即执行 | 是 | 延迟 |
支持临时任务 | 否 | 是 |
统一的全局帐号管理,解决帐号泄漏问题
许多开发框架和客户端类库虽然都提供了参数配置能力,
例如:
- 可以把 MySqlConnection 的连接字符串配置在 appsettings.json 中,
- ASP.NET甚至允许我们为不同环境指定不同的配置文件,
但是这种设计没有解决帐号泄漏的安全问题,因为:配置文件很容易通过源代码方式造成泄漏!
在Nebula框架中,采用了以下方式来解决帐号泄漏问题
- 配置服务:统一管理所有连接帐号,以及各种密码之类的敏感参数
- 封装各种客户端:强制从配置服务中获取必要的连接参数
- 最终结果:应用程序的代码不会包含帐号类的敏感内容
全异步支持
异步是一个非常有用的技术特性,它与同步调用相比,可以在相同的硬件资源下提供更高的系统吞吐量。
尤其是国内各种【互联网应用】和【信息化系统】,因为它们有以下特点:
- 大量的数据库CURD操作
- 大量的服务之间相互调用
这些都是【异步】最适合的使用场景。
Nebula和ClownFish中,几乎所有涉及远程调用的API都提供异步版本,例如:
- 所有数据库CURD操作
- 所有HTTP调用
- 各种中间件服务的客户端
更少的内存占用
Nebula在内存占用方面做了大量优化,主要包含4大类:
- 在框架内部的代码热点路径上涉及的大对象尽可能地使用对象缓存
- 提供许多参数开关用来控制不必要的对象分配
- 代码优化,尽量减少内存的占用
- 运行环境:GC参数的配置优化
例如下图是线上部分服务的真实运行情况,可以关注下 MEM 这个数据,
我们可以看到基于Nebula开发的服务,它们的内存占用都比较低,
公共基础服务
Nebula提供以下公共基础服务
- Nebula.Moon:配置服务
- Nebula.AdminUI:管理员配置界面
- Nebula.Neptune:数据查询服务
- Nebula.Mercury:实时指标统计服务
- Nebula.Venus:监控&日志展示站点
- Nebula.Ceres:WebHook服务
- Nebula.Juno:日志数据清理服务
- Nebula.Log2DB:日志同步工具
- Nebula.Metis:通知服务
Nebula.Moon
配置服务,主要提供以下配置管理能力:
- 数据库连接,例如:
- SQLSERVER
- MySQL
- PostgreSQL
- MongoDB
- InfluxDB
- VictoriaMetrics
- Elasticsearch
- 全局配置参数
- 普通的配置参数,即name=value
- 各种连接参数,例如:RabbitMQ, Redis, OSS
- 密码及敏感文本
- 各种服务地址/URL参数
- 独立配置文件
- xml
- json
- yam
- text
Nebula.AdminUI
后台配置界面,提供一种友好的方式维护一些后台配置参数。
Nebula.Neptune
一个通用的 数据查询服务,服务接口可以在Nebula.AdminUI中配置,
Nebula.Mercury
一个后台服务(没有界面),用于实时统计各个应用的各个节点的调用次数及性能/异常状况。
这个数据最后给Venus展示。
Nebula.Venus
监控展示站点,主要有以下功能:
- 实时汇总并展示 各个应用的整体运行状态
- 实时汇总并展示 各个应用的业务数据指标
- 实时展示 各个应用节点的技术指标
- 可用性监控 根据配置服务所管理中参数中自动发现
- 基于配置文件的 指标监控告警
主界面如下图:
Nebula.Cers
WebHook服务,主要有以下特性:
- 解耦合:应用程序(发布者)不需要与事件的订阅者交互,甚至可以不用知道事件的订阅者是否存在
- 易用性:应用程序(发布者)只负责发布事件,所有 注册/订阅/推送 过程全都不需要关注
- 通用性:Ceres接受多个应用程序的事件,并将事件发送到匹配的第三方程序订阅者
- 可靠性:内置复杂且可靠的重试机制,尽量保证事件能成功发送给第三方
Nebula.Juno
日志数据清理服务 是一个通用的数据表清理服务,它主要解决了以下问题:
- 日志表越来越大,需要清理【较老】的数据。
- 有可能一天就产生大量数据,所以需要及时清理。
- 避免人工执行大量DELETE语句产生大量数据库锁,影响业务系统稳定运行,甚至拖死数据库。
- 支持SaaS的多租户模式
Nebula.Log2DB
日志同步服务 用于将操作日志从ES中同步到RMDB。
按照默认的设置,程序产生的各种日志都会写入Elasticsearch,此时对于运营分析人员就不友好了,
他她们更愿意使用SQL查询来分析数据,因此Log2DB就是为这种需求而设计的。
Log2DB重点包含以下技术特性:
- 允许配置筛选日志范围
- 允许配置将日志同步到各种RMDB
- 后台持续/实时同步
Nebula.Metis
通知服务 主要解决以下问题:
- 应用程序与消息发送过程解耦:程序不需要关注消息发给谁,以及用什么方式发送
- 应用程序与消息内容编排解耦:程序只管提交消息数据,最终发出的消息格式由模板决定
- 消息的发送方式灵活可配置:消息可以用短信发出,或者邮件,或者聊天工具发出
- 发送方式多样性支持:目前已支持 12 种通知方式,4类消息格式
代码执行主体
通常而言,绝大多数业务需求最后会以3类技术方式实现:
以上3类实现方式,ClownFish将它们抽象为3类代码执行主体:
- HttpAction (由ASP.NET支持)
- MessageHandler
- BackgroundTask
设计意图
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
说明:
- 由于采用微服务开发模式,每个服务不应该非常复杂,所以建议采用经典的三层架构
解决方案目录结构
项目目录结构
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
代码示例
代码说明
- 14行,Controller 必须定义在 Controllers 目录下
- 17,24行,[ControllerTitle], [ActionTitle] 用于描述当前Controller/Action是做的,它们将最终写入操作日志(什么人在什么时候执行了什么功能)
- 18,25行,[Route] 定义 URL 路由
- 19行,Controller 必须继承自 BaseController
- 21行,业务逻辑类的实例采用私有属性的方式引用
- 34行,数据库连接对象,必须使用 using(.............) 来管理连接范围,确保及时释放
BLL层职责
具体的业务逻辑实现
- 数据库增删改查
- 数据判断与结构转换
- 其它数据源操作(例如OSS)
代码结构特点
- 小方法
- 利于复用
- 职责单一
- 数据查询
- 数据加工
- 不操作 Request/Response
代码示例
代码说明
- 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来实现数据隔离
数据存储架构
可参考下图
数据库分为3大类:
- 主库(红色):也称为 master库
- 存放租户元数据信息,例如租户管理表
- 只有一个DataBase
- 租户库(蓝色)
- 存放各个租户的业务数据,例如:订单,合同,收支金额等等
- 每个租户一个DataBase, 1万个租户就对应1万个DataBase
- 允许将DataBase划分到不同的数据库实例(Server)中
- 应用库(绿包)
- 存放特定用途的数据,例如:全局配置,日志,等等数据(不需要区分租户,且用途单一)
- DataBase数量不固定
数据访问
对于SaaS应用程序来说,数据访问操作的关键点其实就是:数据库连接的切换问题。
根据上图所示,切换数据库连接有4种场景:
- 访问主库
- 例如:需要校验 "租户ID" 这类操作
- 访问应用库
- 例如:读配置,写日志
- 用户登录
- 此时用户身份没有确定,不知道属于哪个租户
- 已登录用户访问租户业务数据
- 根据当前用户身份,访问对应的租户数据
下面来介绍如何实现以上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
用户登录(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从 提交数据 中获取,根