知识点积累

102 阅读19分钟

Foreword

本文档用于记录一些软件程序设计的知识点,如设计规则、设计模式等,还有可能是一些现象的解释。

Content

1 【设计模式】依赖注入

- 什么是依赖?

依赖关系在软件设计和开发中很常见,它是指:一个对象或模块,需要另一个对象或模块的支持,才能正常运行或完成某个功能。当前置的依赖模块没有被实现时,后续的模块就无法被实现。依赖同时也可以是一种对象之间的关系,表明一个对象需要另一个对象的协助,或者需要使用另一个对象的功能、数据或资源。

- 为什么要有依赖?

依赖关系的存在可以帮助实现模块化和组件化的设计,使代码更具可复用性、可扩展性和可维护性。

然而,过多的依赖关系可能会导致系统的耦合性增加,使代码难以理解、测试和修改。因此,依赖的管理和解耦是软件开发中需要注意的重要问题。依赖注入和依赖倒置等设计模式可以用于管理和解决依赖关系。

- 为什么依赖关系会增加耦合度?

在一个需要数据库查询的场景,首先需求数据库服务的模块要与数据库建立连接。链接成功之后,业务模块才能使用数据库,那么就相当于这个业务模块---依赖于--->数据库模块。

那么如果仅仅实现这个功能,可以写成:

// -------以下是数据库相关-----
// 数据库实体
type MySQLDB struct {
}

// 链接数据库
func (d *MySQLDB) Connection() {
}

// CRUD操作
func (d *MySQLDB) CRUD() {
}

// -------以下是业务相关-------
// 业务实体,要完成业务,依赖于数据库的服务
type Object struct {
	e *MySQLDB //数据库链接层
}

// 业务逻辑
func (o *Object) Process() {
	o.e = &MySQLDB{} //不初始化后面调用方法会panic
	o.e.Connection() //获取DB引擎、链接数据库
	o.e.CRUD()       //具体CRUD操作
}

// ------以下是客户端------
func main() {
	obj := Object{
		e: &MySQLDB{},
	}
	obj.Process()
}

可是,如果我们把MySQL数据库变成MongoDB数据库,那么我们需要重新让业务逻辑依赖于新的数据库,也就是说代码要改成:


// -------以下是数据库相关-----
// 数据库实体(Changed)
type MongoDB struct {
}

// 链接数据库(Changed)
func (d *MongoDB) Connection() {
}

// CRUD操作(Changed)
func (d *MongoDB) CRUD() {
}

// -------以下是业务相关-------
// 业务实体,要完成业务,依赖于数据库的服务(Changed)
type Object struct {
	e *MongoDB //数据库链接层
}

// 业务逻辑(Changed)
func (o *Object) Process() {
	o.e = &MongoDB{} //不初始化后面调用方法会panic
	o.e.Connection() //获取DB引擎、链接数据库
	o.e.CRUD()       //具体CRUD操作
}

// ------以下是客户端------
func main() {
	//(Changed)
	obj := Object{
		e: &MongoDB{},
	}
	obj.Process()
}

可以发现,几乎所有的函数和结构体定义都重新修改了一次。当项目比较复杂和庞大的时候,可能要修改的东西就太多了。

- 依赖注入模式

依赖注入模式之所以诞生,就是为了在这种情况下,不要修改本来应该不受影响的业务逻辑,仅修改数据库层面即可。

那么如何能够做到这一点?让我们一点一点去推导。

以使用MySQL数据库作为业务的依赖时,如果想要实现刚才的需求,即支持换数据库不修改业务代码时,我们的思考路径如下:

  • (1)业务要想不修改,在业务逻辑当中,就不能出现MySQL这个东西。
  • (2)所以,必须找一个替代的逻辑实体,让他代替MySQL出现在业务逻辑当中。
  • (3)这个逻辑实体不仅仅能够代表MySQL,将来还可以代表其他的数据库。
  • (4)因此,这个逻辑实体,必须是一个可以呈现多态的东西。
  • (5)那么就可以定义一个抽象类,承载所有的数据库操作,就像:
