2024年3月

ants - 目前开源最优的协程池

目前我们的项目重度使用 ants 协程池,在开启一个 go 的时候并不是用 go 关键字,而是用一个封装的 go 函数来开启协程。框架底层,则是使用 ants 项目来实现协程池。

ants 是一个协程池的实现,这个项目短小精悍,非常适合用来做代码研究。ants 的作者是国人panjf2000,该项目目前已经广泛应用在腾讯,字节,百度,新浪等大厂了。

相关资料

github仓库地址:
https://github.com/panjf2000/ants

文档地址:
https://pkg.go.dev/github.com/panjf2000/ants/v2

主体思路研究

研究项目有必要先想通下项目的意义和架构思路:

首先的第一个问题,为什么需要有 Golang 的协程池呢?

Golang 提供的 go 关键字能很方便地将多个协程塞在一个进程中。但是在实际开发过程中,我们容易遇到协程滥用的问题。这点我是深有体会:一个项目越复杂,交接次数越多,后续的接手者越不愿意修改主逻辑。而一旦有一些非主逻辑的业务,我们都倾向于开启独立代码分支逻辑,同时封装为独立的协程来完成。这样不仅美其名曰在性能上能达到一个最优,而且在业务逻辑上也能保持单独的独立性,让代码 bug 的出生率达到最低。

但是这种不断叠加分支逻辑、不断增加独立协程的方式本质上就是一种协程滥用,我们不断增加协程数,忽略了协程的本身开销和上下文切换成本,很容易造成一个进程的 goroutine 数量过多,内存增加。不仅如此,这种做法还必须要保证分支代码质量。一个代码分支写的质量不行(比如没有设置 ctx 超时卡在 io 请求上),那么新启动的 goroutine 长时间无法释放,这就可能导致 goroutine 的泄露。这种泄露的 goroutine 如果没有被及时发现,那就是一个灾难。

所以在这里,我们更希望能将一个程序的并发度进行一定的控制,将进程消耗的资源控制在一定比例,比如我希望我的进程最多只执行 1000 个 goroutine,进程能长期保持在 1G 内存以下。所以我们就有协程池的需求了,ants 也为此应运而生。

顺带说下,ants 的名字非常有意思:蚁群,非常多的蚂蚁组成一个蚁群,烦乱但是又瑾然有序。和这个项目的愿景一样,乱而有序。

理解了ants 项目的意义和目的,再思考下,我们使用协程池来控制了协程数,一旦协程池满了之后,想新创建一个协程,这时候应该有什么表现呢?是直接在新创建协程的地方失败,还是在新创建协程的时候阻塞?是的,其实无非就是这两种方式。但是使用失败 or 阻塞 的选择权,应该是交给业务方的,也就是库的使用者。所以 ants 库需要同时能支持这两种的表现。

再思考一下,我们要如何控制 go 这个关键字的使用呢?根据不知道谁的名言,封装能解决程序世界里的所有问题。是的,封装,我们需要将 goroutine 进行封装,并且将 go 关键字也进行一下封装。goroutine 不就是一个协程来运行我们的函数么,我们就封装一个 goWorker 结构来运行我们的函数,goWorker 结构在 run 的时候,再启动实际的 goroutine 。 go 关键字呢,我们也替换为一个方法 Submit,这个方法就只有一个参数,就是我们要运行的函数。考虑到我们要运行的函数是各式各样的,所以我们还需要用一个闭包 func() 来包住我们的实际运行函数。

想到这些,我们有一些大致思路了,首先基于 OO 思想,我们为这个协程池定义一个结构 Pool,他有一系列的 goWorker,我们定义单个 goWorker 的结构,同时我们也定义一系列 goWorker 的结构 workerQueue(这里的思路是我们一定会对这个批量的 workerQueue 有一些需要封装的方法,比如获取一个可运行的 goWorker 等,所以这里并不是简单的实用 slice[goWorker])。回到 Pool 结构,我们定义好 Submit 方法,能提交一个函数。初始化的方法呢,我们要定义好这个 Pool 的goroutine 容量。

按照上述思考,我们基本能得到如下的协程池的框架设计:

classDiagram
class Pool {
+ NewPool(size int, options ...Option) (*Pool, error)
+ Submit(task func()) error
+ workers workerQueue // 可用的 workers
+ capacity int32 // 协程池容量
+ running int32 // 运行中的协程池
}


class goWorker {
+ run() // 运行 worker
}

class workerQueue {
+ detach() worker // 获取一个 worker
}

goWorker --> workerQueue
workerQueue --> Pool

再继续思考下细节,一个 goWorker,本质上是对 goroutine 的封装,而这个 goroutine 我们一旦 run 起来了,我们就不希望它会停止,而是在一个 for 循环中,不断等待有新的任务进入。而 submit又是在另外一个主业务的 goroutine 中执行,它负责把 task 从当前主业务 goroutine 传递给 goWorker run 所在的 goroutine。这里是不是就涉及到两个 goroutine 之间的任务传递了。goroutine 传递我们用什么方法呢?channel?- 对的。

基于以上分析, worker 的 run 函数和 pool 的 submit 函数的联动我们能想象到伪代码大致是这样的:

type goWorker struct {
	task chan func() // task 的 channel
}

func (w *goWorker) run() {
	go func() {
		for f := range w.tasks { // 一旦有 tasks
			f() // 实际运行 func
		}
	}
}

func (p *Pool) Submit(task func()) error {
  w, err := p.workers.detach()
  if err != nil {
    w.task <- task // 将 task 任务直接传递到 worker 的 tasks channel 中 
  }
  
  if w 为空,且 pool 的容量大于运行的 worker 数 {
    worker = newWorker()
    worker.run()
    w.task <- task
  }
  
  if w 为空,且 pool 的容量小于等于运行的 worker 数 {
    if pool 标记为阻塞 {
      p.cond.Wait() // 实用 sync.Cond 阻塞住
    } else {
      return ErrPoolOverload // 返回 pool 已经过载的错误
    }
  }
}


是的,上面这段代码这就是 ants 库最核心的代码逻辑了。

两个goroutine,一个是 goworker 的 run 的 goruotine,for 循环中不断获取任务执行,另外一个是业务 submit goroutine,不断投递任务。submit 投递的时候,一旦达到了容量,就使用 wait 阻塞住,或者返回已经过载的错误。

细节分析

魔鬼在细节,我们了解了 ants 库最核心的代码逻辑,其实只是了解了皮毛。为什么之前也有很多库都是类似的协程池功能,但是只有 ants 脱颖而出呢?原因就是在于 ants 的细节做的非常优异,我们深入研究一下。

使用 sync.Pool 初始化 goworker

当我们在 pool 中获取不到 空闲的goWorker,且 pool 的容量还未满的时候,我们就需要初始化一个 goWorker(上述伪代码的 newWorker 函数),直接 new 是最简单的办法。

但是对于协程池来说, goWorker 的初始化、回收是一个非常频繁的动作,这种动作消耗非常大。

所以我们考虑,是否可以使用对象池 sync.Pool 来优化初始化呢?这样这种大量的获取回收 worker 的行为就可以直接从 pool 中获取,降低内存的消耗回收了。

关于 sync.Pool 的使用和原理这里就不说了,参考官网:
https://pkg.go.dev/sync#Pool
。ants 这里就是使用了对象池的技术优化了 goWorker 的效率。

在 NewPool 函数中,我们定义了一个 workerCache sync.Pool

func NewPool(size int, options ...Option) (*Pool, error) {
	p := &Pool{
		capacity: int32(size),
		...
		options:  opts,
	}
	p.workerCache.New = func() interface{} {
		return &goWorker{
			pool: p,
			task: make(chan func(), workerChanCap),
		}
	}
	...
}

goWorker 的存取同时支持队列和栈方式

前面说过,pool 有一个 workers 的字段,它存储的是可用的/当前正在运行的 goWorker。那么这里就有一个问题,这个 workers 是否需要预先分配呢?

如果预先分配,那么在 Submit 函数的时候,就少了很多 new 的操作,提升了程序运行效率,但是同时带来的问题是进程启动的时候就会多占用内存。

反之,如果不预先分配,我们在每次Submit 的时候就要去初始化,这也是一种方法,特别在 goworker 并不需要特别多的时候,这种模式很合适,能很大程度节省内存。

这两种就是一种是地主做法,地主家有余量,先屯粮, 满足你所有的需求,另外一种就是贫农做法,加中无余量,你要多少,我种多少。本质上是

ants 考虑到了使用者的这种需求,为这两种模式都提供了方法,根据参数 PreAlloc 进行区别。

