2024年8月

前言​

推荐一个基于.NET 8 实现的通用权限开发框架Admin.NET,前端使用Vue3/Element-plus开发。

基于.NET 8(Furion)/SqlSugar实现的通用管理平台。整合最新技术,模块插件式开发,前后端分离,开箱即用。

集成多租户、缓存、数据校验、鉴权、事件总线、动态API、通讯、远程请求、任务调度、gRPC等众多黑科技。

代码简洁、易扩展、注释详细、易于上手、二次开发,即便是复杂业务逻辑也能迅速实现,真正实现"开箱即用"。

让开发更简单、更通用、更流行!

项目介绍

面向中小企业快速开发平台框架,框架采用主流技术开发设计,前后端分离架构模式。

完美适配国产化软硬件环境,支持国产中间件、国产数据库、麒麟操作系统、Windows、Linux部署使用;

集成国密加解密插件,使用SM2、SM3、SM4等国密算法进行签名、数据完整性保护;

软件层面全面遵循等级保护测评要求,完全符合等保、密评要求。

1、支持各种数据库

后台配置文件自行修改(自动生成数据库及种子数据)

2、前端运行步骤

  • 安装依赖pnpm install
  • 运行pnpm run dev
  • 打包pnpm run build

3、演示环境

http://101.43.53.74:5005/dist/index.html

4、项目地址

https://github.com/zuohuaijun/Admin.NET

5、在线文档

http://101.43.53.74:5050/

项目使用

1、建议每个应用系统单独创建一个工程(Admin.NET.Application层只是示例),单独设置各项配置,引用Admin.NET.Core层(非必须不改工程名)

2、Web层引用新建的应用层工程即可(所有应用系统一个解决方案显示一个后台一套代码搞定,可以自由切换不同应用层)

注意:
可以随主仓库升级而升级避免冲突,原则上接口、服务、控制器合并模式不影响自建应用层发挥与使用。若必须修改或补充主框架,也欢迎PR!

项目功能

1、主控面板: 提供核心功能的操作界面,如工作台、数据分析和统计展示。

2、租户管理: 包括租户套餐配置、过期管理、用户数量控制及企业信息维护。

3、租户套餐管理: 定制不同服务级别的套餐,包含可用功能和服务期限。

4、客户端管理: 管理系统集成的各种客户端,支持多种认证方式,并能动态调整认证令牌的有效期。

5、用户管理: 进行用户创建、部门分配、角色设定和岗位指派。

6、机构管理: 维护公司的组织结构,支持多层次的架构设计。

7、职位管理: 管理用户的职位信息,作为身份标识。

8、菜单管理: 配置系统中的菜单结构和操作权限。

9、角色管理: 通过绑定菜单和数据权限来限定角色的访问范围。

10、字典管理: 维护常用且固定的系统数据。

11、日志管理: 记录用户的登录、操作和异常信息。

12、服务监控: 监测服务器运行状态,包括CPU、内存和网络信息。

13、在线用户管理: 查看当前在线用户并支持强制下线。

14、公告管理: 发布系统通知,并实时推送给用户。

15、文件管理: 文件的上传、下载、查看和存储管理。

16、任务调度: 使用Sundial等工具实现定时任务和作业调度。

17、系统配置: 管理系统运行所需的各项参数设置。

18、通信服务: 邮件和短信发送功能。

19、API文档: 自动生成API文档,支持Swagger和Knife4jUI皮肤。

20、代码生成器: 自动生成前后端代码,提高开发效率。

21、在线构建器: 可视化拖拽表单元素生成前端代码。

22、微信集成: 支持微信小程序开发和支付功能。

23、数据导入导出: 支持文件导入导出及报告生成。

24、限流控制: 对API请求进行流量限制。

25、ES日志: 使用Elasticsearch存储系统日志。

26、开放授权: 支持OAuth 2.0标准授权。

27、APIJSON支持: 适配腾讯APIJSON协议,简化后端开发。

项目截图

1、登录界面

2、系统管理

3、平台管理

4、开发工具

5、其他效果

总结

Admin.NET 通用权限框架是一款支持前后端分离架构的权限管理系统,具备开箱即用的特点,能够显著简化开发流程。

感兴趣的朋友们不妨可以体验一下这个框架的强大功能。

如果觉得本文不错的朋友们可以收藏,感谢您的阅读。

如果觉得这篇文章对你有用,欢迎加入微信公众号 [
DotNet技术匠
] 社区,与其他热爱技术的同行交流心得,共同成长。

总览 Vue3 的单向数据流

尽信官网,不如那啥。

vue的版本一直在不断更新,内部实现方式也是不断的优化,官网也在不断更新。
既然一切皆在不停地发展,那么我们呢?等着官网更新还是有自己的思考?
我觉得我们要走在官网的前面,而不是等官网更新后,才知道原来可以这么实现。。。

我习惯先给大家一个整体的概念,然后再介绍各个细节。

脑图版

先整理一下和单向数据流有关的信息,做个脑图:

大纲版

