Skip to content
/ ron Public

Ron is a very small HTTP web framework written in Golang.

Notifications You must be signed in to change notification settings

andyRon/ron

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Go语言从零实现

参考:https://geektutu.com/post/gee.html

1 Web框架ron-web

在设计一个框架之前,需要回答框架核心解决了什么问题。

net/http提供了基础的Web功能,即监听端口,映射静态路由,解析HTTP报文。一些Web开发中简单的需求并不支持,需要手工实现。

  • 动态路由:例如hello/:namehello/*这类的规则。
  • 鉴权:没有分组/统一鉴权的能力,需要在每个路由映射的handler中实现。
  • 模板:没有统一简化的HTML机制。

当我们离开框架,使用基础库时,需要频繁手工处理的地方,就是框架的价值所在。

  • 路由(Routing):将请求映射到函数,支持动态路由。例如'/hello/:name
  • 模板(Templates):使用内置模板引擎提供模板渲染机制。
  • 工具集(Utilites):提供对 cookies,headers 等处理机制。
  • 插件(Plugin):Bottle本身功能有限,但提供了插件机制。可以选择安装到全局,也可以只针对某几个路由生效。

前置知识

标准库启动Web服务

实现http.Handler接口

上下文

将路由(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 中手工调用没有多大的优势了。
  • 中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。

模板(HTML Template)

服务端渲染

前后端分离的开发模式,即 Web 后端提供 RESTful 接口,返回结构化的数据(通常为 JSON 或者 XML)。前端使用 AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。Vue/React 等前端框架持续火热,这种开发模式前后端解耦,优势非常突出。

  • 后端专心解决资源利用,并发,数据库等问题,只需要考虑数据如何生成;前端童鞋专注于界面设计实现,只需要考虑拿到数据后如何渲染即可。

  • 前后端分离另外一个优势。因为后端只关注于数据,接口返回值是结构化的,与前端解耦。同一套后端服务能够同时支撑小程序、移动APP、PC端 Web 页面,以及对外提供的接口。随着前端工程化的不断地发展,Webpack,gulp 等工具层出不穷,前端技术越来越自成体系了。

前后分离的一大问题,页面是在客户端渲染的,比如浏览器,这对于爬虫并不友好。Google 爬虫已经能够爬取渲染后的网页,但是短期内爬取服务端直接渲染的 HTML 页面仍是主流。

静态文件(Serve Static Files)

HTML 模板渲染

错误恢复

2 分布式缓存ron-cache

商业世界里,现金为王;架构世界里,缓存为王。

为什么

直接使用键值对(map)缓存有什么问题呢?

  1. 内存不够了怎么办?

那就随机删掉几条数据好了。随机删掉好呢?还是按照时间顺序好呢?或者是有没有其他更好的淘汰策略呢?不同数据的访问频率是不一样的,优先删除访问频率低的数据是不是更好呢?数据的访问频率可能随着时间变化,那优先删除最近最少访问的数据可能是一个更好的选择。

需要实现一个**==合理的淘汰策略==**。

  1. 并发写入冲突了怎么办?

对缓存的访问,一般不可能是串行的。map 是没有并发保护的,应对并发的场景,修改操作(包括新增,更新和删除)需要加锁。

  1. 单机性能不够怎么办?

单台计算机的资源是有限的,计算、存储等都是有限的。随着业务量和访问量的增加,单台机器很容易遇到瓶颈。如果利用多台计算机的资源,并行处理提高性能就要缓存应用能够支持分布式,这称为水平扩展(scale horizontally)。与水平扩展相对应的是垂直扩展(scale vertically),即通过增加单个节点的计算、存储、带宽等,来提高系统的性能,硬件的成本和性能并非呈线性关系,大部分情况下,分布式系统是一个更优的选择。

是什么

设计一个分布式缓存系统,需要考虑资源控制、淘汰策略、并发、分布式节点通信等各个方面的问题。而且,针对不同的应用场景,还需要在不同的特性之间权衡,例如,是否需要支持缓存更新?还是假定缓存在淘汰之前是不允许改变的。不同的权衡对应着不同的实现。

LRU缓存淘汰策略

单机并发缓存

HTTP服务端

分布式缓存需要实现节点间通信,建立基于 HTTP 的通信机制是比较常见和简单的做法。如果一个节点启动了 HTTP 服务,那么这个节点就可以被其他节点访问。

一致性哈希

一致性哈希算法是ron-cache从单节点走向分布式节点的一个重要的环节。

分布式节点

  • 注册节点(Register Peers),借助一致性哈希算法选择节点。
  • 实现 HTTP 客户端,与远程节点的服务端通信。

防止缓存击穿

缓存雪崩、缓存击穿与缓存穿透

  • ==缓存击穿==:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。【热点数据】

  • ==缓存穿透==:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。

  • ==缓存雪崩==:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。

使用Protobuf通信

为什么要使用 protobuf

protobuf 即 Protocol Buffers,Google 开发的一种数据描述语言,是一种轻便高效的结构化数据存储格式,与语言、平台无关,可扩展可序列化。protobuf 以二进制方式存储,占用空间小。

🔖

3 ORM框架ron-orm

关于ORM框架

对象关系映射(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 框架还需要关注什么问题呢?

  1. MySQL,PostgreSQL,SQLite 等数据库的 SQL 语句是有区别的,ORM 框架如何在开发者不感知的情况下适配多种数据库?
  2. 如果对象的字段发生改变,数据库表结构能够自动更新,即是否支持数据库自动迁移(migrate)?
  3. 数据库支持的功能很多,例如事务(transaction),ORM框架能实现哪些?
  4. ...

关于ron-orm

数据库的特性非常多,简单的增删查改使用 ORM 替代 SQL 语句是没有问题的,但是也有很多特性难以用 ORM 替代,比如复杂的多表关联查询,ORM 也可能支持,但是基于性能的考虑,开发者自己写 SQL 语句很可能更高效。

因此,设计实现一个 ORM 框架,就需要给功能特性排优先级了。

https://github.com/go-gorm/gorm

database/sql基础

database/sql 标准库

  • Exec() 用于执行 SQL 语句,如果是查询语句,不会返回相关的记录。所以查询语句通常使用 Query()QueryRow(),前者可以返回多条记录,后者只返回一条记录。
  • Exec()Query()QueryRow() 接受1或多个入参,第一个入参是 SQL 语句,后面的入参是 SQL 语句中的占位符 ? 对应的值,占位符一般用来防 SQL 注入。

实现一个简单的 log 库

开发一个框架/库并不容易,详细的日志能够帮助我们快速地定位问题。

log 标准库没有日志分级,不打印文件和行号。

对象表结构映射

  • 使用 dialect 隔离不同数据库之间的差异,便于扩展。
  • 使用反射(reflect)获取任意 struct 对象的名称和字段,映射为数据中的表。
  • 数据库表的创建(create)、删除(drop)。

1️⃣Dialect

SQL 语句中的类型和 Go 语言中的类型是不同的,例如Go 语言中的 intint8int16 等类型均对应 SQLite 中的 integer 类型。因此实现 ORM 映射的第一步,需要思考如何将 Go 语言的类型映射为数据库中的类型。

同时,不同数据库支持的数据类型也是有差异的,即使功能相同,在 SQL 语句的表达上也可能有差异。ORM 框架往往需要兼容多种数据库,因此我们需要将差异的这一部分提取出来,每一种数据库分别实现,实现最大程度的复用和解耦。这部分代码称之为 dialect

2️⃣Schema

对象(object)和表(table)的转换

3️⃣Session

Session 的核心功能是与数据库进行交互。因此,我们将数据库表的增/删操作实现在子包 session 中。

记录新增和查询

  • 实现新增(insert)记录的功能。
  • 使用反射(reflect)将数据库的记录转换为对应的结构体实例,实现查询(select)功能。

1️⃣Clause构造SQL语句

2️⃣Insert功能

后续所有构造 SQL 语句的方式都将与 Insert 中构造 SQL 语句的方式一致。分两步:

  1. 多次调用 clause.Set() 构造好每一个子句。
  2. 调用一次 clause.Build() 按照传入的顺序构造出最终的 SQL 语句。

构造完成后,调用 Raw().Exec() 方法执行。

3️⃣Find功能

链式操作与更新删除

  • 通过链式(chain)操作,支持查询条件(where, order by, limit 等)的叠加。
  • 实现记录的更新(update)、删除(delete)和统计(count)功能。

实现钩子

  • 通过反射(reflect)获取结构体绑定的钩子(hooks),并调用。
  • 支持增删查改(CRUD)前后调用钩子。

Hook 机制

Hook,翻译为钩子,其主要思想是提前在可能增加功能的地方埋好(预设)一个钩子,当我们需要重新修改或者增加这个地方的逻辑的时候,把扩展的类或者方法挂载到这个点即可。钩子的应用非常广泛,例如 Github 支持的 travis 持续集成服务,当有 git push 事件发生时,会触发 travis 拉取新的代码进行构建。IDE 中钩子也非常常见,比如,当按下 Ctrl + s 后,自动格式化代码。再比如前端常用的 hot reload 机制,前端代码发生变更时,自动编译打包,通知浏览器自动刷新页面,实现所写即所得。

钩子机制设计的好坏,取决于扩展点选择的是否合适。例如对于持续集成来说,代码如果不发生变更,反复构建是没有意义的,因此钩子应设计在代码可能发生变更的地方,比如 MR、PR 合并前后。

那对于 ORM 框架来说,合适的扩展点在哪里呢?很显然,记录的增删查改前后都是非常合适的。

比如,我们设计一个 Account 类,Account 包含有一个隐私字段 Password,那么每次查询后都需要做脱敏处理,才能继续使用。如果提供了 AfterQuery 的钩子,查询后,自动地将 Password 字段的值脱敏,是不是能省去很多冗余的代码呢?

持事务

SQLite和Go标准库中的事务

数据库迁移

  • 结构体(struct)变更时,数据库表的字段(field)自动迁移(migrate)。
  • 仅支持字段新增与删除,不支持字段类型变更。

4 RPC框架ron-rpc

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框架和微服务框架很多:grpcrpcxgo-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 | ...

支持并发与异步的高性能客户端

服务注册

结构体映射为服务

通过反射实现service

service 的测试用例

集成到服务端

超时处理

为什么需要超时处理机制

超时处理是RPC框架一个比较基本的能力,如果缺少超时处理机制,无论是服务端还是客户端都容易因为网络或其他错误导致挂死,资源耗尽,这些问题的出现大大地降低了服务的可用性。

纵观整个远程调用的过程,需要客户端处理超时的地方有:

  • 与服务端建立连接,导致的超时
  • 发送请求到服务端,写报文导致的超时
  • 等待服务端处理时,等待处理导致的超时(比如服务端已挂死,迟迟不响应)
  • 从服务端接收响应时,读报文导致的超时

需要服务端处理超时的地方有:

  • 读取客户端请求报文时,读报文导致的超时
  • 发送响应报文时,写报文导致的超时
  • 调用映射服务的方法时,处理报文导致的超时

创建连接超时

Client.Call 超时

服务端处理超时

🔖

测试用例

About

Ron is a very small HTTP web framework written in Golang.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published