如果设置了 PreAlloc,则使用循环队列(loopQueue)的方式存取这个 workers。在初始化 pool 的时候,就初始化了 capacity 长度的循环队列,取的时候从队头取,插入的时候往队列尾部插入,整体 queue 保持在 capacity 长度。

如果没有设置 PreAlloc,则使用堆栈(stack)的方式存取这个 workers。初始化的时候不初始化任何的 worker,在第一次取的时候在 stack 中取不到,则会从 sync.Pool 中初始化并取到对象,然后使用完成后插入到 当前这个栈中。下次取就直接从 stack 中再获取了。

type workerQueue interface {
	len() int
	isEmpty() bool
	insert(worker) error
	detach() worker
	refresh(duration time.Duration) []worker // clean up the stale workers and return them
	reset()
}
func newWorkerQueue(qType queueType, size int) workerQueue {
	switch qType {
	case queueTypeStack:
		return newWorkerStack(size)
	case queueTypeLoopQueue:
		return newWorkerLoopQueue(size)
	default:
		return newWorkerStack(size)
	}
}

自定义自旋锁

在存取和回收worker 的时候,是需要使用到锁的,然而 ants 没有使用 sync.mutex 这样的锁,而是自己实现了一个自旋锁(spinlock)。

这个自旋锁和其他锁不同的是,它遵循指数退避(Exponential Backoff)策略。就是说,当我获取不到这个锁的时候,我也会阻塞,但是我的阻塞方案并不是不断 for 循环,在循环中不断获取锁。

指数退避原则认为,我取不到锁的次数越多,说明当前系统越繁忙,即取锁的协程越多,所以从大局出发,我应该再慢一些尝试取锁。即每次重试之后,等待的时间逐渐增加,以避免连续重试造成系统拥塞。

ants 自己实现的自旋锁就是基于这个指数退避原则,让整个系统不至于协程数获取锁的数量太多而导致崩溃。

ants 取锁的过程使用了一个 backoff 变量,当取锁失败之后,backoff 就增加固定倍数(2 倍),然后会等待 backoff 次 goroutine 的调度(runtime.GoSched())再进行下一次取锁。

func (sl *spinLock) Lock() {
	backoff := 1
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		// Leverage the exponential backoff algorithm, see https://en.wikipedia.org/wiki/Exponential_backoff.
		for i := 0; i < backoff; i++ {
			runtime.Gosched()
		}
		if backoff < maxBackoff {
			backoff <<= 1
		}
	}
}

时间戳使用 ticker 来更新

ants 在使用 worker 的时候,每次任务完成后,会希望记录 worker 上次任务时间,这样后续的回收机制能根据这个任务时间判断是否回收。原本这是很简单的一个需求,使用 time.Now()就行,但是 time.Now() 实际上是有系统消耗的,当 ants 这样底层的协程库频繁使用 time.Now() 是会对底层有一定压力的。那么有什么办法呢?我们能不能自己在内存中保持一个当前时间,这样每次要获取当前时间,就从内存中获取就行了,避免系统消耗?

ants 就是这么做的,在启动 pool 的时候,会启动一个 ticker,每 500ms调度一次,来更新 pool 中的一个 now 字段,now 字段这里还不是简单保存 time.Time 类型,而是使用了 atomic.Value 类型(提供并发安全的存取机制)。

type Pool struct {
	now atomic.Value
}

func NewPool(size int, options ...Option) (*Pool, error) {
	...
	p.goTicktock()
}

func (p *Pool) goTicktock() {
	p.now.Store(time.Now())
	var ctx context.Context
	ctx, p.stopTicktock = context.WithCancel(context.Background())
	go p.ticktock(ctx)
}

func (p *Pool) ticktock(ctx context.Context) {
	ticker := time.NewTicker(nowTimeUpdateInterval)
	...

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}

     ...

		p.now.Store(time.Now())
	}
}


总结

ants 是一个非常完善的协程库,它不仅仅在主体逻辑上非常完备,而且在细节上也处理的非常好,非常值得学习和使用。

参考资料

深入解析Golang 协程池 Ants实现原理

Go 每日一库之 ants - 大俊的博客

一、有什么问题吗
java.util.Date

java.util.Date

Date
从现在开始)是一个糟糕的类型,这解释了为什么它的大部分内容在 Java 1.1 中被弃用(但不幸的是仍在使用)。

设计缺陷包括:

  • 它的名称具有误导性:它并不代表一个日期,而是代表时间的一个瞬间。所以它应该被称为
    Instant
    ——正如它的
    java.time
    等价物一样。
  • 它是非最终的:这鼓励了对继承的不良使用,例如
    java.sql.Date
    (这
    意味着
    代表一个日期,并且由于具有相同的短名称而也令人困惑)
  • 它是可变的:日期/时间类型是自然值,可以通过不可变类型有效地建模。可变的事实
    Date
    (例如通过
    setTime
    方法)意味着勤奋的开发人员最终会在各处创建防御性副本。
  • 它在许多地方(包括)隐式使用系统本地时区,
    toString()
    这让许多开发人员感到困惑。有关此内容的更多信息,请参阅“什么是即时”部分
  • 它的月份编号是从 0 开始的,是从 C 语言复制的。这导致了很多很多相差一的错误。
  • 它的年份编号是基于 1900 年的,也是从 C 语言复制的。当然,当 Java 出现时,我们已经意识到这不利于可读性?
  • 它的方法命名不明确:
    getDate()
    返回月份中的某一天,并
    getDay()
    返回星期几。给这些更具描述性的名字有多难?
  • 对于是否支持闰秒含糊其辞:“秒由 0 到 61 之间的整数表示;值 60 和 61 仅在闰秒时出现,即使如此,也仅在实际正确跟踪闰秒的 Java 实现中出现。” 我强烈怀疑大多数开发人员(包括我自己)都做了很多假设,认为 for 的范围
    getSeconds()
    实际上在 0-59 范围内(含)。
  • 它的宽容没有明显的理由:“在所有情况下,为这些目的而对方法给出的论据不必落在指定的范围内; 例如,日期可以指定为 1 月 32 日,并被解释为 2 月 1 日。” 多久有用一次?

关键原因如下:

原文如下:
为什么要避免使用Date类?

二、为啥要改?

我们要改的原因很简单,我们的代码缺陷扫描规则认为这是一个必须修改的缺陷,否则不给发布,不改不行,服了。

解决思路:避免使用
java.util.Date

java.sql.Date
类和其提供的API,考虑使用
java.time.Instant
类或
java.time.LocalDateTime
类及其提供的API替代。

三、怎么改?

只能说这种基础的类改起来牵一发动全身,需要从DO实体类看起,然后就是各种Converter,最后是DTO。由于我们还是微服务架构,业务服务依赖于基础服务的API,所以必须要一起改否则就会报错。这里就不细说修改流程了,主要说一下我们在改造的时候遇到的一些问题。

1. 耐心比对数据库日期字段和DO的映射

(1)确定字段类型

首先你需要确定数据对象中的
Date
字段代表的是日期、时间还是时间戳。

  • 如果字段代表日期和时间,则可能需要使用
    LocalDateTime
  • 如果字段仅代表日期,则可能需要使用
    LocalDate
  • 如果字段仅代表时间,则可能需要使用
    LocalTime
  • 如果字段需要保存时间戳(带时区的),则可能需要使用
    Instant

    ZonedDateTime

(2)更新数据对象类

更新数据对象类中的字段,把
Date
类型改为适当的
java.time
类型。

2. 将DateUtil中的方法改造

(1)替换原来的new Date()和Calendar.getInstance().getTime()

原来的方式:

Date nowDate = new Date();
Date nowCalendarDate = Calendar.getInstance().getTime();

使用
java.time
改造后:

// 使用Instant代表一个时间点,这与Date类似
Instant nowInstant = Instant.now();

// 如果需要用到具体的日期和时间(例如年、月、日、时、分、秒)
LocalDateTime nowLocalDateTime = LocalDateTime.now();

// 如果你需要和特定的时区交互,可以使用ZonedDateTime
ZonedDateTime nowZonedDateTime = ZonedDateTime.now();

// 如果你需要转换回java.util.Date,你可以这样做(假设你的代码其他部分还需要使用Date)
Date nowFromDateInstant = Date.from(nowInstant);

// 如果需要与java.sql.Timestamp交互
java.sql.Timestamp nowFromInstant = java.sql.Timestamp.from(nowInstant);

