参考:https://geektutu.com/post/gee.html
在设计一个框架之前,需要回答框架核心解决了什么问题。
net/http
提供了基础的Web功能,即监听端口,映射静态路由,解析HTTP报文。一些Web开发中简单的需求并不支持,需要手工实现。
- 动态路由:例如
hello/:name
,hello/*
这类的规则。 - 鉴权:没有分组/统一鉴权的能力,需要在每个路由映射的handler中实现。
- 模板:没有统一简化的HTML机制。
- …
当我们离开框架,使用基础库时,需要频繁手工处理的地方,就是框架的价值所在。
- 路由(Routing):将请求映射到函数,支持动态路由。例如
'/hello/:name
。 - 模板(Templates):使用内置模板引擎提供模板渲染机制。
- 工具集(Utilites):提供对 cookies,headers 等处理机制。
- 插件(Plugin):Bottle本身功能有限,但提供了插件机制。可以选择安装到全局,也可以只针对某几个路由生效。
- …
将路由(router)独立
- 设计
上下文(Context)
,封装 Request 和 Response ,提供对 JSON、HTML 等返回类型的支持。
动态路由,即一条路由规则可以匹配某一类型而非某一条固定的路由。
动态路由有很多种实现方式,支持的规则、性能等有很大的差异。例如开源的路由实现gorouter
支持在路由规则中嵌入正则表达式,例如/p/[0-9A-Za-z]+
,即路径中的参数仅匹配数字和字母;另一个开源实现httprouter
就不支持正则表达式。著名的Web开源框架gin
在早期的版本,并没有实现自己的路由,而是直接使用了httprouter
,后来不知道什么原因,放弃了httprouter
,自己实现了一个版本。
实现动态路由最常用的数据结构,被称为**==前缀树(Trie树)==**:每一个节点的所有的子节点都拥有相同的前缀。
curl "http://localhost:9999/login" -X POST -d 'username=geektutu&password=1234'
分组控制(Group Control)是 Web 框架应提供的基础功能之一。所谓分组,是指路由的分组。如果没有路由分组,我们需要针对每一个路由进行控制。但是真实的业务场景中,往往某一组路由需要相似的处理。例如:
- 以
/post
开头的路由匿名可访问。 - 以
/admin
开头的路由需要鉴权。 - 以
/api
开头的路由是 RESTful 接口,可以对接第三方平台,需要三方平台鉴权。
==中间件(middlewares)==就是非业务的技术类组件。Web框架本身不可能去理解所有的业务,因而不可能实现所有的功能。因此,框架需要有一个插口,允许用户自己定义功能,嵌入到框架中,仿佛这个功能是框架原生支持的一样。因此,对中间件而言,需要考虑2个比较关键的点:
- 插入点在哪?使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间件逻辑就会非常复杂。如果插入点离用户太近,那和用户直接定义一组函数,每次在 Handler 中手工调用没有多大的优势了。
- 中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。
前后端分离的开发模式,即 Web 后端提供 RESTful 接口,返回结构化的数据(通常为 JSON 或者 XML)。前端使用 AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。Vue/React 等前端框架持续火热,这种开发模式前后端解耦,优势非常突出。
-
后端专心解决资源利用,并发,数据库等问题,只需要考虑数据如何生成;前端童鞋专注于界面设计实现,只需要考虑拿到数据后如何渲染即可。
-
前后端分离另外一个优势。因为后端只关注于数据,接口返回值是结构化的,与前端解耦。同一套后端服务能够同时支撑小程序、移动APP、PC端 Web 页面,以及对外提供的接口。随着前端工程化的不断地发展,Webpack,gulp 等工具层出不穷,前端技术越来越自成体系了。
前后分离的一大问题,页面是在客户端渲染的,比如浏览器,这对于爬虫并不友好。Google 爬虫已经能够爬取渲染后的网页,但是短期内爬取服务端直接渲染的 HTML 页面仍是主流。
商业世界里,现金为王;架构世界里,缓存为王。
直接使用键值对(map
)缓存有什么问题呢?
- 内存不够了怎么办?
那就随机删掉几条数据好了。随机删掉好呢?还是按照时间顺序好呢?或者是有没有其他更好的淘汰策略呢?不同数据的访问频率是不一样的,优先删除访问频率低的数据是不是更好呢?数据的访问频率可能随着时间变化,那优先删除最近最少访问的数据可能是一个更好的选择。
需要实现一个**==合理的淘汰策略==**。
- 并发写入冲突了怎么办?
对缓存的访问,一般不可能是串行的。map 是没有并发保护的,应对并发的场景,修改操作(包括新增,更新和删除)需要加锁。
- 单机性能不够怎么办?
单台计算机的资源是有限的,计算、存储等都是有限的。随着业务量和访问量的增加,单台机器很容易遇到瓶颈。如果利用多台计算机的资源,并行处理提高性能就要缓存应用能够支持分布式,这称为水平扩展(scale horizontally)。与水平扩展相对应的是垂直扩展(scale vertically),即通过增加单个节点的计算、存储、带宽等,来提高系统的性能,硬件的成本和性能并非呈线性关系,大部分情况下,分布式系统是一个更优的选择。
设计一个分布式缓存系统,需要考虑资源控制、淘汰策略、并发、分布式节点通信等各个方面的问题。而且,针对不同的应用场景,还需要在不同的特性之间权衡,例如,是否需要支持缓存更新?还是假定缓存在淘汰之前是不允许改变的。不同的权衡对应着不同的实现。
分布式缓存需要实现节点间通信,建立基于 HTTP 的通信机制是比较常见和简单的做法。如果一个节点启动了 HTTP 服务,那么这个节点就可以被其他节点访问。
一致性哈希算法是ron-cache从单节点走向分布式节点的一个重要的环节。
- 注册节点(Register Peers),借助一致性哈希算法选择节点。
- 实现 HTTP 客户端,与远程节点的服务端通信。
-
==缓存击穿==:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。【热点数据】
-
==缓存穿透==:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
-
==缓存雪崩==:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
protobuf 即 Protocol Buffers,Google 开发的一种数据描述语言,是一种轻便高效的结构化数据存储格式,与语言、平台无关,可扩展可序列化。protobuf 以二进制方式存储,占用空间小。
🔖
对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。
对象和数据库之间映射关系:
数据库 | 面向对象的编程语言 |
---|---|
表(table) | 类(class/struct) |
记录(record, row) | 对象 (object) |
字段(field, column) | 对象属性(attribute) |
CREATE TABLE `User` (`Name` text, `Age` integer);
INSERT INTO `User` (`Name`, `Age`) VALUES ("Tom", 18);
SELECT * FROM `User`;
type User struct {
Name string
Age int
}
orm.CreateTable(&User{})
orm.Save(&User{"Tom", 18})
var users []User
orm.Find(&users)
ORM 框架相当于对象和数据库中间的一个桥梁,借助 ORM 可以避免写繁琐的 SQL 语言,仅仅通过操作具体的对象,就能够完成对关系型数据库的操作。
如何实现一个 ORM 框架呢?
CreateTable
方法需要从参数&User{}
得到对应的结构体的名称 User 作为表名,成员变量 Name, Age 作为列名,同时还需要知道成员变量对应的类型。Save
方法则需要知道每个成员变量的值。Find
方法仅从传入的空切片&[]User
,得到对应的结构体名也就是表名 User,并从数据库中取到所有的记录,将其转换成 User 对象,添加到切片中。
如果这些方法只接受 User 类型的参数,那是很容易实现的。但是 ORM 框架是通用的,也就是说可以将任意合法的对象转换成数据库中的表和记录。例如:
type Account struct {
Username string
Password string
}
orm.CreateTable(&Account{})
问题:如何根据任意类型的指针,得到其对应的结构体的信息。
通过反射机制(reflect),可以获取到对象对应的结构体名称,成员变量、方法等信息,例如:
typ := reflect.Indirect(reflect.ValueOf(&Account{})).Type()
fmt.Println(typ.Name()) // Account
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fmt.Println(field.Name) // Username Password
}
reflect.ValueOf()
获取指针对应的反射值。reflect.Indirect()
获取指针指向的对象的反射值。(reflect.Type).Name()
返回类名(字符串)。(reflect.Type).Field(i)
获取第 i 个成员变量。
除了对象和表结构/记录的映射以外,设计 ORM 框架还需要关注什么问题呢?
- MySQL,PostgreSQL,SQLite 等数据库的 SQL 语句是有区别的,ORM 框架如何在开发者不感知的情况下适配多种数据库?
- 如果对象的字段发生改变,数据库表结构能够自动更新,即是否支持数据库自动迁移(migrate)?
- 数据库支持的功能很多,例如事务(transaction),ORM框架能实现哪些?
- ...
数据库的特性非常多,简单的增删查改使用 ORM 替代 SQL 语句是没有问题的,但是也有很多特性难以用 ORM 替代,比如复杂的多表关联查询,ORM 也可能支持,但是基于性能的考虑,开发者自己写 SQL 语句很可能更高效。
因此,设计实现一个 ORM 框架,就需要给功能特性排优先级了。
https://github.com/go-gorm/gorm
Exec()
用于执行 SQL 语句,如果是查询语句,不会返回相关的记录。所以查询语句通常使用Query()
和QueryRow()
,前者可以返回多条记录,后者只返回一条记录。Exec()
、Query()
、QueryRow()
接受1或多个入参,第一个入参是 SQL 语句,后面的入参是 SQL 语句中的占位符?
对应的值,占位符一般用来防 SQL 注入。
开发一个框架/库并不容易,详细的日志能够帮助我们快速地定位问题。
log 标准库没有日志分级,不打印文件和行号。
- 使用 dialect 隔离不同数据库之间的差异,便于扩展。
- 使用反射(reflect)获取任意 struct 对象的名称和字段,映射为数据中的表。
- 数据库表的创建(create)、删除(drop)。
SQL 语句中的类型和 Go 语言中的类型是不同的,例如Go 语言中的 int
、int8
、int16
等类型均对应 SQLite 中的 integer
类型。因此实现 ORM 映射的第一步,需要思考如何将 Go 语言的类型映射为数据库中的类型。
同时,不同数据库支持的数据类型也是有差异的,即使功能相同,在 SQL 语句的表达上也可能有差异。ORM 框架往往需要兼容多种数据库,因此我们需要将差异的这一部分提取出来,每一种数据库分别实现,实现最大程度的复用和解耦。这部分代码称之为 dialect
。
对象(object)和表(table)的转换
Session 的核心功能是与数据库进行交互。因此,我们将数据库表的增/删操作实现在子包 session 中。
- 实现新增(insert)记录的功能。
- 使用反射(reflect)将数据库的记录转换为对应的结构体实例,实现查询(select)功能。
后续所有构造 SQL 语句的方式都将与 Insert
中构造 SQL 语句的方式一致。分两步:
- 多次调用
clause.Set()
构造好每一个子句。 - 调用一次
clause.Build()
按照传入的顺序构造出最终的 SQL 语句。
构造完成后,调用 Raw().Exec()
方法执行。
- 通过链式(chain)操作,支持查询条件(where, order by, limit 等)的叠加。
- 实现记录的更新(update)、删除(delete)和统计(count)功能。
- 通过反射(reflect)获取结构体绑定的钩子(hooks),并调用。
- 支持增删查改(CRUD)前后调用钩子。
Hook,翻译为钩子,其主要思想是提前在可能增加功能的地方埋好(预设)一个钩子,当我们需要重新修改或者增加这个地方的逻辑的时候,把扩展的类或者方法挂载到这个点即可。钩子的应用非常广泛,例如 Github 支持的 travis 持续集成服务,当有 git push
事件发生时,会触发 travis 拉取新的代码进行构建。IDE 中钩子也非常常见,比如,当按下 Ctrl + s
后,自动格式化代码。再比如前端常用的 hot reload
机制,前端代码发生变更时,自动编译打包,通知浏览器自动刷新页面,实现所写即所得。
钩子机制设计的好坏,取决于扩展点选择的是否合适。例如对于持续集成来说,代码如果不发生变更,反复构建是没有意义的,因此钩子应设计在代码可能发生变更的地方,比如 MR、PR 合并前后。
那对于 ORM 框架来说,合适的扩展点在哪里呢?很显然,记录的增删查改前后都是非常合适的。
比如,我们设计一个 Account
类,Account
包含有一个隐私字段 Password
,那么每次查询后都需要做脱敏处理,才能继续使用。如果提供了 AfterQuery
的钩子,查询后,自动地将 Password
字段的值脱敏,是不是能省去很多冗余的代码呢?
- 结构体(struct)变更时,数据库表的字段(field)自动迁移(migrate)。
- 仅支持字段新增与删除,不支持字段类型变更。
RPC是什么
RPC(Remote Procedure Call,远程过程调用)是一种计算机通信协议,允许调用不同进程空间的程序。RPC的客户端和服务器可以在一台机器上,也可以在不同的机器上。程序员使用时,就像调用本地程序一样,无需关注内部的实现细节。
不同的应用程序之间的通信方式有很多,比如浏览器和服务器之间广泛使用的基于 HTTP 协议的 Restful API。与 RPC 相比,Restful API 有相对统一的标准,因而更通用,兼容性更好,支持不同的语言。HTTP 协议是基于文本的,一般具备更好的可读性。但是缺点也很明显,RPC和 Restful API对比:
- Restful 接口需要额外的定义,无论是客户端还是服务端,都需要额外的代码来处理,而 RPC 调用则更接近于直接调用。
- 基于 HTTP 协议的 Restful 报文冗余,承载了过多的无效信息,而 RPC 通常使用自定义的协议格式,减少冗余报文。
- RPC 可以采用更高效的序列化协议,将==文本==转为**==二进制==**传输,获得更高的性能。
- 因为 RPC 的灵活性,所以更容易扩展和集成诸如注册中心、负载均衡等功能。
RPC框架需要解决什么问题?或者说,为什么需要RPC框架?
两个应用程序之间通信采用的传输协议:
- 位于不同的机器,一般会选择 TCP 协议或者 HTTP 协议;
- 位于相同的机器,选择 Unix Socket 协议
报文的编码格式:
- 最常用的 JSON 或者 XML;
- 报文比较大,可能会选择 protobuf 等;
- 发送端编码之后,再进行压缩;接收端获取报文,先解压再解码。
解决一系列的可用性问题,例如,连接超时了怎么办?是否支持异步请求和并发?
如果服务端的实例很多,客户端并不关心这些实例的地址和部署位置,只关心自己能否获取到期待的结果,那就引出了==注册中心(registry)==和==负载均衡(load balance)==的问题。简单地说,即客户端和服务端互相不感知对方的存在,服务端启动时将自己注册到注册中心,客户端调用时,从注册中心获取到所有可用的实例,选择一个来调用。这样服务端和客户端只需要感知注册中心的存在就够了。注册中心通常还需要实现服务动态添加、删除,使用心跳确保服务处于可用状态等功能。
再进一步,假设服务端是不同的团队提供的,如果没有统一的 RPC 框架,各个团队的服务提供方就需要各自实现一套消息编解码、连接池、收发线程、超时处理等“业务之外”的重复技术劳动,造成整体的低效。因此,“业务之外”的这部分公共的能力,即是 RPC 框架所需要具备的能力。
ron-rpc
成熟的RPC框架和微服务框架很多:grpc
、rpcx
、go-micro
等
以Go语言官方的标准库net/rpc
为基础,新增协议交换(protocol exchange)、注册中心(registry)、服务发现(service discovery)、负载均衡(load balance)、超时处理(timeout processing)等特性。
| Option{MagicNumber: xxx, CodecType: xxx} | Header{ServiceMethod ...} | Body interface{} |
| <------ 固定 JSON 编码 ------> | <------- 编码方式由 CodeType 决定 ------->|
| Option | Header1 | Body1 | Header2 | Body2 | ...
超时处理是RPC框架一个比较基本的能力,如果缺少超时处理机制,无论是服务端还是客户端都容易因为网络或其他错误导致挂死,资源耗尽,这些问题的出现大大地降低了服务的可用性。
纵观整个远程调用的过程,需要客户端处理超时的地方有:
- 与服务端建立连接,导致的超时
- 发送请求到服务端,写报文导致的超时
- 等待服务端处理时,等待处理导致的超时(比如服务端已挂死,迟迟不响应)
- 从服务端接收响应时,读报文导致的超时
需要服务端处理超时的地方有:
- 读取客户端请求报文时,读报文导致的超时
- 发送响应报文时,写报文导致的超时
- 调用映射服务的方法时,处理报文导致的超时
🔖