Sucha's Blog ~ Welcome

21年2月16日 周2 18:24

Swift(3) - PageEventBus

春节在家,继续思考了一下业务上的一些痛点,并做了整理,趁春节期间的空闲,搞了一个改善页面内通讯,限定 UI 及业务逻辑绑定关系的库 PageEventBus

先说一下之前在业务上觉得不爽的点:

说一下之前的处理方法,使用 RxSwift,各种不管 UI 层级多深的 binder、controlProperty、controlEvent,都透过多层 UI,传递到业务 view controller 这一层来做绑定,带来的问题,有以下几个

基于上面的问题,我有了一些思考,希望有下面这样的东西

就想着能否用上一个页面内的总线,将参数通过总线传递,这样可以对抗业务需求上导致的参数变化,比如 view model 可以接收总线消息,也可以发送消息,有了总线后,可以不需要关心 UI 层级了,也没有参数需要透传了。

另外,UI 的输入、输出抽象到业务逻辑 view model 的输入、输出,可以认为 view 持有了 view model,view model 持有 unowned view 的不可变引用;对于 view controller,也可以相应的持有 page model。这样,UI 总是可以复用的,而 view model,才处理实际业务,如果多个业务本身可以抽象到更高的层级,那么显然 view model 也是可以做到的。

还加上改进的一点,由于上面接收、发送事件的角色都在系统展示树上,可以利用 responder chain 链,在 view didMoveToWindow 或者 controller viewDidAppear 的时机,做事件总线的绑定,意味着总线本身都不需要传递,可以自动连接上。

上面的一些思考,加上春节空余时间的实践,就是上面说到的 PageEventBus

一些代码上的具体实践:

话说这一整套限定的东西还是很多的,我准备使用 event bus 来传递 view model,view model 内部还是使用 RxSwift 来做业务绑定,view model 带 disposeBag,或者使用 view 的 disposeBag 也是可以的。

20年1月24日 周日 22:52

Swift(2)

又写了一个多月的 Swift,除了刚开始时的修修补补,慢慢扩展到页面框架部分,不过这个部分我还是不够熟悉,一部分在于我还是沿用项目之前的 ViewModel,另外一部分,很多业务细节还没能深入理解。

虽然这次的标题是 Swift(2),实际内容大部分是 RxSwift 及 ViewController 的结合,以及一些 UI 层级的实践心得。

页面框架

先说一下我们的网络层,搭建了类似 Moya 的网络层框架,基本上是够用了,但相比之前用的 Model 网络层,这里浅浅的封装,就不会有单独的 model 管理器了。简洁是简洁了很多,但是数据层面转换和粘合就得在 ViewController 处理,带来了 ViewController 的膨胀。比如因为用了 RxSwift,同一份数据,有时需要多次 transform,subscribe,这些可都在 ViewController 里面。

我曾经想过这些代码放在 ViewModel 里面会不会好一些,但我上次也许也有提到,这里的 ViewModel,更像是一个 Driver.System 的状态机,初始状态是 initial,之后是 Signal -> Driver 的状态切换,通过一个 reduce 函数检查 ViewModel 绑定的 struct 结构来判断到底应该 loading、setResult 还是其他,如果弄懂了这个,页面的状态处理就很清晰了。

只是这个 ViewModel 只负责将 app 外的数据,transform 为页面状态数据,不涉及到 UI element 数据的 transform,这是跟之前 ViewModel 的区别。ViewModel 带回来的页面状态数据,最终会在 ViewController 中 transform 并 binding、drive UI element,这个过程有时需要做很多的粘合工作,流程也会相当复杂。

另外,UI 层级方面,已有较为复杂的页面,通常 UI View 就好几层,一层一层的 UIStackView 包裹里面的自定义子 view,将需要传递的数据链路,通过 binder 传递出来,即便是 View,都跟 RxSwift 框架直接绑定了。

说一下对这个的理解,我觉得是没必要,因为这个 binder 或者 driver,其实都只算是单向的数据传递,传递出来的时候用就好,传递进去的时候,感觉其实没那么必要的了。

以上提到的,觉得最复杂的还是 ViewModel 及其 Driver.System,基本上抄了几遍,这段时间除了 K 线 ViewController 没有修改过,其他页面多少接触了一点。

目前的页面框架工作得还可以,通用性也还行,只是觉得业务复杂度还是太靠近 ViewController 了。而且当前的框架,ViewController 绑定 TableView 被封装得很厚重,仅支持单种 cell 的 TableView,不知道当时这个模版构建的时候是怎么想的。

还有 TableView 的 DataSource,依赖了 RxDataSource,在 TableView 上,其实不支持 HeaderView 的数据流绑定,还得用回系统提供的 delegate 方式,不伦不类的,还不如全部都用系统的方式呢,因为这个模版框架,将 createCell 的部分,dataSOurce 的数据创建部分,放在了 TableViewCell 里面,这个我是非常不认同的。

我总觉得 Cell 有复用的可能,两者结合不应该那么紧密,即便有 protocol 作为粘合,有时候还是觉得抽象过了,没必要。

这段时间还将 Carthage 的库切换到了静态库上,模拟器运行节省了不少时间,但这个改进在真机上几乎觉察不出来。