列个大纲看看:

  • 自动版
    • v-model、emit(defineModel):组成无障碍通道,实现父子组件之间的值类型的响应性。
    • pinia.$state、pinia.$patch:状态管理提供的方法。
    • props + reactive:直接改 reactive,争议比较大
    • 注入 + reactive:直接改 reactive,一般可以忍受
  • 手动版
    • 注入 + reactive + function
      :官网建议通过 function 改 reactive,而不是直接改 reactive。
    • 状态管理的getter、mutation、action:状态管理,其实也涉及到了单向数据流。
  • props是否可以直接改?(从代码的角度来分析)
    • 值类型:不可改,否则响应性就崩了。
    • 引用类型:地址不可改,但是属性可以改。对于引用类型,其实都是通过 reactive 实现响应性的。
  • 有无意义的角度 (
    这是一个挨骂的话题

    • 有意义的方式:实现响应性的唯一方式,或者有记录(timeline)、有验证、限制等。
    • 无意义的方式:没有上面说的功能,还自认为是严格遵守规矩。
  • 限制的是谁?
    • 发起者:如果是限制子组件不能发起修改的话,那么任何方式都应该不能被允许,emit 也不行。
    • 方式(手段):如果只是限制一些方式的话,那么为啥 emit 可以,reactive 就不能直接改?有啥区别呢?
      • 二者都没有做记录(timeline),
      • 没有做任何限制、验证。

画个表格对比一下:

再来看看各种方式的对比:

方式 实现手段 有无记录 有无限制、验证 官网意见 适合场景
v-model + emit 抛出事件 可以 以前的方式
v-model + defineModel 抛出事件 推荐 V3.4 推荐的方式
props + reactive 代理,set 不推荐 适合传递引用类型
注入 + reactive 代理,set 不建议直接改reactive 适合多层级的组件结构
注入 + reactive + function 调用指定的函数 可以有 可以有 推荐方式 适合特殊需求
pinia.$patch、$state 代理,set等 timeline
pinia 的 getter、 action 调用指定的函数 timeline 可以有

这样应该有一个明确的总体感觉了吧。

props 的单向数据流

为啥弄得这么复杂?还不是因为两点:

  • vue 自带响应性,主要是 reactive有点太“逆天”。
  • composition API,可以把响应性分离出来单独使用。

如果没有 reactive,那么也就不会这么
乱糟糟
的了,让我们细细道来。

props 本身是单向的

https://cn.vuejs.org/guide/components/props.html#one-way-data-flow

官网里关于 props 的单向数据流是这样描述的:

所有的 props 都遵循着
单向绑定
原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。
这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

整理一下重点:

  • props 本身是单向的,只能接收父组件传入的数据,本身不具有改变父组件数据的能力。
  • 父组件的(响应性)数据如果变化,会通知 props 进行更新。
  • props.xxxx ,自带响应性。
  • props 不具有修改父组件数据的能力,这样就避免了父组件的数据被意外修改而受到影响。
  • 否则,
    数据流向
    会混乱,导致难以理解

其实
props
本来就是单向的,用于子组件接收父组件传入的数据,完全没有让子组件修改父组件里的数据的功能。

那么为何还要强调单向数据流呢?原因有二:
引用类型

reactive

props可以设置两种数据类型:

  • 值类型(数字、字符串等),用于简单情况,比如 input、select 的值等。
  • 引用类型(对象、数组等),用于复杂情况,比如表单、验证信息、查询条件等。

现在,仅从代码的角度看看 props 在什么情况可以改、不可以改。

  • 值类型,那是肯定不能直接改,直接改就破坏了响应性,父子组件的数据也对应不上。
  • 引用类型,又分为两种情况:改地址、改属性。
    • 改地址,那当然也是不行滴!同上,地址换了怎么找到你家?
    • 如果传入的是普通对象,虽然可以改属性,但是没有响应性;
    • 如果传入的是
      reactive
      的话,那就可以改其属性了,因为 reactive 自带响应性。

那么问题来了:

  • reactive 在父组件可以改,不会难以理解。
  • reactive 通过
    依赖注入
    的方式给子组件,虽然官网不建议直接改,但是就问问你,你会不会直接改?
  • reactive 通过 props 的方式给子组件,为啥一改就
    混乱而难以理解
    了呢?
  • 【重点】单向数据流,限制的是发起者,还是“渠道”?

所以重点就是这个 reactive !如果没有他,props 即使直接改了,也无法保证响应性,从而被我们所抛弃,也就不用纠结和争论了。

那么 reactive 到底是怎么回事?大家先不要着急,先看看官网允许的情况,然后再对比思考。那谁不是说了吗,没有对比就没有那啥。。。

为什么会混乱?想到了一种可能性:父组件定义了一个 reactive 的数据,然后通过 props 传递个多个子组件,然后某个子组件里面还有很多子子组件,也传入了这个数据。
某个时候发现状态异常变更,那么问题来了:到底是谁改了状态?(后续跟进)

emit 怎么可以改了?

emit 本意是子组件向父组件抛出一个事件,然后 vue 内部提供了一种方式(update:XXXXX),可以实现子组件修改父组件的需求。

<!-- Child.vue -->
<script setup>
  const props = defineProps(['modelValue'])
  const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

update:XXX 可以视为内部标识,会特殊处理这个 emit。

好了,这里不讨论具体是如何实现了,而是要讨论一下,不是说好的单向数据流,子组件不能改父组件的吗?不是说改了会导致
混乱而难以理解
吗?

官方的说法:emit 并不是直接修改,而是通过向父组件抛出一个事件,父组件响应这个事件来实现的。所以,不是直接改,并没有破坏单向数据流。

这个说法嘛,确实很
官方
。只是从结果来看,还是子组件发起了状态的变更,那么问题来了,如果是上面的那种情况,可以方便获知是谁改了状态吗?(似乎也会导致混乱和难以理解吧)

那么问题来了:单向数据流,是限制
发起者
,还是
手段

  • 如果限制的是发起者的话,那么 emit 也不行,因为也是在子组件发起的,啥时候改,怎么改都是由子组件决定,emit只是一个
    无障碍通道
    的起始端,另一端是 v-model。
  • 如果限制手段的话,那么不同的手段到底有啥区别?为啥 emit 可以,reactive 就不可以?

不要钻牛角尖了,其实是有一个很实际的需求:

  • 父子组件之间要保持响应性
  • 子组件有“直接”改的要求

举个例子,各种 UI库 都有 xx-input 组件,外面用 v-model 绑定一个变量,然后 xx-input 里面必须可以修改传入的变量,而且要保持响应性对吧,否则咋办?

v-model + emit 就是解决这个实际需求的。(解决问题,给大家带来方便,然后才会选择vue,其余其他的嘛。。。)

当然,可以使用 ref,但是 ref 的本体是一个class,属于引用类型,如果传入 ref 本体的话,相当于传入一个对象给子组件。这个咋算?

vue 现在的做法是,template 会默认把 ref.value 传给子组件,而不是 ref 本体,这样传入的还是基础类型。

所以,这是实现父子组件之间,
值类型
的响应性的唯一方法。

defineModel,是直接改?

https://cn.vuejs.org/guide/components/v-model.html

defineModel 是 vue3.4 推出来的语法糖(稳定版),内部依然使用了 emit 的方式,所以可以视为和 emit 等效。

官网示例代码:

<!-- Child.vue -->
<script setup>
const model = defineModel()

function update() {
  model.value++
}
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
</template>

官方的示例代码,特意展示了一下可以在子组件“直接改”的特点。

看过内部实现代码的都知道,其内部有一个内部变量,然后返回的是一个customerRef(官方说是ref),所以我们不是直接改 props,而是改 ref.value,然后内部通过 set 拦截,调用 emit 向父组件提交申请。

如果对内部原理感兴趣可以看这里:

依赖注入(provide/inject)也有单向数据流?

https://cn.vuejs.org/guide/components/provide-inject.html#working-with-reactivity

父子组件之间传值,就不得不说说依赖注入,那么是否存在“单向数据流”的问题呢?那也是必然应该存在呀,只是官网没有直接明确说。

注意:依赖注入只负责传递数据,并不负责响应性。

官网的意思,是让我们在父组件实现状态的变更,然后把状态和负责状态变更的函数一起传给(注入到)子组件,子组件不要直接改状态,而是通过调用 【父组件传入的函数】 来变更状态。

官网原文:

当提供 / 注入响应式的数据时,
建议尽可能将任何对响应式状态的变更都保持在供给方组件中
。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。
有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

官网推荐的方式是这样的:

<!-- 在供给方组件内 -- > 父组件
<script setup>
import { provide, ref } from 'vue'

// 数据、状态
const location = ref('North Pole')

// 变更状态的函数
function updateLocation() {
  location.value = 'South Pole'
}

// 提供数据和操作方法(function)
provide('location', {
  location,
  updateLocation
})
</script>
<!-- 在注入方组件 --> 子组件
<script setup>
import { inject } from 'vue'

// 被注入(得到)状态和方法
const { location, updateLocation } = inject('location')
</script>

<template>
  <!--调用函数修改状态-->
  <button @click="updateLocation">{{ location }}</button>
</template>

看着是不是有点眼熟?这让我想起了 react 的 useState。

其实想一想,为啥非得学 react?react 的特点就是:不能变。所以当需要变更的时候,必须调用专门的 hooks 来处理。

但是 vue 的特点就是响应性呀,和 react 恰恰相反。

当然了,自己写一个函数也是有好处的,比如:


const 张三 = reactive({name:'zs',age:20})

const setAge = (age) => {
  if (age < 0) {
    // 年龄不能是负数
  }
  // 其他验证
  // 通过验证,赋值
  张三.age = age
  // 还可以做记录(timeline)
}

这样就不能瞎改年龄了。或者根据出生日期自动计算年龄。
不是说不能自己写函数,而是说这个函数要有点意义。

状态管理也涉及单向数据流吗?

props 和注入说完了,那么就来到了状态管理,这里以 pinia 为例。

状态管理也涉及单向数据流吗?那当然是必须滴呀,否则 Vuex 的时候,为啥总强调要通过 mutation 去变更状态,而不要直接去改状态?

$state 是直接改吗?

那么 pinia 为什么提供了 $state 用于“直接”改状态呢?这还得看看源码:

  • pinia.mjs 1541 行
    Object.defineProperty(store, '$state', {
        get: () => ((process.env.NODE_ENV !== 'production') && hot ? hotState.value : pinia.state.value[$id]),
        set: (state) => {
            /* istanbul ignore if */
            if ((process.env.NODE_ENV !== 'production') && hot) {
                throw new Error('cannot set hotState');
            }
            $patch(($state) => {
                assign($state, state);
            });
        },
    });

不太会TypeScript,所以我们来看看编译后的代码,是不是有点眼熟。

虽然表面上看是直接修改,但是却被 set 给拦截了,实际上是通过 $patch 和 Object.assign 实现的赋值操作。

这个和 defineModel 有点类似,表面上看直接改,其实都是间接修改。
而 $patch 里面还有一些操作,比如做记录(timeline)。

store.xxx 是直接修改吗?

可能你会说,$state 并不是状态自己的属性,当然不算直接修改了,那么我们来试试直接修改状态。

通过测试我们可以发现:

  • 可以直接改状态
  • 可以产生记录(timeline)

那么是怎么实现的呢?

  • 其实 pinia 的状态(store)也是 reactive。
    pinia.mis:1436行
    const store = reactive((process.env.NODE_ENV !== 'production') || USE_DEVTOOLS
        ? assign({
            _hmrPayload,
            _customProperties: markRaw(new Set()), // devtools custom properties
        }, partialStore
        // must be added later
        // setupStore
        )
        : partialStore);
  • 然后对 reactive 进行了监听
    pinia.mis:1409行
    const partialStore = {
        _p: pinia,
        // _s: scope,
        $id,
        $onAction: addSubscription.bind(null, actionSubscriptions),
        $patch,
        $reset,
        $subscribe(callback, options = {}) {
            const removeSubscription = addSubscription(subscriptions, callback, options.detached, () => stopWatcher());
            const stopWatcher = scope.run(() => watch(() => pinia.state.value[$id], (state) => {
                if (options.flush === 'sync' ? isSyncListening : isListening) {
                    callback({
                        storeId: $id,
                        type: MutationType.direct,
                        events: debuggerEvents,
                    }, state);
                }
            }, assign({}, $subscribeOptions, options)));
            return removeSubscription;
        },
        $dispose,
    };

这里的第10行,用 watch 对状态的属性进行了监听,然后写记录(timeline)。

pinia 不仅没有阻止我们直接改属性,还很贴心的做了记录。

pinia 的 timeline

以前就一直对这个 timeline 非常好奇,想知道记录的是什么,但是奈何各种原因总是看不到,现在vue 推出了,终于看到了。

这里的记录非常详细,有状态名称、动作、属性名称、新旧值、触发时间等等信息,只是有个小问题,
到底是谁改了状态
? 没发现有定位代码位置的功能。

reactive 怎么算?

好了,终于到了比较有争议的 reactive 了,大家有没有等着急?
首先 reactive 的本质是 Proxy,而 Proxy 是代理,这个想必大家都知道,所以我们可以设置这样的代码:


const 张三 = {
  name:'zhangsan',
  age:20
}

const 张三的代理 = reactive(张三)

const setAge = (age) => {
  if (age < 0) {
    // 年龄不能是负数
  }
  // 其他验证
  
  // 通过验证后才能赋值
  张三的代理.age = age
}

平时大家都是一步成,现在分成了两步,是不是就很明确了呢。

张三
是一个普通的对象,没有响应性,
张三的代理
是 reactive 有响应性,是
张三
的代理。

所以,我们传递给子组件的是
张三的代理
,并不是
张三
本尊。
既然子组件根本就得不到
张三
的本尊,那么又何来
直接
修改呢?

如果说通过 emit 是间接修改(抛出事件),那么通过 reactive 也是通过代理间接修改的。
虽然一个是事件,一个是代理,但是有啥本质区别呢?事件是函数,Proxy 里的 set 也是函数呀。
同样都是没有记录(timeline)、判断、验证、限制,想怎么改就怎么改。

如果你还不理解,可以看看这个演化过程。

阶段一:参考官网里面依赖注入的推荐方式

// 阶段一:按照官网里面注入的推荐方式
const person = reactive({
  name:'zhangsan',
  age:20
})

const setAge = (age) => {
  person.age = age 
}

// 通过 props 或者 依赖注入,把 proxyPerson 传给子组件,
const proxyPerson = reactive({
  // 使用 readonly 变成只读形式,只能通过 setAge 修改。
  person: readonly(person),
  setAge
})

这样子组件只能使用 setAge 修改,代理套上 readonly 之后,通过代理的修改方式都给堵死了,是严格遵守单向数据流了吧。

阶段二:充血实体类,把数据和方法合在一起

// 阶段二:充血实体类,把数据和方法合在一起
const person2 = {
  name:'zhangsan',
  _age:20, // 内部成员,相当于“本尊”
  // set 拦截,其实也是一个函数,类似于代理。
  set age(age) { // 拦截设置属性
    // 可以做验证
    this._age = age 
  },
  get age(){ // 拦截读取属性
    return this._age
  }
}

//  给子组件用
const proxyPerson2 = reactive(person2)

// 子组件
// 表名上看是通过属性修改,但是实际上被 set 拦截了,调用的是一个函数
proxyPerson2.age = 30

在父组件里面把数据和变更方法合并,也是符合官网的建议对吧。

那么看看阶段二是不是有点眼熟?如果你熟悉 Proxy 和 reactive 内部原理的话,这不就是 reactive 内部代码的一小部分吗?

既然 reactive 都自带了这种功能,那么我们又何必自己手撸?

当然 reactive 也有点小问题,没有内置记录,不过我们可以用 watch 的 onTrigger 做记录,详细看下面:
给 Pinia 加一个定位代码的功能(支持 reactive)

小结

  • v-model + emit
    目的是实现父子组件之间,
    值类型
    数据的响应性,如果不用 emit 的话,如何实现?

  • defineModel
    语法糖(宏),封装复杂的代码,让我们使用起来更方便。

  • 状态管理
    pinia 提供了 timeline,弥补了 reactive 的不足,方便我们调试代码,提供 $state 方便我们直接赋值。
    给 Pinia 加一个定位代码的功能(支持 reactive)

  • reactive
    我觉得可以直接改,因为本身就是一个代理(Proxy),直接用就好了。
    如果外面再套一个 Proxy 有何意义呢?当然了,如果可以加上 timeline,或者是判断、验证等,那么就有意义了。

  • 数据 + 方法
    可以在方法里面做一些操作,比如验证、判断等,那么就有意义,如果是个“空”函数,除了赋值啥都没做,那么有何意义呢?

最近在学习 Blazor ,在B站上找了一个国外的课程边看边学习。嗯,原价¥1503的课程,大概200多美元,课程链接如下:

B站(大章节分P-适合初学):
.NET 8 Blazor 从入门到精通

B站(小章节分P-适合复习):
Blazor从入门到精通(中文字幕)

官网课程:
Blazor From Start to Finish

image

Blazor 的关键概念

本文主要介绍Blazor 的关键概念,每个知识点都附上了学习过程中查到的参考资料。文中删除了一些常识性或表述不清的内容,如热重载、组件与页面等。

项目模板

项目开发的常用模板配置项如下,其它配置也可以都试一下,观察一下区别:
image

Auto
交互方式:最初使用 Blazor Server,并在随后访问时使用 WebAssembly 自动进行交互式客户端呈现,详细内容参考
.NET8 Blazor的Auto渲染模式的初体验

Razor 语法

参考
ASP.NET Core 的 Razor 语法参考
,前期主要理解下面几个重点语法即可:

  • 隐式 Razor 表达式
    :以 @ 开头,后跟 C# 代码
<p>@DateTime.Now</p>
<p>@DateTime.IsLeapYear(2016)</p>
  • 显式 Razor 表达式
    :由 @ 符号和圆括号组成
<p>Last week this time: @(DateTime.Now - TimeSpan.FromDays(7))</p>
  • @code 块
    :允许 Razor 组件将 C# 成员(字段、属性和方法)添加到组件
@code {
    // C# members (fields, properties, and methods)
}
  • 循环语句和条件语句
    :如
    @for

    @if
    等,直接写在页面中
@for (var i = 0; i < people.Length; i++)
{
    var person = people[i];
    <p>Name: @person.Name</p>
    <p>Age: @person.Age</p>
}

@if (value % 2 == 0)
{
    <p>The value was even.</p>
}

依赖注入

参考
将依赖项注入 Blazor 组件

Program.cs(项目引导程序)
中注册依赖项:

builder.Services.AddSingleton<DemoDependency>();
//用于注册依赖项的其他模式...

对于 Blazor 组件,有两种方法可以指示我们的组件使用哪些依赖项:

//1.在 Razor 标记中
@inject IToDoApi ToDoApi
@inject ISomeServiceType AnotherService

//2.在 C# 代码中
@code
{
  [Inject]
  private IYetAnotherServiceType PropertyInjectedDependency { get; set; }
}

注入配置

参考
ASP.NET Core Blazor 配置
,其中配置的优先级别:
用户机密 > appsettings.{Environment}.json > appsettings.json

在 appsettings.json 中配置连接字符串:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Default": "连接字符串来自appsettings.json"
  }
}