一些注意点:

  1. Instant
    表示的是一个时间点,它是时区无关的,相当于旧的
    Date
    类。它通常用于表示时间戳。
  2. LocalDateTime
    表示没有时区信息的日期和时间,它不能直接转换为时间戳,除非你将其与时区结合使用(例如通过
    ZonedDateTime
    )。
  3. ZonedDateTime
    包含时区信息的日期和时间,它更类似于
    Calendar
    ,因为
    Calendar
    也包含时区信息。
  4. 当你需要将
    java.time
    对象转换回
    java.util.Date
    对象时,可以使用
    Date.from(Instant)
    方法。这在你的代码需要与旧的API或库交互时非常有用。

(2)一些基础的方法改造

a. dateFormat

原来的方式

public static String dateFormat(Date date, String dateFormat) {
    SimpleDateFormat formatter = new SimpleDateFormat(dateFormat);
    return formatter.format(date);
}

使用
java.time
改造后

public static String dateFormat(LocalDateTime date, String dateFormat) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
    return date.format(formatter);
}

b. addSecond、addMinute、addHour、addDay、addMonth、addYear

原来的方式

public static Date addSecond(Date date, int second) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(13, second);
    return calendar.getTime();
}

public static Date addMinute(Date date, int minute) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(12, minute);
    return calendar.getTime();
}

public static Date addHour(Date date, int hour) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(10, hour);
    return calendar.getTime();
}

public static Date addDay(Date date, int day) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(5, day);
    return calendar.getTime();
}

public static Date addMonth(Date date, int month) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(2, month);
    return calendar.getTime();
}

public static Date addYear(Date date, int year) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(1, year);
    return calendar.getTime();
}

使用
java.time
改造后

public static LocalDateTime addSecond(LocalDateTime date, int second) {
    return date.plusSeconds(second);
}

public static LocalDateTime addMinute(LocalDateTime date, int minute) {
    return date.plusMinutes(minute);
}

public static LocalDateTime addHour(LocalDateTime date, int hour) {
    return date.plusHours(hour);
}

public static LocalDateTime addDay(LocalDateTime date, int day) {
    return date.plusDays(day);
}

public static LocalDateTime addMonth(LocalDateTime date, int month) {
    return date.plusMonths(month);
}

public static LocalDateTime addYear(LocalDateTime date, int year) {
    return date.plusYears(year);
}

c. dateToWeek

原来的方式

public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
public static String dateToWeek(Date date) {
    Calendar cal = Calendar.getInstance();
    cal.setTime(date);
    return WEEK_DAY_OF_CHINESE[cal.get(7) - 1];
}

使用
java.time
改造后

public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};

public static String dateToWeek(LocalDate date) {
    DayOfWeek dayOfWeek = date.getDayOfWeek();
    return WEEK_DAY_OF_CHINESE[dayOfWeek.getValue() % 7];
}

d. getStartOfDay和getEndOfDay

原来的方式

public static Date getStartTimeOfDay(Date date) {
    if (date == null) {
        return null;
    } else {
        LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
        LocalDateTime startOfDay = localDateTime.with(LocalTime.MIN);
        return Date.from(startOfDay.atZone(ZoneId.systemDefault()).toInstant());
    }
}

public static Date getEndTimeOfDay(Date date) {
    if (date == null) {
        return null;
    } else {
        LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
        LocalDateTime endOfDay = localDateTime.with(LocalTime.MAX);
        return Date.from(endOfDay.atZone(ZoneId.systemDefault()).toInstant());
    }
}

使用
java.time
改造后

public static LocalDateTime getStartTimeOfDay(LocalDateTime date) {
    if (date == null) {
        return null;
    } else {
        // 获取一天的开始时间,即00:00
        return date.toLocalDate().atStartOfDay();
    }
}

public static LocalDateTime getEndTimeOfDay(LocalDateTime date) {
    if (date == null) {
        return null;
    } else {
        // 获取一天的结束时间,即23:59:59.999999999
        return date.toLocalDate().atTime(LocalTime.MAX);
    }
}

e. betweenStartAndEnd

原来的方式

public static Boolean betweenStartAndEnd(Date nowTime, Date beginTime, Date endTime) {
    Calendar date = Calendar.getInstance();
    date.setTime(nowTime);
    Calendar begin = Calendar.getInstance();
    begin.setTime(beginTime);
    Calendar end = Calendar.getInstance();
    end.setTime(endTime);
    return date.after(begin) && date.before(end);
}

使用
java.time
改造后

public static Boolean betweenStartAndEnd(Instant nowTime, Instant beginTime, Instant endTime) {
    return nowTime.isAfter(beginTime) && nowTime.isBefore(endTime);
}

我这里就只列了一些,如果有缺失的可以自己补充,不会写的话直接问问ChatGPT,它最会干这事了。最后把这些修改后的方法替换一下就行了。

四、小结一下

这个改造难度不高,但是复杂度非常高,一个地方没改好,轻则接口报错,重则启动失败,非常耗费精力,真不想改。

1、简介

Python作为一门灵活而强大的编程语言,提供了许多特殊的方法,被称为魔法函数(Magic methods)。这些魔法函数以双下划线开头和结尾,能够让我们自定义类的行为,使得Python更加灵活和易用。本文将详细介绍Python中的魔法函数,帮助读者理解其作用和用法。

1.1、什么是魔法函数?

魔法函数(Magic methods),也被称为特殊方法(Special methods)或双下划线方法(Dunder methods),是Python中的一种特殊的方法。它们以双下划线开头和结尾,例如
__init__

__str__

__repr__
等。

这些方法在类定义中具有特殊的含义,Python会在特定的情况下自动调用它们。通过实现这些魔法函数,我们可以自定义类的行为,使其具有更多的灵活性和功能。

魔法函数可以用于控制对象的创建与销毁、字符串表示、运算符重载、容器操作、属性访问等多种情况。例如,
__init__
方法用于初始化对象,在对象创建时被调用;
__str__
方法控制对象在被转换为字符串时的行为;
__getitem__

__setitem__
方法用于实现对象的索引操作等。

2、魔法函数分类

魔法函数可以分为几类,包括对象创建与销毁、字符串表示、运算符重载、容器操作等。每种类型的魔法函数都有特定的作用,下面我们将逐一介绍。

对象创建与销毁

  • __new__(cls, *args, **kwargs)
    : 用于创建对象实例,在
    __init__
    之前调用。
  • __init__(self, *args, **kwargs)
    : 对象初始化方法,在创建对象后立即调用。
  • __del__(self)
    : 对象销毁方法,在对象被销毁时调用。

字符串表示

  • __str__(self)
    : 控制对象转换为字符串的行为,通过
    str(object)

    print(object)
    调用。
  • __repr__(self)
    : 控制对象转换为可供解释器读取的字符串的行为,通过
    repr(object)
    调用。

容器操作

  • __len__(self)
    : 控制对象长度的行为,通过
    len(object)
    调用。
  • __getitem__(self, key)
    : 控制对象索引操作的行为,通过
    object[key]
    调用。
  • __setitem__(self, key, value)
    : 控制对象赋值操作的行为,通过
    object[key] = value
    调用。
  • __delitem__(self, key)
    : 控制对象删除操作的行为,通过
    del object[key]
    调用。
  • __iter__(self)
    : 返回一个迭代器对象,用于对象的迭代操作。

比较操作

  • __eq__(self, other)
    : 控制对象相等性比较的行为,通过
    object1 == object2
    调用。
  • __ne__(self, other)
    : 控制对象不等性比较的行为,通过
    object1 != object2
    调用。
  • __lt__(self, other)
    : 控制对象小于比较的行为,通过
    object1 < object2
    调用。
  • __gt__(self, other)
    : 控制对象大于比较的行为,通过
    object1 > object2
    调用。
  • __le__(self, other)
    : 控制对象小于等于比较的行为,通过
    object1 <= object2
    调用。
  • __ge__(self, other)
    : 控制对象大于等于比较的行为,通过
    object1 >= object2
    调用。

数值运算

  • __add__(self, other)
    : 控制对象加法运算的行为,通过
    object1 + object2
    调用。
  • __sub__(self, other)
    : 控制对象减法运算的行为,通过
    object1 - object2
    调用。
  • __mul__(self, other)
    : 控制对象乘法运算的行为,通过
    object1 * object2
    调用。
  • __truediv__(self, other)
    : 控制对象真除运算的行为,通过
    object1 / object2
    调用。
  • __floordiv__(self, other)
    : 控制对象整除运算的行为,通过
    object1 // object2
    调用。
  • __mod__(self, other)
    : 控制对象取模运算的行为,通过
    object1 % object2
    调用。
  • __pow__(self, other[, modulo])
    : 控制对象幂运算的行为,通过
    object1 ** object2
    调用。

