我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。

TypeScript:重新发明一次 JavaScript

作为一个 Node.js 开发者,我很早便了解到了 TypeScript,但又因为我对 CoffeeScript 的喜爱,直到 2016 年才试用了一下 TypeScript,但当时对它的学习并不深入,直到最近又在工作中用 TypeScript 开发了两个后端项目,对 TypeScript 有了一些新的理解。

为 JavaScript 添加类型

大家总会把 TypeScript 和其他语言去做对比,说它是在模仿 Java 或 C#,我也曾一度相信了这种说法。但其实并非如此,TypeScript 的类型系统和工作机制是如此的独特,无法简单地描述成是在模仿哪一个语言,更像是在 JavaScript 的基础上重新发明了 JavaScript

究其根本,TypeScript 并不是一个全新的语言,它是在一个已有的语言 —— 还是一个非常灵活的动态类型语言上添加静态约束。在官方 Wiki 上的 TypeScript Design Goals 中有提到,TypeScript 并不是要从 JavaScript 中抽取出一个具有静态化语义的子集,而是要尽可能去支持之前社区中已有的编程范式,避免与常见的用法产生不兼容。

这意味着 TypeScript 试图为 JavaScript 已有的大量十分「动态」的特性去提供静态语义。一般认为「静态类型」的标志是在编译时为变量确定类型,但 TypeScript 很特殊,因为 JavaScript 本身的动态性,TypeScript 中的类型更像是一种「约束」,它尊重已有的 JavaScript 设计范式,同时尽可能添加一点静态约束 —— 这种约束不会影响到代码的表达能力。或者说,TypeScript 会以 JavaScript 的表达能力为先、以 JavaScript 的运行时行为为先,而静态约束则次之。

这样听起来 TypeScript 是不是很无聊呢,毕竟 Python 也有 Type Checking,JavaScript 之前也有 Flow。的确如此,但 TypeScript 的类型系统的表达能力和工具链的支持实在太强了,并不像其他一些静态类型标注仅能覆盖一些简单的情况,而是能够深刻地参与到整个开发过程中,提高开发效率

前面提到 TypeScript 并不想发明新的范式,而是要尽可能支持 JavaScript 已有的用法。因此虽然 TypeScript 有着强大的类型系统、大量的特性,但对于 JavaScript 开发者开说学习成本并不高,因为几乎每个特性都可以对应 JavaScript 社区中一种常见的范式。

基于属性的类型系统

在 JavaScript 中,对象(Object)是最常用的类型之一,我们会使用大量的对象字面量来组织数据,我们经常将很多不同的参数塞进一个对象,或者从一个函数中返回一个对象,对象中还可以再嵌套对象。可以说对象是 JavaScript 中最常用的数据容器,但并没有类型去约束它。

例如 request 这个库会要求使用者将发起请求的所有参数一股脑地以一个对象的形式作为参数传入。这就是非常典型的 JavaScript 风格。再比如 JavaScript 中一个 Promise 对象只需有 then 和 catch 这两个实例方法就可以,而并不真的需要真的来自标准库中的 Promise 构造器,实际上也有很多第三方的 Promise 的实现,或一些返回类 Promise 对象的库(例如一些 ORM)。

在 JavaScript 中我们通常只关注一个对象是否有我们需要的属性和方法,这种范式被称为「鸭子类型(Duck typing)」,就是说「当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子」。

所以 TypeScript 选择了一种基于属性的类型系统(Structural type system),这种类型系统不再关注一个变量被标称的类型(由哪一个构造器构造),而是 在进行类型检查时,将对象拆开,抽丝剥茧,逐个去比较组成这个对象的每一个不可细分的成员。如果一个对象有着一个类型所要求的所有属性或方法,那么就可以当作这个类型来使用

这就是 TypeScript 类型系统的核心 —— Interface(接口):

interface LabeledValue {
  label: string
}

