地址在 https://github.com/lalawue/SwiftSQLiteORM.git。
之前在公司实现过一个 Swift SQLite 的 ORM 库,基于 GRDB,ORM 的优势就是方便,不用自己 CURD,也能享受到关系数据库的 ACID(原子性、一致性、完整性与持久性)能力。
只不过 ORM 的方式,搜索能力确实一般而已,比如不好自己组 SQL 搜索子句,以及 join 其他表,或者仅 select 输出某几列。
不过这个输出某几列没有实现的原因,倒不是不好组子句,只是 Swift 是有类型系统的,输出的额外的类型必须得先定义,这个其实跟已有的表结构可能完全不相同,所以省略不做的。
因为之前自己也重构过 Lua 的 SQLite 的 ORM,所以多少熟悉了这个部分(没办法,工作很大一部分就是熟能生巧,在限制场景和条件下找解决方案,一边摸索一边实践,顺利的话,条件满足后就可以输出,当然纯逻辑的的数学、理论物理等这些领域不熟悉,不发表意见)。
先给个定义的例子:
// nested struct will store as JSON string
struct ExampleNested: Codable {
let desc: String
let index: Int
}
struct ExampleType: DBTableDef {
let name: String
let data: ExampleNested
typealias ORMKey = Columns
/// keep blank or return nil for using hidden 'rowid' column
static var primaryKey: Columns? {
return .name
}
enum Columns: String, DBTableKey {
case name
case data
}
}
ExampleType
是需要支持 ORM 的结构,遵循 DBTableDef
,可以是 struct 或者是 class,不要求遵循 Codable 协议。
如上也就定义了数据库中的表结构,其中使用 DBTableKey
定义了数据库中的字段类型(支持仅部分属性进入数据库),这里还额外定义了 primary key 是 name,这样就支持了后续的 deletes([T])
的接口,可以直接传递需要删除的实例进去。
如果结构有嵌套的子结构,比如上面的 ExampleNested
,则需要支持 Codable 协议,因为非内建字段类型 ,默认是 Blob,将 encode 为 JSON 后保存,否则将 throw error。
使用如下:
do {
// insert / update
let c = ExampleType(name: "c", data: ExampleNested(desc: "c", index: 1))
let u = ExampleType(name: "u", data: ExampleNested(desc: "u", index: 2))
try DBMgnt.push([c, u])
// select
let arr = try DBMgnt.fetch(ExampleType.self, .eq(.name, c.name))
XCTAssert(arr.count == 1, "failed")
XCTAssert(arr[0].name == c.name, "failed")
// delete
try DBMgnt.deletes([c]) // require primaryKey in table definition
try DBMgnt.delete(ExampleType.self, .eq(.name, u.name))
let count = try DBMgnt.fetch(ExampleType.self, .eq(.name, c.name)).count
XCTAssert(count == 0, "failed")
// clear
try DBMgnt.clear(ExampleType.self)
// drop table
try DBMgnt.drop(ExampleType.self)
} catch {
if let err = error as? DBORMError {
fatalError("failed to try block: \(err.localizedDescription)")
} else {
fatalError("failed to try block: \(error.localizedDescription)")
}
}
CURD 基本上就是 push、fetch、deletes、delete、clear、drop 这样,其中 fetch、delete 接口支持 DBRecordFilter
,关联 DBTableDef
中的字段定义,计算操作、比较操作都是可以传递属性名称进去的,类型则遵循 SQLite select 的类型转换规定,我大体理解是跟字段定义相关,比如 SQLite 给的字段定义可以是 TEXT、INTEGER、REAL、BLOB。
另外,我只实现了部分内建字段的映射定义,用户可以根据自己需要通过 DBPrimitive
协议,定义某些类型支持映射:
/// database column type
/// - will perform relative type calculation in sql expression
/// - https://sqlite.org/datatype3.html
public enum DBStoreType {
/// Int64
case INTEGER
/// Double
case REAL
/// String, Numeric
case TEXT
/// Data
case BLOB
}
/// database store value type
/// - will sotre as type's DatabaseValueConvertible through GRDB
public enum DBStoreValue {
/// box with int64
case integer(Int64)
/// box with double
case real(Double)
/// box with String
case text(String)
/// box with data
case blob(Data)
}
/// type transform for store / restore from database
public protocol DBPrimitive: DefaultConstructor {
/// database column type
static var ormStoreType: DBStoreType { get }
/// return TypeInfo for mocking, for example objc wrapper NSUUID
static func ormTypeInfo() throws -> TypeInfo
/// mapping value to store in database
func ormToStoreValue() -> DBStoreValue?
/// restore value from database
static func ormFromStoreValue(_ value: DBStoreValue) -> Self?
}
其中的 DBStoreType
是数据库列类型,DBStoreValue
则是将自定义字段的数据通过 String、Int64、Double 或者 Data 传递到数据库,以及读取的时候,数据库也将返回这几种数据,给自定义类型来恢复。
比如最开始例子的 name 属性是 String 类型,将映射为 SQLite 下的 TEXT 列类型。
比如下面例子的 URL 将映射为 TEXT 列类型:
extension URL: DBPrimitive {
public init() {
// will be placed by database value later
self.init(string: "a://a.a")!
}
public static var ormStoreType: SwiftSQLiteORM.DBStoreType { .TEXT }
public func ormToStoreValue() -> SwiftSQLiteORM.DBStoreValue? {
return .text(self.absoluteString)
}
public static func ormFromStoreValue(_ value: SwiftSQLiteORM.DBStoreValue) -> URL? {
guard case .text(let string) = value else {
return nil
}
return URL(string: string)
}
}
上面的例子都没有涉及到如何 connect 数据库,以及建立 table 等,因为这些 ORM 库都自动做了,甚至支持 alter table。
比如需要多增加一个属性,并记录到数据库,只需要升级 DBTableDef
下的 tableVersion
就好,是一个 Double 类型的字段。
当然还支持指定 table 名称,以及使用独立的数据库文件,如下:
extension DBTableDef {
/// ...
/// specify table name or use type name
/// - should be unique in all scope
static var tableName: String { get }
/// schema version for table columns, default 0
/// - increase version after you add columns
static var tableVersion: Double { get }
/// specify database file name or use default
static var databaseName: String { get }
/// ...
}
ORM 需要将 struct、class 的指定字段,映射到 SQLite 中的表结构,对应列的类型需要能保存属性数据,便于后续的计算、比较操作,这里对 Swift 语法结构的获取,用的库是 Runtime,这个也是我在 github 上遇到了 SQLiteORM 后,读取其代码了解到的。
从 Runtime 这个库,可以拿到 Swift 结构的属性列表,以及每一个属性的类型,甚至可以构造这个属性的实例。
比如 fetch
接口操作之后,这个实例的字段数据,是依赖数据库字段读取到的,因为 DBTableDef
支持仅保存部分字段,因此其他字段可以通过下面的函数由用户自己填充,或者使用默认值。
/// Table ORM mapping definition
public protocol DBTableDef {
/// ...
/// update instance property value created by type reflection
/// - only ORMKey covered property can restore value from database column
/// - others property will use default value
static func ormUpdateNew(_ value: inout Self) -> Self
}
Swift 侧的表结构大致可以定义,但是数据库这边我偷懒了,不像 SQLiteORM 那样通过 SQLite 的接口组织操作,而是利用了 GRDB 来做保存、读取。
这次我用了 GRDB.swift/SQLCipher
来做底层存储操作接口,其中的加密用的 password phrase 是库自动生成后,保存到 keychain 里面的。所以每次重新安装后,password phrase 都会不一样,但不影响使用,用户侧也无需知道具体是什么。
GRDB 的读取是通过 Row 结构,使用上类似字典,保存的时候是通过 encode
,也是类似字典,将属性保存到 container 中:
/// insert / update
override func encode(to container: inout PersistenceContainer) {
/// ...
}
其中遇到的一些问题,以及不大好解决的问题记录大概有:
SQLite 不支持保存 UInt64,所以这个类型,我是保存为了 TEXT,记录、恢复当然是没有问题的,只是在计算、比较的时候,我大概理解 SQLite 应该是 cast 为了 TEXT 来进行比较的吧。对比之下,GRDB 几乎是不支持 UInt64。
NSDecimalNumber 其实在 Swift 里面就是 Decimal,但在 ObjC 里面,是 NSNumber 的子类,在 GRDB 里面,是被认为是 NSNumber,然后内部比较后来保存的,GRDB 对于 NSDecimalNumber 仅支持保存 >=Int64.min 和 <=Int64.max 的值。
而在本库 NSDecimalNumber 是作为 TEXT 保存,短板也是类似 UInt64。
同样的 NSNumber 本库也是作为 TEXT 保存,估计计算、比较的时候会有点问题吧,但基本存取操作在边界上是没啥问题的。
等于是将计算、比较任务,都扔给 SQLite 列类型来处理了,比如 Decimal 跟 Real 的比较,等于是 SQLite 中 TEXT 字段和输入 REAL 值的比较了。
也许这部分后续可以改进一下,利用 GRDB 的 argument 参数,使用 SQLite 的 bind 接口来完成,估计会更好一些,后面再说吧。
大概就是这样。
这个大部头读了好久,主线是中华民族的发展史,以及当前取得的成就,结合地缘、历史的深入解读。
枢纽是本书强调的中心点。
和美国海权国家(英国也是),俄罗斯陆权国家(德国也是)不同,中国是海陆复合型国家(伊朗也是),我们不仅有绵长的海岸线、广阔的南海,还有这跟中亚、蒙古等国家接壤的国界线,是欧亚大陆(世界岛)的重要国家。跟深入内陆中亚的交流,几千年前就开始了。
在中国的历史上,代表草原文明、农耕文明的各民族,在不同时期作为主线,开启了属于自己的朝代。不同的王朝,都有同样的考虑,如长久的统治、稳定的社会。因此统治时,必须同时考虑草原、农耕文明。在朝代更迭的历史发展中,草原、农耕文明你争我夺,最终融合。
王朝最终是草原、农耕文明的枢纽,只有这样,才能稳定这两种不同的力量。
作为拥有绵长海岸线的国家,王朝通过海洋跟世界其他地方的联系,早就有了规模。比如马来西亚、菲律宾的华人,当年因为各种原因下南洋,还保留着华人(社会)的很多传统,说明文明的传递,其实也跨越了海洋。不过我们近代对于海洋的争斗,比较吃亏,显得海洋方面的发展,比较落后而已。
之前王朝在繁荣发展,人口膨胀后,都会陷入马尔萨斯陷阱,遇到天灾人祸,脆弱的经济将导致崩溃,人口减少后才能平衡下来。
但是膨胀的人口,却不是都坏事。比如即便清朝,跟当时的工业国——英国的贸易,一直都是顺差。
因为当时庞大的人口,使得即便非工业化的手工制造业,成本都很低。因此当时初步的工业化,对于王朝来说,并不划算,没法形成规模,导致社会本身没有强烈的动力去进行工业化。而王朝的框架结构,也没法在社会内部被突破改变,最终是外部世界输入的变化,压垮了王朝。
而作为拥有统一的法律、庞大的受教育人口、巨大内部市场的大国,在如今的工业化时代,却是优势。
比如我们工业化成体系后,对应现代供应链网络的迭代、进化非常快(必须拥有统一法律、巨大内部市场、相当规模人口的大国才可能),在逐渐进化的同时,东南亚的供应链也逐渐融入(一方面文化相距不远方便吸收,另一方面为了规避制裁换个牌其实很快),使得在东亚的制造业规模化成本、优化迭代成本,是全世界最经济的,不仅足以供应全世界。其规模竞争优势,也要远远优于世界其他地方的工业化成本。
我们每天都在进口原材料、输出最经济的工业制成品、高科技产品,在当今社会,我们就是世界经济运转的枢纽。
之前版本的 cincau 是一个 MVC 模式的 HTTP server,内部认为一个业务逻辑单元(controller、或 page)可以匹配多个 URL 数据请求,一次 URL 请求将抽象为一个 request 实例给 controller 处理,controller 将 response 对应的 HTML,而 model 层则是 SVR 内部数据源的抽象。
所以 cincau 的 request 是一次 HTTP URL 请求的数据集合,比如请求的 method、path,header key/value 数据。
但后续 cincau 将支持 WebSocket,本来 WS 也是 HTTP upgrade 而来,只是 WS 是长连接,在 cincau 内部,希望继续复用已有的 MVC 模式,复用之前的 request 结构,并且,WS 和 HTTP 共用一个端口号。
大体上没什么问题的,在框架设计上,可以特例 HTTP 为仅有一帧的 frame,并指定专用的 frame type,而 WS 可能一次带有多帧,区分 PING、PONG、TXT、BINARY 的 frame type。
但作为长连接的 WS,跟 HTTP 的最大的不同,在于
以上两点,都将复用提供 request 给 controller 的函数(参数不变,但不同状态下 request 带有不同的标记),比如使用 isPeer() 区分是 CNT 请求过来的,还是 SVR 主动触发的,以及使用 isDisconnect() 来区分 CNT 是已建立链接,还是掉线了(可能是 CNT 的 TCP 主动断开,或者 SVR PING 后一定时间内未收到 PONG 回应,SVR 主动 close 了 TCP 导致)。
大概是上面这样的想法,感觉应该可行。
然后在 cincau 增加了 WebSocket 的相关 demo,实现了一个简陋的聊天室。
2024 年 6 月 30 日后,GitHub Pages 默认使用 GitHub Actions 拉起 Jekyll 来构建网站(需自己动手配置),若想恢复之前的分支部署,需要在发布工程根目录增加 .nojekyll 文件做标记。
具体可以访问这个链接。
我用的是分支部署的方式,所有文件都会部署为静态资源。
没有跟上时代步伐使用 Jekyll 的原因是,我最开始用的是 EmacsWiki 来通过 wiki 生成静态页面,后续 Emacs 不断更新,但 EmacsWikiMode 已经渐渐没人维护,我就转而使用 CommonMark 引擎的变体 cmark-gfm 来渲染 wiki,然后自己写了一个 build 管理脚本去控制页面渲染流程。
《更换 Markdown Engine 为 cmark-gfm》有详细的描述。
下载的 GTA5,大部分的时间,都是富兰克林开摩托,点开秘籍后,打爆 LSPD 的车胎后调戏,或者就直接调戏,以及各种破坏后,带着 4 星、5 星通缉逃逸(即便在地面上,还是有概率逃逸成功的)。主线的进度没怎么关注,但后面黑悟空发售了,我这边主线还没打完,如果立即切换游戏,就太可惜了,于是继续了之前的主线。
主线其实要认真推起来,也挺快的,最后结局选 C,三人互相帮助,免于一死。
最近看了一本书《1421——中国发现世界》,作者是加文·孟席斯(原英国海军潜艇编队指挥官)。
论证中国早在明朝郑和时,就已经进行了大规模的环球航行,发现了美洲、大洋洲、南极洲等,并出了海图。
哥伦布、麦哲伦、库克等就是靠着这些海图,才进行的环球航行。其舰队规模,人员配置比郑和船队小多了,如果没有这些海图,其舰队规模根本无法支撑其环球航行。
作者花了 14 年研究,给出了许多论据,比如船队沿路经过留下的遗物、碑文、沉船、哥伦布等人的信件等。
有种小时候已熟读的历史被点了刷新的感觉。
🐼
感觉算是短篇小说,很早就下载啦,预计是年底前看完,这会儿终于是看完了,才花了不到一个小时。
因为太短,直接爆内容了,补充一下,这篇小说写于 2003 年。
--
美俄欧军事联合,进攻我国,传统战争下我国并不吃亏,但是信息战很吃亏。
我从书中描述,理解到的是,信息战这里并不是平等对抗,比如你有 1000 辆坦克,我有 500 辆,传统战争下对抗,我多少能耗一点你的坦克,也许是 500 辆,也许是 400 辆、300 辆,拼死之下总有消耗(其实朝鲜战争已经说明,即便是轻步兵对机械化部队,我方都是有优势的)。
但是信息战下不是这样,信息战之下,电磁优势的一方,不仅仅是消息传递畅通无阻,还有就是能输出强大的电磁压制,弱势一方的电磁通讯,包括指挥控制链,会被干扰导致没法使用,精确导弹没法击中目标,通讯不畅,雷达屏幕上一片白点,没有有效的指示。
这样高精度的制导武器不能定位目标,坦克集群作战的话,则通讯不畅,能力反而被打折了。
所以,我从书中理解到的电子信息战,可能造成的是,完全不对称的战争。
书中,我方电磁弱势,导致防守出现问题,被压缩了行动空间,人员、武器都受损,北京紧急。为了扭转这个局面,指挥部研究决定,使用全频段电磁压制,也就是要用不了电磁通讯,那大家都用不了,那传统战争下,我方反而有优势了。
这样就争取了某集团军防守回撤的空间,但是联军调整了战略,先清输出电磁压制的单位,因为发射电磁全频段干扰的其实是个明显的输出源,没法躲藏的,所以没太久,就被一一清理了。
集团军的领导有个儿子,学习成绩太好,最后研究的天体物理,一直以来,离战争都很遥远,无法理解地球上人类争夺生存空间的残酷,太优秀最后进入了研究太阳的飞行器内,独自一人离开地球,逐渐接近太阳。在国家生存受到挑战时,给出了一份解决办法:利用太阳这颗恒星,引导控制这颗恒星爆发电磁风暴,破坏地球上的电磁通讯。
太阳质量、能量占星系的绝大部分,这个干扰源联军是没法消除的,计算结果可以导致地球电磁受影响至一周,足够我方集团军布好防守位置,将双方拉入没有电磁能力的传统战争。
领导的儿子牺牲了,联军在没有电磁优势的情况下,内部有了裂痕,比如法国就退出了联军的进攻,转入防守撤退。
全书结束。
自从 cincau 支持多进程的方式之后,同步数据的能力最开始是通过不同 worker 的 UDP socket 来传递的,即时性有限,大概固定几秒钟去同步一次,而且如果数据量大了也是个问题。
相比较下,即时性的问题才是主要的,比如其中一个 worker 接受了用户的登陆状态,用户的下个请求是另外一个 worker 来服务的,此时 cookie 的 session 信息根本还没同步过来,就成了问题。
当然这个问题也有粗放的解决办法,比如用 SQLite 来同步,但是这种 Key/Value 的信息也使用 SQLite 觉得还是太重了。
这里插播一句,之前了解过使用 mmap 来同步数据的 MMKV,因为 mmap 同样可以用来多进程同步数据,因此这个库也是可以多进程同步的,感觉这个库可以解决 95% 以上的同步问题,其他的例外情况,我下面会说。
在展开其他方案之前,我得检讨一下,一开始走了很多的弯路,虽然了解到需要使用一些多进程同步手段,比如最开始的 socket,比如可以使用 Linux 消息队列,以及 mmap,但是我把这个问题想得简单了,而且一开始我也没想起 MMKV。
比如我一开始就选择了 mmap,想做一个 mmap 的有序字典,我手头有 MIT LICENSE 的 AVL tree 代码,相比之下 MMKV 这种 bitcask 结构的数据库,是无序的,除非你 dump 出来所有的 key 再排序,否则你无法快速拿到按照 Key 排序的第一个节点,而 AVL tree 对 Key 排序的方案就可以。
可是已有的 AVL Tree 的方案,其对节点的引用是指针的,虽然 mmap 只管映射内存区域,但是不同的进程,其映射的内存区域很可能不一样,比如我使用的 LuaJIT,除了我自己主动调用的 mmap,其他不知道什么原因,也有不少的 mmap 调用。导致如果使用 AVL tree 的方案,除非是能保证每一个进程 mmap 过来给 AVL tree 使用的内存块,采用同样的起始地址,否则后续的指针计算,指向的节点地址就有问题。
虽然理论上,我可以将进程空间的一大段都交给这个 AVL tree 共享内存的方案,但是实际做起来其实也不方便,而且 mmap 这个系统调用其实很频繁,用 strace 就可以看到,其他的一些文章也有介绍。
所以,不得不将 AVL tree 的指针引用,改成 int32_t 的间接引用,这里的 int32_t 分成了高 16bit 和低 16bit,其中的高 16bit 用于 index 内存区块,低 16bit 用于 index 每个区块内的位置,byte offset。
之所以这么做,是因为内存块是动态申请的,不会一下就申请完所使用的内存,而是根据使用量逐步申请,虽说是共享内存,也总得使用实际内存空间的嘛。
另外这个 AVL tree 的方案相比 MMKV,其实有很大的限制,因为考虑到不想做 bitcask 那样的回收方案(简单点做),因此设置了限定最大的 Key/Value 长度,这个是调用库在初始化的时候就得进行设置的。
比如 Key/Value 的最大长度是 64 字节,其中 Key 是一个 timestamp 占 32 字节,后续的 Value 是一个 MD5 的 hash 占 32 字节,这里还可以插播一句,可以将这个 Value 作为 MMKV 的 Key,这样就实现了 MMKV Key 的有序,回应了最开始说的 95% 的应用场景。不过这里也得提一下,MMKV 还可以设置每个 Key 的过期时间,所以这个 95% 的应用场景,实际上还可以扩大不少。
回到刚刚的 AVL Tree,所有需要同步的数据都存放到 mmap 的内存区块中,每个进程都有对 AVL Tree 的引用,以及自己保留一份已经打开的内存区块列表,当别的进程因为塞入更多的数据申请了新的内存区块,其他进程在进入库调用的时候,检查到这个场景,将新的内存区块也 mmap 到自己的进程空间上。
因为每条数据是固定的长度(最大 Key/Value 长度),所以 AVL Tree 的 index 间接指针会忽略掉不同进程内存区块的起始地址不一致的情况,只关注内存区块的序号,以及每条数据对应内存区块的偏移字节,当这个内存区块已经映射到进程空间上后,对 index 的数据的读写就没有障碍了。
在实际使用中,当内存空间不足时,申请内存区区块序号(2^16个),映射内部的节点地址偏移,两者揉进每个节点的唯一 index,当删除节点时,节点可以回收而不需要整理内存区块,比如将该节点的 index 保存到未使用空间列表节点的 right 指针(间接指针)中。
上面说完了 mmap AVL Tree 的使用场景、面临的问题,提出的解决方案,和该方案的限制,下面列出来了一些实际跑通该场景的有趣细节:
细节太多了,当然相比较 SQLITE,上述两套方案的效率会高很多。
MMKV 已经很成熟,多平台都能支持,AVL Tree 是我当初小看了 mmap 导致的,而且有先天的短板,虽然说提供了有序 Key 的能力,但这个是在约束了最大 Key、Value(每条数据都是这个最大长度)来达到的,因为可以避免替换 mmap 共享的内存区块。
研究了一下 WebSocket,在 HTTP/1.1 之上,提供了一个双工长连接,通过 header 的 Upgrade 来完成协议升级及参数交换,之后就都是 websocket 的 frame 了。其最基础的协议描述在 RFC6455,压缩部分的扩展在 RFC7962。
之前研究了一下 C 写的 Websocket,验证了一下是可以正常工作的,但它 frame 的解析部分不好单独摘出来。
client 端的 html + js websocket data send/recv 其实很好写,browser 本来就有 WebSocket 的接口给调用,拉起本地的一个 html 就行了,但是 browser 无法指定 http header 的参数(包括 ws extension),发送 text、binary 的 frame 时,是通过 js 的 string、uint8 array 来指定的。
想着找一个可以直接提供解析 websocket frame 的库,虽然找到了 C 写的 wslay,但是实际使用过程中,感觉使用实在是繁琐。
比如 websocket frame 的结构如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
回到之前说的 wslay,霓虹国写的东西太复杂,居然 header 没检测全都能返回好几种错误,我感觉这部分其实可以简单点,比如 header 都解析不了的时候其实可以提示数据不足(毕竟 header 数据都不足),缺点是放弃了一部分错误的检查。
在 HTTP/1.1 的协议升级到 websocket 之前,header 需要交换一些数据,比如 client 会发送如下的字段
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: fSQMpewdgSIz1IhuhL3ERQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
说到 permessage-deflate,server 在 deflate payload 后构建 frame 时,需要设置 rsv1 bit 为 1,要不 client 会忽略 extensions 的描述。
还有 server 在 inflate frame payload 时,实际上认为不断喂进来的 payload 是连续的 data stream,因此不需要做 inflateEnd。
这个感觉也跟 HTTP/1.1 的实际处理类似,实际每个 http requet / response 都是一个单独的 inflate / deflate stream,websocket 升级了协议后,可以认为不断发送过来(出去)的 payload 是没有结束的 http body 数据,因此不需要结束这个 inflate / delfate stream。
其他比如 ping、pong、close frame 类型没怎么研究,不过感觉 close 没啥作用,也许是用于实际关闭 tcp 链接前的一个处理吧,也许给调用层处理,在 websocket frame 这一层感觉没什么必要,毕竟无论如何总是要处理没有发送 close 的资源注销的,那又何必多此一举要这个 close 呢。
这个月忙得要死,继续遗迹2水一篇。
郁闷的是还没通关,感觉应该是最后一关了吧,那个守卫太难打了,而且我的 6600xt 显卡即便用最低特效感觉都挺卡的,玩得有点吃力。
于是我就回过头去肝装备和等级,收获有一些,但不多,估计要年后才能继续这个最终 BOSS 了。
另外,这个游戏有些流程挺坑的,它的大部分地图都是随机的,有不同的版本,比如我进去的涅鲁德,如果最终不打守卫,或者不插钥匙到主控台的话,其实后续是可以过来场景下选择其他的小地图,继续肝等级和装备的,但是我当时不懂呀,钥匙随机就插入主控台了,也没听到从未见过的保管人说了啥,然后继续断断续续肝了涅鲁德的守卫人后,这个场景差不多关闭,只剩下一个宇宙的一角,只有一个录音机的叙事声音,其他场景都被破坏无法进去玩了。
这也太郁闷了,然后我之前不知道这个插钥匙的影响,没有备份角色资料,以及游戏世界的资料,于是就再也回不去刚开局遇到的涅鲁德了,只能开冒险模式单独玩这个章节,重开的这个章节也是随机的,还不是之前原来的那个。
另外,不知道是不是魂系游戏都这样,就是等级、装备上去后,之前比如在我遇到的第一个场景喜乐宫,我当时肝了好几个小时才通,反正拿的远程很难打,现在等级装备上去后,就感觉太容易了,刷经验没有任何乐趣可言。
网上搜了一下,貌似通关一次后,可以选择将世界升级为二周目,难度会上升一个级别,之前的老头环也是如此,有这样的一个选项,不过我没升级,感觉打同样的场景可太没意思了。
老头环我的操作熟练度很差,几乎可以说是靠着 TB 买的魂升级属性硬给拗过的;相比下遗迹 2 因为大部分的地图有随机场景,估计继续肝 2 周目的感觉会好一些吧,不过我现在还没通关一周目,后面再说吧。
法环(艾尔登法环)是我第一次玩魂系,进入游戏的大树守卫者就已经让我崩溃了,后面就是熟悉的操作,上 TB 买游戏内魂币,升级人物属性, 后面的难度确实大大降低,很容易就搞到了厉害的武器,没多久就通关了。
不过也因为走了捷径,后面的游戏性其实没有那么强了,很少死了。
也看过不少厉害的游戏玩家,那个操作,我实在是学不来,罢了罢了,我只是通关走过场。
之后又看到了遗迹2的介绍,也算是一个魂系,配合 FPS 之类的远程打击,以及职业切换(还没玩到这个部分,不熟悉),那就入手试试吧。
然后花费了一个晚上,才算入门,死了四五十次都有,这次倒没想着要上 TB 买游戏币升经验,反正就试试看吧,看独立玩这个魂系能否进行下去吧, 感觉游戏性还是可以的,只是要计算的东西不少,比如弹药用量就有受的,因为有的枪射速太快了,伤害反而低,感觉升级武器的系统还是挺重要的, 武器反而不需要随便换,没那个必要。
相比法环,没有那么在意近距离的格斗,那个实在是太难了,除非把脑子和手都训练成一块,肌肉记忆,要不哪里来的及。
所以攻击力、弹药用量比较重要,可以继续搞搞。