在组件中引入配置依赖:

@page "/"
@inject IConfiguration config

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>
<h2>@config.GetConnectionString("Default")</h2>

IConfiguration 是默认注册的,不需要另外写代码注册,可以直接使用。

HeadOutlet 组件

切换页面时不是整个页面被重新加载,实际上只有根组件
App.razor

<Routes />
被重新渲染。这种渲染方式不利于SEO,可以使用
HeadOutlet
组件来控制 <head> 元素的内容来进行优化。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="KeyConcepts.styles.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet />
</head>

<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

参考
在 ASP.NET Core Blazor 应用中控制 <head> 内容
,指定一个页面的标题和描述:

@page "/control-head-content"

<PageTitle>@title</PageTitle>
<p>Title: @title</p>
<p>Description: @description</p>

<HeadContent>
    <meta name="description" content="@description">
</HeadContent>

@code {
    private string description = "This description is set by the component.";
    private string title = "Control <head> Content";
}
  • 使用
    PageTitle
    组件指定页面标题,这样可以将 HTML <title> 元素呈现给 HeadOutlet 组件。
  • 使用
    HeadContent
    组件指定 <head> 元素内容,该组件为 HeadOutlet 组件提供内容。

需要注意,如果在A页面用了B页面,那么B页面的 PageTitle 会覆盖掉A页面的 PageTitle。所以,
组件不需要作为页面使用时就不要放 PageTitle
了。