// 数据库接口类型,包含了两个方法
type DB interface {
	Connection()
	CRUD()
}
  • (6)以及对应的两种数据库实现类:
// MysqlDB类实现了这两个方法,说明他是一个DB类型的接口(*MysqlDB is a DB)
// mysql数据库实体
type MysqlDB struct {
}

// 链接数据库
func (d *MysqlDB) Connection() {
}

// CRUD操作
func (d *MysqlDB) CRUD() {
}

// MongoDB类同样实现了这两个方法,说明他是一个DB类型的接口(*MongoDB is a DB)
// mongo数据库实体
type MongoDB struct {
}

// 链接数据库
func (d *MongoDB) Connection() {
}

// CRUD操作
func (d *MongoDB) CRUD() {
}
  • (7)有了它,业务代码就可以改成:
// 业务实体
type Object struct {
	e DB //数据库链接层
}

// 把数据库实体注入业务实体当中(构造函数,实现注入)
func NewObject(db DB) *Object {
	return &Object{
		e: db,
	}
}

// 业务逻辑
func (o *Object) Process() {
	o.e.Connection() //获取DB引擎、链接数据库
	o.e.CRUD()       //具体CRUD操作
}
  • (8)客户端代码:
func main() {
	obj := NewObject(&MongoDB{})
	//或者:obj := NewObject(&MysqlDB{}),根据需要的数据库进行选择
	obj.Process()
}

可以看到,无论使用哪一种数据库,业务代码都不需要更改,通过对接口类型DB的复用,业务代码实现了不同数据库类型下的多态性。

这种设计方法,就叫依赖注入模式。

2 【现象解释】为什么浏览器使用GET、POST方法访问接口前总是会先使用OPTIONS方法访问一次这个接口?

- 跨域请求

在解释这个问题之前,首先需要知道什么叫做跨域请求。

当我们访问一个一级域名是aaa.com的网页时,如果我们点击了某个按钮或者触发了某个条件,使得我们访问或者调用了一级域名为bbb.com的接口。这种请求就叫做「跨域请求」。

- 浏览器会如何处理跨域请求?

浏览器出于安全考虑,实施了同源策略(Same-Origin Policy),同源策略要求网页中的脚本只能访问同一域名下的资源,而无法直接访问其他域名下的资源。

当网页中的脚本需要访问不同域名下的资源时,就会涉及到跨域请求。一般情况下,浏览器会阻止跨域请求,因为这可能导致安全漏洞。

同源策略是一种重要的安全措施,它可以防止恶意网站窃取用户的信息。但是,在某些情况下,我们需要进行跨域请求,如使用跨域API或与不同的服务或域名进行数据交互。在这种情况下,可以使用CORS(Cross-Origin Resource Sharing)来实现跨域请求,或者使用其他跨域解决方案,如JSONP或代理服务器。

- 跨域资源共享(CORS,Cross-Origin Resource Sharing)

在一个一定要使用跨域请求的场景,浏览器会使用CORS来实现。

具体来说,CORS实现的基本原理是,在浏览器发起跨域请求时,会先发送一个OPTIONS预检请求(url等于我们希望访问的具体业务API的地址)给服务器,询问服务器是否允许实际请求,而服务器则通过设置响应头部,告诉浏览器是否允许该跨域请求。

image.png

在这个请求的Response Headers当中,与CORS相关的响应头包括:

  • Access-Control-Allow-Origin:允许的请求源,就是来自于什么域名下的网页可以请求我。可以是具体的域名,也可以是通配符"*",表示允许任意源的请求。
  • Access-Control-Allow-Methods:允许的请求方法,如GET、POST等。
  • Access-Control-Allow-Headers:允许的请求头,用于指定允许的自定义请求头。
  • Access-Control-Allow-Credentials:是否允许请求携带凭证(如cookie)。
  • Access-Control-Max-Age:以秒为单位的整数,表示预检请求结果的缓存时间。在缓存时间内,浏览器可以直接使用缓存的预检请求结果,无需再次发送预检请求。

image.png