做了这部分的心得是,我们依赖的第三方库实在是太多了,这也造成了我们启动慢,因为打包好的 App 就有 150M+。比如我们的数据库,为何要用 Realm 呢,是因为 ORM 方便吗,还是因为它更快,总觉得还不如用系统提供的 SQLite3 好了,或者浅浅地封装一层一层,比如 GRDB 就好。

因为这个库太大了,打包的时候都可以看到打的慢。

UI 层级

学习使用 UIStackView,其实这个我在几年前就有做了一个基于 frame 计算的 StackView 框架,可惜是公司财产,不能开源出来(显然我也没有源码)。

UIStackView 使用有些前提,一方面需要 addArrangedView 添加进来,但是如果只是删除 ArrangedView,其实还在 subviews 里面。

另一方面,每个 view 需要有自己的 size,以及 hugging、compress 优先级,要不感觉最后计算宽度大小的时候,总是不准确。

还有学会了在 tableview 的 header view 上面放置复杂的 UI 层级,这个 tableView 的 header view,不支持 AutoLayout,只能是指定 frame 高度。

但是呢,又能通过将这个 view systemLayoutSizeFittingSize 拿到高度,设置给 frame 来确定这个 tableview header view 的高度。

还有 table view 的 cell 是可以不指定高度的,通过 AutoLayout 计算出来,但是呢,collection view 不可以。

这段时间因为对这些 UI 规则不熟悉,浪费了很多时间,有时想着,还不如我来设置 frame 呢,工作早就做完了。

另外,复杂一点的首页等等 ViewController,各种 NSLayout warning,见怪不怪,设置符合 AutoLayout 的规则,太难了。

哦,对了,我们有两套 layout 的库,一个是 SnapKit,一个是 TinyConstraint,常常是混用的状态,真的有点混乱。

--

大概先这样吧。

20年12月13日 周四 22:38

Swift(1)

ObjC 我挺熟悉的,但切换到 Swift 的路并不顺利,虽然我之前写过一个结合 Swift + ObjC 运行时的换肤库 LWTheme,现在看来,确实是相当 ObjC 呀。

写了一点点商业 Swift 代码后,我现在还不能说我熟悉 Swift 了,Swift 内容太多了,特别是 Enum 以及带泛型的 Protocol,抽象能力太强了。

先开始吧。

函数式书写静态类型推断

纯 Swift 相比 ObjC,类型推断能力大大加强,算是一个静态语言,没有什么动态运行时,但 Target-Action 部分,在 Switch 里面,其实还是依赖 ObjC 运行时来跑的。

带 Optional 的静态推断,最大的好处,是类型安全,带 nil 的指针不会到处飞了。一眼开过去,Swift 的语法很像脚本语言,实际使用起来,也很函数式,很多时候,确实是用更少的代码粘合了更多的功能,带来的问题是能量压缩是想当大的。

反面的例子如下:

let stringArray = ["a", "bb", "cc"]
let countsArray = stringArray.filter { $0.count >= 2 }.map { $0.count }
print(countsArray)

输出是 [2,2]

假若 stringArray 带的是其他的类型,而在 countsArray 里面的 filter、map 操作有很多,那里面一长串的 $0 看的真的让人头大,因为实际的代码可不会像上面这样只有 2 行,也不会仅仅只有一种 transform,而是各种转换带下来,表达密度真的太大了。

闭包及简写

闭包相比 ObjC 容易写多了,但也有一些变种,比如上面 filter、map 的参数就是闭包,闭包只有一行的时候是可以省略 return 的,还有尾随闭包,比如上面的 filter 及 map,下面这个也是尾随闭包

let seq = ["A", "B", "C"].sorted { $0 > $1 }
print(seq)

输出是 ["C", "B", "A"]

不得不说 Swift 的语法有点过于自由,这些隐含的缺省用法,看代码的时候得留意了。

Enum 的相关值

Swift 的 Enum 语法不仅仅是只有枚举,以及 raw value,还可以带相关值,提升为了一个带标记的容器,使得即便作为一种输入,其输入内容其实也是千变万化的,只是标记了一种路径输入而已,输入内容是带进来的,比如

enum Goods {
    case name(String)
    case price(Int,Int) // 任性的将小数做为单独的 Int 带入
}

除了上面,常见的例子还有定义网络请求,将部分请求参数做为相关值带进来。

结构体

struct 相比 class,不能被继承,以及都是值传递而不是引用传递,这点让人不是那么满意,虽然写代码的时候往往不会注意到这点。

因为现实世界中的结构体其实可以很大的,有时候不是刚开始设计的问题,是后续的需求催大的。

泛型

有了严格的类型推导,泛型到处都可以存在,这种抽象容器能力,是 ObjC 所达不到的了。

protocol 及 extension

Swift 的 protocol 结合泛型,然后 extension 使用 protocol 将 class 能力大大扩大了,感觉就像是一种带入的计算属性一样,强。

现实中的 protocol 加上泛型,结合类实现,将类抽象为一个巨大的逻辑容器,代码真是让人看得头大,但另外一方面,不得不说如果是类似数据构建的页面,数据逻辑的抽象层让人放心了。