@code 分离

Blazor可以支持在razor文件里面添加cs代码,但是代码一旦复杂了之后就会变得特别的麻烦。其实,这部分代码在编译时实际是被分离出来的,我们也可以在编译前手动将它们分离出来。

右键
code
,选择
快速操作和重构
,然后如下图所示选择
将块提取到代码隐藏中

image

结果如下,其中①只是②的一个快捷方式:
image

上面是使用VS的自动分离功能,也可以使用手动的方式进行分离。参考
C# Blazor 学习笔记(4):blazor代码分离
,注意以下几点:

  • 直接右键razor组件的上级目录,添加一个
    partial局部类
  • 新建类的
    类名是xxx.razor.cs
    ,这样才能挂到组件上面
xxx.razor
xxx.razor.cs:代码
xxx.razor.css:css样式

代码分离后,依赖项也需要改成属性注入:

using Microsoft.AspNetCore.Components;

namespace KeyConcepts.Client.Pages;

public partial class Demo
{
    // 在razor组件中是这样的 @inject IConfiguration config
    [Inject]
    protected IConfiguration config { get; set; }=default!;

    private string? GetConnectionString()
    {
        return config.GetConnectionString("Default");
    }
}

Blazor 调试

调试没什么好说的,就在VS中正常打断点、单步运行、监控变量值就行了,具体参考
调试 ASP.NET Core 应用

CSS 隔离

CSS 隔离可以
将 CSS 范围限定到 Razor 组件
,以简化 CSS 并避免与其他组件或库发生冲突,但过多的使用也会导致 CSS 追踪困难。

参考
ASP.NET Core Blazor CSS 隔离
,在与组件相同文件夹中创建一个
.razor.css
文件,该文件与组件的
.razor
文件的名称相匹配。例如为
Counter.razor
组件创建一个
Counter.razor.css
文件:

h1 {
    color:red;
}

生成时 Blazor 会重写 CSS 选择器以匹配组件呈现的标记, 重写的 CSS 样式被作为静态资产捆绑和生成, 默认情况下在 <head> 标记中引用表样式:

<!-- {ASSEMBLY NAME} 占位符是项目的程序集名称 !-->
<link href="{ASSEMBLY NAME}.styles.css" rel="stylesheet">

在捆绑的文件中,
每个组件都与范围标识符关联
。 对于每个具有样式的组件,HTML 属性追加有
格式 b-{STRING},其中 {STRING} 占位符是框架生成的十个字符的字符串
。 标识符对每个应用都是唯一的。

