status
Published
slug
7days-golang-learning-note
type
Post
category
Technology
date
May 30, 2023 → Feb 22, 2024
tags
笔记
golang
后端
summary
7天用Go从零实现Web框架,Gee教程系列学习笔记
7天用Go从零实现Web框架Gee教程系列
首先贴一下教程本体,教程源码库🥳,本笔记主要记录了跟着教程构建项目过程的思路梳理+遇到的一些知识点补充,不够完备,仅供存档参考
7days-golang
geektutu • Updated Jan 20, 2025
DAY1
标准库启动Web服务
day1-http-base/base1/main.go
运行后,使用curl指令终端的输出结果:
- powershell
- 原因:在powershell中
curl
命令被映射为Invoke-WebRequest
cmdlet 的别名,需要改用curl.exe
来运行
curl输出
curl.exe输出(与下同)
- Cmd
- 使用浏览器打开看到的内容
DAY2
- context:
- 必要性:
- 封装
*http.Request
和http.ResponseWriter
的方法,简化相关接口的调用 - 用于存储动态路由解析的内容,若有中间件用于存储中间件的相关信息
- 静态路由的实现:
- 数据结构:map键值对存储
DAY3
- 动态路由的实现:
- 数据结构:前缀树(Trie树)
- 路由:
- 路由注册:开发服务时,注册路由规则,映射handler
- 路由访问:访问时,匹配路由规则,查找到对应的handler
Golang 单元测试
- 需要用到package为testing
- 文件命名为XXX_test.go
- 代码示例:
- 运行单元测试
go test -v
reflect包
- 提供了Go语言中的反射功能,定义了大量的方法用于获取类型信息,设置值等
- 常用方法
reflect.TypeOf()
:获取对象类型reflect.ValueOf()
:获取反射值对象,返回值为reflect.Value
类型reflect.Value.NumField()
:从结构体的反射值对象中获取它的字段个数;reflect.Value.Field(i)
:从结构体的反射值对象中获取第i
个字段的反射值对象;reflect.Kind()
:从反射值对象中获取种类;reflect.Int()/reflect.Uint()/reflect.String()/reflect.Bool()
:这些方法从反射值对象做取出具体类型。
DAY4
- 实现路由分组控制(Route Group Control)
- 分组控制(Group Control)是 Web 框架应提供的基础功能之一
- 大部分情况下的路由分组,是以相同的前缀来区分的
- 分组控制的作用:
- 直接对分组应用中间件,不需要针对单个路由进行控制
Go的嵌套类型
- 在Go语言中,嵌套类型是将已有的类型直接声明在新的结构类型里。被嵌入的类型被称为新的外部类型的内部类型。
- 通过嵌入类型,与内部类型相关的成员变量会提升到外部类型上。就好像这些成员变量直接声明在外部类型一样
DAY5
- 中间件(middlewares),简单说,就是非业务的技术类组件。
- 其实从广义来说操作系统上,业务系统下与业务无关的,都是中间件,包括数据库,离线等。
- 路由服务:Tengine、VipServer
- 消息中间件:Apache kafka,Apache RocketMQ等
- RPC框架:Dubbo
- 缓存服务:Redis
- …
- 为Web框架提供插口,允许用户自己定义功能,嵌入到框架中,仿佛这个功能是框架原生支持的一样
- 插入点在哪?使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间件逻辑就会非常复杂。如果插入点离用户太近,那和用户直接定义一组函数,每次在 Handler 中手工调用没有多大的优势了。
- 中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。
对中间件而言,需要考虑2个比较关键的点:
⇒ 中间件设计:Gee 的中间件的定义与路由映射的 Handler 一致,处理的输入是
Context
对象。插入点是框架接收到请求初始化Context
对象后,允许用户使用自己定义的中间件做一些额外的处理,例如记录日志等,以及对Context
进行二次加工。DAY6
- 服务端渲染,Web框架要支持 JS、CSS 等静态文件
- 如何做?解析请求的地址,映射到服务器上文件的真实地址,交给
http.FileServer
处理
DAY7
Go中的panic:
- 当出现无法恢复的错误,可以手动触发 panic
- panic 会导致程序被中止,但当defer遇到panic的时候,defer会先执行完
- defer是go中一种延迟调用机制
- 当函数遇到panic,defer仍然会被执行。Go会先执行所有的defer链表(该函数的所有defer),当所有defer被执行完毕且没有recover时,才会进行panic。
- 可以用于资源的关闭,避免异常情况的发生
- 在defer中进行recover,实现类似于
try…catch
的效果
⇒ 基于此特性,defer的作用如下:
runtime包
runtime.Callers(3, pcs[:])
,Callers 用来返回调用栈的程序计数器
- 通过
runtime.FuncForPC(pc)
可以获取pc
对应的函数,在通过fn.FileLine(pc)
获取到调用该函数的文件名和行号
7天用Go从零实现分布式缓存GeeCache
分布式缓存:例如Redis、memcached等
- 设计一个分布式缓存系统,需要考虑资源控制、淘汰策略、并发、分布式节点通信等各个方面的问题
Day1
- 缓存淘汰算法:
- FIFO(仅考虑时间因素):
- 很多场景下,部分记录虽然是最早添加但也最常被访问,而不得不因为呆的时间太长而被淘汰,存在瓶颈
- LFU(仅考虑效率因素):
- 维护每个记录的访问次数,对内存的消耗高
- 受历史数据的影响比较大,若某个数据历史上访问次数奇高,但在某个时间点之后几乎不再被访问,但因为历史访问次数过高,而迟迟不能被淘汰。
- LRU:相对平衡的一种淘汰算法
⇒ LRU的实现:键值对+双向链表
Day2
互斥锁
多个协程(goroutine)同时读写同一个变量,在并发度较高的情况下,会发生冲突。
⇒ 需要互斥锁去控制访问
sync.Mutex
是 Go 语言标准库提供的一个互斥锁,当一个协程(goroutine)获得了这个锁的拥有权后,其它请求锁的协程(goroutine) 就会阻塞在 Lock()
方法的调用上,直到调用 Unlock()
锁被释放接口型函数
- 实现了一个接口的函数类型
优点:既能够将普通的函数类型(需类型转换)作为参数,也可以将结构体作为参数,使用更为灵活,可读性也更好,这就是接口型函数的价值
Day3
为分布式缓存建立基于 HTTP 的通信机制
- 服务端
- 客户端
Day4
单节点缓存⇒分布式缓存
如何访问分布式缓存节点
期望:不同节点中存储的缓存内容尽可能不同,用户多次访问同一个数据的时候尽可能选择同一个节点
⇒ 对于给定的 key,每一次都选择同一个节点
⇒ 哈希:如何处理节点数量变化情况?
⇒ 一致性哈希算法
一致性哈希:
一致性哈希算法将 key 映射到 2^32 的空间中,将这个数字首尾相连,形成一个环。
- 计算节点/机器(通常使用节点的名称、编号和 IP 地址)的哈希值,放置在环上。
- 计算 key 的哈希值,放置在环上,顺时针寻找到的第一个节点,就是应选取的节点/机器。
节点的增加和删除
一致性哈希算法,在新增/删除节点时,只需要重新定位该节点附近的一小部分数据,而不需要重新定位所有的节点
数据倾斜问题
如果服务器的节点过少,容易引起 key 的倾斜,为了解决这个问题,可以引入虚拟节点的概念,一个真实节点对应多个虚拟节点。
- 该方法扩充了节点数量,且开销小,只需要增加一个字典(map)维护真实节点与虚拟节点的映射关系
Day5
- 添加HTTP客户端⇒实现分布式节点
- 分布式缓存的工作流程
- 代码逻辑梳理:
- 可以把group看成一个控制中心,maincache是本地缓存(缓存节点的实体,真正存放缓存的地方),getter是用户访问缓存的入口,peers的实际类型为HTTPPool,可以看成是一个缓存节点池(并没有真正的缓存节点),管理着对远程节点的访问,包装了访问缓存节点的一致性哈希算法。
- run.sh脚本一共创建了3个group,并且启动了三个缓存节点服务(8001,8002,8003),启动了一个api服务(9999),其中api服务(9999)和缓存节点服务(8003)是通过同一个group进行管理的,在创建每个group的时候,远程节点的addr信息写入了每个group的HTTPPool中。
- 用户访问api的过程,首先访问该group中的maincache,若没有命中,再通过HTTPPool中的一致性哈希算法选择远程节点或本地节点,返回缓存值
Day6
缓存雪崩、缓存击穿和缓存穿透
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
- 对于相同的 key,如何做到只向远端节点发起一次请求
- 利用加锁的方法控制并发访问,在geecache中的实现是有多个相同key的请求同时访问时,阻塞第一个请求之后的所有请求,待第一个缓存读取结束后,将结果存入map,并释放阻塞的goroutine,剩余请求直接读取map结果
Day7
protolbuf(Protocol Buffers)
protobuf 即 Protocol Buffers,是一种轻便高效的结构化数据存储格式,与语言、平台无关,可扩展可序列化。
protobuf 广泛地应用于远程过程调用(RPC) 的二进制传输,使用 protobuf 的目的非常简单,为了获得更高的性能。传输前使用 protobuf 编码,接收方再进行解码,可以显著地降低二进制传输的大小。另外一方面,protobuf 可非常适合传输结构化数据,便于通信字段的扩展。
7天用Go从零实现ORM框架GeeORM
什么是ORM?
对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。
ORM框架相当于对象和数据库中间的桥梁,通过操作具体的对象完成对关系型数据库的操作。
设计ORM框架需要关注的问题:
1)MySQL,PostgreSQL,SQLite 等数据库的 SQL 语句是有区别的,ORM 框架如何在开发者不感知的情况下适配多种数据库?
2)如何对象的字段发生改变,数据库表结构能够自动更新,即是否支持数据库自动迁移(migrate)?
3)数据库支持的功能很多,例如事务(transaction),ORM 框架能实现哪些?
4)…
Day1
- SQLite了解:
- SQLite 是一款轻量级的,遵守 ACID 事务原则的关系型数据库。
"database/sql"
标准库可以对数据库进行访问
- VT100控制码:以
\033
打头
- 封装sql中原生方法的目的:
- 统一打印日志
- 执行完成后,清空
(s *Session).sql
和(s *Session).sqlVars
两个变量,实现Session的复用
Day2
如何将 Go 语言的类型映射为数据库中的类型?
- eg:Go 语言中的
int
、int8
、int16
等类型均对应 SQLite 中的integer
类型
- 不同数据库支持的数据类型之间有差异
- Dialect:抽象出各个数据库差异的部分,实现最大程度的复用和解耦,使得ORM 框架兼容多种数据库
- 映射数据类型和特定的SQL语句
- 屏蔽数据库差异
- Schema:利用反射(reflect)完成结构体和数据库表结构的映射,包括表名、字段名、字段类型、字段 tag 等。
Day3
- 重点:使用反射(reflect)将数据库的记录转换为对应的结构体实例
Day4
- 链式调用:链式调用是一种简化代码的编程方式,能够使代码更简洁、易读。
- 链式调用的原理也非常简单,某个对象调用某个方法后,将该对象的引用/指针返回,即可以继续调用该对象的其他方法。
Day5
- Hook机制:翻译为钩子,其主要思想是提前在可能增加功能的地方埋好(预设)一个钩子,当我们需要重新修改或者增加这个地方的逻辑的时候,把扩展的类或者方法挂载到这个点即可。
- 对于ORM框架,合适的扩展点在记录的增删差改前后都非常合适
- 钩子机制同样是通过反射来实现的
Day6
- 事务(transaction):数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。
- 事务的ACID属性
- 原子性(Atomicity):事务中的全部操作在数据库中是不可分割的,要么全部完成,要么全部不执行。
- 一致性(Consistency): 几个并行执行的事务,其执行结果必须与按某一顺序 串行执行的结果相一致。
- 隔离性(Isolation):事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务必须是透明的。
- 持久性(Durability):对于任意已提交事务,系统必须保证该事务对数据库的改变不被丢失,即使数据库出现故障。
Day7
- 实现字段新增与删除
difference
用来计算前后两个字段切片的差集。新表 - 旧表 = 新增字段,旧表 - 新表 = 删除字段。- 使用
ALTER
语句新增字段。 - 使用创建新表并重命名的方式删除字段。
7天用Go从零实现RPC框架GeeRPC
通信:
- RPC:RPC(Remote Procedure Call,远程过程调用)是一种计算机通信协议,允许调用不同进程空间的程序。RPC 的客户端和服务器可以在一台机器上,也可以在不同的机器上。程序员使用时,就像调用本地程序一样,无需关注内部的实现细节。
- Restful API:浏览器和服务器之间广泛使用的基于 HTTP 协议的 Restful API。与 RPC 相比,Restful API 有相对统一的标准,因而更通用,兼容性更好,支持不同的语言。
- RPC与Resful API的比较:
- Restful 接口需要额外的定义,无论是客户端还是服务端,都需要额外的代码来处理,而 RPC 调用则更接近于直接调用。
- 基于 HTTP 协议的 Restful 报文冗余,承载了过多的无效信息,而 RPC 通常使用自定义的协议格式,减少冗余报文。
- RPC 可以采用更高效的序列化协议,将文本转为二进制传输,获得更高的性能。
- 因为 RPC 的灵活性,所以更容易扩展和集成诸如注册中心(registry)、负载均衡(load balance)等功能。
- 注册中心:客户端和服务端互相不感知对方的存在,服务端启动时将自己注册到注册中心,客户端调用时,从注册中心获取到所有可用的实例,选择一个来调用。这样服务端和客户端只需要感知注册中心的存在就够了。注册中心通常还需要实现服务动态添加、删除,使用心跳确保服务处于可用状态等功能。
RPC框架需要解决什么问题?
- 解决传输协议和报文编码的问题,在进行两个应用程序之间的通信前,需要确定采用的传输协议和报文编码格式,需要考虑不同的情况和需求。
- 实现注册中心,通过注册中心统一管理,服务断启动时把自己注册到注册中心,客户端调用时,从注册中心获取到所有可用的实例。
- 注册中心的功能:
- 消息传递
- 实现服务动态添加、删除
- 使用心跳确保服务处于可用状态
- 统一的RPC框架,可以避免团队服务提供方实现消息编解码等“业务之外”的重复技术扰动,提供公共能力
Day1
- 什么是gob?(encoding/gob包)
- gob包管理gob流——编码器(发送器)和解码器(接收器)之间交换的二进制值。典型的用法是传输远程过程调用(rpc)的参数和结果,例如包“net/rpc”提供的那些。
- 该实现为流中的每种数据类型编译自定义编解码器,并且当使用单个编码器传输值流时效率最高,从而分摊编译成本。
- Codec:对消息体进行编解码的接口
- 实例有
GobCodec
、JsonCodec
- 通信过程:客户端与服务端的通信需要协商一些内容,如消息编解码方式、报文的内容、格式、长度等信息
- 消息通常分为head、body两部分,如HTTP报文,对于RPC协议可以自主设计
- 为了提升性能,报文一般会在报文的开头部分规划固定的字节,用于协商相关的信息,如定义压缩方法、序列化方式、header长度、body长度等
- geerpc只需要协商消息编解码方式,这部分信息由结构体
Option
承载。
- 报文的设计:
GeeRPC 客户端固定采用 JSON 编码 Option,后续的 header 和 body 的编码方式由 Option 中的 CodeType 指定
serveCodec
的过程非常简单。主要包含三个阶段- 读取请求 readRequest
- 处理请求 handleRequest
- 回复请求 sendResponse
- handleRequest 使用了协程并发执行请求。
- 处理请求是并发的,但是回复请求的报文必须是逐个发送的,并发容易导致多个回复报文交织在一起,客户端无法解析。在这里使用锁(sending)保证。
- 使用waitGroup来防止服务处理过程中,程序的提前退出
- 每个handleRequest执行结束,调用一次waitGroup.done()
需要注意的内容:
Day2
- 实现一个支持异步和并发的高性能客户端,,客户端(client)的最重要功能为:
- 接收响应(三种不同的情况)
- call 不存在,可能是请求没有发送完整,或者因为其他原因被取消,但是服务端仍旧处理了
- call 存在,但服务端处理出错,即 h.Error 不为空
- call 存在,服务端处理正常,那么需要从 body 中读取 Reply 的值
- 发送请求
- 什么是Call?表示一次RPC调用
- 在rpc中,一个能够被远程调用的函数需要满足五个条件
- the method’s type is exported. – 方法所属类型是导出的。
- the method is exported. – 方式是导出的。
- the method has two arguments, both exported (or builtin) types. – 两个入参,均为导出或内置类型。
- the method’s second argument is a pointer. – 第二个入参必须是一个指针。
- the method has return type error. – 返回值为 error 类型。
- Call就是根据上述要求封装的结构体,用于承载RPC调用的所有信息
- Call支持异步调用
- Call 结构体中添加了一个字段 Done,Done 的类型是
chan *Call
,当调用结束时,会调用call.done()
通知调用方。
go 里面是通过channel
(也就是chan
类型)来进行通信,一个协程往chan
里面写数据,其他协程可以从chan
中读取数据
- channel类型
- 实现client
- 实现与Call相关的方法
- 注册/移除Call
- 通知错误信息
- 创建连接
- 明确好编解码方式
- 明确好服务端地址
- 接收响应
- 发送请求
Day3
- RPC框架的基础功能:像调用本地程序一样调用远程服务
- 需要将程序映射为服务
- how?
- 在rpc中,一个能够被远程调用的函数需要满足五个条件
- the method’s type is exported. – 方法所属类型是导出的。
- the method is exported. – 方式是导出的。
- the method has two arguments, both exported (or builtin) types. – 两个入参,均为导出或内置类型。
- the method’s second argument is a pointer. – 第二个入参必须是一个指针。
- the method has return type error. – 返回值为 error 类型。
- 客户端发送的请求中会携带ServiceMethod(服务方法)和Argv(参数),这些都需要通过解析请求,然后调用对应的服务,通过反射可以很容易地自动化映射的过程,获取某个结构体的所有方法、参数类型和返回值
- 通过反射实现结构体与service的映射关系
- 定义methodType结构体,包含了一个方法的完整信息
- 实现方法,用于创建参数
- sync/atomic包:提供了5种类型的原子操作和1个
Value
类型,在偏底层的应用场景里才使用 - 使用
sync/atomic
提供的原子操作可以确保在任意时刻只有一个goroutine对变量进行操作,避免并发冲突。
Day4
- 超时处理:在RPC框架中,超时处理可以避免客户端or服务端由于网络或错误导致的挂死、资源耗尽,能够提升服务的可用性。
- RPC过程中需要处理超时的地方:
- 客户端
- 与服务端建立连接,导致的超时
- 发送请求到服务端,写报文导致的超时
- 等待服务端处理时,等待处理导致的超时(比如服务端已挂死,迟迟不响应)
- 从服务端接收响应时,读报文导致的超时
- 服务端
- 读取客户端请求报文时,读报文导致的超时
- 发送响应报文时,写报文导致的超时
- 调用映射服务的方法时,处理报文导致的超时
- 无缓冲区channel
- 用make(chan int) 创建的chan, 是无缓冲区的, send 数据到chan 时,在没有协程取出数据的情况下, 会阻塞当前协程的运行。ch <- 后面的代码就不会再运行,直到channel 的数据被接收,当前协程才会继续往下执行。
Day5
- 支持HTTP协议:RPC 的消息格式与标准的 HTTP 协议并不兼容,因此需要对协议进行转换,HTTP 协议的 CONNECT 方法恰好提供了这个能力,CONNECT 一般用于代理服务
- 实现逻辑:
- 浏览器通过 HTTP 明文形式向代理服务器发送一个 CONNECT 请求告诉代理服务器目标地址和端口
- 代理服务器接收到这个请求后,会在对应端口与目标站点建立一个 TCP 连接,连接建立成功后返回 HTTP 200 状态码告诉浏览器与该站点的加密通道已经完成
- 接下来代理服务器仅需透传浏览器和服务器之间的加密数据包即可,代理服务器无需解析 HTTPS 报文
- 服务端支持HTTP协议;
- 通信过程
- 客户端向 RPC 服务器发送 CONNECT 请求
- RPC 服务器返回 HTTP 200 状态码表示连接建立。
- 客户端使用创建好的连接发送 RPC 报文,先发送 Option,再发送 N 个请求报文,服务端处理 RPC 请求并响应
- 客户端支持HTTP协议
- 通信过程:
- 发起 CONNECT 请求
- 检查从服务端返回状态码,即可成功建立连接。
- runtime包:Go 语言的 goroutine 是由 运行时(runtime)调度和管理的,Go 语言的 runtime 类似 Java 和 .NET 语言所用到的虚拟机,它负责管理包括内存分配、垃圾回收(第 10.8 节)、栈处理、goroutine、channel、切片(slice)、map 和反射(reflection)等等
- 支持 HTTP 协议的好处:
- RPC 服务仅仅使用了监听端口的
/_geerpc
路径,在其他路径上我们可以提供诸如日志、统计等更为丰富的功能。
Day6
- 负载均衡:存在多个服务实例时,客户端可以选择任意一个实例进行调度,选择的策略有很多,负载均衡的策略可以提高系统的吞吐量,确保服务的响应速度、提高资源利用率
- 随机选择策略 - 从服务列表中随机选择一个。
- 轮询算法(Round Robin) - 依次调度不同的服务器,每次调度执行 i = (i + 1) mode n。
- 加权轮询(Weight Round Robin) - 在轮询算法的基础上,为每个服务实例设置一个权重,高性能的机器赋予更高的权重,也可以根据服务实例的当前的负载情况做动态的调整,例如考虑最近5分钟部署服务器的 CPU、内存消耗情况。
- 哈希/一致性哈希策略 - 依据请求的某些特征,计算一个 hash 值,根据 hash 值将请求发送到对应的机器。一致性 hash 还可以解决服务实例动态添加情况下,调度抖动的问题。一致性哈希的一个典型应用场景是分布式缓存服务。
负载均衡的策略:
- 服务发现:
- 与通信部分解耦
- SelectMode:代表不同的负载均衡策略
- Discovery:接口类型,包含了服务发现所需要的最基本的接口
- Broadcast:将请求广播到所有的服务实例,如果任意一个实例发生错误,则返回其中一个错误;如果调用成功,则返回其中一个的结果。
Day7
- 注册中心:主流的注册中心有 etcd、zookeeper 等,功能强大,GeeRPC 实现一个简单的支持心跳保活的注册中心。
- 注册中心的基础功能:
- 添加服务实例
- 返回可用的服务列表
- 什么是心跳:通过心跳检测请求可以维持连接(避免连接因长时间不活跃而被网关防火墙关闭),也能让服务端比较及时的知道客户端是否异常掉线。
- 客户端定时每X秒(推荐小于60秒)向服务端发送特定数据(任意数据都可)
- 服务端设定为X秒没有收到客户端心跳则认为客户端掉线,并关闭连接触发onClose回调。
- 为什么需要心跳?
- 正常的情况客户端断开连接会向服务端发送一个fin包,服务端收到fin包后得知客户端连接断开,则立刻触发onClose事件回调。
- 但是有些极端情况如客户端掉电、网络关闭、拔网线、路由故障等,这些极端情况客户端无法发送fin包给服务端,服务端便无法知道连接已经断开。如果客户端与服务端定时有心跳数据传输,则会比较及时的发现连接断开,触发onClose事件回调。
走走停停,完成整个7days的教程历时大半年【 May 30, 2023-Feb 22, 2024 】,真的很感谢这个教程。最后Go相关的学习也算是告一段落了,下面是个人觉得比较好的Go学习资料