你好,耳先生 https://www.kilerd.me 先生贵姓?耳东陈。好的,这边请,耳先生。 zh-CN Staple iPhone 下的 99% 自动化记账 https://www.kilerd.me/iphone-sms-financial-worker 因为一些众所周知的原因,国内的大部分银行都无法通过API的方式访问到个人账户下的账单,同时因为支付宝与微信的普及,银行系统里面通常记录的交易信息也是不完整的,以至于在中国环境下实现自动化记账是一件很麻烦的事情。经过多次更改后,我的记账模式已经做到了相当高的自动化,于是便有了这篇文章,记录并分享一下我是如何进行自动化记账的。

]]>
https://www.kilerd.me/iphone-sms-financial-worker Wed, 17 Apr 2024 18:00:00 +0800 因为一些众所周知的原因,国内的大部分银行都无法通过API的方式访问到个人账户下的账单,同时因为支付宝与微信的普及,银行系统里面通常记录的交易信息也是不完整的,以至于在中国环境下实现自动化记账是一件很麻烦的事情。经过多次更改后,我的记账模式已经做到了相当高的自动化,于是便有了这篇文章,记录并分享一下我是如何进行自动化记账的。

前提条件、准备:

  • iPhone 一台
  • 银行 APP (例如:招商银行)
  • 个人域名
  • cloudflare 账号
  • 个人服务器

我们会把整个流程分成三个部分来讨论:数据采集端、数据处理端、数据修正端

数据采集端

本阶段的核心是我们需要把银行消费的短信通知采集到,然后推送给有编程能力的处理器。

银行交易短信开通

这是一切的开始,我们需要在银行APP中开启「账务变动通知」,并且把「通知起点金额」设置为0,这样我们就可以通过短信来接受到所有银行卡的变动

Shortcut 自动化

iOS因为安全的考虑,应用程序除了「短信过滤」分类之外并没有能力感知和处理短信,可是自动化中却存在一种神奇的自动化「短信:当收到妈妈的短信时」,他是一种可以根据「短信发件人」和「短信关键字」来触发自动化的触发点。

光有这个前提还不可以完成数据的采集流程,以下两点才是关键:

  • 所有交易短信都包含了招商银行四个字,所以我们可以采用它为触发点
  • 自动化中,可以通过「输入快捷指令的短信」这个参数获取到具体的短信内容

那么我们可以把它发送到一个数据处理端中真正的处理我们的短信,所以我们的自动化其实是做了以下几件事情:

  • 当短信内容包含「招商银行」四个字时,触发自动化
  • 把短信内容作为 payload 以 HTTPS 的方式(自动化中的执行步骤叫「获取URL内容」)发送至数据处理端

数据处理端

这里我们采用了 cloudflare worker 来作为我们的数据处理端,从上文可知,我们的短信会以https post的方式发送到worker 中来。 那么我们就可以通过以下代码在worker中取出短信内容

const request_payload: { sms: string } = await request.json();

那么我们就需要做数据提取了,招商银行的短信可以简单的分为好几类:

  • 支出消费
  • Apple pay 支付
  • 退款

那么我们就以支出消费来举例子,一个简单的支出短信是这样的

【招商银行】您账户0000于04月09日06:43在【支付宝-payee】快捷支付114.15元,余额9999.99 

那么我们就可以通过正则的模式来取出交易账户、时间、payee、金额 等信息,这里我的正则大概长这个样子

const CONSUME = /您账户(?<account>\d+)于(?<month>\d+)月(?<day>\d+)日(?<hour>\d+):(?<min>\d+)在【(?<payee>[^】]+)】快捷支付(?<amount>\d+(\.\d+))元/;

因为我们用的是正则中 named group 的写法,在真正的处理流程中,我们可以通过以下的代码获取到短信的细节

const match_consume = request_payload.sms.match(CONSUME);
if (match_consume) {
    // @ts-ignore
    const { groups: { account, month, day, hour, min, payee, amount } } = CONSUME.exec(request_payload.sms);
    // add your logic here...
}

自此我们获取到了短信的交易时间、交易对手、金额,接下来就可以把它转换成复式记账的账户了

const ACCOUNTS = {
'0000': 'Assets:CMB-DebitCard'
};

const target_account = ACCOUNTS[account] ?? DEFAULT_ACCOUNT;

const PAYEE = {
'肯德基': 'Expenses:Eat'
};

const target_payee = PAYEE[payee] ?? "Expenses:FixMe";

然后可以构建 API 请求,把数据发送到 zhang 或者 fava 等具有API请求能力的服务端。

const payload = { 
    // construct the json payload based on your financial tool
}

自此我们就可以完成了把数据处理并推送至服务端储存的流程,如果你在使用 zhang,那么就可以在web UI中查看到一条记录

Expenses:FixMe

关于那些payee 无法分类的,我目前的做法会分配到 Expenses:FixMe 中,并把交易标注成 Waring ,以便在后续的流程中重点修正。

数据修正端

虽然我们完成了数据的采集,但是就如同上图的交易以下,我们只获取到了

  • payee 为 上海拉扎斯信息科技有限公司
  • narration 为空

实际上,这是一笔饿了么消费,金额流转路经为 饿了么支付宝招商银行 。我们在饿了么上购买了什么商品早已经在一层一层数据流转中消失了,这时候我们就需要人工介入做数据修正工作了。

目前来说对于 zhang,有两种方式做数据修正: web ui 和 手机APP。 这里我们就用手机APP来举例,我们可以选择晚上睡觉前把一天的数据再次回顾和修正

通过 APP 的交易编辑功能可以完成交易的修正

一些注意的点

  • 建议最后的消费卡集中到同一张卡中,因为整个流程的前提是基于银行短信通知来实现的,而银行的短信通知是需要收费的,3元一张卡,所以集中到同一张卡可以降低服务费的支出
  • 前提准备中提及到了个人域名,这里主要是两个用途 - cloudflare 的 worker 绑定个人域名的访问成功率高很多 - 个人账本需要可以被 worker 直接访问,所以也需要绑定个人域名,当然也可以使用 clouldflare tunnel 来做内网服务穿透
  • 如果你家里有多的iPhone和 NAS,可以通过iPhone自带的短信同步功能同步到家里内网备用机,备用机执行Shortcut自动化,worker 和账本都放在nas内网中,避免账本因配置不当暴露在公网
]]>
东澳岛之旅:理想与现实 https://www.kilerd.me/trip-of-dongao-island 4月29号,朋友问我五一去哪里玩,我想都没想就说了哪都不去。事实上也是这样,并没有任何出游的计划,但是回头仔细想想五一假期那么长,没理由那么多天都在家里吧。于是就挑了一个短程旅行,目标地点就是东澳岛。

]]>
https://www.kilerd.me/trip-of-dongao-island Thu, 05 May 2022 17:38:36 +0800 4月29号,朋友问我五一去哪里玩,我想都没想就说了哪都不去。事实上也是这样,并没有任何出游的计划,但是回头仔细想想五一假期那么长,没理由那么多天都在家里吧。于是就挑了一个短程旅行,目标地点就是东澳岛。

对于东澳岛,其实已经早早地躺在了我的出游目的地里面了,但是由于疫情,深圳前往岛的渡轮一直都处于停航状态,恰逢五一,深圳和珠海的疫情都得到不错的控制,难得的开放了数天的渡轮,于是一上头便买下了2号去4号回的返程渡轮。其他的关于吃住玩就没有过多的考虑,这也算是个人习惯,我本来就不喜欢那种安排得满满当当的出行。于是决定慢慢筹备,反正距离出行还有好几天呢。

当晚回到家后开始谋划住宿。在我印象中东澳岛绝对是珠海众多海岛中开发程度和商业化程度第一梯队的存在,而当我在诸多酒店、民宿平台上查询住宿时,我整个人都惊呆了。只有一家酒店,同时剩下的就只有一个3000块的海景套间,怎么想都不适合我这种一个人出门的人。

当时,一个念头突然涌上心头「露营」。没错,就是露营,之前在关注「马蜂窝」这个网站的时候看到过很多关于东澳岛如何露营的文章,也确信这是一个可行的方案,同时考虑到我这种摄影人怎么可能会安心地呆在酒店里面呢,能在酒店里面呆几个小时就不错了。考虑下来露营绝对是我这次出行的最佳方案。要命的是我完全没有留意最后的文章时间停留在了2020年,这绝对给这次出行留下了巨大的伏笔。

海岛嘛,等价代名词就是日落、日出、海浪、星空、无光污染,这恰好命中摄影人的职业病。而我也在这几个名词和露营的组合下,对出行有了一个基本的构想:

  • 2号抵达岛上后,下海游泳、赶往露营基地扎营过夜,看日落
  • 3号游玩岛上的景色,晚上去山顶扎营,拍星空
  • 4号早早起床,拍日出

我寻思着有着露营基地的地方对于我这种露营新手估计难度应该不大,也没有过多的往坏处想。于是在30号便启程去把露营装备买齐了。

本以为万事具备,只欠东风。而现实就是那么残酷,往往不能跟你想象的完美契合。

启程

登船

谁又能想象得到晴空万里的29号、30号,在踏入五月的那个夜晚会风雨交加呢,倾盆的大雨一度让我打消了出行的念头,但是在查了天气预报和卫星图后,推测一番感觉2号会重新回到那个万里无云的天气。而这个1号的大雨天直接让当天的渡轮停航了,估计也劝退了大部分2号出游的旅客。以至于2号当天去程的船上几乎就没有什么人。

之前出现过出门忘记带相机储存卡、忘记给相机充电的情况,所以我养成了写出行checklist的习惯,写清楚了前一天需要做什么,当天出门需要做什么的详细列表。

2号当天9点起床整理东西并对着checklist 做一项一项的检查,发现没有任何遗漏后,便出门。估摸着9点50下楼,吃个早餐10点出发去渡口时间绰绰有余,但是在吃早餐的时候突然想起来自己好像少带了稳定器跟相机的连接线。即便是那条数据线长得跟普通的Tpye-C 数据线没任何差区别,但是我还是不敢冒这个风险,一旦不兼容,我的稳定器就等于是白带了。那么这一折腾、一来一回就消耗了我20分钟。

就是这20分钟的戏剧性,当我飞奔到渡口大厅的时候,在国内出发的那个区域,检票的小哥一直在重复喊「耳先生,在吗? 耳先生,你到了吗?」。不了解的还以为是什么达官贵人要坐渡轮去哪里巡查游玩,殊不知只是一个即将要迟到、赶不上渡轮的倒霉蛋,原因竟是一条数据线引发的「血案」。

香港机场

当我踏上船的那一刻,渡轮的登船口也应声关闭,近乎1个小时的时间可以极致放松的躺在椅子上,看着外面的海浪和乌云。同时也没有想过疫情过后能以这样的方式再次见到香港机场,心里不禁一阵唏嘘。

露营

东澳岛码头

踏上岛的那一刻,并没有我印象中对于海岛的那种「刻板印象」,我对海岛的印象还停留在了泰国普吉和台湾垦丁上,那广阔的海岸,一望无际的海平线,那条蜿蜒曲折的滨海小街。而东澳岛映入眼帘的却是寂寥和荒漠。基本上没有太多人影。

找个地方放好行李歇息几分钟的时间,整个码头又恢复到了一如既往的门可罗雀。想想也合理,所有的酒店和海岸都在那一头,每天也就那么几班渡轮在此停靠。连服务中心那售票小姐姐都搬起了小椅子,嗑起了瓜子来。