在呈现的 Counter 组件中,Blazor 将范围标识符追加到 h1 元素:

<h1 b-zdeg3nv67a="">Counter</h1>

image

注:如果CSS不生效,需要清理一下浏览器的缓存。

调用JavaScript

js文件可以放到wwwroot目录下,也可以关联到特定组件,参考
从与组件并置的外部 JavaScript 文件 (.js) 加载脚本
为 Counter 组件添加并置js文件:

//Counter.razor.js
export function displayCount(count) {
    alert('The count is' + count);
}

export function createMessage(count) {
    return 'The count is' + count;
}

Blazor 应用的 Razor 组件使用
.razor.js
扩展名并置 JS 文件(参考 CSS 隔离部分),并且可通过项目中文件的路径公开寻址
{PATH}/{COMPONENT}.razor.js

  • 占位符 {PATH} 是指向组件的路径
  • 占位符 {COMPONENT} 是组件

修改 Counter 组件的代码,调用js函数:

@page "/counter"
@rendermode InteractiveAuto
@inject IJSRuntime JSRuntime

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>
<h2>@subMessage</h2>
<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary"  @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;
    private string subMessage = "";
    private IJSObjectReference? jsModule;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Pages/Counter.razor.js");
        }    
    }
    private async Task IncrementCount()
    {
        currentCount++;
        await jsModule.InvokeVoidAsync("displayCount", currentCount);
        subMessage = await jsModule.InvokeAsync<string>("createMessage", currentCount);
    }
}
  • @inject IJSRuntime JSRuntime
    :注入 IJSRuntime 接口,用于与客户端 JavaScript 交互
  • IJSObjectReference? jsModule
    :保存对 JavaScript 模块的引用
  • JSRuntime.InvokeAsync
    <IJSObjectReference>

    :加载 JavaScript 模块并保存其引用

实际项目中,尽量不要使用js控制DOM,而是使用Blazor组件,因为两者可能起冲突。

优化

上一次实验 代码写的较为随意,本次穷尽所学,优化了一把,

不过果然还是没 比过 Dapper aot, 虽然没使用 Interceptor, 但理论上其优化不该有这么大差距

知识差距不少呀,都看不懂 Dapper aot 利用了什么姿势领先, 有大神们能教教吗?

优化点

减少类型判断

提前 做类型判断,并在生成时利用 switch case 减少判断

之前

 var needConvert = typeof(string) != reader.GetFieldType(i);
s.Add((d,r) => d.Name = DBExtensions.ReadToString(r,j,needConvert));