TypeScript 并不关心 Interface 本身的名字,与其说是「类型」,它更像是一种约束。一个对象只要有一个字符串类型的 label 属性,就可以说它满足了 LabeledValue 的约束。它可以是一个其他类的实例、可以是字面量、可以有额外的属性;只要它满足 LabeledValue 所要求的属性,就可以被赋值给这个类型的变量、传递给这个类型的参数。

前面提到 Interface 实际上是一组属性或一组约束的集合,说到集合,当然就可以进行交集、并集之类的运算。例如 type C = A & B 表示 C 需要同时满足类型 A 和类型 B 的约束,可以简单地实现类型的组合;而 type C = A | B 则表示 C 只需满足 A 和 B 任一类型的约束,可以实现联合类型(Union Type)。

接下来我会挑选一些 TypeScript 具有代表性的一些特性进行介绍,它们之间环环相扣,十分精妙。

字符串魔法:字面量

在 TypeScript 中,字面量也是一种类型:

type Name = 'ziting'

const myName: Name = 'ziting'

在上面的代码中,Name 类型唯一合法的值就是 ziting 这个字符串 —— 这看起来毫无意义,但如果我们引入前面提到的集合运算(联合类型)呢?

type Method = 'GET' | 'PUT' | 'DELETE'

interface Request {
  method: Method
  url: string
}

上面的代码中我们约束了 Request 的 method 只能是 GET、PUT 和 DELETE 之一,这比单纯地约束它是一个字符串类型要更加准确。这是 JavaScript 开发者经常使用的一种模式 —— 用字符串来表示枚举类型,字符串更灵活也更具有可读性。

在 lodash 之类的库中,JavaScript 开发者还非常喜欢使用字符串来传递属性名,在 JavaScript 中这很容易出错。而 TypeScript 则提供了专门的语法和内建的工具类型来实现对这些字符串字面量的计算,提供静态的类型检查:

interface Todo {
  title: string
  description: string
  completed: boolean
}

// keyof 将 interface 的所有属性名提取成一个新的联合类型
type KeyOfTodo = keyof Todo // 'title' | 'description' | 'completed'
// Pick 可以从一个 interface 中提取一组属性,生成新的类型
type TodoPreview = Pick<Todo, 'title' | 'completed'> // {title: string, completed: boolean}
// Extract 可以找到两个并集类型的交集,生成新的类型
type Inter = Extract<keyof Todo, "title" | "author"> // "title"

借助这些语法和后面提到的泛型能力,JavaScript 中各种以字符串的形式传递属性名、魔法般的对象处理,也都可以得到准确的类型检查。

类型元编程:泛型

泛型提供了一种将类型参数化的能力,在其他语言中最基本的用途是定义容器类型,使得工具函数可以不必知道被操作的变量的具体类型。JavaScript 中的数组或 Promise 在 TypeScript 中都会被表述为这样的泛型类型,例如 Promise.all 的类型定义可以写成:

function all<T>(values: Array<T | Promise<T>>): Promise<Array<T>>

可以看到类型参数可以被用来构造更复杂的类型,进行集合运算或嵌套。

默认情况下,因为类型参数可以是任意的类型,所以不能假定它有某些属性或方法,也就不能访问它的任何属性,只有添加了约束才能遵循这个约束去使用它,同时 TypeScript 会依照这个约束限制传入的类型:

interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise>(arg: T) {
  console.log(arg.length)
}

约束中也可以用到其他的类型参数或使用多个类型参数,在下面的代码中我们限制类型参数 K 必须是 obj 的一个属性名:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

除了在函数上使用泛型之外,我们还可以定义泛型类型:

type Partial<T> = {
  [P in keyof T]?: T[P];
}

当定义泛型类型时我们实际上是在定义一种处理类型的「函数」,使用泛型参数去生成新的类型,这也被称作「元编程」。例如 Partial 会遍历传入类型 T 的每一个属性,返回一个所有属性都可空的新类型:

interface Person {
  name: string
}