属性访问

  • __getattr__(self, name)
    : 控制对不存在的属性的访问。
  • __setattr__(self, name, value)
    : 控制对属性的赋值操作。
  • __delattr__(self, name)
    : 控制对属性的删除操作。

其他

  • __contains__(self, item)
    : 控制对象成员关系测试的行为,通过
    item in object
    调用。
  • __call__(self, *args, **kwargs)
    : 控制对象的调用行为,使对象可以像函数一样被调用。

这些是Python中常见的魔法函数,通过实现其中的一个或多个,我们可以对类的行为进行高度定制化。

3、重写魔法函数

除了使用Python提供的魔法函数默认功能外,
我们还可以重写类的魔法函数,以实现特定的行为。

例如:重写
__str__
函数,但是里面只返回123,这样在print对象时,就会打印123,而不是对象的地址。

class MyClass:
    def __str__(self):
        return '123'

obj = MyClass()
print(obj)  # 输出: 123

4、总结

通过本文的介绍,相信你应该对Python中的魔法函数有了基础的了解。魔法函数为我们提供了丰富的功能和灵活的定制选项,使得我们能够更加轻松地编写出强大而优雅的Python代码,希望你能够通过本文的学习,更加熟练地运用魔法函数,提高自己的编程水平。

关注公众号【Python魔法师】,回复
python
一起进群沟通交流~

qrcode.jpg

推荐链接

LLAMA介绍

LLaMA是由Facebook的母公司Meta AI设计的一个新的大型语言模型。LLaMA拥有70亿到650亿个参数的模型集合,是目前最全面的语言模型之一。

Llama是目前唯一一个可以进行本地部署和本地训练的大型模型,对各种提问有非常好的处理能力。非常适合个人和中小型企业,构建自己的大数据模型。

很多人都说是ChatGPT的平替。通过微调来满足特定小众行业的使用,将会在未来有非常大的潜力。

Mac上由于没有Nvidia显卡的加持,无法配置CUDA进行深度学习。好在有大神制作了C++的库,能实现小成本在低配Mac上跑模型的能力。

file

llama.cpp

是一个推理框架,在没有GPU跑LLAMA时,利用Mac M1/M2的GPU进行推理和量化计算。

Mac跑LLAMA唯一的路。同样也可以在Windows下面跑起来。

它是ggml这个机器学习库的衍生项目,专门用于Llama系列模型的推理。llama.cpp和ggml均为纯C/C++实现,针对Apple Silicon芯片进行优化和硬件加速,支持模型的整型量化 (Integer Quantization): 4-bit, 5-bit, 8-bit等。社区同时开发了其他语言的bindings,例如llama-cpp-python,由此提供其他语言下的API调用。

https://github.com/ggerganov/llama.cpp

安装llama.cpp

本地快速部署体验推荐使用经过指令精调的Alpaca-2模型,有条件的推荐使用6-bit或者8-bit模型,效果更佳。 下面以中文Alpaca-2-7B模型为例介绍,运行前请确保:
1、系统应有make(MacOS/Linux自带)或cmake(Windows需自行安装)编译工具
2、建议使用Python 3.10以上编译和运行该工具
3、必装的mac依赖
xcode-select --install # Mac的Xcode开发者工具,基本是必装的,很多地方都需要用到。
brew install pkgconfig cmake # c和c++的编译工具。

1、源码编译

git clone https://github.com/ggerganov/llama.cpp

2、编译
对llama.cpp项目进行编译,生成./main(用于推理)和./quantize(用于量化)二进制文件。

make

Windows/Linux用户如需启用GPU推理,则推荐与BLAS(或cuBLAS如果有GPU)一起编译,可以提高prompt处理速度。以下是和cuBLAS一起编译的命令,适用于NVIDIA相关GPU。

make LLAMA_CUBLAS=1

macOS用户无需额外操作,llama.cpp已对ARM NEON做优化,并且已自动启用BLAS。M系列芯片推荐使用Metal启用GPU推理,显著提升速度。只需将编译命令改为:LLAMA_METAL=1 make,

LLAMA_METAL=1 make

3、检查
编译成功会在目录下产生main等可执行的命令,下面转换量化模型文件时,会用到的命令就准备好了。

手动转换模型文件为GGUF格式

如果下载的是生成好的gguf模型就不需要手动转换了。为啥要这个格式。这个格式的LLAMA.cpp才认。其它格式的数据不认。

1、下载 Llama 2 模型
首先,从 Hugging Face
https://huggingface.co/meta-llama
上下载你想要使用的 Llama 2 模型,比如 7B-Chat,我的Mac是8G内存,M2芯片,估计也只能跑到这个模型,再大的机器跑不动。
值得一提的是:
https://huggingface.co/meta-llama/Llama-2-7b-chat
下载时,第一次需要授权,需要到meta官网,下面这个链接
https://llama.meta.com/llama-downloads

去提交一下邮件。这里选国家时会有意想不到的结果,自己思考一下。

如果要体验英文原版,就用上面的,会比较麻烦,但是对英文的回复比较好。
参考教程
https://github.com/ymcui/Chinese-LLaMA-Alpaca-2/wiki/manual_conversion_zh

如果要使用中文语料库,需要先合并为原始模型和中文的模型,再生成bin,再去转换为gguf格式。喜欢折腾的可以试试。

如果要使用我这个中文混合模型,可以直接下载gguf格式。下面这几步都不用了。省事多了。

下载地址:
https://huggingface.co/hfl/chinese-llama-2-7b-gguf/tree/main
记得选ggml-model-q4_0.gguf这个模型。

2、下载 llama.cpp 库,并按上面的流程进行编译安装成功

3、转换模型格式
然后,你需要把模型的文件转换成 GGUF 格式,使用 llama.cpp 库中的 convert.py 脚本来完成。转换时需要指定模型的路径和上下文长度(模型可以处理的最大的文本长度),不同的模型可能有不同的上下文长度。

如果模型是 LLaMA v1,则使用 --ctx 2048,如果你的模型是 LLaMA v2,则使用 --ctx 4096。这里使用 --ctx 4096。如下所示:

# 转换模型文件
python3 convert.py models/7B-Chat --ctx 4096

如果安装过程缺python包直接pip install 安装即可。

4、量化模型文件

使用 llama.cpp 库中的 quantize 程序来进行模型量化,使用 quantize 命令:

# 运行 quantize 程序,指定输入和输出的模型文件和量化方式
./quantize ./models/7B/ggml-model-f16.gguf ./models/7B/ggml-model-q4_0.gguf q4_0

这样,在 7B-Chat 文件夹中就生成一个 4 位整数的 GGUF 模型文件。

5、运行模型

./main -m ./models/7B/ggml-model-q4_0.bin \
        -t 8 \
        -n 128 \
        -p 'The first president of the USA was '

# run the inference 推理
./main -m ./models/llama-2-7b-hf/ggml-model-q4_0.bin -n 128
#以交互式对话
./main -m ./models/llama-2-7b-hf/ggml-model-q4_0.bin --color -f prompts/alpaca.txt -ins -c 2048 --temp 0.2 -n 256 --repeat_penalty 1.3
#chat with bob
./main -m ./models/llama-2-7b-hf/ggml-model-q4_0.bin -n 256 --repeat_penalty 1.0 --color -i -r "User:" -f prompts/chat-with-bob.txt

此步骤过于烦锁,主要是模型文件占了几十GB。所以我直接下载别人的中文模型进行使用。不需要再手动进行转换、量化等操作。

以WebServer形式启动

调用手册:
https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md

用WebServer形式。可以对接到别的系统里面,像FastGPT或者一些界面上,就可以无缝使用了。

1、启动server 参数请./server -h 查看,或者参考手册

./server --host 0.0.0.0 -m /Users/kyle/MyCodeEnv/models/ggml-model-q4_0.gguf -c 4096 --n-gpu-layers 1

默认会开到8080端口上,配置可改。不加gpu-layers走CPU,会报错。设个1就行

2、用CURL进行测试

curl --request POST \
    --url http://127.0.0.1:8080/completion \
    --header "Content-Type: application/json" \
    --data '{"prompt": "给我讲个冷笑话:","n_predict": 128}'

3、效果如图
file

file
感觉 就是训练的还是量少,有些问题会胡说。理解不了的问题反应会非常慢。会花很长的时间。

Python调用接口库

https://github.com/abetlen/llama-cpp-python
https://llama-cpp-python.readthedocs.io/en/latest/install/macos/