当浏览器收到这次反馈之后,就可以向该域名发送相应的GET、POST请求方法了。

3【技术概念】定时任务——Cron表达式

- 什么是Cron?

往往我们需要一些定时任务的场景,周期性地执行一些逻辑,此时我们需要一种可以表达“定时”或者“多久执行一次”,或者“什么时候应该执行一次”的通用语法表达式。

而Cron(时间表达式)是一种用于表示时间和日期的字符串格式,通常用于在计划任务中指定任务的执行时间。Cron表达式由一系列字段组成,每个字段表示一个时间单位(如秒、分、小时等)。

Wiki是这么说的:

The cron command-line utility is a job scheduler on Unix-like operating systems. Users who set up and maintain software environments use cron to schedule jobs[1] (commands or shell scripts), also known as cron jobs,[2][3] to run periodically at fixed times, dates, or intervals.[4] It typically automates system maintenance or administration—though its general-purpose nature makes it useful for things like downloading files from the Internet and downloading email at regular intervals.[5]

Cron is most suitable for scheduling repetitive tasks. Scheduling one-time tasks can be accomplished using the associated at utility.

Cron's name originates from chronos, the Greek word for time.

- Cron语法

Cron表达式的字段通常按以下顺序排列:

* * * * * *
- - - - - -
| | | | | |
| | | | | +--- 星期 (0 - 6) (周日为0)
| | | | +----- 月份 (1 - 12)
| | | +------- 一个月中的第几天 (1 - 31)
| | +--------- 小时 (0 - 23)
| +----------- 分钟 (0 - 59)
+------------- 秒 (0 - 59)

每个字段可以包含一个或多个值,用逗号分隔。字段中的星号(*)表示该字段的所有可能值。例如,小时字段中的星号表示“每小时”。

Cron表达式支持以下特殊字符,用于表示更复杂的时间规则:

  1. 星号(*):表示该字段的所有可能值。例如,在分钟字段中使用星号表示“每分钟”。

    示例:* * * * *表示每分钟执行一次。

  2. 逗号(,):用于指定多个值。例如,在小时字段中使用逗号表示“在指定的多个小时执行”。

    示例:0 0 8,12,16 * * *表示在每天的8点、12点和16点执行。

  3. 连字符(-):用于表示一个范围。例如,在小时字段中使用连字符表示“在指定的小时范围内执行”。

    示例:0 0 8-16 * * *表示在每天的8点至16点之间的每个整点执行。

  4. 斜线(/):用于表示步长。例如,在分钟字段中使用斜线表示“每隔指定分钟数执行”。

    示例:0 */15 * * * *表示每隔15分钟执行一次。

  5. 问号(?):表示不指定值,通常用于日期和星期字段。当你需要指定其中一个字段时,可以在另一个字段中使用问号。

    示例:0 0 12 15 * ?表示每月15日的12点执行。

  6. L:表示“最后”,仅适用于日期和星期字段。在日期字段中表示一个月的最后一天,而在星期字段中表示星期的最后一天(周六)。

    示例:0 0 12 L * *表示每月最后一天的12点执行。

  7. W:表示“工作日”,仅适用于日期字段。用于指定离指定日期最近的工作日(周一至周五)。

    示例:0 0 12 15W * *表示每月15日或离15日最近的工作日的12点执行。

  8. #:表示“第几个”,仅适用于星期字段。用于指定一个月中的第几个星期几。

    示例:0 0 12 * * 2#1表示每月的第一个周二的12点执行。

- 示例

Cron表达式还支持一些特殊字符,如:

以下是一些常见的Cron表达式示例:

0 0 12 * * ? :每天中午12点触发

0 15 10 ? * * :每天上午10:15触发

0 0/5 14 * * ? :在每天下午2点到2点59分期间的每5分钟触发

0 0 9-17 * * MON-FRI:周一至周五的上午9点至下午5点每小时触发

4 【Golang相关】sync包