之后

     switch (name)
    {
        
    case "age":
        s.Add(type == typeof(int) ? 1 : 2); 
        break;


    switch (ss[j])
    {
        
    case 1:
        d.Age = EntitiesGenerator.ReadToInt32Nullable(reader,j);
        break;
    case 2:
        d.Age = EntitiesGenerator.ReadToInt32NullableConvert(reader,j);
        break;

避免生成委托

去除委托生成使用

之前

var s = new List<Action<BenchmarkTest.Dog, IDataReader>>(reader.FieldCount);
for (int i = 0; i < reader.FieldCount; i++)
{
    var j = i;
    switch (reader.GetName(j).ToLower())
    {
        
        case "age": 
        {
            // int?
            
            var needConvert = typeof(int) != reader.GetFieldType(i);
            s.Add((d,r) => d.Age = DBExtensions.ReadToInt32Nullable(r,j,needConvert));
             
        }
        break;
        case "name": 
        {
            // string
            
            var needConvert = typeof(string) != reader.GetFieldType(i);
            s.Add((d,r) => d.Name = DBExtensions.ReadToString(r,j,needConvert));
             
        }
        break;
        case "weight": 
        {
            // float?
            
            var needConvert = typeof(float) != reader.GetFieldType(i);
            s.Add((d,r) => d.Weight = DBExtensions.ReadToFloatNullable(r,j,needConvert));
             
        }
        break;
        default:
            break;
    }
}
while (reader.Read())
{
    var d = new BenchmarkTest.Dog();
    foreach (var item in s)
    {
        item?.Invoke(d,reader);
    }
    yield return d;
}

之后

var s = new List<int>(reader.FieldCount);
for (int i = 0; i < reader.FieldCount; i++)
{
    var name = reader.GetName(i).ToLower();
    var type = reader.GetFieldType(i);
    switch (name)
    {
        
    case "age":
        s.Add(type == typeof(int) ? 1 : 2); 
        break;

    case "name":
        s.Add(type == typeof(string) ? 3 : 4); 
        break;

    case "weight":
        s.Add(type == typeof(float) ? 5 : 6); 
        break;

        default:
            break;
    }
}
ss = s.ToArray();

var d = new BenchmarkTest.Dog();
for (int j = 0; j < ss.Length; j++)
{
    switch (ss[j])
    {
        
    case 1:
        d.Age = EntitiesGenerator.ReadToInt32Nullable(reader,j);
        break;
    case 2:
        d.Age = EntitiesGenerator.ReadToInt32NullableConvert(reader,j);
        break;

    case 3:
        d.Name = EntitiesGenerator.ReadToString(reader,j);
        break;
    case 4:
        d.Name = EntitiesGenerator.ReadToStringConvert(reader,j);
        break;

    case 5:
        d.Weight = EntitiesGenerator.ReadToFloatNullable(reader,j);
        break;
    case 6:
        d.Weight = EntitiesGenerator.ReadToFloatNullableConvert(reader,j);
        break;

        default:
            break;
    }
}

添加 reader 字段判断缓存

添加缓存,减少重复生成

   var h = reader.GetColumnHash();
   if (!tokenCache.TryGetValue(h, out var ss))
   {
       var s = new List<int>(reader.FieldCount);
       for (int i = 0; i < reader.FieldCount; i++)

结果


BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.4651/22H2/2022Update)
Intel Core i7-10700 CPU 2.90GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.100-preview.5.24307.3
  [Host]     : .NET 8.0.6 (8.0.624.26715), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.6 (8.0.624.26715), X64 RyuJIT AVX2


Method Categories Mean Error StdDev Ratio RatioSD Gen0 Gen1 Gen2 Allocated Alloc Ratio
SourceGeneratorMappingFirst 1 434.7 ns 8.67 ns 7.69 ns 0.84 0.02 0.0401 0.0396 - 336 B 1.20
SetClassFirst 1 516.8 ns 9.86 ns 10.55 ns 1.00 0.00 0.0334 0.0324 0.0019 280 B 1.00
DapperMappingFirst AOT 1 1,333.4 ns 2.49 ns 2.33 ns 2.58 0.06 0.0324 - - 280 B 1.00
DapperMappingFirst 1 1,421.4 ns 3.08 ns 2.88 ns 2.84 0.12 0.0496 - - 416 B 1.49
SetClass 1000 8,139.8 ns 130.22 ns 115.43 ns 1.00 0.00 6.7902 1.6937 - 56840 B 1.00
DapperMapping AOT 1000 16,373.8 ns 275.34 ns 244.08 ns 2.01 0.05 6.7749 0.9460 - 56840 B 1.00
SourceGeneratorMapping 1000 20,911.5 ns 77.69 ns 60.65 ns 2.57 0.04 6.7749 1.6785 - 56896 B 1.00
DapperMapping 1000 48,707.3 ns 430.05 ns 381.23 ns 5.67 0.29 12.5122 2.0752 - 105120 B 1.85

Netty 是一个高性能、异步事件驱动的网络应用框架,它基于 Java NIO 构建,广泛应用于互联网、大数据、游戏开发、通信行业等多个领域。以下是对 Netty 的源码分析、业务场景的详细介绍:

源码概述

  1. Netty 的核心组件
    :Netty 的架构设计围绕着事件驱动的核心思想,主要包括 Channel、EventLoopGroup、ChannelHandlerContext 和 ChannelPipeline 等关键概念。
  2. Channel
    :是网络连接的抽象表示,每个 Channel 都有一个或多个 ChannelHandler 来处理网络事件,如连接建立、数据接收等。
  3. EventLoopGroup
    :是一组 EventLoop 的集合,每个 EventLoop 负责处理一组 Channel 的 I/O 事件。当 Channel 的事件触发时,相应的 EventLoop 会调用 ChannelHandler 中的方法进行处理。
  4. ChannelPipeline
    :是 ChannelHandler 的有序集合,用于处理进来的和出站的数据。通过在 Pipeline 中添加不同的 Handler,可以实现复杂的业务逻辑。
  5. 源码中的关键流程
    :Netty 的源码分析需要关注的关键流程包括初始化、Channel 的注册、EventLoop 的工作流程、以及连接的建立和绑定过程。

Netty 提供了一个 Echo 示例,用于演示客户端和服务器端的基本通信流程。在这个示例中,客户端发送的消息被服务器端接收并原样返回,展示了 Netty 处理网络通信的基本方法。

下面 V 哥来详细介绍一下这几外关键核心组件。

1. Channel组件

Netty 的
Channel
组件是整个框架的核心之一,它代表了网络中的一个连接,可以是客户端的也可以是服务器端的。
Channel
是一个低级别的接口,用于执行网络 I/O 操作。以下是对
Channel
组件的源码分析和解释:

Channel 接口定义

Channel
接口定义了一组操作网络连接的方法,例如绑定、连接、读取、写入和关闭。

public interface Channel extends AttributeMap {

    /**
     * Returns the {@link ChannelId} of this {@link Channel}.
     */
    ChannelId id();

    /**
     * Returns the parent {@link Channel} of this channel. {@code null} if this is the top-level channel.
     */
    Channel parent();

    /**
     * Returns the {@link ChannelConfig} of this channel.
     */
    ChannelConfig config();

    /**
     * Returns the local address of this channel.
     */
   SocketAddress localAddress();

    /**
     * Returns the remote address of this channel. {@code null} if the channel is not connected.
     */
    SocketAddress remoteAddress();

    /**
     * Returns {@code true} if this channel is open and may be used.
     */
    boolean isOpen();

    /**
     * Returns {@code true} if this channel is active and may be used for IO.
     */
    boolean isActive();

    /**
     * Returns the {@link ChannelPipeline}.
     */
    ChannelPipeline pipeline();

    /**
     * Returns the {@link ChannelFuture} which is fired once the channel is registered with its {@link EventLoop}.
     */
    ChannelFuture whenRegistered();

    /**
     * Returns the {@link ChannelFuture} which is fired once the channel is deregistered from its {@link EventLoop}.
     */
    ChannelFuture whenDeregistered();

    /**
     * Returns the {@link ChannelFuture} which is fired once the channel is closed.
     */
    ChannelFuture whenClosed();

    /**
     * Register this channel to the given {@link EventLoop}.
     */
    ChannelFuture register(EventLoop loop);

    /**
     * Bind and listen for incoming connections.
     */
    ChannelFuture bind(SocketAddress localAddress);

    /**
     * Connect to the given remote address.
     */
    ChannelFuture connect(SocketAddress remoteAddress, SocketAddress localAddress);

    /**
     * Disconnect if connected.
     */
    ChannelFuture disconnect();

    /**
     * Close this channel.
     */
    ChannelFuture close();

    /**
     * Deregister this channel from its {@link EventLoop}.
     */
    ChannelFuture deregister();

    /**
     * Write the specified message to this channel.
     */
    ChannelFuture write(Object msg);

    /**
     * Write the specified message to this channel and generate a {@link ChannelFuture} which is done
     * when the message is written.
     */
    ChannelFuture writeAndFlush(Object msg);

    /**
     * Flushes all pending messages.
     */
    ChannelFuture flush();

    // ... 更多方法定义
}

Channel 的关键方法

  • id()
    : 返回
    Channel
    的唯一标识符。
  • parent()
    : 返回父
    Channel
    ,如果是顶级
    Channel
    ,则返回
    null
  • config()
    : 获取
    Channel
    的配置信息。
  • localAddress()

    remoteAddress()
    : 分别返回本地和远程地址。
  • isOpen()

    isActive()
    : 分别检查
    Channel
    是否打开和激活。
  • pipeline()
    : 返回与
    Channel
    关联的
    ChannelPipeline
    ,它是处理网络事件的处理器链。
  • register()
    ,
    bind()
    ,
    connect()
    ,
    disconnect()
    ,
    close()
    ,
    deregister()
    : 这些方法用于执行网络 I/O 操作。

Channel 的实现类

Netty 为不同类型的网络通信协议提供了多种
Channel
的实现,例如:

  • NioSocketChannel
    :用于 NIO 传输的 TCP 协议的
    Channel
    实现。
  • NioServerSocketChannel
    :用于 NIO 传输的 TCP 服务器端
    Channel
    实现。
  • OioSocketChannel

    OioServerSocketChannel
    :类似 NIO,但是用于阻塞 I/O。

Channel 的生命周期

  1. 创建

    Channel
    通过其工厂方法创建,通常与特定的
    EventLoop
    关联。
  2. 注册

    Channel
    必须注册到
    EventLoop
    上,以便可以处理 I/O 事件。
  3. 绑定/连接
    :服务器端
    Channel
    绑定到特定地址并开始监听;客户端
    Channel
    连接到远程地址。
  4. 读取和写入
    :通过
    Channel
    读取和写入数据。
  5. 关闭
    :关闭
    Channel
    ,释放相关资源。

Channel 的事件处理

Channel
的事件处理是通过
ChannelPipeline

ChannelHandler
完成的。
ChannelPipeline
是一个处理器链,负责处理所有的 I/O 事件和 I/O 操作。每个
Channel
都有一个与之关联的
ChannelPipeline
,可以通过
Channel

pipeline()
方法访问。

异步处理

Channel
的操作(如绑定、连接、写入、关闭)都是异步的,返回一个
ChannelFuture
对象,允许开发者设置回调,当操作完成或失败时执行。

内存管理

Netty 的
Channel
实现还涉及内存管理,使用
ByteBuf
作为数据容器,它是一个可变的字节容器,提供了一系列的操作方法来读写网络数据。

小结

Channel
是 Netty 中的一个核心接口,它定义了网络通信的基本操作。Netty 提供了多种
Channel
的实现,以支持不同的 I/O 模型和协议。通过
Channel
,Netty 实现了高性能、异步和事件驱动的网络通信。

2. EventLoopGroup组件

EventLoopGroup
是 Netty 中一个非常重要的组件,它负责管理一组
EventLoop
,每个
EventLoop
可以处理多个
Channel
的 I/O 事件。以下是对
EventLoopGroup
组件的详细分析和解释:

EventLoopGroup 接口定义

EventLoopGroup
接口定义了一组管理
EventLoop
的方法,以下是一些关键方法:

public interface EventLoopGroup extends ExecutorService {

    /**
     * Returns the next {@link EventLoop} this group will use to handle an event.
     * This will either return an existing or a new instance depending on the implementation.
     */
    EventLoop next();

    /**
     * Shuts down all {@link EventLoop}s and releases all resources.
     */
    ChannelFuture shutdownGracefully();

    /**
     * Shuts down all {@link EventLoop}s and releases all resources.
     */
    ChannelFuture shutdownGracefully(long quietPeriod, long timeout, TimeUnit unit);

    /**
     * Returns a copy of the list of all {@link EventLoop}s that are part of this group.
     */
    List<EventLoop> eventLoops();
}

EventLoopGroup 的关键方法

  • next()
    : 返回下一个
    EventLoop
    ,用于处理事件。这可以是现有的
    EventLoop
    或者新创建的实例,具体取决于实现。
  • shutdownGracefully()
    : 优雅地关闭所有
    EventLoop
    并释放所有资源。这个方法允许指定一个静默期和一个超时时间,以便在关闭之前等待所有任务完成。
  • eventLoops()
    : 返回当前
    EventLoopGroup
    中所有
    EventLoop
    的列表。

EventLoopGroup 的实现类

Netty 提供了几种
EventLoopGroup
的实现,主要包括:

  • DefaultEventLoopGroup
    : 默认的
    EventLoopGroup
    实现,使用
    NioEventLoop
    作为其
    EventLoop
    实现。
  • EpollEventLoopGroup
    : 特定于 Linux 的
    EventLoopGroup
    实现,使用
    EpollEventLoop
    作为其
    EventLoop
    实现,利用 Linux 的
    epoll
    机制提高性能。
  • OioEventLoopGroup
    : 阻塞 I/O 模式下的
    EventLoopGroup
    实现,使用
    OioEventLoop
    作为其
    EventLoop
    实现。

EventLoopGroup 的工作原理

  1. 创建
    :
    EventLoopGroup
    通过其构造函数创建,可以指定线程数。
  2. 注册
    :
    Channel
    需要注册到
    EventLoop
    上,以便
    EventLoop
    可以处理其 I/O 事件。
  3. 事件循环
    : 每个
    EventLoop
    在其线程中运行一个事件循环,处理注册到它的
    Channel
    的 I/O 事件。
  4. 关闭
    :
    EventLoopGroup
    可以被关闭,释放所有资源。

EventLoopGroup 的线程模型

  • 单线程模型
    : 一个
    EventLoopGroup
    只包含一个
    EventLoop
    ,适用于小容量应用。
  • 多线程模型
    : 一个
    EventLoopGroup
    包含多个
    EventLoop
    ,每个
    EventLoop
    在单独的线程中运行,适用于高并发应用。

EventLoopGroup 的使用场景

  • 服务器端
    : 在服务器端,通常使用两个
    EventLoopGroup
    。一个用于接受连接(
    bossGroup
    ),一个用于处理连接(
    workerGroup
    )。
    bossGroup
    通常使用较少的线程,而
    workerGroup
    可以根据需要处理更多的并发连接。
  • 客户端端
    : 在客户端,通常只需要一个
    EventLoopGroup
    ,用于处理所有的连接。

示例代码

以下是如何在 Netty 中使用
EventLoopGroup
的示例代码:

public class NettyServer {

    public static void main(String[] args) {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 用于接受连接
        EventLoopGroup workerGroup = new NioEventLoopGroup(); // 用于处理连接

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     p.addLast(new LoggingHandler());
                     p.addLast(new MyServerHandler());
                 }
             });

            ChannelFuture f = b.bind(8080).sync(); // 绑定端口并启动服务器
            System.out.println("Server started on port 8080");
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

在这个示例中,
bossGroup
用于接受连接,
workerGroup
用于处理连接。通过
ServerBootstrap
类配置服务器,并使用
ChannelInitializer
来设置
Channel
的处理器链。

总结

EventLoopGroup
是 Netty 中管理事件循环的核心组件,它通过
EventLoop
处理 I/O 事件,支持高并发和异步操作。通过合理配置
EventLoopGroup
,可以显著提高网络应用的性能和可扩展性。

3. ChannelPipeline组件

ChannelPipeline
是 Netty 中的一个核心组件,它负责管理一组
ChannelHandler
,并且定义了 I/O 事件和操作如何在这些处理器之间流动。以下是对
ChannelPipeline
组件的详细分析和解释:

ChannelPipeline 接口定义

ChannelPipeline
是一个接口,定义了操作
ChannelHandler
的方法:

public interface ChannelPipeline extends Iterable<ChannelHandler> {

    /**
     * Add the specified handler to the context of the current channel.
     */
    void addLast(EventExecutorGroup executor, String name, ChannelHandler handler);

    /**
     * Add the specified handlers to the context of the current channel.
     */
    void addLast(EventExecutorGroup executor, ChannelHandler... handlers);

    // ... 省略其他 addFirst, addBefore, addAfter, remove, replace 方法

    /**
     * Get the {@link ChannelHandler} by its name.
     */
    ChannelHandler get(String name);

    /**
     * Find the first {@link ChannelHandler} in the {@link ChannelPipeline} that matches the specified class.
     */
    ChannelHandler first();

    /**
     * Find the last {@link ChannelHandler} in the {@link ChannelPipeline} that matches the specified class.
     */
    ChannelHandler last();

    /**
     * Returns the context object of the specified handler.
     */
    ChannelHandlerContext context(ChannelHandler handler);

    // ... 省略 contextFor, remove, replace, fireChannelRegistered, fireChannelUnregistered 等方法
}

ChannelPipeline 的关键方法

  • addLast(String name, ChannelHandler handler)
    : 在管道的末尾添加一个新的处理器,并为其指定一个名称。
  • addFirst(String name, ChannelHandler handler)
    : 在管道的开头添加一个新的处理器。
  • addBefore(String baseName, String name, ChannelHandler handler)
    : 在指定处理器前添加一个新的处理器。
  • addAfter(String baseName, String name, ChannelHandler handler)
    : 在指定处理器后添加一个新的处理器。
  • get(String name)
    : 根据名称获取
    ChannelHandler
  • first()

    last()
    : 分别获取管道中的第一个和最后一个处理器。
  • context(ChannelHandler handler)
    : 获取指定处理器的上下文。

ChannelHandlerContext

ChannelHandlerContext

ChannelHandler

ChannelPipeline
之间的桥梁,提供了访问和管理
Channel

ChannelPipeline

ChannelFuture
的能力:

public interface ChannelHandlerContext extends AttributeMap, ResourceLeakHint {

    /**
     * Return the current channel to which this context is bound.
     */
    Channel channel();

    /**
     * Return the current pipeline to which this context is bound.
     */
    ChannelPipeline pipeline();

    /**
     * Return the name of the {@link ChannelHandler} which is represented by this context.
     */
    String name();

    /**
     * Return the {@link ChannelHandler} which is represented by this context.
     */
    ChannelHandler handler();

    // ... 省略其他方法
}

ChannelPipeline 的工作原理

ChannelPipeline
维护了一个双向链表的
ChannelHandler
集合。每个
Channel
实例都有一个与之关联的
ChannelPipeline
。当 I/O 事件发生时,如数据被读取到
Channel
,该事件会被传递到
ChannelPipeline
,然后按照
ChannelHandler
在管道中的顺序进行处理。

处理器的执行顺序

  • 入站事件
    :当数据被读取到
    Channel
    时,事件会从管道的尾部向头部传递,直到某个
    ChannelHandler
    处理该事件。
  • 出站事件
    :当需要发送数据时,事件会从管道的头部向尾部传递,直到数据被写出。

源码分析

ChannelPipeline
的实现类
DefaultChannelPipeline
内部使用了一个
ChannelHandler
的双向链表来维护处理器的顺序:

private final AbstractChannelHandlerContext head;
private final AbstractChannelHandlerContext tail;
private final List<ChannelHandler> handlers = new ArrayList<ChannelHandler>();
  • head

    tail
    是链表的头尾节点。
  • handlers
    是存储所有处理器的列表。

添加处理器时,
DefaultChannelPipeline
会更新链表和列表:

public void addLast(EventExecutorGroup executor, String name, ChannelHandler handler) {
    if (handler == null) {
        throw new NullPointerException("handler");
    }
    if (name == null) {
        throw new NullPointerException("name");
    }
    AbstractChannelHandlerContext newCtx = new TailContext(this, executor, name, handler);
    synchronized (this) {
        if (tail == null) {
            head = tail = newCtx;
        } else {
            tail.next = newCtx;
            newCtx.prev = tail;
            tail = newCtx;
        }
        handlers.add(newCtx);
    }
}

小结

ChannelPipeline
是 Netty 中处理网络事件和请求的管道,它通过维护一个
ChannelHandler
的链表来管理事件的流动。通过
ChannelHandlerContext

ChannelHandler
能够访问和修改
Channel

ChannelPipeline
的状态。这种设计使得事件处理流程高度可定制和灵活,是 Netty 高性能和易于使用的关键因素之一。

4. 源码中的关键流程

在 Netty 的
ChannelPipeline
的源码中,关键流程涉及处理器的添加、事件的触发、以及事件在处理器之间的流动。以下是一些关键流程的分析:

1. 处理器的添加

当创建
ChannelPipeline
并准备添加
ChannelHandler
时,需要确定处理器的顺序和位置。Netty 允许开发者在管道的开始、结束或指定位置插入处理器。

ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast("myHandler", new MyChannelHandler());


DefaultChannelPipeline
类中,处理器被添加到一个双向链表中,每个处理器节点(
AbstractChannelHandlerContext
)保存了指向前一个和后一个处理器的引用。

2. 事件循环和触发

每个
Channel
都与一个
EventLoop
关联,
EventLoop
负责处理所有注册到它上面的
Channel
的事件。当
EventLoop
运行时,它会不断地循环,等待并处理 I/O 事件。

// EventLoop 的事件循环
public void run() {
    for (;;) {
        // ...
        processSelectedKeys();
        // ...
    }
}

3. 事件的捕获和传递


EventLoop
检测到一个 I/O 事件(如数据到达)时,它会触发相应的操作。对于
ChannelPipeline
来说,这意味着需要调用适当的
ChannelHandler
方法。

// 伪代码,展示了事件如何被传递到 ChannelHandler
if (channelRead) {
    pipeline.fireChannelRead(msg);
}

4. 入站和出站事件的处理

  • 入站事件
    (如数据被读取)通常从
    ChannelPipeline
    的尾部开始传递,沿着管道向前,直到某个处理器处理了该事件。
  • 出站事件
    (如写数据)则从
    ChannelPipeline
    的头部开始传递,沿着管道向后,直到数据被写出。
// 入站事件处理
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    // 处理消息或传递给下一个处理器
    ctx.fireChannelRead(msg);
}