1、Mac用户,pip编译,最简
安装llama-cpp-python (with Metal support)
为了启用对于Metal (Apple的GPU加速框架) 的支持,使用以下命令安装llama-cpp-python:
CMAKE_ARGS="-DLLAMA_METAL=on" FORCE_CMAKE=1 pip install llama-cpp-python

2、代码中使用,安装好之后可以直接用requests调用。无需第1步的llama-cpp-python依赖包。使用通用的ChatGPT的问答形式回答。
也可以不经Server直接调用模型文件

# -*- coding: utf-8 -*-
import requests

url = 'http://localhost:8080/v1/chat/completions'
headers = {
    'accept': 'application/json',
    'Content-Type': 'application/json'
}
dataEn = {
    'messages': [
        {
            'content': 'You are a helpful assistant.',
            'role': 'system'
        },
        {
            'content': 'What is the capital of France?',
            'role': 'user'
        }
    ]
}
data = {
    'messages': [
        {
            'content': '你是一个乐于助人的助手',
            'role': 'system'
        },
        {
            'content': '二战是哪一年爆发的?',
            'role': 'user'
        }
    ]
}

response = requests.post(url, headers=headers, json=data)
print(response.json())
print(response.json()['choices'][0]['message']['content'])

3、直接调用模型文件,需要安装llama-cpp-python包

# -*- coding: utf-8 -*-
from llama_cpp import Llama

# 加截模型
# llm = Llama(model_path='/Users/kyle/MyCodeEnv/models/ggml-model-q4_0.gguf', chat_format="llama-2") # 可以指定聊天格式
llm = Llama(model_path='/Users/kyle/MyCodeEnv/models/ggml-model-q4_0.gguf')

# 提问
response = llm("给我讲一下英国建国多少年了", max_tokens=320, echo=True)
# response = llm.create_chat_completion(
#     messages=[
#         {"role": "system", "content": "你是一个乐于助人的助手"},
#         {
#             "role": "user",
#             "content": "给我讲一个笑话"
#         }
#     ]
# )
# print(response)

# 回答
print(response['choices'][0])

最后贴个官方的教程

https://llama-cpp-python.readthedocs.io/en/latest/install/macos/

再慢慢研究研究微调和训练自己的语料吧。

跟上LLM的步伐。不接触AI就要落后了。
更多精彩内容,请关注我的公众号:青塬科技。

1、前言

为什么说是伪微服务框架,常见微服务框架可能还包括服务容错、服务间的通信、服务追踪和监控、服务注册和发现等等,而我这里为了在使用中的更简单,将很多东西进行了简化或者省略了。

年前到现在在开发一个新的小项目,刚好项目最初的很多功能是比较通用的,所以就想着将这些功能抽离出来,然后做成一个通用的基础服务,然后其他项目可以直接引用这个基础服务,这样就可以减少很多重复的工作了。我在做的过程中也是参考了公司原有的一个项目,目标是尽量的简单,但是项目搞着搞着就越来越大了,所以我也是在不断的进行简化和优化。当然我的思考和架构能力还存在很大的问题,另外还由于时间比较仓促,很多东西还没有经过我的深思熟虑,而且现在项目还在初期的开发阶段,问题肯定是有很多的,这里也是希望自己通过整理出来,加深对项目的理解,也希望如果大家能够给我一点指导和建议那就更好了。
总之,后期会慢慢优化和完善这个项目,也会在这里记录下来。后端如果差不多了,就会进行前端项目的开发,然后再进行整合。

直接上github链接:
https://github.com/aehyok/NET8.0

现阶段部署的一个单节点的服务:
http://101.200.243.192:8080/docs/index.html

2、全文思维导航图

其中列举了我觉得比较重点的一些知识点吧,当然其实还有很多知识点,可能我忽略掉了,后期有时间看到了还会加进来。

3、简单整体框架

  • Libraries
    里面包含了各种外部类库,对其深加工使用在项目中
    • EFCore
    • Excel
    • RabbitMQ
    • Redis
    • Serilog
    • Swagger
    • Skywalking(暂未接入)
  • Services/Basic
    微服务:基础支撑子系统
  • Services/NCDP
    微服务:业务子系统
  • Services/SystemService
    微服务:系统服务(包括数据库的更新、定时任务、数据初始化、Swagger承载、RabbitMQ队列事件处理器等)
  • sun.Core

首先我将sun.Core作为了中转,其他外部或者自己封装的类库,在引用的时候都是在sun.Core中进行的引用,
算是间接引用,来简化项目中的依赖关系。同时在sun.Core也封装了一些核心组件和服务。

  • sun.Infrastructure
    其中主要封装一些通用的方法,以及基础设施组件,供外部使用。

4、已实现业务功能

目前基本实现的功能有

  • 用户管理
  • 角色管理
  • 区域管理
  • 查看日志(登录日志和操作日志)
  • 菜单管理
  • 基本的登录、登出、权限控制都已实现
  • 系统管理:其中包含很多包括方便开发运维的功能想到就做进去

5、依赖注入和控制反转

针对依赖注入和控制反转概念进行讲解的文章已经非常多了这里我就不进行说明了,找到一篇不错的讲解,有兴趣的可以看看
https://www.cnblogs.com/laozhang-is-phi/p/9541414.html

依赖注入主要有三种方式

  • 构造函数注入
  • 属性注入
  • 方法参数注入

通过属性方式注入容易和类的实例属性混淆,不建议使用。

通过方法参数注入有时候经常会与其他参数混合,当在原模块中添加新的依赖的时候,通常会带来一些麻烦。

这里通常建议使用构造函数注入的方式,而且在.NET8.0中新增加了主构造函数的语法糖,使声明构造函数的参数更加简洁

没有使用主构造函数的方式

    public class DictController : BasicControllerBase
    {
        private readonly IDictionaryGroupService dictionaryGroupService;
        private readonly IDictionaryItemService dictionaryItemService;

        public DictController(IDictionaryGroupService dictionaryGroupService, IDictionaryItemService dictionaryItemService)
        {
            this.dictionaryGroupService = dictionaryGroupService;
            this.dictionaryItemService = dictionaryItemService;
        }

使用主构造函数之后的方法,看上去代码就简洁了很多

    public class DictionaryController(
        IDictionaryGroupService dictionaryGroupService,
        IDictionaryItemService dictionaryItemService) : BasicControllerBase
    {
    
    }

6、双token实现登录,并实现无感刷新前端token

通过输入用户名和密码以及验证码之后,调用接口进行返回结果如下

image.png

expirationDate超时时间对应的是token的,而refreshToken的超时时间是在后端进行设置的通常要比token的超时时间要长的长

            var token = new UserToken()
            {
                ExpirationDate = DateTime.Now.AddHours(10),
                IpAddress = ipAddress.ToString(),
                PlatformType = platform,
                UserAgent = userAgent,
                UserId = user.Id,
                LoginType = LoginType.Login,
                RefreshTokenIsAvailable = true
            };

            token.Token = StringExtensions.GenerateToken(user.Id.ToString(), token.ExpirationDate);
            token.TokenHash = StringExtensions.EncodeMD5(token.Token);
            token.RefreshToken = StringExtensions.GenerateToken(token.Token, token.ExpirationDate.AddMonths(1));

我这里后端的代码token设置的有效时间为10个小时,而refreshToken设置的过期时间则为一个月

当前端请求接口时间超过10个小时之后,后端则会现在redis中进行查找

 await redisService.SetAsync(CoreRedisConstants.UserToken.Format(token.TokenHash), cacheData, TimeSpan.FromHours(10));

但是redis中已经设置了过期时间,在接口访问校验token时如果超过了设置的过期时间,则返回为空值。后端则直接报错给前端,此时前端便可以通过RefreshToken进行重新获取token。

通过前端进行调用

if (code === ResultEnum.NOT_LOGIN && !res.config.url?.includes("/basic/Token/Refresh")) {
    if (!isRefreshing) {
      isRefreshing = true;
      try {
        const { code, data } = await refreshTokenApi({
          userId: storage.get(UserEnum.ACCESS_TOKEN_INFO).userId,
          refreshToken: storage.get(UserEnum.ACCESS_TOKEN_INFO).refreshToken
        });
        if (code === ResultEnum.SUCCESS) {
          storage.set(UserEnum.ACCESS_TOKEN_INFO, data);
          res.config.headers.Authorization = `${data?.token}`;
          res.config.url = res.config.url?.replace("/api", "");

          // token 刷新后将数组的方法重新执行
          requests.forEach((cb) => cb(data?.token));
          requests = []; // 重新请求完清空
          // @ts-ignore
          return http.request(res.config, res.config.requestOptions);
        }
      } catch (err) {
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
    }

后端方法的实现则是通过RefreshToken进行确认身份,然后重新生成登录的token和refreshToken,以及重新设置token的过期时间,跟登录时的逻辑是一样的。

7、实现Authentication安全授权

首先在初始化应用程序的时候注册授权认证的中间件

builder.Services.AddAuthentication("Authorization-Token").AddScheme<RequestAuthenticationSchemeOptions, RequestAuthenticationHandler>("Authorization-Token", options => { });

然后来看一下我的RequestAuthenticationHandler具体实现如下

    /// <summary>
    /// 请求认证处理器(Token校验)
    /// </summary>
    public class RequestAuthenticationHandler(IOptionsMonitor<RequestAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserTokenService userTokenService) : AuthenticationHandler<RequestAuthenticationSchemeOptions>(options, logger, encoder, clock)
    {
        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            var token = Request.Headers.Authorization.ToString();

            if(!string.IsNullOrEmpty(token))
            {
                token = token.Trim();

                // 验证 Token 是否有效,并获取用户信息
                var userToken = await userTokenService.ValidateTokenAsync(token);
                if (userToken == null)
                {
                    return AuthenticateResult.Fail("Invalid Token!");
                }

                var claims = new List<Claim>
                {
                    new(DvsClaimTypes.RegionId, userToken.RegionId.ToString()),
                    new(DvsClaimTypes.UserId, userToken.UserId.ToString()),
                    new(DvsClaimTypes.Token, token),
                    new(DvsClaimTypes.RoleId, userToken.RoleId.ToString()),
                    new(DvsClaimTypes.PopulationId, userToken.PopulationId.ToString()),
                    new(ClaimTypes.NameIdentifier, userToken.UserId.ToString()),
                    new(DvsClaimTypes.TokenId, userToken.Id.ToString()),
                    new(DvsClaimTypes.PlatFormType, userToken.PlatformType.ToString()),
                };

                // 将当前用户的所有角色添加到 Claims 中
                userToken.Roles.ForEach(a =>
                {
                    claims.Add(new Claim(ClaimTypes.Role, a));
                });

                var claimsIdentity = new ClaimsIdentity(claims, nameof(RequestAuthenticationHandler));

                var ticket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);
                return AuthenticateResult.Success(ticket);
            }
            return AuthenticateResult.NoResult();
        }
    }

处理认证流程中的一个核心方法,这个方法返回
AuthenticateResult
来标记是否认证成功以及返回认证过后的票据(AuthenticationTicket)。

这样后续便可以通过context.HttpContext.User.Identity.IsAuthenticated 来判断是否已经认证

 // 其他需要登录验证的,则通过AuthenticationHandler进行用户认证
 if (!context.HttpContext.User.Identity.IsAuthenticated)
 {
     context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status401Unauthorized, "请先登录", null));
     return;
 }