一个饭店老板娘向我招了招手「小伙子吃饭还是住宿啊」。完全不知道居然还有民宿的我在惊讶之余还是跟老板娘唠嗑起来了。原来那200块的小房间只是他们饭店二楼的一个小隔间,闲置着没用就改造起来当民宿,期望在疫情期间能回一点本就回一点本。但是哪个傻子会没定好酒店就出发来岛上旅行呢,毕竟一下船就只能等第二天才有渡轮回去了。

为了保险期间,我问老板娘要了名片,寻思着如果实在找不到地方扎营,那至少有一个保障不至于睡街上。

东澳岛号称92%的绿植覆盖率,再加上每天只有寥寥数班的渡轮,这意味着什么呢?意味着岛上的人数上限得到了极大的控制,再加上1号的大雨天,岛上更是廖无人烟。在一路朝海岸走去的路上,除了几个建筑工人外,似乎整个岛就只有我一个人了。哪怕是我走到了沙滩上,岛上的人数也不过十数人,似乎跟我同一班渡轮过来的人都消失得无影无踪。

在海岸边拍了一小会便来到了3点多,我觉得先去扎营基地踩好点,以免晚上太晚来没有位置或者工作人员下班之类的。扎营基地在岛的另一边,这一次的穿越还是只有自己一个人,路上也只能碰到寥寥数个建筑工人和建筑工地几条土狗。当我到达大竹湾,也就是地图和玩家口里说的官方扎营基地,当看到竖立在海岸边的警告牌「禁止烧烤、禁止游泳、禁止露营」时,觉得唯独「露营」二字尤为刺眼。伴随着呼啸而过的海风,心里直冒出「完了」两个字。

此时我已经把整个岛上有人类迹象的地方都逛遍了,唯独没见到任何跟扎营相关的人烟。恰逢路过一个保安大哥,便急忙上前询问相关事宜,大哥表示这个沙滩确实已经不让扎营了,岛上唯一让的地方是码头边上的「文化广场」。

码头?文化广场?我刚从那里过来的,那里不像是也没见到露营相关的东西啊。我当时指了一指山顶的大露台,问大哥「那里可以扎营吗」。我心想如果晚上我拍照应该是留在山上的,可以顺势扎营过一晚。大哥表示山上可以随便扎,但是要小心有蛇,同时表示晚上保安不会上山巡查,还说隔壁的塔里面没那么大风,扎营会舒服一点。

东澳岛唯一的路

道谢完大哥后,一路闲逛到码头,一为了确认文化广场的露营地,二为了吃饭。在原路返回码头的路上终于看到了零星的人,看来吃饭始终是第一位。这时就不得不谈谈岛上的饮食了,珠海嘛,又是海岛,来了不可能不吃海鲜,但是吧,海岛上的餐饮似乎形成了某种意义上的垄断,也形成了诸如广州黄沙和南海一般的「市场自主购买,店家帮忙加工」的合作模式。可是问题就出现在所谓的「统筹规划」:岛上只有寥寥几家的餐厅,大概率是跟卖海鲜的贩子合作的,甚至是一伙的。不知道是天气问题还是本来如此,贩子那里贩卖的海鲜也不见得多新鲜,只靠着打氧机输送的氧气勉强苟活。

垄断必然出现劣币驱逐良币,餐厅师傅的手艺也不见得有多好,总之一顿饭下来给我的感觉甚至不如广州的随便一家大排档,可这是你能在岛上找到为数不多的饭馆了。听说3000块的贵价酒店里面有着自己的餐厅,我知道我应该进不去。

饭后闲逛一会,依旧没发现文化广场有任何扎营相关的工作人员,虽然文化广场开着门,开着展览,但是一个工作人员都没有。折返回码头游客中心询问那位嗑瓜子的小姐,得知岛上已经没有了任何官方的露营基地。也就是说现在我只有两个选择:一是去餐馆老板娘的200小单间,二是冲上山顶找到保安大哥说的那座塔。

当我抬头看了看天,云层消散了不少,估摸着当天狼座升到合适的位置,云层也消散得差不多了,那么刚好可以拍拍银河。于是毅然决然地朝山顶出发。

东澳岛很大,大到平时不怎么看到其他游客;东澳岛很小,小到来去都是同一条路。天色渐晚,大家都选择了坐摆渡车从这头到那头,于是路上就又一次只剩我一个人,漆黑的街上没有路灯,没有行人,满世界都是我自己一个人,哪怕是路中间唯一一个便利店也早早关了灯,有的只是偶尔从路边下水道处传来的牛蛙叫声。为了驱赶黑夜带来的恐惧,我用音响放起了音乐,这可能是唯一一个稍微能增加一丝人类文明迹象的动作了。

我实属想不到在晚上8点多还需要去爬山是一种什么体验,尤其是白天保安大哥多次叮嘱山上有蛇,有蛇,有蛇。在登上山顶的那段路程,只要旁边有啥风吹草动都能把我吓得一惊一乍的,生怕哪里窜出一条蛇来。其实蛇也没有那么可怕,可怕的是保安大哥的那句话「你小心别被咬了,大晚上的不一定有船送你出去打抗体」。

哪怕是风和日丽的日子,夜晚山顶的风都是惊人的,更不用说在这种大雨过后的夜晚,风大得更是让人可怕,在登山路上还觉得这风吹得很舒服,但是当登顶后才意识到不对,这么大的风我怎么扎营啊。还记得大哥跟我讲的山顶的亭子,其实那是一个观光塔,外貌看起来有二三层,看起来里面是一个理想的避风港。可是当我登上山顶的时候,发现整座塔冒出了瘆人的红光。没错,东澳岛的风光审美还停留在了对建筑物打上各种各样的奇怪颜色灯光的阶段,而这座名为「蜜月阁」的亭子被安排上了红光,想象一下这个画面,在一片漆黑的山顶上,出现了一座通体冒着红光的塔,而你的目的地就是那里,可怕吧?放在电视剧里面,里面打斗的剧情估摸着都能演10集,就差一个妖怪站在门口跟你招手说「来吧来吧,我知道你很累了,进来睡觉吧」

纵使是胆大的我也没有勇气扎入那座「蜜月塔」,折中的,我选择了在山顶的一个小亭子落脚,本来想录一个扎营的视频,但是风大到吹翻了数次我的三脚架,便作罢。因为选择在小亭子里面,水泥地面上没办法打地钉,所以只能用绳子把帐篷的几个角绑在亭子的石凳上,帐篷只能勉强稳定住基本形状,一晚上还被吹倒了好几次,调整了几次帐篷的方向后才能勉强把风卸掉,好不容易撑到了第二天早上。

我从来没有想象过我的第一次独自露营就是那么高难度的,当我躺在帐篷里面的时候,海浪声、风声、帐篷被风吹得嗡嗡的声响交替而上,以至于后来听到海浪声都能预料到接下来的事情了。同时还要担心会不会有蛇出现,但是在帐篷拉好拉链的情况下,我倒是不担心蛇会钻进来,反而我担心的是某个保安大哥突发奇想地夜晚登高望远,发现我的帐篷,把我赶走。

庆幸的是,这一切都没发生。

摄影

看似一切都不太顺利的一路,在扎营的晚上起来调整帐篷时有了一丝的转机,在12点多起来的时候发现天上的云已经消散得差不多了,快要接近万里无云的程度,天空也清晰可见。顿时大喜,这绝对是一个拍星空的绝佳时刻啊,可是下一刻就把我从天堂拉回了地狱,还记得前文讲的在路上偶遇的几位建筑大哥吗?原来岛上在南北两侧各有一个大型度假酒店的工地,工地晚上的施工亮光几乎把整个岛都照亮了,那种光污染简直堪比在城市中心,而且在山顶毫无遮拦的地方更是能借助灯光的情况下把整个工地看得一清二楚,这可能在人眼上没啥区别,但是对摄影绝对是致命性的打击。无奈之下只能放弃,便又钻回帐篷睡觉,但是唯一一个好消息是早上的日出还是很有期待的。

东澳岛日出

5点闹钟准时响起,把帐篷收拾好后,天空开始逐渐转亮,山顶的风大到即便是冲锋衣都难以抵挡得住。虽然这里的日出不能说很好看,甚至只能用普通来形容,但是我觉得前一天前一晚的那种铺垫,让我觉得这样的日出也十分值得。在架好相机自动拍摄后,便四处闲逛运动以驱散海风带来的寒冷,然后惊现在我晚上扎营不到5米的低洼处一点风都没有,当时真的是把我震惊到了。为什么昨晚我没有多寻找几分钟来确定营地,为什么要睡在一个风口处。

熬恼之余,山顶上逐渐热闹了起来,建筑工地的几条土狗也上到了山顶游玩,环卫阿姨也开始沿路维护,看到我如此早的就在山顶也猜到了我是在山上露营的,便打趣道「诶,小伙子,没碰到蛇吧」。

拍完日出便下山,5点多的沙滩一个人都没有,边上的店铺也没有开门。我取出睡垫在旁边的草坪上躺了下来,享受了一次真正的海风拂面,而不是夜晚的那种呼啸大风。

实话说,东澳岛的两个海滩都是南北朝向的,所以即便是能下海游泳的天气也不能在沙滩的海平面上看到日落与日出,这估计是整个海岛最让人失望的地方了。在海边扎营,在沙滩上看着日落渐黑,看着日出渐亮,这绝对是一件很美好的事情。可惜这些在东澳岛都做不到,而东西两侧都还处于开发阶段,只有一小小路过去。

钓鱼的那哥们

在草坪上小憩到9点多时,碰到了前一天在海岸礁石边上碰到的老哥,老哥见到我第一句话便是「昨天那哥们钓到鱼了吗」,那哥们钓没钓到鱼我真的不知道,我只知道我跟他在那海岸边上琢磨了快20分钟怎么爬到最外面的礁石上。他寻思着走出去鱼儿好上钩,我寻思着我怎么去到隔壁的石头上拍他钓鱼的画面,数十分钟的折腾,他开始了他的钓鱼之旅,我开始了我的拍照之旅,也算是一拍即合?很可惜的是恰逢国内休渔期,不然说不定岛上还有出海钓鱼的活动。

归程

海岸

也很幸庆昨晚刮了一晚上的大风,把附近的云都吹得一干二净,第二天看到了久违的太阳,沙滩边上也竖起了允许下海游泳的绿色旗帜。天气也在太阳的照射下逐渐变得暖和起来,9点多的时候海里就已经开始有人游泳。

而我此时躺在草坪上,一边吹着海风享受阳光,一边思考着接下来要做什么。夜晚的星空已经泡汤了,海滩的日落日出也没有任何盼头,于是决定提早一天踏上归程。殊不知当我思考完后,发现当天回程的票只有仅剩的一张了,买好票后心情尤其的好,丝毫不考虑某个倒霉蛋还需要在岛上呆多一天,换了一身泳装便加入了游泳队伍中。

这是2022年的第一次「浸咸水」吗?我甚至不记得我上一次去海边是什么时候了。岛上的人本来就不多,愿意下水的就更少了,整个游泳体验简直太舒适了。欢乐的时光总是过得很快,我已经游了一个多小时了。没有吃早饭和午饭的我刚踏上沙滩、重新感受重力时,我累到甚至走不出直线,晃晃悠悠地来到一张椅子上歇息了起来。

西餐-莫吉托

洗漱一番后便踏上了去码头吃饭坐船的路程,不知道是赶上了饭点,还是大家也对这岛有了一丝失望,路上连续碰到了不少人,摆渡车上也逐渐没有了空位。当我再次回到码头时,有了昨天饭店不好吃的印象后,我果断地去了一下西餐厅。毫不夸张的说他家的黑椒牛柳意面是我吃过最好吃的,再配上一杯莫吉托简直太舒适了,我不得不感叹在一个本该吃海鲜的地方居然能吃到那么好西餐,实属不易。但是我也不太确定是不是我前一天没吃到好东西的缘故。