当然 UI 操作逻辑的抽象层也是可以做的。

访问控制

相比 ObjC 运行时的无所不能,Swift 带来了严格的访问控制,open、public、internal、fileprivate、private 应该有这五种吧,让 ObjC 转行过来的同学头都大了。

--

潦草的先这样吧。

20年11月9日 周一 16:02

JavaScript(1)

学习了一下 javascript,使用的是 JavaScript 教程,基础的 ES5,包含的内容可太丰富了。

跟其他语言的差别,主要在语法、面向对象,基础库和运行环境这几个部分。

语法

语法部分,'==' 可真够让人头疼的,为了比较会自动转换类型,所以教程建议直接使用 ‘===’ 进行严格比较。

相对高级的语言,都有 << 和 >>,甚至 >>> 操作符,可惜了,Lua 5.1 比如 LuaJIT 并没有这么好用的操作符,只能通过 bit 库来使用,或者塞给 C 代码处理吧。

JS 里面的 null 和 undefined 也得注意一下,它们几乎是一样的,除了 null 在 Number(null) 时为 0,而 Number(undefined) 为 NaN。

面向对象

这里一开始让我挺头疼的,比如原型对象及继承关系。

对比 C++/Java 的面向对象实现都隐藏在语言核心运行时里面,通过语法‘类’来实现继承,在类里面写构造函数,描述继承关系。且 C++ 因为是零成本抽象,极端强调运行时性能,继承、面向对象的逻辑在编译期几乎都解决了,只留下了动态派发给运行时处理。Java 有反射,任何时候都能拿到其继承关系,其所有的类都继承自 Object。两者作为静态语言,在运行时无法动态修改继承关系。

JS 虽然跟 Java 一样所有类都继承自 Object,但其继承的语言特性,是通过原型对象来实现的,运行时可以访问。原型对象就是类的 prototype 属性,构造函数则是 prototype.constructor,因为有 new 关键字的存在,定义一个新类,只需要定义构造函数就好了,比如

function Car(color) {
    this.color = color;
    console.log("color:" + color);
}

var car = new Car("white");

会输出 'color:white"。如果定义 Bus 继承 Car 是这么实现的:

function Bus(color) {
    Car.call(this, color);
}
Bus.prototype = Object.create(Car.prototype)
Bus.prototype.constructor = Bus

var bus = new Bus("red")

将输出 'color:red',如果需要在运行时修改类的继承关系,propotype 和 prototype.constructor 必须同时修改。

感觉原型对象这个词,在继承关系上,跟 Objective-C 的类对象有一点像,不过实际表现及使用,千差万别。

对比 Lua 实现面向对象,感觉还是有一点像的,不过 Lua 里面实现面向对象,继承关系,需要了解的可太多了,需要了解 Lua 的元表(metatable)才好。

基础库和运行环境

作为一个动态脚本语言,JS 的基础库和运行环境挺丰富的,作为 Web 端统治性的语言,运行环境包含对 HTML DOM 的操作,以及浏览器环境,这两个部分我还没怎么看呢。

作为后端语言,基于 Node.js 事件驱动、非阻塞式 I/O 模型,极大降低了后端开发的难度,不过 Node 下的运行环境,我还没怎么接触。

虽然 JS 是单线程的 VM,但是 HTML 5 及 Node 都实践了其多线程交互的方式,也有了不同领域下的解决方案,其成熟程度,丰富程度,对比 Lua 可要好太多了。

引擎部分,标准桌面、后端用的是 Chrome V8,嵌入式应该用的是其他方案。现在已经有了前后兼容的 ES5、ES6 标准,貌似 ES7 也快要出来了。

--

总的来说,JS 是一门比较完善,还在不断渐进式发展,且考虑兼容性的编程语言,慢慢的将变成桌面应用编程语言,部分后端编程语言,可不再是脚本语言了。

相比 Go,JS 是动态类型,也不是静态编译的;相比 Lua,语言成熟度,库的丰富程度,应用范围都好好很多,没有 Lua 目的是宿主语言的累赘,虽然 Lua 还有速度上的优势。

而且,学会 JS 的好处可是,前端通吃,后端独立。

20年11月5日 周四 09:49

go_bitcask

学习了 Go 后要使用,实践的第一个程序是 go_bitcask。其逻辑大部分源于 ffi_bitcask.lua

但在 Go 这个版本中,因为一直开关读写文件效率比较低,所以做了一层抽象优化,将文件的读写抽象到了 DataFile 里面,其中,读的部分使用了 mmap,写的时候只保留一个活跃文件,一直追加写,也兼顾当前活跃文件的读,待到关闭数据库的时候才做 Sync(),并关闭文件。

在我的 16 年 MacBook Pro 版本 10.15.7 中,Go 1.13.4 读写文件极慢,LuaJIT FFI 版本用了不到 0.2 秒完成的测试,在 Go 这边需要 2 秒,太夸张了。

试了一下在云端 Linux 上面,效果好了很多,大概需要 0.2 秒,但 LuaJIT FFI 版本只需要 0.02 秒

LuaJIT FFI 版本的 bitcask