8、引入Swagger 生成REST APIs文档工具

最终的效果如下图所示

  • 包含可以承载多个微服务项目,通过右上角进行切换,便可以查看当前微服务项目的接口文档,并可以进行测试
  • 测试接口直接可在swagger ui上进行
  • 统一添加接口中的Header参数

通过对swagger ui进行部分的自定义,使的更好的适配自己的项目,比如添加登录,这样接口便直接可以在swagger ui上面进行。

同时通过配置文件的方式,添加多个微服务项目进行切换测试

直接通过以下代码

        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            if (operation.Parameters == null)
                operation.Parameters = new List<OpenApiParameter>();

            operation.Parameters.Add(new OpenApiParameter
            {
                Name = "Menu-Code",
                Description = "当前操作的menuCode",
                In = ParameterLocation.Header,
                Required = false,
                Schema = new OpenApiSchema
                {
                    Type = "string"
                }
            });
        }

统一在Header中添加一个Menu-Code的参数

这里主要是为了写入操作日志时使用的,后面会专门提到。

9、初始化加载appsettings.json配置信息

开发环境,我的配置文件是单独放在src/etc下面的

通过代码,这样一方面配置文件可以统一位置方便修改,以及编译的时候配置文件不在编译目录中,不用改来改去

            builder.ConfigureAppConfiguration((context, options) =>
            {
                // 正式环境配置文件路径
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../etc/appsettings.json"), true, true);
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../etc/{moduleKey}-appsettings.json"), true, true);

                // 本地开发环境配置文件路径
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../../../../../../etc/appsettings.json"), true, true);
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../../../../../../etc/{moduleKey}-appsettings.json"), true, true);
            });

10、引入Serilog实现过滤器IAsyncExceptionFilter进行记录错误日志,并部署docker进行可视化快速定位问题

这个通过安装一个docker容器遍可以跑起来了,非常简单
安装地址为:
https://docs.datalust.co/docs/getting-started-with-docker

安装成功后,访问地址,然后在上面配置一下api-key
https://docs.datalust.co/docs/api-keys

然后便可以在程序调用中进行配置

代码的位置

其中还可以对日志封装一些特殊字段,方便查看日志,定位问题的字段。例如下面我封装了三个特殊字段

  • IpAddressEnricher 在日志中记录请求的 IP 地址
  • TokenEnricher 将TokenId写入日志
  • WorkerEnricher 将配置文件中的WorkId写入日志

然后遍可以在seq可视化平台进行查看定位问题

实现IAsyncExceptionFilter接口,统一记录错误日志,以及统一返回前端错误

    /// <summary>
    /// 错误异常处理过滤器(控制器构造函数、执行Action接口方法、执行ResultFilter结果过滤器)
    /// </summary>
    public class ApiAsyncExceptionFilter : IAsyncExceptionFilter
    {
        private readonly ILogger<ApiAsyncExceptionFilter> logger;

        public ApiAsyncExceptionFilter(ILogger<ApiAsyncExceptionFilter> logger)
        {
            this.logger = logger;
        }

        public async Task OnExceptionAsync(ExceptionContext context)
        {
            var exception = context.Exception;

            //设置错误返回结果
            var resultModel = new RequestResultModel();
            if(exception is ErrorCodeException errorCodeException)
            {
                resultModel.Code = errorCodeException.ErrorCode;
            }
            else
            {
                resultModel.Code = (int)HttpStatusCode.InternalServerError;
            }

            resultModel.Message = exception.Message;

            // 读取配置文件中是否配置了显示堆栈信息
            if(App.Options<CommonOptions>().ShowStackTrace)
            {
                resultModel.Data = exception.StackTrace;
            }

            context.Result = new RequestJsonResult(resultModel);

            //用来指示错误异常已处理
            context.ExceptionHandled = true;

            //所有接口如果包含异常,都返回500
            context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

            var message = exception.Message;

            logger.LogError(exception, message);

            await Task.CompletedTask;
        }
    }

11、通过实现过滤器IAsyncActionFilter结合反射来记录操作日志,并通过请求头中的Menu-Code来辨别具体接口

直接看一下对过滤器IAsyncActionFilter的实现

/// <summary>
/// 操作日志记录过滤器
/// </summary>
public class OperationLogActionFilter(IOperationLogService operationLogService, IEventPublisher publisher, ICurrentUser currentUser) : IAsyncActionFilter
{
    /// <summary>
    /// 执行时机可通过代码中的的位置(await next();)来分辨
    /// </summary>
    /// <param name="context"></param>
    /// <exception cref="NotImplementedException"></exception>

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;

        if (context.HttpContext.Request.Headers.ContainsKey("Menu-Code") && !string.IsNullOrEmpty(context.HttpContext.Request.Headers["Menu-Code"]))
        {
            var menuCode = context.HttpContext.Request.Headers["Menu-Code"].ToString();
            if (actionDescriptor != null)
            {
                var json = JsonConvert.SerializeObject(context.ActionArguments);

                var logAttribute = actionDescriptor.MethodInfo.GetCustomAttribute<OperationLogActionAttribute>();
                string logMessage = null;
                if (logAttribute != null)
                {
                    logMessage = logAttribute.MessageTemplate;
                    if(logMessage is not null)
                    {
                        CreateOperationLogContent(json, ref logMessage);
                    } 
                }
                else
                {
                    // 获取 Action 注释
                    var commentsInfo = DocsHelper.GetMethodComments(actionDescriptor.ControllerTypeInfo.Assembly.GetName().Name, actionDescriptor.MethodInfo);
                    logMessage = commentsInfo;
                }
                // 待处理发布事件

                publisher.Publish(new OperationLogEventData()
                {
                    Code = menuCode,
                    Content = logMessage,
                    Json = json,
                    UserId = currentUser.UserId,
                    IpAddress = context.HttpContext.Request.GetRemoteIpAddress(),
                    UserAgent = context.HttpContext.Request.Headers.UserAgent
                }) ;
                //await operationLogService.LogAsync(menuCode, logMessage, json);
            }
        }
        await next();
    }