const a: Person = {} // 报错 Property 'name' is missing in type '{}' but required in type 'Person'.
const b: Partial<Person> = {}

前面我们提到的 Pick 和 Extract 都是这样的泛型类型。

在此之外 TypeScript 甚至可以在定义泛型类型时进行条件判断和递归,这使得 TypeScript 的类型系统变成了 图灵完备的,可以在编译阶段进行任何计算。

你可能会怀疑这样复杂的类型真的有用么?其实这些特性更多地是提供给库开发者使用的,对于 JavaScript 社区中的 ORM、数据结构,或者是 lodash 这样的库来说,如此强大的类型系统是非常必要的,lodash 的 类型定义 行数甚至是它本身代码的几十倍。

类型方程式:自动推导

但其实我们并不一定要掌握这么复杂的类型系统,实际上前面介绍的高级特性在业务代码中都极少被用到。TypeScript 并不希望标注类型给开发者造成太大的负担,因此 TypeScript 会尽可能地进行类型推导,让开发者在大多数情况下不必手动标注类型。

const bool = true // bool 是字面量类型 true
let num = 1 // num 是 number
let arr = [0, 1, 'str'] // arr 是 (number | string)[]

let body = await fs.readFile() // body 是 Buffer

// cpuModels 是 string[]
let cpuModels = os.cpus().map( cpu => {
  // cpu 是 os.CpuInfo
  return cpu.model
})

类型推导同样可以用在泛型中,例如前面提到的 Promise.all 和 getProperty,我们在使用时都不必去管泛型参数:

// 调用 Promise.all<Buffer>,files 的类型是 Promise<Buffer[]>
const files = Promise.all(paths.map( path => fs.readFile(path)))
// 调用 Promise.all<number[]>,numbers 的类型是 Promise<number[]>
const numbers = Promise.all([1, 2, 3, 4])

// 调用 getProperty<{a: number}, 'a'>,a 的类型是 number
const a = getProperty({a: 2}, 'a')

前面提到泛型是在将类型参数化,引入一个未知数来代替实际的类型,所以说泛型对于 TypeScript 就像是一个方程式一样,只要你提供了能够解开这个方程的其他未知数,TypeScript 就可以推导出剩余的泛型类型。

价值十亿美金的错误

在很多语言中访问空指针都会报出异常(在 JavaScript 中是从 null 或 undefined 上读取属性时),空指针异常被称为「价值十亿美元的错误」。TypeScript 则为空值检查也提供了支持(需开启 strictNullChecks),虽然这依赖于类型定义的正确性,并没有运行时的保证,但依然可以提前在编译期发现大部分的错误,提高开发效率。

TypeScript 中的类型是不可为空(undefined 或 null)的,对于可空的类型必须表示成和 undefined 或 null 的并集类型,这样当你试图从一个可能为 undefined 的变量上读取属性时,TypeScript 就会报错了。

function logDateValue1(date: Date) { // 不可空
  console.log(date.valueOf())
}

logDateValue1(new Date)
logDateValue1() // 报错 An argument for 'date' was not provided.

function logDateValue2(date: Date | undefined) { // 可空
  console.log(date.valueOf()) // 报错 Object is possibly 'undefined'.
}

logDateValue2(new Date)
logDateValue2()

在这种情况下 TypeScript 会要求你先对这个值进行判断,排除其为 undefined 可能性。这就要说到 TypeScript 的另外一项特性 —— 其基于控制流的类型分析。例如在你使用 if 对变量进行非空判断后,在 if 之后的花括号中这个变量就会变成非空类型:

function print(str: string | null) {
  // str 在这里的类型是 string | null
  console.log(str.trim()) // 报错 Object is possibly 'null'.
  if (str !== null) {
    // str 在这里的类型是 string
    console.log(str.trim())
  }
}

同样的类型分析也发生在使用 if、switch 等语句对并集类型进行判断时:

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Circle {
  kind: 'circle'
  radius: number
}