归程的码头大厅上终于不会出现呼喊耳先生的广播了,我也很顺利的搭上了这班船,在排队上船的时候,我发现了好几个背着旅行包的大哥,上面都明显地挂着帐篷和睡垫。好奇在前一天没有找到其他露营者的我上去了搭话。

「老哥,你们昨晚是在哪里扎营的啊,我怎么没见到你们呢」

「啊,我们在万山岛,你呢」

「我就在东澳岛啊」

「这边好扎营吗?」

「地面不让扎,我去山顶扎的」

看着老哥手里的鱼竿装备,我心有所想。啊,原来是在万山岛扎的营、钓的鱼啊,难怪我见不到他们。不过,我懂了。

下一站,万山岛!

]]>
2021: 一地鸡毛 https://www.kilerd.me/summaries-my-2021 每一年的自我审视,不仅仅是写给别人看,更是对这一年的自我评价。活得糟糕与否并不是总结的意义,而是来年避免重蹈覆辙,越过越好才是本意。我也无意跟其他人对比,每个人都有自己的生活轨迹,强行匹配他人的轨迹不见得活得更加美好。

]]>
https://www.kilerd.me/summaries-my-2021 Fri, 31 Dec 2021 22:49:20 +0800 每一年的自我审视,不仅仅是写给别人看,更是对这一年的自我评价。活得糟糕与否并不是总结的意义,而是来年避免重蹈覆辙,越过越好才是本意。我也无意跟其他人对比,每个人都有自己的生活轨迹,强行匹配他人的轨迹不见得活得更加美好。

工作

出差与扎根

这一年出差辗转多地,从西安到成都,最后回到深圳稳定下来。不仅是地区的辗转,每一个地方的流动也代表着一个项目的兴衰。后来跟其他出差来的同事聊天才得知原来的项目早就结束了,并不能延续下来,这个事情其实只是公司层面的影响,并不会涉及到员工个人。毕竟去哪个项目不是写 CRUD 呢,但是反过来想,作为乙方公司的我们很多时候都是在项目初期接管项目的构建,而不会真正地涉及到一个产品在运营阶段的任何事情,这意味着我们只会「做产品」,完全不会「运营」一个产品。

我司是没有产品基因的,这句话说得并不无道理。这种刻在公司基因里的元素直接反应在了员工的执行模式上,很多时候做需求都是草草了事,并不会把视野放得稍微远一点点,为不久的将来做出足够的兼容和扩展。今年在公司听得越来越多的话语就是「先这样实现,出了性能问题再改」,然而在这种情况下,Junior的员工写出了越来越烂的代码,Senior的员工擦的屁股越来越多,公司的任务分配量越来约不平衡,真的很难说有一个良好的氛围。

回到出差的这个话题,2021年后半年基本稳定地扎根在深圳,同时被借调到了另外一个部门,体验了一番其他部门的良好氛围。「归属感」这个词再一次涌入了我的脑海里,足够稳定的项目发展,一眼望去就能看到大部分的人,肆意聊天沟通的氛围确实很让人羡慕。相比入我的部门,大部分人都处于出差、被借调的处境,大部分时候都是网友见面,甚至有些同事一整年都在WFH,这种大氛围下我着实想不到谈何归属感。这一次我又唤起了离开的想法。

扩张、注水和技术卓越性

每一个公司都是一座围城,城里的人想出去,城外的人想进来。这句话很好地描述了当代互联网公司的求职情况。但是对于我司这么一家看似在变好的公司来说,并不见得。

这一切的源头都归咎于公司的肆意扩张,在某些职业经纪人的加入下,公司的行为被一次又一次的「规范」,我们更加开始趋于利润,开始着眼KPI。一个无法避免的恶性循环开始出现:

  • 部门的主管需要KPI就开始扩张招人
  • 一开始不怎么卷、但是现在开始卷的公司给不出高薪,招不到太好的人
  • KPI要求下开始放低要求
  • 公司老员工疲于帮能力不太行的新员工擦屁股,受不了了开始陆续离职
  • 人员出现缺口需要再次招人

是的,一家市场竞争力不强的公司是没办法要求太多的。同时另外一个公司发展关键点在于公司改logo然后成功上市美股。这更加为公司的未来埋下了一颗定时炸弹,本来陆续每个月都会有人离职的公司突然没人离职了,大家都在等着一年后股票兑现了再离职。在这种情况下部门主管的压力会被无限放大,看似良好的部门会在一年后开始土崩瓦解,为了避免一年后的项目正常运作,为了部门员工还能满足主管KPI的需求,扩张与放水被无限放大,恶性循环加剧。

这一年里,3年工作经验的我逐渐往senior的title靠近,但是越是靠近越觉得公司的「技术卓越性」都是虚假的。作为一个乙方公司,涉及不到太多甲方公司的核心业务,以至于一整年都是在做简单的CRUD,最难的事情只能停留在对已有系统SAAS化改造的方案设计了。很多时候我们并不是再迈向技术卓越,而是为自己,为TL的技术选型买单。无数次因为TL选择了 Hibernate,不是Mybatis 而加班。又加上上文说的注水情况,新员工在代码上逐渐倾向于「业务能跑就行」,而苦了我这些对技术稍微有一些追求的人。他们就花技术重构的时间来摸鱼,我就只能加班为他们擦屁股,人生不值得说的估计就是这样把。

项目的老员工逐渐离去,而我也能在经验上拍得上名号,很多次都因为他们的技术不卓越在Code Review阶段吵起来,脾气也变得越来越不好,后来我也懒得再去理这些东西了,毕竟又不能写到自己的KPI上。不值得不值得。

再回到公司层面的技术卓越性上,「无」这个字真的很好的概括了这家公司的情况。没有靠谱的跨团队技术分享,没有公司,甚至部门层面的技术wiki,这对一家公司来说简直太离谱了。

写到这,我回想到了两年前的公司。在项目与项目的 gap time,之前还可以自己学习技术,扩展技术广度深度,写文章,写书。但是这一年来,只要你是在gap time,都会被拉入一些无意义的培训里面,自由度再次降低,这是公司发展的必然,也可能归咎于某些职业经纪人吧。

在这样一家走下坡路的围城公司,保留自我内心估计会是最重要的事情了吧。

跑,赶紧跑。

技术

我的技术生涯是分阶段的,完全割裂的:从大学时代的Python为主,到上班时候的Java后端,下班的Rust学习,我觉得我的技术生涯是很悲哀的。

上班写着自己完全不喜欢的语言,工作时间糊业务,下班不写Java,完全没有自己认真地去读过学过Java底层的内容。也幸好得益于公司的业务模型让我并不需要太了解Java的底层,不需要设计JVM虚拟机、字节码之类的。在业务类型上只要吃透 Spring boot 就可以达成公司对 Senior的标准要求,而我在这方面做得十分好,也符合了我对上班的定义。

但是从另外一方面讲,这是一个很致命的问题。上班时从来没有人说过我的技术很好,确实相比于其他专精于Java的同事来说我真的太菜了。这是一个信号,一个可能会影响我职业生涯的信号。我也正视过这个问题,我反问了自己几个问题:

  • 当前的Java技术能满足在公司1-2年的发展吗?
  • 跳槽后你还写Java吗?

是啊,不换公司我能满足于工作要求,换公司我更宁愿去写 Rust,而不是Java。那这个问题可能真不是太大的问题,尤其是在我还打算一年后跳槽回广州的情况来看。

PL 与 Rust

我很想说我是一个PL人,但是我很清楚我对于PL什么都不懂,我也很乐意去学这个方向。我一直在坚持的Rust 就是一个很好的 PL 届良性例子,有了太多太多很棒的设计与理念。尤其是我这种情况:上班写着一个历史包袱很重很重、面向对象典范的Java,下班写着几乎象征着很优秀PL设计的Rust,在两者的长时间对比与实践中也更加坚定了「不学 Java、深入 Rust」的观点。

而关于 Rust 的优点其实我并不想在这里深入地阐述,从2016年左右开始慢慢接触 Rust,到现在能用 Rust 写出中小型软件的情况下,不仅是我个人的技术进步,更加是陪伴见证了 Rust 这门语言的进步。无论是 NLL,null-safe,tagged union,错误处理,pattern matching 等等优秀的设计都是在Java 中体验不到的。每次在上班的时候碰到 NullPoinerException 的时候,我就在感叹 Kotlin 和 Rust 的好。

Side Project

前些年,我注册了 3min.work 这个域名,本意是用来做一些小项目的存放,寓意着「3分热度」。3分热度有3分收获,确实我收获了很多,折腾了服务器集群部署,折腾了很多试验性的前端框架,折腾了很多有意思的东西。同时这也意味着我在某一项领域并没有做到深耕。

在把部分业余直接分割到阅读和摄影之后,我发现我已经没有足够的时间来进行探索性的折腾了。仔细想了想这一年确实并没有做出什么让自己很满意的作品,哪怕是小作品也没有,都是各种浅尝即止,着实很失败。于是我并没有继续续费 3min.work,我希望接下来自己能专注于一两个项目,老老实实做下去。在我脑海里面一直萦绕着几个想做的东西:

  • 记账软件
  • Instagram类似的图片分享软件
  • typhoon 编程语言

这估计也是也会是我接下来会继续坚持的项目吧,其他的估计就不会太怎么维护了。实在是精力不够。

除了项目,这一年我基本也做出了自己的技术选型,Rust + NextJS 会是我以后的前后端技术栈。

摄影

出差得越多,见得越多,便越喜欢拍照。手里拿着的佳能200D配套头再也不能满足于我的拍摄,后来买了一个新的 28-200的超长变焦头,拍出了一些我很满意的照片,记录下了很多很精彩的瞬间。这是我认为人生新的意义,人生确实不应该只有眼前的编程,更有远方的风景和美食。

很可惜,很多照片并没有发布到互联网上,没能让大家看到,我觉得这是一件很遗憾的事情,也没能把旅途中的故事写下来配套的图片。这可能是我执着于做一个 Instagram 类似物的缘由。希望能够快点做好分享出来。

再后来索尼出了 A7m4,我便下定决定要买这一机子,在加价的情况下很顺利的在12月初拿到了机子,也买了一个适马的 35 F1.4 定焦头。很多朋友都不是很理解这种行为,但是我已经在上一台机子上测试过自己不是那种「买相机拍几次就吃灰、然后用手机拍照」的人了。所以对于一个成年人来说为自己的一个成熟爱好投资3-4万块并不是一个很离谱的事情。

视频与 vlog

每年 iPhone 都会给我发一个 annual summary,自动地把一年里面精彩的事件照片制作成一个简单的slide,又得益于live photo 的存在,slide 是一个看似简陋剪辑的小视频,每一张照片都是带声音、带动效的。看似简陋但是透露出了照片表达不出的临场感,自此我好想有点爱上了拍视频。

再有一次,EDG打决赛那天晚上,带着相机去拍了拍线下观赛点的照片和视频。直到现在回看起夺冠的现场视频,都会有一种肾上腺素分泌的感觉,这是照片无法比拟的震撼。

这也是我为什么要买索尼的原因,强大的对焦系统让拍视频变得简单。摄影圈有一句名话「拍到永远比拍好更又意义」,自此我希望我能够成为一名摄影师。

2022 年我希望我能够运营一个自媒体账号,学习剪辑视频发布出去,哪怕没有什么人看,哪怕剪辑很烂。

记忆会消散,但是照片不会,视频不会。

感情

我哥在上一年结了婚,毫无疑问,爸妈开始对我进行了催婚,进而进行了相亲。掰了掰指头,我已经快26岁了,因为是年底出生的,虚岁已经快30了。 爸妈就已经以这一点做威逼「你看你都快三章了(三十岁),是不是该结婚了」。