// 出站事件处理
public void write(ChannelHandlerContext ctx, Object msg) {
    // 写消息或传递给下一个处理器
    ctx.write(msg);
}

5. 处理器链的遍历

ChannelPipeline
需要能够遍历处理器链,以便按顺序触发事件。这通常通过从
ChannelHandlerContext
获取下一个或前一个处理器来实现。

// 伪代码,展示了如何获取下一个处理器并调用它
ChannelHandlerContext nextCtx = ctx.next();
if (nextCtx != null) {
    nextCtx.invokeChannelRead(msg);
}

6. 动态修改处理器链

在事件处理过程中,可能需要动态地修改处理器链,如添加新的处理器或移除当前处理器。

pipeline.addLast("newHandler", new AnotherChannelHandler());
pipeline.remove(ctx.handler());

7. 资源管理和清理


Channel
关闭时,
ChannelPipeline
需要确保所有的
ChannelHandler
都能够执行它们的清理逻辑,释放资源。

public void channelInactive(ChannelHandlerContext ctx) {
    // 清理逻辑
}

8. 异常处理

在事件处理过程中,如果抛出异常,
ChannelPipeline
需要能够捕获并适当地处理这些异常,避免影响整个管道的运行。

try {
    // 可能抛出异常的操作
} catch (Exception e) {
    ctx.fireExceptionCaught(e);
}