function area(s: Rectangle | Circle) {
  // s 在这里的类型是 Rectangle | Circle
  switch (s.kind) {
    case "rectangle":
      // s 在这里的类型是 Rectangle
      return s.height * s.width
    case "circle":
      // s 在这里的类型是 Circle
      return Math.PI * s.radius ** 2;
  }
}

仅仅工作在编译阶段

TypeScript 最终仍然会编译到 JavaScript,再被 JavaScript 引擎(如 V8)执行,在生成出的代码中不会包含任何类型信息,TypeScript 也不会添加任何与运行时行为有关的功能。

TypeScript 仅仅提供了类型检查,但它并没有去保证通过检查的代码一定是可以正确运行的。可能一个变量在 TypeScript 的类型声明中是一个数字,但并不能阻止它在运行时变成一个字符串 —— 可能是使用了强制类型转换或使用了其他非 TypeScript 的库且类型定义文件有误。

在 TypeScript 中你可以将类型设置为 any 来绕过几乎所有检查,或者用 as 来强制「转换」类型,当然就像前面提到的那样,这里转换的仅仅是 TypeScript 在编译阶段的类型标注,并不会改变运行时的类型。虽然 TypeScript 设计上要去支持 JavaScript 的所有范式,但难免有一些极端的用例无法覆盖到,这时如何使用 any 就非常考验开发者的经验了。

编程语言的类型系统总是需要在灵活和复杂、简单和死板之间做出权衡,TypeScript 则给出了一个完全不同的答案 —— 将编译期的检查和运行时的行为分别看待。这是 TypeScript 饱受争议的一点,有人认为这样非常没有安全感,即使通过了编译期检查在运行时依然有可能得到错误的类型,也有人认为 这是一个非常切合工程实际的选择 —— 你可以用 any 来跳过类型检查,添加一些过于复杂或无法实现的代码,虽然这破坏了类型安全,但确实又解决了问题

那么这种仅仅工作在编译阶段类型检查有意义么?我认为当然是有的,毕竟 JavaScript 已经提供了足够使用的运行时行为,而且要保持与 JavaScript 的互操作性。大家需要的只是 TypeScript 的类型检查来提高开发效率,除了编译阶段的检查来尽早发现错误以外,TypeScript 的类型信息也可以给编辑器(IDE)非常准确的补全建议。

与 JavaScript 代码一起工作

任何基于 JavaScript 的技术都要去解决和标准 JavaScript 代码的互操作性 —— TypeScript 不可能创造出一个平行与 JavaScript 的世界,它必须依赖社区中已有的数十万的 JavaScript 包。

因此 TypeScript 引入了一种类型描述文件,允许社区为 JavaScript 编写类型描述文件,来让用到它们的代码可以得到 TypeScript 的类型检查。

描述文件的确是 TypeScript 开发中最大的痛点,毕竟只有当找全了定义文件之后,才会有流畅的开发体验。在开发的过程中不可避免地会用到一些特定领域的、小众的库,这时就必须要去考虑这个库是否有定义文件、定义文件的质量如何、是否需要自己为其编写定义文件。对于不涉及复杂泛型的库来说,写定义文件并不会花太多时间,你也只需要给自己用到的接口写定义,但终究是一个分心的点。

小结

TypeScript 有着先进的类型系统,而且这个先进并不是「学术」意义上的先进,而是「工程」意义上的先进,能够切实地提高开发效率,减轻动态类型的心理负担,提前发现错误。所以在此建议所有的 JavaScript 开发者都了解和尝试一下 TypeScript,对于 JavaScript 的开发者来说,TypeScript 的入门成本非常低。

在 LeanCloud,控制台在最近的一次的重构中切换到了 TypeScript,提高了前端项目的工程化水平,让代码可以被长时间地维护下去。同时我们一部分既有的基于 Node.js 的后端项目也在切换到 TypeScript。