$ time luajit test/test_bitcask.lua
PASS Bucket
PASS Set/Get
PASS Delete
PASS GC

real    0m0.021s
user    0m0.011s
sys     0m0.008s

Go 版本我编译后才测试的

$ time ./test
using database: {Path:/tmp/go_bitcask BucketName:0 DataFileSize:512}
Pass Bucket
PASS Set/Get
PASS Delete
PASS GC

real    0m0.234s
user    0m0.004s
sys     0m0.030s

也许 Go 启动准备需要不少时间吧,或者是我实现的问题。

在 Go 版本中,我同样是使用了 struct map 到内存的方式来表示一条 record,然后一次性读取 key 及 value,再 slice 切割为 key 和 value。

映射的代码如下,感觉也没什么呀

// map recordinfo to bytes
func recordInfoToBytes(ri *recordInfo) []byte {
        var x reflect.SliceHeader
        x.Len = int(recordSize)
        x.Cap = int(recordSize)
        x.Data = uintptr(unsafe.Pointer(ri))
        retBytes := *(*[]byte)(unsafe.Pointer(&ri))
        return retBytes[:recordSize]
}

// map byte to recordinfo
func bytesToRecordInfo(b []byte) *recordInfo {
        return (*recordInfo)(unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data))
}

也许完全运行起来以后,Go 的速度才会又继续上去吧,我 pprof 的结果,是 system call 占用时间最多,其中 Set 是占用很多的,其次是 GC,但是 GC 不常用,这个倒没什么关系。

Set 占用多,感觉说不过去呀,因为就检测如果有重复 key,做了 remove,然后再 set 新的 key/value,两次 system call write 函数。

也许 Go 版本真的应该做完整一些,将写的操作单独一个 goroutine,并且做 LSM 操作,将读写都 encode/decode 起来,再做一层抽象才好吧,但是目前这个算是简单版本,不打算再做这一层了。

20年11月4日 周三 10:32

Go(1)

又开始学习 Go,之前其实断断续续看过一些,因为没有实践过,也陆续淡忘了不少,现在重新拾起,教程用的是 Go 语言官方教程中文版以及另外一本(彻底贯彻学完就扔的习惯现在找不到网址了),后面因为反复查询标准库,用了很多 Golang 标准库文档

Go 是一门命令式的语言,入门曲线比较低,比如非面向对象,简单的控制流、少了继承、枚举等等,变量声明也简单明了;但是要掌握我觉得不算简单,比如 goroutine 以及 CSP 数据交换的并发模型,slice、反射等,因此学习起来有一个曲线。

Go 有不少必知必会的语言规定,比如变量名首字母大写才会在包里面导出,变量需要驼峰命名,带间隔的下划线是不被推荐的(VSCode 里面的 golints 有对我多次提示啦)。相比 C/C++、Java、Lua 等,我觉得变量声明、defer、slice、接口、并发模型、包管理系统,是 Go 的特色,可以拣出来说一说。

变量声明

这个知识点很简单了,可以不用指明变量类型,让系统推断,比如

a := 10

或者指明类型

var a uint32 = 10

上面的两种是有区别的,第一种 Go 默认是 int 类型,下面指定了 uint32 类型,Go 是强类型语言,变量的后续使用必须对齐类型,否则编译报错。当然可以这样写 a := uint32(10),成功用第一种方式声明了 uint32 类型。类型转换还有下面这样的

var b interface{} = 10
var c interface{} = "hello"
var d string = c.(string)
fmt.Println(b, c, d)

可以看到,虽然 Go 没有 C/C++ 的 void*,但可以用万能的接口 interface{} 弥补,interface{} 还可以在运行期用来做类型推断,这里不细说了。

Go 里面方便的地方,if 控制块一开始就可以定义新的变量在控制块内使用,比如下面判断文件是否打开成功的例子:

if fp, err := os.Open("/path/to/file"); err != nil {
    defer fp.Close()
    // ...
} else {
    return err
}

上面的 fp、err 只能在 if 及 else 块内使用,出了 if、else 的控制范围,编译会报错。

以上,方便的变量定义,算是 go 的特色了,其方便程度,跟 Lua 有点像,但是 Lua 默认是全局可见的,Go 有控制块的限定,包内控制块之外必须首字母大写才能导出。对于大型系统,我觉得 Go 的定义更稳妥一些。

defer

上面的例子已经提前用了 defer,比如在文件打开成功后,defer 了一个关闭函数,当 if 语句所在函数返回后,defer 按照先进后出的原则退栈运行。

defer 运行的只能是函数,所以如果是一些变量需要离开函数后保存,可以下面这样

stepRecorder = "enter function"
defer func(){
    stepRecorder = "leave function"
}()
// ...

defer 写得太爽了之后,遇到了习惯的反面,比如在 for 循环里面打开了文件,希望下个循环关闭,就老想着能够有这样一个关键字,在当前循环结束前运行这个关键字后面带的函数,因为在复杂的 for 里面,有 return,还有 continue,还有正常的流程。

然而 Go 没有这样的关键字,所以很遗憾的老老实实按老路子写代码了。

slice

Go 里面 array 跟 slice 有紧密的联系,我总感觉 array 是 slice 的特例,是一个预定义的 slice,举个例子:

a := []int{1, 2, 3, 4}
b := a[:2]
fmt.Println(a, b)

打印出来的是下面这样的

[1 2 3 4] [1 2]

可以看到其实两者底层是一致的,只不过 array 固定了 slice 的 len 和 cap,slice 的切割是左闭右开的区间 [),类似 for 定义的 for i := 0; i < 5; i++ 的一个语言习惯吧。

slice 可以使用 append 扩容,比如

b = append(a, 5)

结果是输出

[1 2 3 4 5]

slice 使用 make([]int, 5) 等方式定义,虽然用着像数组,但实际上可变长度的设计跟 C++ 里面的 vector 有点像了,由于 Go 没有 C++ STL 的 vector,slice 就是这样大量使用了。

不过我总觉得,虽然底层的抽象是这样,但是用着确实不方便,使用者得很熟悉底层的抽象、分割才行。

接口

Go 没有继承,没有 Java 这样 Object 的基类,也就没有面向对象的基础。其语言设计者认为面向对象不是必要的,特别是深层级的继承,过于复杂了。其推荐通过接口抽象,所有实现了接口定义函数的 struct 类型,认为具有接口的功能,是接口抽象的代表。在实现上 struct 没有特别的关键字来声明实现了某些接口,编译期、运行时核心是知道的,提前匹配了。

比如我们定义一个接口,并写某个 struct 实现接口

type Dog interface {
    Name() string
}

type BullDog struct {}
func (dog *BullDog) Name() string {
    return "BullDog"
}

var dog Dog = nil
dog = &BullDog{}
fmt.Println("dog is", dog.Name())

以上,斗牛犬 BullDog 实现了 Dog 的 Name() 接口,没有声明实现的关键字,只需要实际实现了这个接口,就可以编译运行通过。

通过上面的定义,对比 Java 的接口、抽象基类定义,可以想像,由于 Go 没有继承,以及 interface 的默认实现或者说抽象基类的东西,也没有函数重载,如果在 Go 里面定义了复杂的接口,或者某些特性通过接口暴露但只有很少量的实现需要特别处理,都是一件头疼的事情吧。

不过相反的,在稍微大型一点的代码里,应该可以新定义调整一个接口来区分不同的类型实现吧。

并发模型

Go 的并发模型是 goroutine,定义了函数后,通过命令字 go 来驱动,其通讯模式,用的是 CSP,通过管道(channel)通讯,而不鼓励通过共享变量通讯。

C 抽象了体系的基本寄存器,C++ 是个牛刀,多模式的语言,但在早期的语言规范里面,没有多线程的定义,我查了一下,直到 C++11 才有语言提供的多线程,Java 的多线程语言内置支持,但是 C/C++/Java 的多线程实现,可以看成都是 pthread 的封装,一个语言线程,对应操作系统的一个线程。

但在 Go 里面不是这样的,Go 的线程独立于操作系统的线程,是协程,但调度是 Go 核心在用户空间实现的,虽然用起来跟线程类似。goroutine 的通讯、锁,官方的例子都是建议通过 channel 传递数据以及控制,但实际上不是什么都适合在 channel 中传递,有时候传递行为的控制反而是复杂的,所以也是有锁的,得合理区分场景使用,且这个时候的锁也是得考虑死锁的。

由于没有很深入的使用,这里就不展开了(没实料了)。

对比一下 Lua 的协程,Lua 是单线程模型,因为 Lua 语言本身的设计就是一个宿主语言,最开始的设计是靠着 C,而 C 的语言核心没有定义线程的,这也导致了 Lua 协程的设计没有考虑多线程。网上 Lua 多线程的模式,其实就是 C 多线程的模式,一个 C pthread 线程,带一个 Lua VM,通过 CSP 或者共享内存通讯。

而 Go 直接抽象了协程、CSP 这套协程、多线程通讯机制。

包管理系统

Go 的包管理系统我有看过浅显的资料,区分不同发展阶段,有使用 vendor 目录带支持包,或者新版本 go mod 的阶段共享支持包。

Go 使用关键字 package 定义包,包里面首字母大写导出函数或者变量,这是语言的强制约定,减少了关键字,另外 main 函数是打包编译为二进制后的运行入口。

C/C++ 是没有明确包管理的,只有系统层级的二进制库,以及编译、链接顺序。理好 include,然后依赖不同的 build tool 来构建,比如大名鼎鼎的 make,或者 Nijia,一般不会生成静态链接的二进制,当然也可以生成,只是真的很少见,由于可以控制编译期的任何一个阶段输出,在嵌入式应用可好了,输出动态链接,还可以各种裁剪,但相反就是要求高了不少,得自己学会搞定编译运行期的所有问题。

Java 语言的定义也是不带包管理工具的,但工程实践是有 mavel 或者 gradle,我不熟悉,就不展开了。

Lua 也没有包管理,不过默认的操作系统文件夹,都可以是一个包层级,然后命令行可以指定在哪里寻找包,以及动态库。

话说 Rust 也有包管理,但我没有深入具体跟仓库是怎么结合的。