比较重要的便是这个Menu-Code,前端会在Header中进行传递,同时我上面也说了Swagger UI中也可以传递Menu-Code进行测试写入操作日志。

那么这个Menu-Code到底是哪里来的呢

这个MenuCode就是菜单的Code而已,每个菜单下的所有按钮也会保存在数据库中

然后根据接口的action 先找有没有对action接口方法进行标记

有进行标记,则将参数进行转换即可,如果没有标记,则通过反射进行读取action接口方法上的注释作为操作日志的内容,每个接口上我都会进行注释。

准备好操作内容之后,接下来就是写入数据库,这里操作日志可能会有很多很多,因为这里我的想法是尽可能多的写入操作日志,其实内容也没多少吧。但是可能写入是非常的频繁,于是这里引入了RabbitMQ的队列慢慢排队写入到数据库就可以了。

                    // 待处理发布事件

                    publisher.Publish(new OperationLogEventData()
                    {
                        Code = menuCode,
                        Content = logMessage,
                        Json = json,
                        UserId = currentUser.UserId,
                        IpAddress = context.HttpContext.Request.GetRemoteIpAddress(),
                        UserAgent = context.HttpContext.Request.Headers.UserAgent
                    }) ;

姑且有关RabbitMQ的内容我下面会继续记录,这里暂时就点到为止。

12、通过实现IAsyncAuthorizationFilter来验证用户身份,并判断接口访问的权限

先看一下对IAsyncAuthorizationFilter接口的实现

    /// <summary>
    /// 请求接口权限过滤器而AuthenticationHandler则是用户认证,token认证
    /// </summary>
    public class RequestAuthorizeFilter(IPermissionService permissionService) : IAsyncAuthorizationFilter
    {
        public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
        {
            // 接口标记了[AllowAnonymous],则不需要进行权限验证
            if (context.ActionDescriptor.EndpointMetadata.Any(a => a.GetType() == typeof(AllowAnonymousAttribute)))
            {
                return;
            }

            // 其他需要登录验证的,则通过AuthenticationHandler进行用户认证
            if (!context.HttpContext.User.Identity.IsAuthenticated)
            {
                context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status401Unauthorized, "请先登录", null));
                return;
            }

            if (context.ActionDescriptor is not null && context.ActionDescriptor is ControllerActionDescriptor descriptor)
            {
                var namespaceStr = descriptor.ControllerTypeInfo.Namespace;
                var controllerName = descriptor.ControllerName;
                var actionName = descriptor.ActionName;

                var code = $"{namespaceStr}.{controllerName}.{actionName}";

                var menuCode = string.Empty;
                if (context.HttpContext.Request.Headers.ContainsKey("Menu-Code") && !string.IsNullOrEmpty(context.HttpContext.Request.Headers["Menu-Code"]))
                {
                    menuCode = context.HttpContext.Request.Headers["Menu-Code"].ToString();
                }

                // 通过menuCode找到菜单Id,通过code找到接口Id
                var hasPermission = false;

                //有些操作是不在菜单下面的,则默认有访问接口的权限
                if (string.IsNullOrEmpty(menuCode))
                {
                    hasPermission = true;
                }

                hasPermission = await permissionService.JudgeHasPermissionAsync(code, menuCode);
                if (hasPermission)
                {
                    return;
                }

                context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status403Forbidden, "暂无权限", null));
                await Task.CompletedTask;
            }
        }
    }   

通过最上面的代码可以看到如果接口上标注了[AllowAnonymous] 则访问接口不需要进行校验token。例如下面这个接口

        /// <summary>
        /// 使用 Refresh Token 获取新的 Token
        /// </summary>
        /// <param name="model"></param>
        /// <returns></returns>
        [HttpPost("Refresh")]
        [AllowAnonymous]
        public Task<UserTokenDto> RefreshAsync(RefreshTokenDto model)
        {
            return userTokenService.RefreshTokenAsync(model.UserId, model.RefreshToken);
        }

下面则进行判断token是否已经校验。然后再根据接口的命名空间名称、控制器名称、接口名称的拼接 来判断当前操作是否有勾选对应的接口(当前操作则是通过传递的Menu-Code进行的)。

目前设计是一个操作对应一个接口,也就是只勾选一个接口即可。这里其实勾选多个接口应该也没什么问题。操作日志相当于一个Menu-Code下有两个访问接口的日志而已。

同时,这里的接口列表也是通过反射进行完成映射并写入数据库的。这个在初始化在后面会详细说明。

13、通过实现IAsyncResultFilter来统一返回前端数据

直接来看代码实现

/// <summary>
/// 异步请求结果过滤器
/// </summary>
public class RequestAsyncResultFilter : IAsyncResultFilter
{
    /// <summary>
    /// 在返回结果之前调用,用于统一返回数据格式
    /// </summary>
    /// <param name="context"></param>
    /// <param name="next"></param>
    /// <returns></returns>
    /// <exception cref="NotImplementedException"></exception>
    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        if (Activity.Current is not null)
        {
            context.HttpContext.Response.Headers.Append("X-TraceId", Activity.Current?.TraceId.ToString());
        }

        if(context.Result is BadRequestObjectResult badRequestObjectResult)
        {
            var resultModel = new RequestResultModel
            {
                Code = badRequestObjectResult.StatusCode ?? StatusCodes.Status400BadRequest,
                Message = "请求参数验证错误",
                Data = badRequestObjectResult.Value
            };

            context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
            context.Result = new RequestJsonResult(resultModel);
        }
        // 比如直接return Ok();
        else if(context.Result is StatusCodeResult statusCodeResult)
        {
            var resultModel = new RequestResultModel
            {
                Code = statusCodeResult.StatusCode,
                Message = statusCodeResult.StatusCode == 200 ? "Success" : "请求发生错误",
                Data = statusCodeResult.StatusCode == 200
            };

            context.Result = new RequestJsonResult(resultModel);
        }
        else if(context.Result is ObjectResult result)
        {
            if(result.Value is null)
            {
                var resultModel = new RequestResultModel
                {
                    Code = result.StatusCode ?? context.HttpContext.Response.StatusCode,
                    Message = "未请求到数据"
                };
                context.Result = new RequestJsonResult(resultModel);
            }
            else if(result.Value is not RequestJsonResult)
            {
                if (result.Value is IPagedList pagedList)
                {
                    var resultModel = new RequestPagedResultModel
                    {
                        Message = "Success",
                        Data = result.Value,
                        Total = pagedList.TotalItemCount,
                        Page = pagedList.PageNumber,
                        TotalPage = pagedList.PageCount,
                        Limit = pagedList.PageSize,
                        Code = result.StatusCode ?? context.HttpContext.Response.StatusCode
                    };

                    context.Result = new RequestJsonResult(resultModel);
                }
                else
                {
                    var resultModel = new RequestResultModel
                    {
                        Code = result.StatusCode ?? context.HttpContext.Response.StatusCode,
                        Message = "Success",
                        Data = result.Value
                    };

                    context.Result = new RequestJsonResult(resultModel);
                }
            }
        }

        await next();
    }
}

主要就是三种情况

  • 请求参数验证错误的返回提示
  • 正常返回例如详情的结果数据
  • 单独针对分页数据的返回

这样前端也可以更好的根据情况进行封装统一,便于维护的代码

14、初始化EFCore,并实现Repository仓储模式

这部分包含的代码和知识点还是比较多的,这里暂时通过一个截图来看看。

  • DvsContext 中则是简单封装了基础的数据库上下文
  • Entities 业务实体基类和基础接口
  • Mapping 实现针对每个业务实体的映射基类,方便针对属性字段进行定制化的设置
  • Repository 仓储模式
    • AutoMapper自动化映射的封装
    • Base DbContext基础操作的封装 新增 修改 删除 事物等
    • Query 主要是查询的封装 以及对查询分页的封装
  • DvsSaveChangeInterceptor 针对通用查询、新增、修改的统一封装逻辑处理

15、引入Snowflake,实现分布式雪花Id生成器

所使用的开源类库:
https://github.com/stulzq/snowflake-net

    /// <summary>
    /// 分布式雪花Id生成器
    /// </summary>
    public class SnowFlake
    {
        /// <summary>
        /// 通过静态类只实例化一次IdWorker 否则生成的Id会有重复
        /// </summary>
        private static readonly Lazy<IdWorker> _instance = new(() =>
        {
            var commonOptions = App.Options<CommonOptions>();

            return new IdWorker(commonOptions.WorkerId, commonOptions.DatacenterId);
        });

        public static IdWorker Instance = _instance.Value;
    }