LeanCloud 的一些内部工具和边缘服务也会优先考虑 TypeScript,较低的学习成本(谁没写过几行 JavaScript 呀!)、静态类型检查和优秀的 IDE 支持,极大地降低了新同事参与不熟悉或长时间无人维护的项目的门槛,提高大家改进内部工具的积极性。

LeanCloud 的 JavaScript SDK、Node SDK 和 Play SDK 都添加了 TypeScript 的定义文件(并且打算在之后的版本中使用 TypeScript 改写),让使用 LeanCloud 的开发者可以在 TypeScript 中使用 SDK,即使不用 TypeScript,定义文件也可以帮助编辑器来改进代码补全和类型提示。

如果你也希望一起来完善这些项目,可以了解一下在 LeanCloud 的 工作机会

参考资料:

加湿器选购指南

我从小一直生活在湿度很低的北方,常年鼻子和咽喉不舒服,经常流鼻血,但因为从小如此,也没觉得有什么不对劲。而来到苏州之后,我就觉得苏州湿润的空气真是好呀,鼻炎和咽炎的症状几乎没有了。但等到冬天开空调的时候就觉得无法再回到那么干燥的环境了,于是开始疯狂地买加湿器,这么多年下来,应该买过差不多十个加湿器了。

加湿原理

超声波 加湿器使用超声波将水打散成为非常小的小液滴,然后用风扇吹出,小水珠通常会在落地之前蒸发为水蒸汽,溶解至空气中。超声波加湿器是使用最普遍、价格最低、同价位加湿量最大的加湿器类型。其主要缺点是超声波会将水中的固体颗粒物和溶解物一起吹出,且容易有颗粒较大的液滴未能在落地之前完全溶解到空气中。虽说超声波加湿器被认为并没有耗材,但实际上其核心组件雾化片的寿命只有两年左右,且难以更换。

冷蒸发 加湿器使用风扇将空气吹过吸水的多孔材料,加速水的蒸发。冷蒸发是近一段时间中被高端的加湿器所采用的技术,不会有固体颗粒物进入空气,加湿量随湿度变化有一定自我调节的能力。其主要缺点是单位价格加湿量较低(且加湿量和噪音成正比),用于吸水的多孔材料作为耗材需要定时更换。

热蒸发 加湿器简单地通过加热水来产生水蒸汽。热蒸发加湿量较大(和功率成正比),同样没有固体颗粒物进入空气。其主要缺点是耗电较多,会增加室内温度(冬天倒是不要紧),且高温蒸汽遇到低温物体会产生水珠凝结。

类型 价格 水珠凝结 耗电 耗材和使用寿命 固体颗粒物 加湿量(同价位)
超声波 无耗材 寿命短
冷蒸发 有耗材
热蒸发 中等 无耗材 中等

在加湿原理上面我觉得很难一概而论,需要具体情况具体分析。但如果你不知道怎么选的话,可以先买个性价比最高的超声波加湿器试试看。

湿度

我们通常说的湿度其实是指相对湿度,即相对于当前空气中能溶解水蒸汽的最大量来说的湿度。人在不同的温度下对于同一相对湿度的感受是完全不同的,在 28 度下 40% 的相对湿度会比较舒适,而在 22 度下则需要 60% 的相对湿度才能达到相同的感受。

于是我找到了一个单一指标来衡量体感湿度 —— 露点,露点描述的是在当前温度和相对湿度的条件下,空气中的水蒸汽遇到多冷的物体时会凝结成小水珠,综合了温度、相对湿度和大气压力的影响。

对于我而言我觉得理想的露点是在 13 度左右,也就是我前面说的 28 度下 40%、22 度下 60%。如果你不像我这么喜欢湿润的空气,可以调低一点,例如 10 度左右。

加湿量

首先我们根据房间容积(这里以我家客厅 90 立方米计算)、目标温度和目标湿度来计算所需要的加湿量。我这里的计算并没有扣除家具所占用的空间,主要是考虑到家具本身(尤其是织物)也是会吸水的,所以综合考量下来不加也不减。