我只用过 go mod 的包管理,go mod 命令行初始化后,感觉后面就很简单了。再说 IDE 也是强,VSCode 配置好后,任何不需要的包,都无法导入,而实际用了标准库的包,IDE 都自动帮忙导入到 import 里面。

感觉强在 github 等依赖外部仓库的包,go mod 看到有用到,运行 tidy 命令后,可以帮忙直接下载,这里并不依赖中央仓库的,算是一个特色,毕竟类似 CocoaPod 也是有中央仓库的,或者起码得指定私有仓库的地址才行。

编译成静态链接的的好处,是可以无视系统版本了,对于大型的程序,增大的这点空间不算什么,毕竟对比大量异构部署来说,极大减轻了部署的困难,俗称统一交付。估计谷歌内部对于 C/C++ 可以输出静态链接的版本,早就这么做了,估计不这么做的话,那么多个二进制输出版本,debug 也有困难吧。

从包管理,可以看到交付的逻辑,当然也注定了 Go 不会应用到嵌入式里面。除非能出一个非静态链接的版本,但是 Go 的核心运行时,能够单独出供动态链接的二进制版本吗,感觉往这个方向的估计都没考虑了。

有脚手架的语言,Rust、Go 是很方便的,有统一的脚手架,也规范了交付的正统逻辑。

--

用 Go 写了一点小东西,所以有了上面的一些思考,可惜是还没有用上 goroutine,那就先输出这么多吧。

最后说一下内存占用,C/C++ <- Lua <- Go <- Java,Lua 不是正统的非宿主语言,这里有加入 VM 对比的考虑,一般的评测,都是认为 Go 是要比 Java 快的,再说 JVM 基本占用内存就很大了,如果不是大型程序,用 JVM 的成本会比较高。

但是 Java 多年来的基础建设都很好,不管是 JVM 还是 IDE,我用过最好的 IDE 就是 Java 的,太省心了。但是另一方面,Java 的交付、部署应该是没有 Go 的简单,至少也是有 JVM 版本的,而 Go 真的就是统一交付了。

Go 席卷了互联网,Docker 及云的基础建设就是明证,Java 瓜分了剩下的很大一块,接着是 Python 的简易运维,C++ 是核心中的核心,不晓得这个分布是不是当前占有率的反映。

20年10月26日 周一 22:48

《在故宫寻找苏东坡》

一直很喜欢苏东坡的词,不记得在哪里看到了推荐,就买了这本书。

这本书随着苏东坡的一生展开,考取功名、入仕、被贬,然后其诗词的变化,心境的变化,所写词在当时心境的解读等等。

考取功名、入仕在前面几章,不表。

被贬是因为文字狱,经历了生死,但苏东坡并未因此颓废不堪,像陶渊明一样淡泊修仙,远离尘世,相反他一直在入世,只是不再朝中爭功名了(精神小伙爭儒家正统)。

被贬到黄州后,俸禄不够养活全家,不得不把自己当成农民,在县城东边买了一块地自给自足,并自称东坡居士。种植、收成、酿酒,看到黄州人民不爱吃猪肉,猪肉很便宜,又自创了东坡肉。这一切充满了泥土间的汗水、快乐,满是人世的酸甜,虽然消瘦了许多,但却是脚踏实地的。

随着王安石新政停摆(老皇帝驾崩),新皇帝又将苏东坡等老臣请上了朝,但是快乐很短暂,很快又因为写诗词的影响被贬。

即便如此,短暂的就职,也给杭州留下了疏通后的西湖,淤泥堆成了白堤。然后是一路向南的贬职,最远到了海南岛。当时海南岛都还是原住民,以当时的条件,过了海就当成是半个死囚了,当时那边连书都没有,这是他最痛苦的地方。

书籍里面对于苏东坡诗词画的介绍,结合当时环境、心境的分析太细腻了,我表达不出来,对比在其时代之前相同的主题,认为其自创了一派,在表达,艺术以及美的结合上,大大拓展了延伸的范围。

最悲哀的是,作者认为诗、词的顶峰在千年以前,自从苏东坡之后,就没有诗、词的达人了,至少没有那种高度的。

20年10月21日 周三 20:51

ffi_gccload 在 Lua 中动态加载 C Source

不晓得从哪里搜到了这个库 lua-tcc,可以在 Lua 运行期间,通过 one pass 的 TCC 动态加载 C 代码,让 Lua 的控制可以更深入底层,甚至可以在 Lua 中进行 C 的编程。

可惜这个项目距离久远,TCC 在 MacOS 下面编译也成问题,意味着通用性欠佳,挺可惜的。不过有了这个想法后,想着不通过 TCC,用 GCC、Clang 其实也可以的嘛,于是就有了 ffi_gccload

流程变成了这样,将 C Source String 导入到一个源文件里面,通过 GCC 编译得到动态库,使用 ffi.load() 加载进来,并通过 ffi.cdef 声明接口,就可以使用了。

其实就是将 C 的编译、链接过程给固定了就好,如下的代码:

local config = require("ffi_gccload").new()