老实说,在如今的时代和我接受的教育,单身并不等于孤独,也不等于不幸福。每个人在当今都开始趋向于做一个精明的利己主义者,在这个大前提时下,男女各方都很难拉低姿态去对另外乙方委曲求全地讨好对方,那么除非是出现 1+1 > 2 的「交易」下才能催生初所谓的爱情。

阶段性陪伴

这一年遇到了很多人,很多人也「逐渐消失」了在我的人生中,从某段时间的亲密无话不说,到最后的打开聊天框不知道说什么,只能在对方的朋友圈里面回复几句「真好看」。这中间发生了什么呢?估计是某一次聊天无话可说的一句「哦」,也可能是冷静过后的暧昧消散。不管如何,事实就是某些人在那段时间内的陪伴已经变成了过去式。只有翻篇才能遇到更好的人。

这里说的陪伴并不特指恋爱关系的陪伴,也指的是那些你以为会成为好朋友,成为知己的陪伴。

相亲

在爸妈没办法接受新时代的恋爱观和爱情观的情况下,相亲被迫进行。老实说我并不抗拒相亲,像我一个同事所说「你只有经历过它,才有资格,才有本钱去讨论和吐槽它」,我就冲着不成也积累、记录生活经验、认识一些朋友的冲动去相亲。

但是我远远低谷了相亲这件事带来的背后影响。在我爸妈知道我「接受」相亲之后,就开始推女孩子的微信过来。在那一次,我是真的被委屈到哭了。推过来的女孩子甚至都不知道我要加她,这还不是最重要的,重要的是我爸妈连续五天每天都给我打电话问我跟她聊成怎样。要知道再次之前,爸妈甚至一个月都不会给我打一次电话,纯属放养状态,那一刻我甚至不知道到底谁是谁的孩子,谁更重视谁。

后来我也思考了一番,相亲这件事于几方人的意义是什么。我甚至我爸妈绝对是很开放的人,但完全不清楚在这件事上为何态度会这番奇怪。

作为一个自认生活质量还算高,精神世界丰富的人来说,我绝对不会滥情或者缺爱到需要向一个没见过面、朋友圈啥都没有的所谓「相亲对象」倾诉我的个人生活,更何况对方的回应甚至还不如一个类似的舔狗AI,甚至不如一个卖茶叶的美女回应强烈。那我这是何必呢。

后来又经历了一次相当传统的相亲国产,我妈带我去见一个女生和她姑姑。这个过程简直尴尬到丝毫没有任何值得描述的价值,全程是家长在讲话。

父母的婚姻价值观还停留在了「相亲看一看,觉得可以就自己聊一聊,差不多就结婚,感情啥的之后在经营」。可是现代年轻人对此确实不屑一顾。

我可不愿意一辈子就这么娶一个自己不了解的人,一不小心遇到一些脾气暴躁的,怕不是被欺负一辈子。庆幸的事,这几件事之后,回去对着父母大发雷霆一次,他们对于相亲这件事已经克制了很多了。我觉得这辈子有那么几次相亲经历就足够了。

总结

这一年,看开了很多,承认了自己平庸的事实,放弃了一些自己不切实际的想法,过得很忙,已经不再计较忙得有没有意义了。

就这样吧,希望下一年还能过得更好,找到一个对的人,做出一些有成就感的作品。如同我兄弟说的「我无数次幻想着你我带着女朋友,开着一辆车出去旅游,你拍我,我拍你,开车你累了我开」。是啊,我也无数次幻想过这样的画面。

]]>
奇怪的内卷:感谢联通 https://www.kilerd.me/daily/dance-of-companies 从很早开始只有联通在B站上面发舞蹈区视频,但是只能说是平平无奇并没有引起太大的波浪,基本只有10多20几万的播放量,基本上很少能登上分区的排行版,直到中信银行在2020年6月发出了书记舞一下子就打开了这个潘多拉魔盒,成功出圈,也能在他的视频上面看到无数的「感谢联通」弹幕。

]]>
https://www.kilerd.me/daily/dance-of-companies Thu, 10 Jun 2021 23:53:29 +0800 从很早开始只有联通在B站上面发舞蹈区视频,但是只能说是平平无奇并没有引起太大的波浪,基本只有10多20几万的播放量,基本上很少能登上分区的排行版,直到中信银行在2020年6月发出了书记舞一下子就打开了这个潘多拉魔盒,成功出圈,也能在他的视频上面看到无数的「感谢联通」弹幕。

再到后来【书记舞】论初中生有多呆 一个普通视频发出来之后,就更火了。这个事情说起来就离谱,一个跳得普普通通的舞蹈就因为舞者年纪太小了,然后各位看官就在评论或者弹幕里面发了一个 「小妹妹不要在网上晒自己,要多学习提高自己的知识才是正事,那么就让XX来考考你一个问题:blablabla」。起初这种问题还是挺正常的,比如什么解方程式啥的。但是这一下就炸开了锅,啥人啥玩意的问题都出来了。有大学官方号来问土木的,有问建筑的,有问物理的,有问计算机的。随之把这个视频推到了一个莫名的高度。

有了中信银行的书记舞和这个小妹妹的爆火书记舞,这些移动联通的官号怎么还坐得住啊,一下子就全部冲出来拍了相对应的视频,其中最火的估计是招行的这个了【招行特供】 ❤ 挑战全网最甜书记舞 ❤

在这段时间内,有无数企业加入了这场莫名的战斗,本着「他们能拍,我也要拍」的宗旨,于是这些官号不再是那种只会发广告的视频了,同时他们也发现了发舞蹈视频能带来很大的人气,同时把需要宣传的内容放在评论置顶,能带来相比于之前的模式更大的收益。于是乎内卷出现了。

「感谢联通」这句话就随着不断的发展已经找不到比较靠谱的源头了,而跟着出现的是「感谢招行」「感谢移动」「感谢平安」。没有人知道这场战争到底什么时候会结束,也没有人知道还有谁会加入这场有声的PK。这就如那句「好人一生平安」的祝福语一样,给沉闷的舞蹈区带来了一丝不一样的气息。

最后是整理出来比较出名的一些作品:

发布时间视频名称
2020-06-05 12:00:56【中信银行信用卡中心】爱杀宝贝ED 还原:客服双人组,老胳膊老腿不容易
2020-06-27 11:00:46【中信银行信用卡中心】超还原书记舞,不看错亿!
2020-09-29 17:49:58【国家电网】高甜预警❤️JK制服女孩来啦❤️
2020-11-20 12:10:18【中国联通】睫毛弯弯- 把爷青回打在公屏上!
2020-12-15 19:00:02【中国电信】不跳书记舞的官方不是好官方
2020-12-22 15:27:45【中国联通】客服小姐姐版-dududu️❤把爱了爱了打在公屏上
2021-02-14 11:00:19【中国联通】你的女友❤
2021-03-26 18:16:02【招行特供】 ❤ 挑战全网最甜书记舞 ❤
2021-05-07 18:32:59【招行特供】❤️ 元气 ❤️ 阳光 ❤️ 超甜 ❤️ 我们一起与梦盛开
]]>
年轻人的第一次删库跑路 https://www.kilerd.me/accidents/first-time-deleting-database-on-production 恭喜自己,这是第一次生产事故,也是第一个 T0 事故

规范化自己服务器的事故级别:

  • T0: 极其严重事故。用户数据造成不可逆的损失
  • T1: 严重事故。用户数据造成可逆损失,需要 ops 的接入恢复数据。或者服务功能出现不可用情况
  • T2: 一般事故。用户数据未损失,只存在显示异常等展示型内容异常
  • T3: UI 异常。因为UI布局等问题,导致使用出现了麻烦
]]>
https://www.kilerd.me/accidents/first-time-deleting-database-on-production Mon, 19 Apr 2021 16:53:33 +0800 恭喜自己,这是第一次生产事故,也是第一个 T0 事故

规范化自己服务器的事故级别:

  • T0: 极其严重事故。用户数据造成不可逆的损失
  • T1: 严重事故。用户数据造成可逆损失,需要 ops 的接入恢复数据。或者服务功能出现不可用情况
  • T2: 一般事故。用户数据未损失,只存在显示异常等展示型内容异常
  • T3: UI 异常。因为UI布局等问题,导致使用出现了麻烦

基本

  • 编号: 0x0001
  • 发生日期: 2021-04-17
  • 级别: T0

过程

04-17 下午,登陆生产服务器,执行 yum upgrade update 命令,缘由是服务器的Docker环境版本过于低,需要做一系列更新。 执行完命令后,服务器自动重启,并未给出对应的重启提示,初步判断是由于涉及到了内核的更新。更新后迟迟不能恢复服务,接入VNC查看后,虚拟机无法加载磁盘,至此事故发生。服务器内容全部损失。

事后及时联系服务商,企图拿到损坏的磁盘镜像进行恢复,结果是拿不到,得到的建议只有重装。

奇怪

在重装后,找服务商要回了同样的磁盘系统模版,安装后重新执行 yum upgrade update 命令,也没有产生任何内核级别的更新,甚至一个更新都没有,所以这次事故的根因到底是啥我一直没太懂。

影响

  • 服务器中带状态的服务只有 Miser,且用户只有本人,由于没有做及时的定时数据备份,导致数据只能恢复到3个月前人工恢复的版本。
  • 服务相关文件储存传到了 backblaze 服务器,不受该次事故影响

Actions

这件事情后,我思考了很久关于「正式运营一个产品」的事情,不幸中的万幸是 Miser 自始至终都只有我一个人在使用,即便是朋友多次说想一起使用参与整个产品的发展。我个人的数据其实并不是那么值钱,但是一旦有用户真正的把内容放在你这里,然后你还弄丢了,那么诚信和信任度会变得很低很低吧。

关于监控

在此之前我只在服务上面搭了 sentry,用来实时监控服务里面未处理的异常情况,即 5XX 的错误的收集。而实际上在运营过程中还碰到过很多奇奇怪怪的问题,比如说突然间CPU彪得很高,内存突然吃紧了。

由于用的是传统的虚拟机,并没能上自由伸缩的云,因此 CPU、内存等系统级别的监控也是应该提上日程的,也就是说一套完善的 prometheus + Grafana 的基本配合是一定要部署到生产的。

关于可靠性与备份

说起来很奇怪,实际上我已经数个月,快半年的时间没有动过生产服务器,基本都是通过 GitHub Actions 来实现一套完成的 CI/CD 的。这次手动登陆服务器的缘由便是上去把备份服务部署上,而就是这么一次操作导致了事故的发生,也有可能是生疏了。

服务商跟我讲得很好,我的操作确实存在很大的问题。他说如果他来会这么操作:先拍一个硬盘snapshot,然后执行操作,没问题万事大吉,有问题用snapshot恢复整个磁盘。

确实,我这一次并没有很好地做一次dryrun,每次都是直接跑docker pod 级别操作,出问题了就直接rm,这次在宿主机的操作实在缺少了一丝敬畏。

为此

  • 减少对宿主机的直接操作,docker 集群搭建起来之后应该都在docker 层面操作
  • 备份服务的优先级应该被提到最高,毕竟数据才是重中之重
  • dryrun的可靠性,一套跟生产一一对应的dev 或者 uat 环境的必要行需要进行思考
]]>
狗子的一天 https://www.kilerd.me/days-of-dogs

有一种思念叫你家的狗子在想你

]]>
https://www.kilerd.me/days-of-dogs Fri, 05 Mar 2021 22:13:03 +0800

有一种思念叫你家的狗子在想你

旺旺