众所周知,在需要用到并发编程的场景时,就一定会出现各种各样的并发问题,比如我们好几个线程要同时编辑一个资源(竞争关系),或者有的任务只需要一个线程执行、有的任务需要串行执行(并发控制)。恰巧,Golang还是一个鼓励使用多线程的语言。那么他既然鼓励了,就必须拿出诚意,于是就推出了sync包,官方学名叫做“同步原语(Synchronization Primitive)”。

image.png

在这个包里面,我们可以看到一些熟悉的组件,比如说“互斥锁”、“读写锁”,还有可以充当信号量使用的WaitGroup等等,下面是一些sync包的组件举例:

  • Locker、Mutex、RWMutex: 这些都是锁的组件。「锁」是并发编程中必须用到的一个概念。并发编程中,多个线程可能同时访问一个资源。就好像大家挤着看医生,但是一个医生同时只能给一个病人服务。那么没被服务的病人就得在上锁的门外候着,直到里面的病人出来了,其他人才能进去。这几个组件都可以实现类似的功能。Locker是最基础的锁组件,仅提供开锁上锁的功能;Mutex可以看作是一个升级版的大门,除了开锁上锁,还有其他的功能(TryLock);RWMutex指的是读写锁,即可以从读和写的层面区分大门是否上锁(写锁锁上啥也获取不了,读锁锁上还能获取读锁)。

  • WaitGroup: sync.WaitGroup就像是一个门禁牌,帮助我们等待一组协程全部完成。使用sync.WaitGroup非常简单,首先我们需要创建一个WaitGroup对象。然后,我们可以在协程开始时就使用Add()方法来设置计数器的值,每调用一次,表示有一个协程需要等待。然后在每个协程的结束处调用WaitGroup的Done()方法,表示一个协程已经完成了。而在主线程中,我们可以调用Wait()方法来等待所有协程都完成。这样,我们就可以确保在所有协程都完成后再继续执行后面的代码。

  • Once: 有时候,我们多个线程都在执行相同的逻辑代码,但是有的逻辑可能只需要执行一次(初始化、刷新数据等),那么我们就可以在所有的协程里用sync.Once类型中的方法Do来指定某个逻辑在所有的线程当中只有一个会被执行。

  • Pool: sync.Pool是Go语言中的一个对象池,可以用于提高对象的复用性和性能。假设一个后端开发组里面有好多好多程序员。这个时候老板来了,给项目组提了个项目,整个项目组就是一个Pool,里面就有一些程序员在工作。这个新项目来了之后,肯定先找没事干的程序员接活,此时便调用了Pool.Get()方法,那如果项目组里有可用的程序员,Get()方法会直接返回一个程序员;如果没有可用程序员,Get()方法会调用之前设置好的函数,来招聘一个新的程序员。(New成员就是一个函数变量,每次调用Get时,如果发现Pool里面什么都没有了,就去执行这个New所代表的函数,返回一个程序员) 这样的逻辑就像是如果项目组里有可用的程序员,我们就可以直接用;如果没有,我们就需要根据招聘策略,招聘一个新的。最后,在使用完程序员后,要调用Pool的Put()方法将程序员放回项目组里供其他老板使用(为什么不直接销毁呢?因为这样总是创建销毁,会增加系统的负担)。这样,我们就可以通过对象池来复用程序员,提高效率和性能。

5 【Golang相关】context.Context

在互联网应用开发过程中,很多情况下都需要去传递ctx作为输入参数。那么这个ctx到底是个什么东西,他的类型context.Context到底起到了什么作用?

  • 直观感受:从最表面的角度来说,context的意思是上下文,那么就从这个意思入手。

context.Context的作用就是:把某一个方法里或者函数里的什么东西(可能是变量、可能是超时时间等等...),带到另一个方法或者函数里。当然你可能会问,我要是通过参数传递,行不行?也不是不行,但是第一,你通过参数列表规定了一些他需要传递的数据,那么这个数据的类型就定了,而且必须传递,那一旦数据比较多呢?参数列表写一整页么?第二,定制每个函数的参数列表是一件非常恶心的事情,万一以后加一个参数或者少一个参数,咋整呢,所有的函数调用是不是都要改了?