先计算不开空调和加湿器时的基准值,按室外温度 10 度,相对湿度 60% 计算,查表可得每立方米有 5.63g 的水蒸汽,总共就是 90m^2 * 5.63g = 506.7g,相当于我们房间里本来就有这么多水。

然后按照目标温度 24 度,目标露点 13 度,算出目标相对湿度是 50%,查表可得每立方米有 10.85g 的水蒸汽,总共就是 90m^2 * 10.85g = 976.5g,相当于我们的目标是房间里有这么多水。

这意味着我们需要补充差不多 500g 的水到空气中,但需要注意的是房间并不是密闭的,房间里的空气一直在和外界进行着交换,水蒸汽会流失到外界,也会在低温物体(如玻璃)上凝结为小水珠。以我个人的经验,如果你需要补充 500g 的水到空气中的话,那差不多你就需要一个加湿量 500ml/h 的加湿器 —— 相当于每小时房间里的水分都会流失掉一遍。如果加湿器的加湿量不够的话,就会出现即使一直开到最大,湿度也升不上去的情况。

500ml/h 已经算是加湿量比较大量的加湿器了,你需要考虑是使用多个加湿器还是单个加湿器。多个加湿器的话维护成本会比较高,你需要单独为每个加湿器加水、进行清洗和维护;单个加湿器的话则要考虑如何增加空气的对流,让整个房间保持相对均匀的湿度,例如冬天开空调的话,可以把加湿器放在空调可以直接吹到的地方。

功能

在购买加湿器的时候需要留意这些功能:

  • 上加水 极大地提升了加水的便利性,直接关系到使用体验。
  • 水箱 注意确认容量是否足够、水箱的形状是否容易清洗。
  • 通电自动启动 有些加湿器在接通电源后还需要触摸按键才能启动,这样便无法与智能插座配合使用。
  • 出雾高度和方向 出雾方向是否可调,这会影响加湿器放置的灵活性。
  • 紫外线除菌 见后面「固体颗粒物」小节。

可有可无的功能:

  • 红外线遥控 可以和智能家居组件配合使用。
  • 指示灯 如果是放在卧室的话,需要确认指示灯的亮度以及是否可以关闭,否则可能关灯后会显得太亮。
  • 智能控制 大多需要专门的应用,难以和既有的智能家居一同工作,不如用智能插座或红外遥控来解决。
  • 恒湿 位于机器本体上的湿度传感器非常不准确,效果有限(尤其对于超声波加湿器)。

不太需要关注的功能:

  • 静音 大多数超声波加湿器都没多大差别。
  • 缺水保护 所有加湿器都有的功能。

没什么用的功能:

  • 银离子 效果要比紫外线差很多,而且需要耗材。
  • 净水滤芯 本来加的都是干净的水,没有必要过滤,而且需要耗材。
  • 负离子 并没有可靠的资料表明这个功能有什么作用。

关于大雾量加湿器:

单个超声波雾化片可以提供 300ml/h - 400ml/h 的加湿量,再大的加湿量就需要考虑多个雾化片的加湿器了。

固体颗粒物

超声波加湿器会将水打成小液滴,这会导致家用的固体颗粒物传感器瞬间爆表,但实际上家用的传感器是无法区分小液滴和真正的固体颗粒物的,因此这个传感器的数值并不能说明有多少固体颗粒物进入到了空气中。

但我们仍有必要考虑微生物在加湿器内滋生并随着小液滴进入空气的情况,市场上目前有紫外线和银离子两种方案来解决这一问题。

经过我的调查,紫外线的除菌效果要远优于银离子,且不需要耗材:

  • 紫外线被标注为「除菌」,而银离子被标注为「抑菌」
  • 网络上一些细菌培养实验显示紫外线的除菌效果更好
  • 高端的加湿器通常采用紫外线除菌

下一个问题就是加湿器中到底应该加什么水呢?