我家在农村,在家附近有一块老妈围起来的菜地,一半种青菜,一半养鸡。同时妈子还养了两条狗,一条黄白色,一条黄黑色;一条住在菜园子的东南角,一条住在西北侧,每天遥遥相望;一条叫旺旺,另外一条也叫旺旺。由于村里偷狗现象很严重,所以旺旺是一年四季一日三餐都是锁在菜园子里面的,活动空间只有狗窝附近的一平米地。

我也不知道狗子它知不知道它自己叫旺旺,也知不知道另外一只也叫旺旺。但是奇怪的是,每次一喊旺旺永远都只会有一个狗子回应你,似乎它们之间达成了某种奇怪的协议,每狗每天轮流上班8个小时。

幸运还是悲哀

旺旺是母的,同时也是幸运的,相比于旺旺她被允许晚上松开狗链,因此她获得了夜晚整个菜园子的制霸权,旺旺只能蹲在自己的窝里面眼巴巴地看着她欢腾飞跃。当然,自由是有代价的,有一次旺旺利用漏洞钻入了菜地,弄死了很多白菜花菜,随即而来的是一顿来自母亲慈祥的打以及“禁闭”数天。

旺旺是公的,是悲哀的也是幸运的。他的品相很好,即便养在黄泥地里面,他的毛发都亮呼呼的,蓬松地不想一只狗子,反而更像一只小狮子。但就因为是狮子,叫声洪亮,凶恶的他终日无法脱离狗链的束缚,用我妈的话来说“晚上放两个狗出来菜园都可以给你拆了”,他是悲哀的。幸运的是旺旺很爱他,每天晚上被放出来的旺旺绕着菜园子跑几圈、上完厕所就主动回来陪他,直至早上。虽然说脖子上长年挂着狗链,但是一到晚上的相遇相见似乎可以冲淡这一切。

生产事故

直到有一天,干柴烈火的她们嘛,总要干出一些大事,不出我们所料,旺旺怀孕了,这是听我妈说的,我可没在家里看着。狗嘛,在基因里面就写好了如何生娃如何养娃的能力,所以我们就没怎么理旺旺,依旧遵循这白天锁晚上放的放风策略。

在某一天的很早的早上,我妈听到了旺旺发出的不同寻常的嘤嘤声,只在阳台上看了一眼确认了旺旺的安全就没有多理,直到早上进入菜园才发现刚出生没多久、还没看眼的小狗子掉进菜园子中间的鱼塘里面了。又由于旺旺平时没有下水游玩的习惯,只能在边上嘤嘤地叫。最终就酿出了这局惨案,可惜了那两个还没看眼只是在到处找奶吃的小狗子。旺旺也因为这事抑郁了好几天。

过年

过年这件大事并不是对人类有重大的意义,对旺旺而言同时也是“重获天日”的一段日子。因为我的回去,旺旺每天都能获得二十分钟左右的游玩时间,那是唯一一段可以看一眼村子的时刻。

由于我家只有一条多余的狗链,放风一次只能放一个。只要你敢放旺旺出来,那么旺旺绝对对急得乱蹦,口吐芬芳地乱叫,如果我听得懂狗语,那旺旺大致说的应该是“你怎么放它出来?我呢?你怎么不放我?明明是我先摆尾的,明明是我先来的。WDNMD”。不过即便听不懂,看旺旺那个架势和语气,应该差不多就是这个意思。

只要你牵着一条狗子出现在另外一只的视野内,他都会嘤嘤地叫得不停,唯一的方法就是走远走远再走远,直到它看不到:“原来爱是会消失的,对吗” 便趴会自己的窝边上。

可能是天生骨骼惊奇,也可能是长时间没放风,狗子的力气极其大,甚至有种把你拽飞的节奏,20分钟的遛狗可以称得上一天的运动量了。

村里隔壁家散养了3条狗,每次我把那只狮子般的旺旺带出去溜时,他们都需要紧紧地站在一团,生怕我家的旺旺会手撕了落单的他们,实际上旺旺温柔地很,就是除了力气大一点以外。

再次生产

年后离开家的不只是我的人和思绪,还有旺旺对我的一丝牵挂,那一份遨游村子的盼念。同时旺旺也再次怀了孕,这一次我和我妈讨论了很多怎么看护小狗子的事宜。可惜的是在临走一天还是等不到她生产的那一刻。

]]>
你可能真的不那么需要复式记账 https://www.kilerd.me/things-about-double-entry-accounting 我翻了翻 GitHub 上面的项目记录,从2019年的2月开始初始化了记账项目的代码库,到现在2021年的近2月,也该是时候说一说我这两年来设计到的记账心路历程。

关于记账这件事早就已经是一篇红海,做的人很多,死的更多。无数项目突然涌现出来而后又死得消无声息。这篇文章会从几个方面来细数为什么我们那么喜欢记账而又坚持不下来:流水账与复式记账、趣味性与专业性、自动入账与手动入账。

]]>
https://www.kilerd.me/things-about-double-entry-accounting Wed, 03 Feb 2021 18:44:53 +0800 我翻了翻 GitHub 上面的项目记录,从2019年的2月开始初始化了记账项目的代码库,到现在2021年的近2月,也该是时候说一说我这两年来设计到的记账心路历程。

关于记账这件事早就已经是一篇红海,做的人很多,死的更多。无数项目突然涌现出来而后又死得消无声息。这篇文章会从几个方面来细数为什么我们那么喜欢记账而又坚持不下来:流水账与复式记账、趣味性与专业性、自动入账与手动入账。

流水账与复式记账

流水账是指只记录了「花了多少钱在什么事情上」,比如「今天吃饭AA了100块」。看起来似乎确实满足了记账最简单的几个点:什么时候花的钱;为什么花了钱;花了多少钱。但是在对账的时候却很难定位到账单与记账的关联。

为了能更清晰地阐述流水账与复式记账的区别,我会根据「AA吃饭」这个例子给出一个更加具体的场景。

1月1号,你跟 A 先生与 B 先生出去吃了一顿饭,一共300块,AA下来每人100块,于是你先用微信支付了300块。

1月3号,A 先生转了100块到你的支付宝账户。

B 先生作为一个老赖,「无意」地忘记了还你钱,同时把你拉黑了。

那么对于流水账来说你可能记录了两笔账:

  • 1月1号,吃饭消费 300 块
  • 1月3号,A先生转账100块作为AA平摊费用

那么随即而来会有几个比较明显的问题:

  • 为什么我这个月吃饭的钱花了那么多?
  • 还有100块钱的差额去了哪里?(如果你还记得B先生没有还你100块的话。)
  • 当前你的微信支付还有多少钱?支付宝账户呢?

总结下来流水账的弱点出现在资产流动不明确与资产占比不明确上,复式记账比较好地解决了这几个问题。

复式记账

在会计学中,复式簿记(又称为复式记账法)是商业及其他组织上记录金融交易的标准系统。

该系统之所以称为复式簿记,是因为每笔交易都至少记录在两个不同的账户当中。每笔交易的结果至少被记录在一个借方和一个贷方的账户,且该笔交易的借贷双方总额相等,即“有借必有贷,借贷必相等”。

例如,如果A企业向B企业销售商品,B企业用即期支票向A企业支付货款,那么A企业的会计就应该在贷方记为“销售收入”,在借方记为“现金”。相反地,B企业的会计应该在借方记为“进货”,并在贷方记为“银行存款”。

借方项目通常记在左边,贷方则记在右边,空白账簿看起来像个T字,故账户也被称为T字帐。

复式记账来自于会计行业的专业术语,简单来说我们要记录每一次交易的来源与去向(在会计学中称之为 credit 和 debit)。那么我们通过这种方式来分析刚刚举的AA吃饭例子。

1月1号,你跟 A 先生与 B 先生出去吃了一顿饭,一共300块,AA下来每人100块,于是你先用微信支付了300块。

300块看起来是从「微信账户」流转到了「消费:吃饭」账户,但是实际并不是,因为对于账户所有人来说,只花了100在「消费:吃饭」上,剩下的200块实际上是用一种类似信贷的方式由你借给了A先生和B先生。所以对于复式记账来讲,这里要拆开三条记录:

  • 100块: 「微信账户」流转到「消费:吃饭」
  • 100块: 「微信账户」流转到「借款:A先生」
  • 100块: 「微信账户」流转到「借款:B先生」

自此我们解决了上述说得第一个问题「为什么我这个月吃饭怎么花了那么多钱」。复式记账的好处之一就体验出来了:你可以把一笔交易拆成很多细小的组成部分。如果要举另外一个例子的话可以是工资收入,你的3000月薪实际上是从「公司」账户流转到了「银行卡」「公积金」「医疗保险」「养老金」等数个账户,这也能统计出来你为国家交了多少税。

1月3号,A 先生转了100块到你的支付宝账户。

对于这一条记录,很简单就是从「借款:A先生」流转到「支付宝账户」。自此 「借款:A先生」的余额从「100.00」抹平到「0.00」,意味着 A 先生不再欠你的钱。

B 先生作为一个老赖,「无意」地忘记了还你钱,同时把你拉黑了。

对于这位老赖并没有产生任何在记账上的记录,但是不要忘记了在第一笔的记录中,「借款:B先生」是被记录成了 「100.00」 的,那么这个情况,它再也不会消失。

依靠 「微信账户」「支付宝账户」「消费:吃饭」「借款:A先生」「借款:B先生」这几个账户的变动,我们可以成功的算平我们的收入与支出:

  • 「微信账户」 - 300.00
  • 「支付宝账户」 + 100.00
  • 「消费:吃饭」+ 100.00
  • 「借款:A先生」0.00
  • 「借款:B先生」 + 100.00

得出来的总计是0.00,证明我们的记账流程并没有出现差错

这里可能有人比较纠结每个账户上面的正负是什么意思,这就是ledger-like app的优势,相比于传统的复式记账,一笔账需要在两个账户中同时记下一笔交易,ledger-like 使用了正负来代表了credit 方和 debit 方,一条记录就完成了传统的两次记录的复杂模式,也让交易更加人类易读。

趣味性与专业性

上部分介绍了流水账和复式记账的区别与复式记账相较于流水账的优势,这里会直击问题的本质:你真的那么需要复式记账吗?答案可能是否定的。在长时间的记账之后,统计发现,在绝大部分时间的交易都是简单地从一个账户到另外一个账户的划转(这里把消费也当做了一个独立的账户),那么复式记账其实会更加麻烦,因为现在的流水账软件也能够新建各种账户,所以复式记账的分账户记录也不再是一个比较突出的优势了。

对于例如工资收入、购房贷款、分期购物等一些在时间跨度上比较长的交易,在流水账的APP上也有类似的循环交易与拆分交易的功能。

同时流水账的APP普遍都做的比专业软件更加适合用户使用。记账是一件很枯燥很无赖也很费事的时间,APP就会抓住这个痛点:简化日常记账流程、美化记账界面、增加趣味功能。对于趣味性,有的APP把记账设计成是跟一个 IDOL 聊天,有的把界面设计成一个养成系游戏(记账城市的思路大赞)。很明显这些设计有很有效,确实抓住了一部分人的痛点。

相比于 ledger 和 beancount 的文字编辑型的记账模式,一来需要学习它特有的写法,二来没有比较用户友善的界面,三来没有APP。APP真的是当代的一个超级大的痛点,没有APP意味着你不能随时随地地在手机上记账,需要找一个专门的时间来记账,这会击退绝大部分的普通用户。

但是无论流水账的趣味性做的再好也能难撼动以 beancount 在部分注重资产流转的用户的地位。主要的原因有几:

一,对账功能真的是让复式记账的门槛再次降低,它指的是你可以对某一个时间点把账户的金额抹成一个你指定的数。这用于少记了某几笔金额极小的交易,账户日常派息等无法感知的情况。 这对于流水账APP来说是一个难以解决的问题,就会经常出现记着记着就发现软件上的账户余额跟真实的账户余额对不上,也很少人愿意一笔一笔地往下一笔一笔的核对,导致最后流水账软件上的信息不正确,这也是流水账用户“弃坑“的原因之一。

二,复杂而专业的查询与报表功能。绝大部分APP都无法针对性地定制自己的报表,只能给你一个账户消费占比,月度余额波动图等比较笼统而无用的报表,如果想做到「某几天因某原因在某地的消费总额」就需要使用beancount 中的查询功能,这也是这一类软件比较强化的点,分析为主。

自动入账与手动入账

自动入账在国内巨头垄断的局面下基本是不太可能的事情,市面上唯一一款能直接对接支付宝和银行卡账单的记账软件「网易有钱」也凭仗着自己也是巨头而做到了其他小型开发商做不到的事情。

想比如国外的信用卡广泛使用的场景不同,国内的消费习惯都是走第三方交易的,简单来说就是由于电子交易的普及,大家都习惯于用支付宝和微信支付,而在使用这两种支付方式的时候也是通过绑定银行卡来实现金额划转的。那么在银行卡的账单系统中就只能记录下从「银行卡」到「支付宝」的账面信息,这对记账是没有任何意义的,因为你不知道支付宝到底支付了什么东西,而真的交易信息存在了不怎么开放的支付宝里面,微信也是同理。

即便采用了账单导出的功能,那么银行卡账单于支付宝账单去重也是一件极其复杂和麻烦的事情。市面上也有不少半自动的从支付宝手动下载账单然后脚本导入的模式,但是本质上并没有解决这一个痛点。

关于造轮子这件事

对于我个人来说,Beancount这款复式记账软件已经够用了,也极大的降低了复式记账的复杂度。但是我这段时间还在模仿beancount造一个记账软件出来的意义,对于其他人来说可能不是痛点,但是对于我来说缺难以忍受。

我个人是有攒小票的习惯的,那么不能在交易上把小票等信息关联起来就是一个难以接受的事情,所以我为交易增加了一个附件的功能,它可以关联各种形式的文件,我现在用来关联购物小票,购物发票,借款声明等等。但是目前还没有办法把关联的图片直接显示出来,只能通过下载查看,这是一个比较大的问题。

上述说到日常的90%交易都是点对点的账户交易,beancount并没有优化这种交易的记录模式,我也是为了解决这个痛点,让日常记账更加容易和易读。

beancount中是有tag系统的,可以为交易挂上各种标签,比如说这些交易都是发送在某次旅行中的,但是这个「挂标签」的动作是每次交易都需要手动加上,所以我就自己写了一个事件系统,可以开始某个事件结束事件,在事件中记录软件会自动加上事件定义的标签,从而解决通过标签分类账单的问题。

为了可以延续beancount的生态,导出成beancount的文件,从而接入beancount强大查询功能也是在计划当中。

广告

如果你对记账感兴趣,而且有空,那么可以联系我,缺UX!缺前端!(如果能有人糊一个APP出来就更好了

]]>
2020:他说有点累了 https://www.kilerd.me/summaries-my-2020 “你说,你觉得我是一个很菜的人吗?”我没有回答,只是静静地拿起了桌上的酒杯,事实上我也不知道怎么回答?

坐在我对面的是耳先生,我和他已经一年没有见面了。今天是圣诞节的晚上,很难得我可以把他约出来,但是坐下来已经快半个小时了,他才缓缓说出这句话。

]]>
https://www.kilerd.me/summaries-my-2020 Sun, 10 Jan 2021 01:27:11 +0800 “你说,你觉得我是一个很菜的人吗?”我没有回答,只是静静地拿起了桌上的酒杯,事实上我也不知道怎么回答?

坐在我对面的是耳先生,我和他已经一年没有见面了。今天是圣诞节的晚上,很难得我可以把他约出来,但是坐下来已经快半个小时了,他才缓缓说出这句话。

我从他的眼里看出了一丝疲倦,我不知道他这一年里经历了什么,我也不知道怎么回答他,只能举起了酒杯跟他碰了一下,然后就等着他自己讲出他这一年碰到的事情。

我认识耳先生已经快有十年的时间了,他在我的印象中一直都是一个很勤奋很聪明的人,他身边的人都很习惯地碰到什么问题都会找他解答,耳先生他也是来者不拒。

“我感觉我这一年好像有这么荒废过去了,想学的没学好,还莫名的受了好几次气”他咕咕咕喝下一大口啤酒说。“不会啊,你不是还跟我说过你把好几个软件都写出来了吗?那个叫什么stap...”终于我能搭上了话。“哦,你说的是 staple啊,嗯我是把他快写完了”

对,就是Staple,按照他当时的说法,他要做一个静态博客生成器,就跟hugo那些一样。我是不知道Hugo是个什么东西,但是我知道静态博客生成器是什么。在他编写它的那一段时间里面有跟我保持联系,我们一直在讨论staple怎么设计好,需要做什么功能。虽然我不是一个互联网从业者,但是我从一个小白和使用者的角度跟他聊了很多很多,他也很会从一个通俗易懂的角度跟我阐释清楚。他这是把我当成了产品经理了。

我很佩服耳先生的一点就是他似乎拥有着无尽的动力去写自己感兴趣的东西,但是别人不知道的是他基本花光了他所有业余的时间在上面。他是我见过为数不多真正喜欢编程的人,同时我也挺替他感到惋惜的,这样优秀的人却在外包公司写着屎一样的代码。我也跟他聊过这点“要不你就准备准备,面一个BAT,以你的水平应该是进得去的”

“可我实在没办法忍受加班,那样我就没办法出去玩,没办法学习了”,也对。

“干了!”耳先生看着酒杯里面不多的啤酒,说出了这句话,然后我们的话题又回到了“菜”上面。

耳先生菜吗?这个疑问从他说出那句话开始我就在思考。你说他厉害吧,他的英文水平着实从高中开始就是每天都是被罚站的水平;你说他菜吧,他的逻辑思考能力确实又超出常人一丝。

“其实你还是挺优秀的啊,至少你同事对你的评价都是挺正面的啊”

耳先生深深叹了一口气,我也知道,他是那种一遍学不会就再学一遍的人。他说他花了那么多时间在上面才有那一点点的产出和收益,只能说明他真的没有这方面的天赋,他又举了几个TU毕业、咕咕噜上班、BAT上班的人,说他们平时聊的东西自己根本就没怎么接触过。“你看,差距就是这么来的了”。确实啊,这让我又想起了他提及过高中一个每天都在拼命学最后只刚刚达到重本线的女生。耳先生的一部分压力其实也来自于他公司内部,他公司里面有着数不胜数的 985 和海龟学生,每一个的资历都是吊打他的,在那个环境下我可能都会感到压抑。

想起了他英文课天天被罚站,我就想起了他要做的一个产品 Resource.rs ,那一个他跟我讲的时候满眼放光的产品:“我要整合网络上关于 rust 的各种资讯、各种文章,给中文学习者一份很大很大的礼物”。到现在一年过去了,我估计是为数不多偶尔会点开看看更新情况的人,这个网站的内容已经很久很久没有更新了,本身就为数不多的内容很多都已经过时了。我本人是很欣赏这种想法的,把一个门槛很高的东西用简单化的语言讲述出来,这是一件很高尚的事情。“估计也只有程序员才那么傻愿意投入大量时间维护和撰写这种没什么收益的事情”我心里这样想,但是我没敢告诉耳先生。

耳先生跟我说他可能做不下去了,总结了一下价值有一下几个原因:

  • 能力不足。互联网上的资源是参差不齐的,为了筛选和总结这些内容意味着维护者需要有辨识这些内容的能力和产出总结文章的精力。那么这要求了维护者至少是一个rust精通使用者,耳先生自认为达不到这样的水平。而且这便随即带来另外一个问题,如果维护的内容很官方的话,那么这跟《TRPL》、《Rust by example》没有区别了。一个人靠业余的精力真的无法维护出那么精细的内容。
  • 定位不明确。耳先生想把 Resource.rs 定位成一个覆盖面广、知识准确的入门级资讯网站。但是实践出来的内容却更像是他个人的Rust学习历程。英文水平高的用户完全有能力阅读英文文章,中文社区又不能产生出比较又水平的文章,导致了这个网站的内容缺乏。

其实,经常关注耳先生的都能发现他博客里面有着挺多关于 Rust 的博文,我有一次就比较好奇问耳先生:“为什么你不把你Rust的博文发到你那个Rust资讯网呢?”

“不是我不想啊,我自己也是刚开始学这方面的知识,连我都不知道这是对的还是错的,根本没有勇气把它放在网站上,就怕误导了别人,是不是?国外有个很好的 cheats.rs ,国内有 rust-lang-cn,有 rust.cc 论坛。我真不知道我到底还能不能坚持下去。”

一阵晚风从耳边吹过,我们又陷入了短暂的沉默,独自地喝着酒,隔壁桌嘻嘻闹闹的声音时不时吹到我们桌来。

“哎,这段时间真的好累,我都想辞职休息一段时间了“ 两个酒杯又碰在了一起,打破了那一刻的沉默。“嗯,我好久之前就想这么做了“我附和道。

我都不知道这一年我自己干了什么,感觉时间过得很快。眼睛刚闭上没几分闹钟就醒了,一眨眼就上班了,再一眨眼就下班了,一眨眼又深夜该睡了。

上班又经常犯错,被老板同事说。整天做着千遍一律的内容,还要跟自己公司的同事扯皮吵架,说好的我们公司同事间没有竞争压力呢?为什么会出现个问题都急迫着推卸责任。

还要被老板强制转岗,闹了好一阵子估计转岗培训里面对我的评价应该是最低的吧,反正今年的调薪估计是没我什么事的了。我实在想不到我的出名居然是因为我的犯错让几乎整个中国区的高层都听说过了我的名字,真的是太惨了。在这个公司估计是快混不下去了。

有时候我在想我是不是真的不适合这个行业,我上班看技术,下班看技术,周末看技术,还是没学会什么东西。总感觉自己陷入了「越菜越学,越学越菜」的困境里面。

每次在网络上看到其他人说我师从某某某,我跟某某某学技术的时候都很羡慕,感觉我平时都只能自己在瞎胡闹。做无用功估计说的就是我这种行为。

我很羡慕和佩服那些人。考上985院校、喜欢计算机、对逻辑数理敏感、有份好工作、身边有志同道合的人,每一项我都做不到。

哎,我也想这样啊。

我知道这个时间不应该说话,我也不知道要说什么,只知道陪着他默默地喝酒。每个人都有每个人的难处,就像那句话说的“人与人的悲欢各不相同,我只觉得他们吵闹“,大家都没有嘲笑互相的资本,至少我没有嘲笑他的能力,哪怕他说的多么幼稚。

“也不早了,回吧”

“嗯,也该回了”

“好,那我走了”

“嗯,我也走了”

]]>
Rust 过程宏 101 https://www.kilerd.me/rust-proc-macro-101 在 Rust 1.45 中,Rust 的卫生宏(Hygienic macro)迎来了 stable 版本,这意味着过程宏(Procedural macro)声明宏(Declare macro)板块全面稳定。那么是时候该认真学习一边过程宏的内容了。