-- like ffi.cdef
config:addSourceDef([[
    int print_name(void);
    int add_num(int a, int b); 
]])
local p = config:loadSourceString([[<a id="include"></a>
 <stdio.h>
int print_name(void) {
    printf("Hello, world\n");
    return 0;
}
int add_num(int a, int b) {
    return a * 2 + b;
}
]])
if p then
    p.print_name()
    local a, b = ...
    a, b = a and tonumber(a) or 0, b and tonumber(b) or 0
    print("result", p.add_num(a, b))
else
    print("failed to load")
end

生成一个动态编译配置,会输出

$ lua test.lua 2 3
Hello, world
result  7

其实是将编译产生的中间 .so 放在临时目录,加载后其实就可以删除掉这个 so 了,这在 MacOS 以及 Linux 应该都是可以的。

上面生成的动态编译配置,其实可以加入 include、libary 的 path 以及 lib name,已经越来越像一个 build 系统了,其实就是类似的。

有了上面这套,虽然相比 TCC 速度慢一点(毕竟 TCC 是 one pass 就出成果的),但是胜在兼容性号,修改一下后,估计 Windows 下面也是能跑的。

往后可以在服务端拿到 C 代码,捣鼓以下就能用到客户端上面了,或者一些少量使用 C 操作更方面的逻辑,结合放在 Lua 中作为源代码发布出去,使用的时候,先编译加载动态库,也是一个方案。

20年10月11日 周二 02:12

DNS service 重构

将 rpc_framework 中 service_dns 网络层的控制从 C 层移到了 Lua 层,C 层只负责 UDP 包的数据拼接、解包,将代码统一到了 m_dnsutils 里。

在减少了大量 C 跟 Lua 的交互后,两者算是各自做了自己擅长的方面,自测后的效果也不错。

20年9月29日 周一 18:25

Rust(1)

开始学习 Rust,从 rustlings 入手,可以看中文版的 Rust 程序设计语言。看了两天书,边看边写习题,完全当成一门新语言来学,内容太厚重了,而且对于我自己已有的经验来说,跟我以往了解的语言,大不一样。

其实 Rust 无需垃圾回收的所有权(borrow)部分,为了防止多线程竞态条件的部分,有 C/C++/Java 多线程经验的话,是很好理解的。Rust 虽然也有指针,以我的理解,因为编译器可以确切知道具体引用情况,是不需要多写 *p 这样的了。

深入骨髓的不同,从编译器、语言设计角度来说,是模式匹配、None、trait、宏,字符串、迭代器。

模式匹配

模式匹配不仅仅只是 match 关键字,及其控制流,从 let 到 if let 都有模式匹配的影子,如下

let tup = (500, 6.4, 1);
let (x, y, z) = tup;

上面就是元组的模式匹配,如果数量或类型对应不上,编译期就抛错误。而 if let 是为了简写 match 出现的。因为 Rust 自己的错误处理机制,其实大量用了 if let 或者 match,所以模式匹配一定会遇到的。

Rust 这里还深入提到了一点,有些模式匹配必须成功,程序才能编译通过,比如上面的例子,下面 Some(T) 在其实也是模式匹配,是可以接受匹配失败的。

None

跟很多语言不同,Rust 的 None 不算是一个值,而是一种状态,是我的感觉。下面两个是语言固定了的,一定会遇到

enum Result<T, E> {
    Ok(T),
    Err(E),
}

pub enum Option<T> {
    /// No value
    None,
    /// Some value `T`
    Some(T),
}

Result 用于表明结果是否正确,正确的使用 Ok(T) 返回,错误的用 Err(E) 返回,具体的值需要解包才能使用。Option 表示要么有值,要么没有值,Option::None 没有别的作用,就只是表明没有值而已,如果是 Some(T) 返回的,也是需要解包才能用具体的值。

Lua 的 nil 是 false 含义,还能塞入 table 表示 array 终结,ObjC 的 nil 也是 false 的含义,还能初始化指针,C 里面的 NULL 同理,Java 里面的 null 也是经常用来比较的,还容易引发 NullPointerException,反正是一个大量使用的合理值,新世代的 C++ 我不了解了,但 Rust 不一样,None 就是没有值,是一个枚举定义,我们经常使用且想见到的是 Some(T) 包裹的值。

trait

Rust 没有继承,跟 C++/Java 不同,其实鼓励使用组合来完成功能,认为两个不同实例拥有继承关系,因此隐含了大量重复的代码是危险的行为。trait 跟接口很像,但 Java 的 interface 还可以定义函数行为,Rust 这里就不可以了。而且 Rust 教程里面也不会使用 interface 这样已有的词汇来描述 trait,因为 Rust 还有非常严格的类型系统,如果需要做容器的话,会用到很多 trait 提供的能力,比如 Box dyn 之类的。

trait AppendBar {
    fn append_bar(self) -> Self;
}

impl AppendBar for String {
    //Add your code here
    fn append_bar(mut self) -> Self {
        self = self + &"Bar".to_string();
        self
    }
}

上面为 String 增加了 append_bar 的接口,添加固定的 "Bar" 后缀。

因为 Rust 的编译器需要在编译期知道所有变量的大小,对于容器来说,是通过 Box 包裹来描述。Rust 为了实现零成本的抽象,泛型的处理实际上很多时候是编译期展开的,只有一些特殊的情况才是运行期动态决定的,这里我了解不深,先略过。