其中 WorkerId和DatacenterId保持不同的话,例如两个微服务WorkerId一个为1一个为2,那么在同一毫秒数生成的Id肯定是不同的。

同一个IdWorker在一个毫秒中可以生成4096个序列号 足够大型系统使用了,不怕重复的问题

16、引入Redis统一封装实现分布式缓存和分布式锁

所使用的开源类库:
https://github.com/2881099/csredis

目前主要封装了几个常用的接口方法

    public interface IRedisService
    {
        /// <summary>
        /// 查看服务是否运行
        /// </summary>
        /// <returns></returns>
        bool PingAsync();

        /// <summary>
        /// 根据key获取缓存
        /// </summary>
        /// <param name="key"></param>
        /// <returns></returns>
        Task<T> GetAsync<T>(string key);



        /// <summary>
        /// 设置指定key的缓存值(不过期)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value);

        /// <summary>
        /// 设置指定key的缓存值(可设置过期时间和Nx、Xx)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <param name="expire"></param>
        /// <param name="exists"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value, TimeSpan expire, RedisExistence? exists = null);

        /// <summary>
        /// 设置指定key的缓存值(可设置过期秒数和Nx、Xx)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <param name="expireSeconds">过期时间单位为秒</param>
        /// <param name="exists"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value, int expireSeconds = -1, RedisExistence? exists = null);

        /// <summary>
        /// 删除Key
        /// </summary>
        /// <param name="key"></param>
        /// <returns></returns>
        Task<long> DeleteAsync(string key);


        Task<Dictionary<string,string>> ScanAsync();
    }

主要是为了保持与redis cli中的方法一致,选了这个类库,当然你也可以选择其他的类库 还是蛮多的。
同时还封装了一个接口用于前端监测所有的key和value。

        public async Task<dynamic> ScanAsync(PagedQueryModelBase model)
        {
            List<string> list = new List<string>();

            //根沐model.Keyword进行模糊匹配
            var scanResult = await RedisHelper.ScanAsync(model.Page, $"*{model.Keyword}*", model.Limit);
            list.AddRange(scanResult.Items);

            var values = await RedisHelper.MGetAsync(list.ToArray());

            var resultDictionary = list.Zip(values, (key, value) => new { key, value })
                                            .ToDictionary(item => item.key, item => item.value);
            dynamic result = new ExpandoObject();
            result.Items = resultDictionary;
            result.Cursor = scanResult.Cursor;  // 下一次要通过这个Cursor获取下一页的keys
           return result;
        }

https://www.redis.net.cn/order/3552.html

17、引入RabbitMQ统一封装实现异步任务,例如上传和下载文件等

暂时只使用了direct模式,根据routingKey和exchange决定的那个唯一的queue可以接收消息。

我这里封装了一个统一的消息队列处理器,具体的订阅逻辑都在EventSubscriber。
调用的时候参考如下代码

定义好要传输的消息实体,发布消息,然后RabbitMQ通用方法收到消息后会进行处理,然后交给指定的处理器

直接实现IEventHandler
,这个T便是AsyncTaskEventData,根据需要进行定义就好了。

// 发布任务
publisher.Publish(new AsyncTaskEventData(task));    

这里其实可以通过RabbitMQ后台管理查看,这里我的Queues队列名中直接也包含了对应的事件处理器,方便查看。
这里我也可以将事件处理器批量写入到数据库,再写个接口,方便在系统中直接查看,后面有时间了加进去。

18、引入Cronos并结合自带BackgroundService后台任务实现秒级定时任务处理

所使用的开源类库:
https://github.com/HangfireIO/Cronos
表达式具体的使用规则可以直接打开上面的链接进行学习查看,也可以查看在线的表达式进行对比查看https://cron.qqe2.com/ 。

使用.net内置 BackgroundService后台异步执行任务程序运行后,定时任务便会一直运行着,封装统一处理定时任务基类CronScheduleService,会在sun.SystemService系统服务开启后将服务本身同步到Mysql和Redis(ScheduleTask)

会对定时任务的执行过程进行记录,记录到数据库中(ScheduleTaskRecord) 记录开始执行时间,结束执行时间,执行是否成功,以及表达式的转换时间等。

来看一个定时任务的例子

    /// <summary>
    /// 测试调查问卷的功能
    /// </summary>
    public class QuestionSchedule2(IServiceScopeFactory serviceFactory) : CronScheduleService(serviceFactory)
    {
        protected override string Expression { get; set; } = "0/2 * * * * ?";

        protected override bool Singleton => true;

        protected override Task ProcessAsync(CancellationToken cancellationToken)
        {
            Console.WriteLine("实现调查问卷的功能");
            return Task.CompletedTask;
        }
    }

相当于只需实现ProcessAsync 定时任务中的业务逻辑,然后指定Expression 该什么时候执行即可。

后面搞前端的时候顺便加上定时任务的是否启用,以及可以在线修改表达式,也就是修改定时任务的执行时间。

19、通过BackgroundService实现数据的初始化服务,例如字典数据等

上面是通用的定时任务执行。这里主要就是根据BackgroundService来初始化或更新一些数据,例如 字典项、初始化区域、初始化角色等等

这是一个通用的初始化数据的执行器,然后可以单独进行实现每个想要初始化的数据执行器

可以对执行进行设置顺序,因为有些数据是有依赖的。

这里可以看到上面的定时任务列表,我就是通过这里实现的初始化数据

其中里面用到了反射来读取类的信息。

20、通过BackgroundService和反射实现所有接口的写入数据库

程序中所有的接口列表,我也是在这里进行单独初始化的,通过类似反射来读取项目中的所有接口,来初始化到数据库中,然后在程序中进行使用的。

21、引入EPPlus实现Excel的导入和导出

所使用的开源类库:
https://github.com/EPPlusSoftware/EPPlus

统一封装关于Excel导入导出中的通用方法。

22、goploy一键部署前后端项目

所使用的开源类库:
https://github.com/zhenorzz/goploy
部署其实也非常简单的,能通过脚本使用的,便可以在工具上进行设置,然后点一下就可以进行一键部署,当然了还需要服务器的支持了。

同时我也将.net8的后端部署为本地宿主的服务也是没问题的

这是部署后进行查看服务状态的,通过一个命令便可以查看三个服务的状态

systemctl status sun-*,同样也可以一起重启和关闭服务

23、我还通过google/zx使用nodejs开发了一个脚本,用于自动化部署

可以参考我的github的地址:
https://github.com/aehyok/zx-deploy

主要是用于开发环境,通过

pnpm sun-baisc
pnpm sun-ncdp
pnpm sun-systemserivce

当然你还可以通过组合命令进行部署,例如想一起部署三个服务

pnpm sun-all 其实就是  "pnpm sun-ncdp && pnpm sun-basic && pnpm sun-systemservice"

这里我用的
&&
相当于上面三个命令串行执行,先执行sun-ncdp,再执行sun-basic,最后执行sun-systemservice。如果你的电脑或者服务器性能足够好,可以使用
&
符号,这样就是并行执行,三个服务同时启动,这样可以节省时间。

24、docker一键部署后端项目

写了个脚本和Dockerfile文件,可单独更新某个服务,也可以三个服务一起更新。

同样我现在开发使用的Mysql、Redis、RabbitMQ、Seq、等等也可以通过docker进行运行,很湿方便啊。

25、总结

经过这段时间的项目实践,也学到了非常多的知识,同时也发现了一些自身的问题。同时也发现现有项目中方方面面如果再有一个月的时间,很多代码可以做一波新的优化和重写。后面有时间我还会整理一套简易的微前端框架,同时要将后端的大部分接口进行实现, pnpm + vue3 + vite5 + wujie 微前端。

项目中的一些问题:

  • 针对复杂业务的处理 EFCore事物的处理
  • RabbitMQ 更深入的使用
  • 微服务框架的有些地方设计的不够合理吧
  • 缓存中到底要存储那些数据还可以进行调整
  • EFCore中的批量操作还可以进行优化调整
  • Linq多表查询还可以进一步的学习使用
  • Excel导入和导出还可以进一步的通用化
  • 考虑处理sso单点登录和多端登录的问题
  • zabbix监控还可以进一步的学习使用
  • opentelemetry
    可考虑接入
  • agileconfig
    分布式配置中心和服务治理
  • https://github.com/hashicorp/consul
    当然服务治理也可以考虑使用