过程宏相比于声明宏的灵活度更加高,其本质是输入一段 Rust 的 AST 产生一段 AST 的函数,同时 Rust 提供了三种不一样的语法糖来满足不同的使用场景。

  • 函数式(Function-like)的宏 - 这跟声明宏很类似
  • Derive 宏 - #[derive(CustomDerive)] - 一个用于结构体和枚举类型的宏
  • 参数宏(Attribute macros) - #[CustomAttribute]
]]>
https://www.kilerd.me/rust-proc-macro-101 Thu, 11 Jun 2020 18:43:26 +0800 在 Rust 1.45 中,Rust 的卫生宏(Hygienic macro)迎来了 stable 版本,这意味着过程宏(Procedural macro)声明宏(Declare macro)板块全面稳定。那么是时候该认真学习一边过程宏的内容了。

过程宏相比于声明宏的灵活度更加高,其本质是输入一段 Rust 的 AST 产生一段 AST 的函数,同时 Rust 提供了三种不一样的语法糖来满足不同的使用场景。

  • 函数式(Function-like)的宏 - 这跟声明宏很类似
  • Derive 宏 - #[derive(CustomDerive)] - 一个用于结构体和枚举类型的宏
  • 参数宏(Attribute macros) - #[CustomAttribute]

行为影响

这三种宏的的效果也不完全一致。 函数式宏(Function-like macro)参数宏(Attribute macros) 拥有修改原AST的能力,而Derive 宏就只能做追加的工作。

函数式宏(Function-like macro)

#[proc_macro]
pub fn my_macro(INPUT_TOKEN_STREAM) -> TokenStream {
    OUTPUT_TOKEN_STREAM
}

my_macro!(INPUT_TOKEN_STREAM)

经过编译之后,6L 就会被替换OUTPUT_TOKEN_STREAM

Derive 宏

#[proc_macro_derive(MyMacro)]
pub fn derive_my_macro(INPUT_TOKEN_STREAM) -> TokenStream {
	OUTPUT_TOKEN_STREAM
}

#[derive(MyMacro)]
struct MyStruct {...}

经过编译之后, 6-7L 就会被编译成以下:

#[derive(MyMacro)]
struct MyStruct {...}

OUTPUT_TOKEN_STREAM

可见,原来的 MyStruct 并不会被影响,也无法改变,而能做的只是在其后追加新的AST,通常用来生成 Builderimpl Blabla for MyStruct 从而改变MyStruct 的行为。

参数宏(Attribute macros)

#[proc_macro_attribute]
pub fn my_macro(ATTR_TOKEN_STREAM, INPUT_TOKEN_STREAM) -> TokenStream {
    OUTPUT_TOKEN_STREAM
}

#[my_macro(a=1,b=2)]
fn method() {...}

在这个例子中

  • ATTR_TOKEN_STREAMa=1, b=2
  • INPUT_TOKEN_STREAMfn method() {...}

而编译之后, 6-7L 编译成 OUTPUT_TOKEN_STREAM

入门例子使用

了解了过程宏的相关基本知识之后呢,就可以根据自己的需求选择不同的实现方式来简化代码。下面会以一个例子来介绍怎么设计一个 Derive 宏,不感兴趣的可以跳过这个章节。

该章节的代码实现已经放在了 Github kilerd/rust-derive-macro-demo

非过程宏实现

在一次业务实现中,需要根据错误类型返回前端不同的错误码和消息。这意味着我们对于不同的错误需要三个不同的字段

  • HTTP 返回码(status code)
  • 错误的 Code
  • 错误的具体描述内容

返回给前端的结构是这样的

{
    "code": "INVALID_EMAIL",
    "message": "Invalid email"
}

对于Java来说,这很容易用一个枚举类型来描述这样的需求:

public enum BusinessError {

    InvalidEmail(400, "INVALID_EMAIL", "Invalid email"),
    InvalidPassword(400, "INVALID_Password", "Invalid password");

    int httpCode;
    String code;
    String message;
    BusinessError(int httpCode, String code, String message) {
        this.httpCode = httpCode;
        this.code = code;
        this.message = message;
    }
}

在这种情况下需要增加错误类型的时候,只需要在 4L 处新增即可,影响的范围不大。

而对于Rust来说,枚举类型(enum)更加像是一种数据结构,所以无法像 Java 一样在 3-4L 里面储存这样的信息,为了达成同样的效果,我们需要在函数里面自己实现返回的内容:

pub enum BusinessError {
    InvalidEmail,
    InvalidPassword
}

impl BusinessError {
    pub fn get_http_code(&self) -> u16 {
        match self {
            BusinessError::InvalidEmail => 400,
            BusinessError::InvalidPassword => 400,
        }
    }
    pub fn get_code(&self) -> String {
        match self {
            BusinessError::InvalidEmail => String::from("INVALID_EMAIL"),
            BusinessError::InvalidPassword => String::from("INVALID_PASSWORD"),
        }
    }
    pub fn get_message(&self) -> String {
        match self {
            BusinessError::InvalidEmail => String::from("Invalid email"),
            BusinessError::InvalidPassword => String::from("Invalid password"),
        }
    }
}

实际看起来问题也不是很大,可以很好的完成业务需求,但是考虑一下增加错误类型这个业务场景,那么就需要在 3L,10L,16L,22L处做修改,影响的范围就很大了。

同时我们可以很轻松的看得出来对于 get_codeget_message 都是对枚举值进行简单的字面格式转换,那么人工做这么一件事件是很耗时的。这个时候就可以让过程宏代替我们实现 impl BusinessError {...} 里面的所有内容。

Derive 宏的建立

为了简化代码,我们决定把 BusinessError 改造成以下的格式:

#[derive(DetailError)]
pub enum BusinessError {
    InvalidEmail,
    #[detail(code=400, message="this is an invalid password")]
    InvalidPassword
}

对于错误类型 InvalidEmail ,我们默认返回 httpCode 400, code INVALID_EMAIL , message Invalid email。但是我们可以通过 #[detail(code, message)] 来定制化 httpCodemessage

我们先拟定需要创建的宏的名称为 DetailError 。那么第一步先把项目改成 workspace 的目录结构。然后在其下面新增一个 detail_error 的lib。

[workspace]
members = [".", "detail_error"]

[dependencies]
detail_error = {path="./detail_error"}

通过 cargo new detail_error --lib 创建好 lib 后,需要对 detail_error/Cargo.toml 增加「这个库是过程宏库」才可以访问到 proc_macro 这么一个特殊的库。

[lib]
proc-macro = true

其后,在 detail_error/lib.rs 中声明过程宏处理函数:

use proc_macro::TokenStream;

#[proc_macro_derive(DetailError, attributes(detail))]
pub fn detail_error_fn(input: TokenStream) -> TokenStream {
    "".parse().unwrap()
}

自此,我们的代码就不会报错了,但是我们还没有在detail_error_fn 里面返回我们期望的 impl BusinessError{...} 的 AST。实际上这个宏没有做任何事情。

实现 get_http_code 方法

第一步,我们需要先把TokenStream 格式化成我们期望的枚举结构。那么就用到了 syn 库,这个库提供了parse_macro_input! 这个宏来更加方便得访问 AST,在我们把 TokenStream 格式化成 ItemEnum 后就可以用dbg! 来查看其内部的数据了。