Rust 的宏跟 C/C++ 的 define 完全不同,因为不懂 C++ 的 template,所以也回答不上是否拥有 template 这么强大的元编程能力。同样作为元编程,能力强是真的,这个部分我还看不大懂,大概了解到,Rust 宏在 AST 构建完成后才展开,能捕捉下面这些元素

item: an item, like a function, struct, module, etc.
block: a block (i.e. a block of statements and/or an expression, surrounded by braces)
stmt: a statement
pat: a pattern
expr: an expression
ty: a type
ident: an identifier
path: a path (e.g. foo, ::std::mem::replace, transmute::<_, int>, …)
meta: a meta item; the things that go inside #[...] and #![...] attributes
tt: a single token tree

举个例子,下面 vec! 宏捕捉了 expr,push 每个 x

let v: Vec<u32> = vec![1, 2, 3];
<a id="[macro_export]"></a>

macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

例子都来自 The Little Book of Rust Macros

字符串

Rust 内建了 str 类型,表示字符串的纸面含义,是输入字符串原来的值,String 是一个 std 类,内部是 UTF-8 编码的字符串,char 在 Rust 实现是 4 个字节的 UNICODE。

做习题的时候,经常性的 String::From("abc") 或者 a.to_string(),习题里面的 char 倒是很少用。

感觉字符串在哪一种语言都不是一个简单的事情,现在都是全球化了,考虑字符串是一种输入,可能是任何一种语言,因此其长度、分割、排版显示都不是小事情。

迭代器

Rust 中每种容器都有迭代器,即便是 (0..10) 表示 0 - 9,(0..=10) 表示 0 - 10,可以下面这样用

pub fn factorial(num: u64) -> u64 {
    if num <= 0 {
        0
    } else {
        (1..=num).fold(1, |acc, x| acc * x)
    }
}

assert_eq!(24, factorial(4));

上面的斐波那契数列计算,没有用到多余的变量,没有用到 for、while、loop 和递归,只用了 Rust 里面的 fold 累加器,其他的还有 sum、filter、map 等,还有,上面忘了说的,expression 放到最后,就直接返回计算的值了。

--

举了好几个跟我之前了解的编程语言大不相同的例子,其实除此之外,还有不少,一些是我还不了解的,比如生命周期,比如 unsafe pointer,生命周期的描述是可以根据一些 trait 控制来改变的。Rust 性能优先,还考虑能高效调用 C 接口,unsafe pointer 是在 Rust 的借用模型、寿命周期外,自己控制程序的行为,毕竟需要调用 C 的接口等等。性能优先,因此零成本抽象、宏等等都是在这个基础上展开的,动态的部分不能说没有,只能说场景很少了。

这两天的心得,大概是这样吧。

20年9月26日 周六 23:07

ffi_bitcask.lua

写了一个 bitcask 模型的 key/value store ffi_bitcask.lua。利用了 LuaJIT cdata memory layout 来写每一个 record 前面 timestamp、crc、fid、ksize、vsize 部分,然后接着写入 key 及 value,读取的时候,先读取每个 record 的前面固定部分,得到 kszie 及 vsize 后,再读取 key 及 value。

ffi_bitcask 的模型,db 目录下有不同的 bucket 目录,bucket 里面可以有相同的 key,bucket 类似于 namespace。每一个 bucket 只有一个活跃文件,所有的 SET/GET/DELETE 操作都是记录,添加到活跃文件末尾。当活跃文件超出阈值后,重新开启一个新的活跃文件,旧的文件是只读的。

我偷了一个懒,所有的活跃文件写入操作,实际上都有重新打开并添加数据,没有像网上介绍的 bitcask 模型一样,一直保留一个打开的文件指针,也许待后面写入速度影响之后,再做这个修改吧,目前自己使用,吞吐量还没到这个份上。

由于删除操作也是记录,原有的记录空间重复占用,只有在下次整理文件时才能回收。我觉得这里自己的算法不好,总共读取了两遍。因为先要扫描出来,哪些是需要删除的条目,并按照文件 id 分类,之后再做一次读取,过滤掉删除的条目,将其他不受影响的条目拷贝到一个新的活跃文件中。

如果有不少删除操作,而许久没有做回收操作,文件目录占用空间比实际使用要多上许多。我自己还加上了将回收后的文件 id 重新利用的逻辑,文件 id 绝对值的增长不会因此影响。

LuaJIT 的 FFI 定义 cdata,内存 layout 跟 C 是一样的,缺点就是 uint64_t 跟 uint32_t 混用的话,是按照 uint64_t 对齐的,另外 Lua 没有 unsigned number 的说法,如果需要用到这个,需要用到 bit.tobit 函数来做比较了。

目前 ffi_bitcask 还缺少的是 hint 文件,现在还不影响,后面可以考虑加上去。还有就是回收操作,其实可以每次只扫描新产生的文件就可以了,因为删除操作只会在之前扫面过后,新生成的数据文件中产生,不过这里需要记录哪些是新生成的文件,如果文件 id 只朝一个方向增加的话是挺好办的,但像我这样重复利用已经回收后的文件 id 的话,就不好处理了。

后面再看看吧。