一派观点认为自来水中的余氯有助于减少加湿器中的细菌;另一派观点认为有条件加纯水当然应该加纯水,可以减少被超声波打入空气中的溶解物数量(尤其一些地区自来水的 TDS 非常高),也可以延长超声波雾化片、冷蒸发的多孔材料、热蒸发的电热管的使用寿命。

所以综合考虑,建议大家选择有紫外线除菌功能的加湿器,然后向加湿器中添加净水器产生的纯水来避免自来水中的溶解物进入空气。

参考资料

2019 年度小结(技术方面)

Redis 的工程价值

今年一开始先是完善了去年的 任务队列,为了让它真正地被用起来,我将之前由另外一个服务实现的定时任务系统也合并进了这个任务队列,让它有了一些固定的流量,以便我来发现性能上的问题,进一步完善它。

这项工作最后取得了还不错的效果,开始有了用户使用任务队列的功能。我也写了一篇文章来介绍 用 Redis 来实现定时任务,当然这篇文章中介绍的是一种经过极度简化的范式,实际上实际的代码要复杂的多,例如序列化、错误处理、结果查询、限流、统计等额外的特性。

这个项目让我再度增加了对 Redis 的好感,这次我用到了比较大量的 Lua Script 来保证分布式架构下的一致性(Redis 的Lua Script 会被独占地执行)。在将一致性需求限制到单个 Redis 实例可以容纳的范围(Redis 只使用一个线程)并且 Redis 运行相对稳定的情况(故障切换会丢失数据),Redis 为业务层提供了一个非常「够用」的高性能的、有一致性保证的数据同步方案。这并不是说 Redis 提供的方案有多么完美,而是在性能、功能、一致性、可靠性上提供了一个非常好的这种,更具有工程价值。

Golang 的表达能力

我还尝试了为 LeanCloud 写一个 Golang 的 SDK(后来因时间安排的关系暂停掉了,目前还未发布),就像之前我为 DeployBeta 写 ORM 一样,同样遇到了 Golang表达能力不足的问题。问题主要在于 Golang 中并没有能让 Struct 继承方法的机制(数据字段则可以通过内嵌匿名 Struct 的方式来继承)。

所以当用户定义一个「继承」自 ORM 基类的 Struct 时,我们无法向用户定义的 Struct 上添加例如 Save、Set 之类的方法,无法有效地追踪用户对于数据对象的改动。

经过几个版本的改动,我最后选择了一种将所有基本类型(string、int)包装为 Struct 的方案:

type Todo struct {
  orm.ObjectMeta

  Name     orm.String `orm:"name"`
  Priority orm.Number `orm:"priority"`
}

todo := Todo{
  Name:     orm.NewString("test"),
  Priority: orm.NewNumber(1),
}

err = orm.Save(&todo)

todo.Name.Set("test")
todo.Priority.Incr(1)

err = orm.Save(&todo)

fmt.Println(todo.Name.Get(), todo.Priority.Get())

这个方案可以做到不以字符串的形式传递字段名(可以得到编译期的类型检查),可以追踪对每个字段进行的修改(包括 Incr 等运算)。我将 Set 添加到了基本类型的封装类型上,将 Save 作为了一个全局方法,避开了 Golang 对于继承的限制。带来的问题则是用户需要通过我们的封装方法(Get)来访问字段的值;同时今后设计嵌套对象时也需要更大的工作量。

所以并不是如 Golang 的支持者说的那样,更少的特性意味着更简单的设计。当业务逻辑确实复杂,语言表达能力又非常匮乏的情况下,会逼着开发者做出一些不优雅的、不易理解的、反常规的设计,这些代码往往非常容易出错(例如反射、代码生成、强制类型转换等),而本来这些需求(如继承)在其他语言里是可以非常轻易地解决的。

TypeScript 的胜利

之前因为对 CoffeeScript 的喜爱,我的 TypeScript 使用经验非常少,终于今年我也不得不去接受 TypeScript 了。今年我用 TypeScript 开发了两个新的后端项目,也更深入地学习了 TypeScript,经过进一步的了解,我逐渐地发现了 TypeScript 的闪光点,之后我会单独写一篇文章来介绍 TypeScript。