let enum_struct = parse_macro_input!(input as syn::ItemEnum);
dbg!(enum_struct);
enum_struct = ItemEnum {
    attrs: [],
    vis: Public(...),
    enum_token: Enum,
    ident: Ident { ident: "BusinessError", span: #0 bytes(64..77),},
    generics: Generics {...},
    brace_token: Brace,
    variants: [
        Variant {
            attrs: [],
            ident: Ident {ident: "InvalidEmail", span: #0 bytes(84..96),},
        },
        Comma,
        Variant {
            attrs: [...],
            ident: Ident { ident: "InvalidPassword", span: #0 bytes(165..180),},
        },
    ],
}

在这里我们先 hardcode 所有的返回值是 400,先不理会在 #[detail] 中的配置,那么我们最关心的是

  • .ident - 枚举的名字
  • .variants[].ident - 枚举里面有多少成员,以及成员的名字

那么我们可以很轻松的拿到这些值:

let ident = &enum_struct.ident;
let variants_ident:Vec<&Ident> = enum_struct.variants.iter().map(|variant| &variant.ident).collect();

但是拿到这些值之后,我们的期望还不够,我们期望的是构建出以下的代码:

impl BusinessError {
    pub fn get_http_code(&self) -> u16 {
        match self {
            BusinessError::InvalidEmail => 400,
            BusinessError::InvalidPassword => 400,
        }
    }
}

想比如手动拼 TokenStreamquote 这个库提供了更加人性化的方式来生成TokenStream。我们可以通过以下的代码来生产我们期望的那个函数:

let output = quote! {
    impl #ident {
        pub fn get_http_code(&self) -> u16 {
            match self {
                #(#ident::#variants_ident => 400,)*
            }
        }
    }
};

这里面一些 quote 特定的文法

  • #VARIABLE 可以访问到当前作用域下的同名变量
  • #( )* 用于展开循环

自此,我们完成了get_http_code的方法实现。

实现 get_code 方法

get_http_code 中我们了解了怎么输出一整个函数,对于 get_code 来说,每一个枚举分支类型返回的值都是不同的,这意味着我们在 let variants_ident:Vec<&Ident> = enum_struct.variants.iter().map(|variant| &variant.ident).collect(); 这里就不能简单的拿到枚举成员的 Ident 了,我们需要在循环内构件出类似 BusinessError::InvalidEmail => String::from("INVALID_EMAIL") 这样的完整分支语句。这里其实也是很简单的。

let code_fn_codegen:Vec<proc_macro2::TokenStream> = enum_struct.variants.iter().map(|variant| {
        let variant_ident = &variant.ident;
        let content = inflector::cases::screamingsnakecase::to_screaming_snake_case(&variant_ident.to_string());
        quote! {
            #ident::#variant_ident => String::from(content)
        }
    }).collect();
  1. 这里为了简单的演示效果,才用了 inflector 这个字符串格式转换库
  2. 这里用到了 proc_macro2 这个库,下文会讲为什么需要和其与proc_macro的区别

然后再拼凑 get_code 方法签名:

pub fn get_code(&self) -> String {
    match self {
        #(#code_fn_codegen,)*
    }
}

get_message的方法也是同样的道理这里就不重复描述了。

#[detail] 中读取数据实现配置化

对于每一个 Variant 的 attr 数据都会储存在 attrs 这个字段中。 #[detail(code=400, message="this is an invalid password")] 就会被格式化成以下的AST: (省略了很多没必要的字段)

attrs: [
    Attribute {
        path: Path { segments: [ PathSegment { ident: Ident { ident: "detail",}},],},
        tokens: TokenStream [
            Group {
                stream: TokenStream [
                    Ident { ident: "code", },
                    Punct { ch: '=', },
                    Literal { lit: Lit { kind: Integer, symbol: "400" }},
                    Ident { ident: "message", },
                    Punct { ch: '=', },
                    Literal { lit: Lit { kind: Str, symbol: "this is an invalid password" }},
                ],
            },
        ],
    },
],

可以看到 code=400, message="this is an invalid password" 一样被格式化成了 TokenStream 。然而取数据出来也不是一件很简单的事情。所以为了解决这个问题,darling 应运而生,其借鉴了 serde 的思想,把TokenStream 反序列化成自定义的结构。

根据 darling 的写法,我们需要把我们期望的数据写成结构体:

// derive FromDeriveInput, 表示这个结构体可以用 `syn::DeriveInput` 转换过来
#[derive(Debug, FromDeriveInput)]
// darling 自身的配置,接受 `detail` attr的数据,只允许 enum 的结构体,struct 报错。
#[darling(attributes(detail), supports(enum_any))]
struct DetailErrorEnum {
    // enum 的名称
    ident: syn::Ident,
    // enum 的枚举成员格式化成 DetailErrorVariant 
    data: darling::ast::Data<DetailErrorVariant, darling::util::Ignored>,
}

#[derive(Debug, FromVariant)]
#[darling(attributes(detail))]
struct DetailErrorVariant {
    ident: syn::Ident,
    // fields 的数据, 指的是 `InvalidEmail(String)` 里面的 `String`
    fields: darling::ast::Fields<syn::Field>,
    // 这里表示从 `FromMeta` 中取数据,这里特指 `#[detail(code=400)]`
    #[darling(default)]
    code: Option<u16>,
    // 这里表示从 `FromMeta` 中取数据,这里特指 `#[detail(message="detail message")]`
    #[darling(default)]
    message: Option<String>,
}

接着我们需要把 proc_macro::TokenStream 转换成 proc_macro2::TokenStream 再转换成 syn::DeriveInput 再转换成 DetailErrorEnum

let proc_macro2_token = proc_macro2::TokenStream::from(input);
let derive_input = syn::parse2::<DeriveInput>(input).unwrap();
let detail_error: DetailErrorEnum = DetailErrorEnum::from_derive_input(&derive_input).unwrap();

通过dbg!() 可以看到反序列化之后的结果:

[detail_error/src/lib.rs:39] &detail_error = DetailErrorEnum {
    ident: Ident { ident: "BusinessError", },
    data: Enum(
        [
            DetailErrorVariant {
                ident: Ident { ident: "InvalidEmail", },
                fields: Fields { style: Unit, fields: [], },
                code: None,
                message: None,
            },
            DetailErrorVariant {
                ident: Ident { ident: "InvalidPassword", },
                fields: Fields { style: Unit, fields: [], },
                code: Some( 500, ),
                message: Some(  "this is an invalid password", ),
            },
        ],
    ),
}

这样的结果和过程都比直接操作 TokenStream 更加直观和可靠。

但是至今我还不知道对于 #[detail(code=400, message("password {} is invalid", p1))] 这种 message一组的数据(group token stream)怎么用 darling 来写

这个时候就可以遍历 detail_error.data[] 来完成 get_http_code 的 AST 生成

let ident = &detail_error.ident;
let variants = detail_error.data.take_enum().unwrap();
let http_code_fn_codegen: Vec<proc_macro2::TokenStream> = variants.iter().map(|variant| {
    let variant_ident = &variant.ident;
    let http_code = variant.code.unwrap_or(400);
    quote! {
        #ident::#variant_ident => #http_code
    }
}).collect();

相比于之前的hardcode,现在我们在 5L 取出了在 #[detail(code=500)] 中的值。

同理 get_message 也可以用同样的方法生成:

let message = variant.message.clone().unwrap_or_else(|| {
    inflector::cases::sentencecase::to_sentence_case(&variant_ident.to_string())
});

自此整个 BusinessError 就用过程宏改造完成了。但是真实的业务还没有那么简单,举个例子说,对于认证错误(AuthenticationError),通常需要返回具体的错误内容,这意味着 message 需要跟随着变化。也就是说真正的代码是长这个样子的:

enum BusinessError {
    AuthenticationError(String)
}
fn get_message(&self) {
    match self {
        BusinessError:AuthenticationError(p1) => format!("with detail {}", p1),
    }
}

那么我们之前的过程宏并不支持这样的特性,其实改造也很简单,在 darling 的 DetailErrorVariantfields 里面就存有着 String 这个信息,那么我们只需要在循环体中构建出类似 #ident::#variant_ident#fields => format!(#message, #fields) 的语句即可。 感兴趣的读者可以试着让这个demo 支持该功能。

在我的真实业务场景用使用 #[detail(message="with detail {0}")] 这样的方法来访问具体的字段的

关于过程宏的一些实践和认知

proc_macroproc_macro2 的区别

前者是 rust 中为 过程宏库(在 Cargo.toml 中声明了 #[lib] proc_macro=true)中才能访问的特殊库, 而 proc_macro2 是与 proc_macro 基本一致,但是只是一个普通的库,所以 syn , quote , darling 这些都是建立在 proc_macro2 之上的, 所以在我们编写过程宏的时候基本上都是先把 proc_macro::TokenStream 转换成 proc_macro2::TokenStream 进行各种处理,最后才转换成 proc_macro::TokenStream 交回给 rustc。

关于测试

根据第一点的前提下,在转换成 proc_macro2::TokenStream 之后其实就跟过程宏没任何关系了,在抽象出一个独立的函数来处理和生成 proc_macro2::TokenStream ,我们就可以很轻松的对这个方法进行测试:

#[proc_macro_derive(DetailError, attributes(detail))]
pub fn detail_error_fn(input: TokenStream) -> TokenStream {
    handler(input.into()).into()
}

fn handler(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
    // real handler
}

简单来说,我们可以通过 quote::quote!来生成 inputhandler 测试:

#[test]
    fn it_works() {
        let input = quote! {...};
        let expected_output = quote! {...};
        let output = handler(input);
        assert_eq!(expected_output.to_string(), output.to_string());
    }

7L 里面简单的用了 to_string() 来判断是否一致,导致输出的代码其实并没有带缩进,如果有需要可以用 syn::visit模块进行更加友善的结果输出。

用了过程宏之后,为什么就没有代码提示了

这点很正常,因为impl BusinessError {...} 里面的内容是编译时生产的,确实是没有办法做到代码提示。试想下有了代码提示又跳转到哪里呢?

其实这个问题也不是无解的。通常的做法是建立一个 Trait DetailError 里面定义好我们需要的三个函数,然后再通过过程宏为 BusinessError 实现 impl DetailError for Business {...}。 这样代码提示和跳转就可以跳到 DetailError的定义里面去了。

为此我们需要把原来 detail_error 这个lib 改名成 detail_error_macro ,再创建一个新的lib 叫 detail_error 来定义 Trait DetailError

这点其实是 Rust 的限制,因为过程宏库无法再暴露(expose)出其他的任何 Trait 和结构体。

注意 ident 和非ident 的处理

quote::quote! 这个宏在处理 String 类型的时候会自动加上" 形成 "content" ,正如数字类型会在后面追加具体的类型一样400u16。 所以如果通过format! 拼凑出一个 ident 之后需要用 quote::format_ident! 转换成 ident 类型,或者直接用 format_ident! 代替 format!

]]>
2019 个人总结 https://www.kilerd.me/summaries-my-2019 无意中翻博客草稿的时候,发现了 2018 的总结还在停留在草稿阶段,现在就已经要写 2019 年度总结了,不禁感叹时间流逝之快。

]]>
https://www.kilerd.me/summaries-my-2019 Tue, 31 Dec 2019 21:34:22 +0800 无意中翻博客草稿的时候,发现了 2018 的总结还在停留在草稿阶段,现在就已经要写 2019 年度总结了,不禁感叹时间流逝之快。

R.I.P Python 2

Python 2 停止维护,这绝对是一件所有 Pythonista 值得写入 2020 第一篇文章内的描述。我大概从2014年开始接触Python,但是就已经开始用 Python 3来写项目了。从 14 年到现在,除了写项目逻辑之外更多的时间是花费在 2 与 3 的兼容上面。虽然说 six 这种专门用来做兼容性库的存在极大的简化了兼容的实现,我还是十分希望能免去这些工作量。

一开始确实没有太大的理由和动力去做迁移工作,但是 Python 3 的一点点进步足以让迁移有足够的优势:Hash 算法的优化提升了部分性能;async 语法和 asyncio 生态的建立;type hint 的出现。这些让 Python 使用起来更加像一门现代化的语言。

时至今日,Python 2 的死去,是一件好事,摆脱了这么一个巨大的历史包袱,希望 Python 3 可以有更好的发展,搞搞 JIT,研究一下GIL。希望Python 3 越走越好。

另外 Guido 的退位也为 Python 带来了新的治理模式,不再是独裁者的所有物。hpy 的出现也让 Python 有望存在一个标准的 spec,这样下来越来越多的更好的解析器有望可以涌现。

OverWatch 赛事

工作后对游戏的热爱就只能投放在赛事上。LOL 上 FPX 夺冠,Dota 里 大巴黎老干爹没能杀入决赛复仇 OG,这些都不是很关心。守望先锋在 2019 年的表现才是让人,让我无比兴奋的。

先是在世界杯上拿下亚军,再是在国内组出了 4 支俱乐部角逐 OWL 第二赛季的战场。同时 成都 Hunter 队的全华姿态也让国内对其抱有了极大的盼头。一是世界杯上中国队的超常发挥,二是对全华班的执着。听闻 Hunter 背后的老板跟 RNG 的老板还是同一个。从 OWL 第一赛季的「我们根本u知道怎么才能赢」到这个赛季的龙队获得第三赛段冠军,4支还是3支战队杀入季后赛这一切都在宣告着守望先锋在国内的蓬勃发展。正如林迟青说的那样「 We are ready to let the world know CHINA again」。

2020 年的第三赛季的主客场机制让不少 OWL 比赛在国内举行,相信氛围一定很好。可惜的事情是 Hunter 那位被誉为「神医」的主教练 RUI 因伤离队了,不知道成都队能不能在第三赛季保持水平的同时越战越勇。

坚定了 Rust 的路线

在工作上写了一年 Java,虽说还是一如既往的讨厌它,但是毕竟是用来吃饭的本领,还是专研了一下,起码保证了自己的饭碗不会丢失。但是在业务的时间里面,更加坚定了3年前做的一个决定「学习 Rust」。

怎么说呢,前段时间看到一段文字可以很好的描述我对 Rust 的态度:

大概五六月的时候我领着团队系统地学习了一下 Rust 语言,后来就有一搭没一搭的写点随手就扔的一次性代码。看到 Signal 的这篇文章后,我按捺不住心头的激情一一终于可以 用 Rust 做一个似乎有点什么用的工具了!写下来总体感觉,Rust 有可以媲美 ruby 的表现力,又有可以媲美 C++ 的性能(如果使用正确了),加上略逊于 haskell,但可以秒杀大部分主流语言的类型系统,使得用 rust 写代码是一种享受(除了编译速度慢)。这样一个 小工具200来行代码(包括单元测试,生成式测试以及一个简单的benchmark)就可以完 成,估计用 python, elixir 和 nodejs 都不那么容易达到。

大概就是这样,得益于过程宏等的一些生态,可以让代码写起来如同脚本语言那样的表现力和编写体验,既有极优秀的性能,还有完备的类型系统。这样 Rust 在各个领域都可以表现得很棒。

Rust 也让我真正的走上了 PL 的道路,之前的我可能是站在巨人肩膀上的,完全不知道脚下的巨人是谁,能干什么。但是 Rust 让我成功的走出了这一步。慢慢地了解到了类型系统及其图灵完备性,数理系统,逆变协变等等这些可能你日常都在使用,但是不知道其缘由和机理的事情。

我很庆幸在业务我不再是一个简单的CRUD boy,虽然我还有很长的一条路要走,但是起码我在2019迈出了那一步。很感谢 Rust 为我带来的这一个改变。

Side Projects

如同我在「技术断舍离」里面描述的那样,我开始不喜欢写同类型的项目,逐渐接触不同领域的东西。我开始认真地想做一个社区,希望能把 Resource.rs 给做好。我认真反思自己做过的东西,那些没能让我学习到的项目都是一次拖慢你节奏的过程。我注册了3min.work,寓意是「三分热度工作室」,我希望我的一些零时性的,阶段性的,实验性的作品或者尝试可以放在这里,让我有一个更加直观的感受,同时也不会阻止我的前进。

最后

一年来,虽说工作不如意,学习上没啥进步,也开始慢慢接受自己的平庸。但是我始终坚信着「勤能补拙」这个朴实的道理。

]]>