所以,这个context.Context的目的就是为了帮我们解决这种问题,你比如说,从http请求的headers里面带进来的字段,可能在整个API的处理周期都需要用到。而这个处理周期可能有很多很多层的rpc调用、函数调用,把这些数据都存在ctx里面,然后每次只传递ctx,就十分方便了,不然一旦headers里面的数据一大堆,无论是定义一个数据结构存他,还是用什么别的方法传递,都太麻烦了。

  • Context实际上是一个什么东西?

这就得搞清楚context.Context这玩意到底是怎么设计出来的。 其实,Context是一个接口类型:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

根据Go语言对接口类型的定义,只要有一个结构体或者对象,他实现了这四个方法,那他就是一个Context。

到这里就出现了一个巨大的直觉反差:他是一个接口类型,那他是怎么存储信息的? 他又没有成员变量。

实际上,在创建一个ctx的时候,会调用一个创建函数,比如说:context.WithValue()或者context.Background()或者context.ToDo(),这些函数会返回一个context.Context类型的变量。但是,他们返回的东西是不一样的。

func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}
func Background() Context {
	return background
}
func TODO() Context {
	return todo
}

你可能会察觉到,background、todo这几个变量的类型,估摸着是实现了Context接口规定的几个方法,从而升级成了Context类型。valueCtx类型本身可能也是这样,不是包含了Context类型,就是实现了四个方法完成了升级。

没错,就是这么回事。todo和background变量的类型是emptyCtx,这个类型,实际上就是int,参见:

type emptyCtx int

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

这个emptyCtx实现了四个方法,变成了Context类型。

【注】比较有趣的是,background和todo本身并没有什么不同,他们都是用来创建一个新的Context的,甚至,你把项目里所有context.Background()都换成context.ToDo()也不会报错。他们两个底层基本是一模一样的。之所以把他们区分开,是为了在项目中做逻辑上的区分。比如你使用context.ToDo(),那语义应该是,这个ctx先建立在这个地方,一会用它做什么不一定。而context.Background()指的是,我这个ctx就是万物的起源,从这里开始使用这个ctx,直到API调用结束。

  • Context的数据到底是怎么存进去的?

通过func WithValue的实现不难看出来,每次我想存一个key-value进一个ctx的时候,他必然有一个parent,也就是父节点。所以可以这么理解,一个key-value键值对会附着在一个Context上面,接下来,这些Context果实会按照树形排列,根据调用关系形成一棵树。

  • 读写Context里面的值不会出现并发问题么?

设计Context的初衷就是希望他可以在多线程环境中持续存在并支持并发安全的读写操作。为此,才设计出了树状的结构,果实一旦结上(key-value注册完毕),就不能再更改了。而如果要加,树形结构也是线程安全的。所以整体而言,ctx在多线程场景下不会出现并发问题。类似的,WithTimeout和WithDeadline这两个方法一旦设置好了超时时长和超时时间,就不能更改了。

  • 超时会怎么样?

Ctx里面有三个与超时相关的函数:

Deadline函数,它会返回一个时间以及一个bool值。这个时间就是你设定的“截止时间”,bool值告诉你,你有没有设置这个截止时间,如果你设置了,就返回true,没设置就是false。

而Done函数,它返回一个只读的channel,你如果要关注这个Context是否被取消或者超时,你就可以读这个channel。如果这个channel被关闭了,那就说明Context被取消或者超时了。

Err函数呢,是用来返回具体的取消原因的。如果Context还没被取消,那返回的就是nil。如果被取消了,那就会告诉你是因为什么取消的,超时、主动调用cancel函数,还是父Context被取消。

  • cancel函数

当你用context.WithCancel, context.WithDeadline, context.WithTimeout来创建一个context时,他们都会返回一个cancel函数。这个cancel函数一旦被调用,就会主动去取消这个context。

具体来说,这个cancel函数一旦被调用,它就会关闭该context的Done通道,然后Err就会返回一个错误。这样下游所有依赖这个context的函数就知道该context已经被取消了,应该尽快释放资源,结束执行。