TypeScript 有着一个先进的类型系统,这种先进并非是学术意义上的先进,而是工程意义上的先进。它几乎可以为所有 JavaScript 中常见的范式添加静态约束,得益于强大的类型推导,在大部分情况下并不需要自己添加类型标注,但却能在编译期提前发现错误、配合 Language Server 得到准确的代码补全和类型提示信息,完全没有前面提到的 Golang 中的那种束缚感。

因为 TypeScript 并不打算创造新的范式,而是尽可能将 JavaScript 社区中已有的范式用静态类型的语义描述起来。这样最大程度上地降低了 JavaScript 开发者学习的成本,提高了与标准 JavaScript 代码的互操作性,我认为这也是 TypeScript 能够取得成功的关键。

同时我也不得不接受 Atom 的市场已经几乎完全被 VS Code 取代的现实,切换到了对 TypeScript 支持更好的 VS Code。现在想想 Atom 失败的原因一方面是在 CoffeeScript 已经表现出没落的时候选择了 CoffeeScript;另一方面是希望依靠社区的力量,但又缺乏对社区的引导。例如对于插件的 GUI 改动引导不够导致界面卡顿,对于代码补全、调试等常见需求没能建立统一的标准等等。

Kubernetes 的阴谋

今年其中一个新项目是开发一个数据库调度平台,在 Kubernetes 上运行数据库容器,这和我之前在 DeployBeta 实现的原型非常相似,只不过这次是真的要上线的项目。

在去年和今年对 Kubernetes 的了解过程中我逐渐对 Kubernetes 由粉转黑。我现在认为 Kubernetes 是以 Google 为首的三大云计算巨头的垄断工具,他们开发出了一个如此复杂的系统,并引导其作为行业标准。虽然 Kubernetes 是开源并由社区维护的,但真正能够独立搭建好 Kubernetes 及其插件的公司是极少数,甚至可以说除了三大巨头之外,其他的云计算公司都不能提供稳定可靠的 Kubernetes 集群。最后大家在尝试过自己搭建之后,还是不得不购买三大巨头的 Kubernetes 云,毕竟这是行业标准嘛。

今年看过觉得最好的书是「数据密集型应用系统设计(DDIA)」,它给我的数据库调度平台带来了很多启发。书中介绍了分布式架构对于数据库的挑战,包括数据模型、复制、分区、事务、分布式共识等等,以及各个数据库在面对这些挑战时采取的解决方案,只有理清这些思路,才能在面对复杂的业务的时候采用一种或几种合适的数据库。

我的理解是当数据存在于两台或更多的计算机之上时(原因可能是容量或可用性要求),就可以称作「大数据」了。因为从一台到两台是一个质的变化,而从两台到更多只不过是量的变化。就如书中所说,在单机条件下,所有的称作都是确定的,一个操作要么成功要么失败(可能伴随着程序或系统的崩溃);但在分布式条件下,对于经过网络的操作会引入成功和失败之外的第三种情况 —— 网络延迟,你无法预测一个操作会在下一秒完成还是永远都不会完成。所以分布式系统需要被设计成可以在容忍一定的错误(部分失效)的情况下继续运行。无论是一个分布式数据库还是一个分布式的容器平台,其实都在与这种不确定性的超时进行对抗。

写不下去的业余项目

现在我愈发认识到软件开发不是一个人的单打独斗,之前在做一些业余项目的时候还会有一些幻想,幻想自己能长期维护下去、能吸引到其他的贡献者、能建立起一个社区。但现在想想还是以内容作为主要的输出更有可行性。同时因为我对现在的工作非常满意,在工作中基本完全满足了我对于写代码和团队协作的欲望,所以我可以将业余时间放在其他的输出形式上,在接下来一年中输出更多的文章或视频,这样我的经验和知识会给读者带来更大的价值。

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2024-04-08.