小结

ChannelPipeline
的源码中包含了多个关键流程,确保了事件能够按顺序在处理器之间传递,同时提供了动态修改处理器链和异常处理的能力。这些流程共同构成了 Netty 中事件驱动的网络编程模型的基础。

业务场景

  1. 微服务架构
    :Netty 可以作为 RPC 框架的基础,实现服务间的高效通信。
  2. 游戏服务器
    :由于游戏行业对延迟和并发要求极高,Netty 的异步非阻塞特性非常适合构建高并发的游戏服务器。
  3. 实时通信系统
    :Netty 可用于构建如即时消息、视频会议等需要低延迟数据传输的实时通信系统。
  4. 物联网平台
    :Netty 可以作为设备与云平台之间的通信桥梁,处理大规模的设备连接和数据流。
  5. 互联网行业
    :在分布式系统中,Netty 常作为基础通信组件被 RPC 框架使用,例如阿里的分布式服务框架 Dubbo 使用 Netty 作为其通信组件。
  6. 大数据领域
    :Netty 也被用于大数据技术的网络通信部分,例如 Hadoop 的高性能通信组件 Avro 的 RPC 框架就采用了 Netty。

最后

通过深入分析 Netty 的源码和理解其在不同业务场景下的应用,开发者可以更好地利用这一强大的网络编程框架,构建高效、稳定且可扩展的网络应用。