关于 Chen Junda

北京大学计算机应用技术硕士,上海微软工作。爱好包括计算机、游戏、羽毛球、纯音乐、电影和音乐剧。

RSS 地址: https://ddadaal.me/rss.xml

请复制 RSS 到你的阅读器,或快速订阅到 :

Chen Junda RSS 预览

在西雅图,给生活换个环境

2024-06-11 23:13:00

“洗洗班味”

上班之后的生活千篇一律:起床,上班,去食堂吃午饭,去游戏室睡觉,上班,下班,看视频玩游戏,睡觉。按朋友的话来说,这样的日子会让人“班味很重”。所以当在3月份第一次听说公司组织的Aspire Start Strong+活动的时候,我虽然不是那么一个喜欢旅游的人,但是还是仍然感觉非常激动,很珍惜这个机会,毕竟这个活动由公司出钱不占用自己的假期和全世界去年的校招生一起去美国参加,而因为有了这三点,活动具体是干什么的反而都不重要了,在全新的环境里给生活暂时换个节奏,按同事的话说,“洗洗班味”,是最重要的。于是开始火速走流程,给老板科普这个活动是干什么的、去北京办美签、订酒店,终于在上周成行。

机票

体验另一个世界

这不是我第一次出国出境,但是确实是第一次在到美国,这个完全和国内、甚至东亚主流生活方式完全不一样的地方。

由于活动会场在市区,所以我们住在西雅图的市区(downtown)。这一个区域确实有大城市的感觉,高楼大厦鳞次栉比,由于西雅图并不平,有山,车道也普遍仅有双向四车道,第一次进市区以为回到了重庆的渝中。但是这个区域并不移居,人不多,有经典流浪汉,并且也比较危险,途中左边的麦当劳被称为“死亡麦当劳”,听说我们到达的前一天发生了枪击案,因此,第三天晚上被迫不得不在快天黑的时候出去时,我和舍友不得不加快脚步。并且,市区虽然有很多商场,但是实际上非常萧条,我们去的一个比较大的商场Pacific Place里面仅仅只有几个商店。

西雅图市区街景

市区的Pacific Place

而在市区之外的地方,并不是国内郊区常见的农田或者工厂,而仍然是城市,只不过是一望无际的平房大house,大多数的中产生活在这里。这些地方不能叫西雅图的郊区,因为实际上是另一个小城市,各个城市有自己的downtown,甚至Apple Store和一些品牌的专卖店都只有这些小城市的购物中心才有。各个城市之间的交通,也主要以车为主。各个城市之间非常近,例如微软总部所在的Redmond和西雅图市区开车仅需30分钟,相当于同属主城区的闵行和人民广场之间的距离。所以,这次我深刻体验到了什么叫”没车就是没腿”。

市区之外的地区,拍摄于Space Needle

由于我对我的驾驶技术并没有太大信心,加上市区开车成本极高,所以没有租车,所以我也只能在市区简单逛逛旅游景点,旅游景点之间的交通也是通过打车。这和国内所有资源都集中在市区的情况可谓大相径庭。

世界的不同同样也体现在社会的各个角落:和陌生人进入电梯也要打招呼,和其他人沟通时经常能看到幅度很大甚至有点夸张的表情和肢体动作,几乎每次打车遇到的司机都想和乘客聊天,甚至餐厅吃饭时,服务员的服务,结账时的流程也完全不同,于是在前一两天,干啥事都要做好出丑的准备。

社交,社交,还TM是社交

微软官方对这个活动的主要内容的一个关键词就是Networking社交。整个活动总共三天,每一天都由几个讲座以及讲座之间的休息时间组成,而在休息时间,也包括讲座期间,主要工作就是和其他人社交。到这里,E人听了狂喜,I人听了害怕。而我作为一个IE各半的人(测试结果仅供参考),虽然并不害怕日常社交,但是仍然感觉有点陌生和有挑战性,完全不知道和这些来自世界各地的人会如何社交。

MBTI测试结果(仅供参考)

在活动正式开始的前一天,我所在的组织安排了一次参观交流的活动,邀请了全组织的新人去园区参观,以及和组织内老板的的交流。这种形式的参观交流活动,我也是经历过很多次了,本科时我甚至参与组织过一次组织微软俱乐部的同学去苏州微软参观交流的活动,总体体验还是比较放松的:行程都安排好了,照着做就好了,交流的时候有问题就问,不想公开问就等着私下交流的时候问,没啥好问的就划水,轻松+愉快。但这次的参观交流活动加上了浓浓的社交元素,情况就完全不一样了。从10点多登上去园区的大巴开始,一直到4点整个活动结束,社交过程一刻不停:在大巴车上,主持人就让所有人打乱座位,开始和不认识的同事聊天;下午,先和老板有一次Panel,也就是各位领导坐在前面,参与者坐在下面,领导分享,参与者提问的形式,之后,又是所有所有的参与者在一个空间里自由的交流。只有上下午中间的参观园区的Visitor Center的环节有一点休息的时间。

参观Microsoft Visitor Center

而接下来的三天也是同样的节奏:讲座的时间,大家坐在一个大圆桌,除了听讲座的内容,就是根据讲座的要求做一些同桌之间的交流;而在讲座时间以外,就是在一个大会场内找吃的喝的,以及社交。甚至第三天的晚上,公司组织了所有的参与者去西雅图的流行音乐博物馆(Museum of Pop Culture, MoPoP),在里面除了常规的参观博物馆展馆,还可以参与蹦迪、剧场小游戏等可能在西方世界常见的娱乐活动,而,当然,还有一个大的空间可以用于社交。每天这样的节奏从早到晚,使得每天晚上几乎都是沾枕头就能睡着。

讲座会场

MoPoP的各个活动

语言壁垒

由于我从小到大都是生活在汉语的环境下,社交遇到的第一个问题就是语言

虽说工作在外企,但是这只意味着工作相关的文字资料以及占据少比例时间的会议是英文的,而其他时候,尤其是日常的沟通交流,仍然使用汉语。而当处在英语环境下的时候,情况就完全不一样了。读写英文对大多数来说都不是问题,看到不会的可以查,写的时候可以慢慢斟酌用词,也可以让AI帮忙。而开会的时候,由于大多数内容都是工作相关的,工作相关的内容本来从头就是用英文思考的,所以听说没有遇到什么大的障碍,即使遇到可能听不懂的,也可以用Teams的Live Caption实时生成字幕,把听转换为读,难度一下子就降下来了。而日常交流最重要的是听和说的情况就完全不一样了。

关于听,容易出现的一个问题就是在关键的地方卡壳,这会使聊天进入一个不停地pardon/sorry状态,很影响聊天的氛围。第二天中午吃饭的时候和几个来自美国的员工聊关于电动汽车的事情,其中一个美国员工提到美国对中国的电动汽车加征了100%的关税tariff。这个词如果写出来给我看我还是认识的,但是当时完全没有反应过来,于是聊天被迫中断了一下。还好我很快根据语境猜出了这个词是在说关税,聊天才可以继续进行下去。这还是比较容易的情况,而更多的例子是伙伴说了一句话,我甚至没有听出这句话是在问问题,敷衍地笑笑,然后尴尬地发现聊天中断了,甚至还不知道为什么。另外,众所周知,微软、亚马逊等公司招募在大量的来自全世界的员工,本次活动我推测来自美国、英国等英语国家的员工甚至不到一半,而非英语国家的员工的英文也有不同的口音,毫不夸张地说,每听一句话都说一下sorry,让对方重复一下。

而至于说,说出一个句子简单,但要流畅、快速地说出完整的、简单的句子继续对话,这让我耗费了大量精力,以至于时间长了我甚至有点不敢说话了。去园区参观的车程总共20分钟,我在对话刚开始的时候,除了找关键词表达意思之外,还可以注意句子的时态、语法等细节。但是聊到后面,尤其是聊到熟悉或者不熟悉的话题、情绪比较激动的时候,就只能保证表达出关键词,什么is/was、问句结构,通通一边去吧。另外,由于中文和英文的语言表达习惯不一样,而我仍然是用中文思考,加一个汉译英的环节,这会使得说出来的话不那么简洁,甚至感觉有点奇怪。有一次打车的时候,我想问司机最近有没有接到其他同样来自微软的员工。由于我仍然是中文思维,要说出来需要进行一次汉译英,但是定语稍微一长(这里的同样来自微软),我就喜欢用从句,于是我脱口而出:

Have you taken any other people who also come from Microsoft?

说到who的时候,我就感觉有点奇怪,于是后面变得有点不自信,说话声音都变小了。果不其然,司机没有听清,于是我又重新说了一句这段话,这时才感觉到,似乎没有必要这样说,一个简单的Microsoft employees甚至Microsoft people就行。

总的来说,这是我第一次在全英文的环境下的与人日常交流。虽然大家都会很耐心,但是在本来就不是很擅长日常聊天的情况下叠加一个语言debuff,仍然让我精疲力竭。我一直以为我的英语能力还可以,每天都无字幕看YouTube视频,但是真到对应的环境下,还是处处体现出不适应。语言果然还是要一个环境,没有环境的语言就是哑巴语言,如果之后真被relocate到国外,第一个要迈的坎就是语言壁垒。

同龄人社交和环境

由于公司在上海的规模不大,而且近几年校招的名额非常少,分配到每个组的新人就更少,而且绝大多数员工来公司都不是来奋斗的(奋斗比滚出微软!),所以公司的氛围比较传统,公司就是工作,到点就回家,甚至公司组织的活动都是面向家庭的。这对于有家庭的人来说当然是天堂,但是对于我这种刚毕业单身狗来说,虽然工作也非常轻松愉快,但是同样容易感觉无聊。再加上公司所在的地理位置又是邻近一个50年代开始开发的郊区卫星城,周边的城建、商业、住房等都是纯纯的老城区模样,居民也是中老年人居多,甚至在附近的羽毛球俱乐部里每次都能遇上头发花白的老大爷老奶奶,毛估所有参与者的平均年龄没有40也得有35。虽然有两个高校,但是高校自成一体,基本和社会面不在一个圈子里。本来我并没有注意到身边环境的特征,而契机是在今年春节回家时,和研究生同学约在家里的附近羽毛球馆,发现球馆里全是年轻人的时候,我突然意识到,我所处的环境似乎有点老了。

这次去了Aspire活动,我体验到了一种年轻人的环境。所有参与者都是2023年4月后加入微软的校招新员工,背景都是类似的,很多人还愿意去互相了解,对职业发展抱有期待,聊天的时候更容易有共同话题,即使都只是工作中,由于同处同一个职业阶段,所以大家思考的、追求的东西基本都是类似的。在去园区参观的路上,我和一位来自印度的女生聊了很久,虽然上文提到,对我来说听说仍然不够流畅,但是仍然交流了很多,为什么选择学计算机,为什么来微软,来美国的感受,组内的情况,想要什么样的生活。我们甚至后面还留了邮箱,互相发了几封极其类似上学时英语课写的小作文(英语课小作业还是有用的:D)。在活动中,也认识了来自印度、美国、日本、以色列等各个地方的新同事。有的同事很符合“刻板印象”,有的甚至完全相反;有的主动过来聊天,完全被带飞,而有的交流寥寥几句后,似乎只能以Nice to meet you结束;有的加了LinkedIn等联系方式,有的甚至出现在面前也不会再认出来。在认识新人之外,同样也见了很多许久没有见面的、同在微软的南大同学,聊的话题除了工作,还包括本科时期经过的一些事情,似乎又回到了2020年前的本科生活。

回想这次活动之前和期间的自己的感受,我发现环境真的很影响人。为什么在学校的时候,和同学交朋友似乎很容易?朝夕相处,同处一个人生阶段,工作和休息的节奏、思考、烦恼、追求的事情都是一致的,自然而然就能有话聊,相互理解,发展关系。而在工作后,有家庭的同事的生活的重心自然而然会放到家庭中去,没有家庭的也会去形成以自己为主的工作生活节奏,不会轻易因为他人而改变,即使参加活动,目的性也都极为明确,也就是说,每个人或多或少都会稳定到一个适合自己的生活方式上。而环境又会影响回每个人。如果实验室的同学都在努力学习发论文,那么自己不去科研就会被认为为“异类”;如果身边的人都到处参加活动,那么自己可能也在某一刻想去试试;如果身边的人的生活都非常稳定,那自己也得主动或者被动地去寻找一个稳定的生活方式。而这个环境的不同,才是上学和上班最大的区别。

重回现实

由于没有找到固定的搭子抱团,所以我并没有安排活动的后续旅程,活动结束后在市区和几位之前认识的小伙伴简单玩了玩市区景点后就回上海了。

回想起来,我到底得到了什么?在西雅图的全新的环境中,锻炼了一下之前一直处于哑巴状态的英语,体验了美国的生活方式,和来自全世界各地的同龄人高强度社会……这是一次独一无二,甚至很可能不会再有第二次的机会。感谢在西雅图的6天短暂、全新,比现在更有活力的生活,让我更理解环境的意义,让我在解决“我想要什么”这个终极问题的路上更进一步。

从调库到翻源代码:给wakapi增加SQL Server支持

2024-01-17 22:58:00

不就是调库嘛……

上一篇文章中,我给博客增加了点击量监测,并将这个服务部署到了Azure,数据库采用了使用SQL Server的Azure的SQL服务。由于SQL Server有免费的订阅,微软的Azure Data Studio也还算好用,于是我觉得可以重用一下刚才学习的这个技能,将其他的服务也使用SQL Server部署。

我之前在用wakatime来记录我的编程的数据(例如每天的编程时间、所使用的编程语言等),但是wakatime免费用户只能保存14天的数据,而且wakatime没有提供官方的可自己部署的后端,所以我也一直在寻找wakatime的替代品。之前尝试使用了一段时间的codetime。这个软件的功能和wakatime类似,但是它当前只支持VSCode客户端,虽然免费保存数据,但是仍然没有提供可自己部署的后端。我在很早之前也尝试找过wakatime的后端替代品,但是当时并没有找到一个能用的。但前不久,同学给我推荐了wakapi项目,这个项目重新实现了wakatime的后端API,这样wakatime的丰富的客户端插件可以直接使用,并且完全可以自己部署,不用担心数据并存放在别人的服务器上。

它就是我一直寻找的wakatime替代品!

很激动地浏览了一下项目,看到wakapi当时并没有原生支持SQL Server,但是在README中提到,wakatime使用了gorm作为数据库访问框架,而gorm本身是支持SQL Server的。

我想,既然库都支持了,SQL Server支持有什么难的?引入gorm.io/sqlserver包,引入创建一个Dialector,用gorm的API编写的绝大多数数据库操作就完成了。

sqlserver.Open(mssqlConnectionString(c))

这有什么难的,开跑!结果遇到了一大堆报错。

遇到并解决一个一个一个的问题

SQL语句

仔细一看,这些报错主要是来自于数据库migration中的原生SQL语句和片段。

程序启动的时候,会运行数据库migration脚本。随着软件开发更多的新功能,其使用的数据库的结构总会发生变化,而migration是指一些代码,这些代码的作用是修改数据库schema、让schema满足当前版本的要求。当软件功能越来越多,对数据库的变化也就越来越多,所以在一个成熟的软件中,你常常会看到有很多的数据库migration的代码。在程序启动的时候,这些migration将会被一个一个地执行,使得程序正式开始时,数据库的schema也更新到最新。

> tree migrations
migrations
├── 20201103_rename_language_mappings_table.go
├── 20201106_migration_cascade_constraints.go
├── 20210202_fix_cascade_for_alias_user_constraint.go
├── 20210206_drop_badges_column_add_sharing_flags.go
├── 20210213_add_has_data_field.go
├── 20210221_add_created_date_column.go
├── 20210411_add_imprint_content.go
├── 20210411_drop_migrations_table.go
├── 20210806_remove_persisted_project_labels.go
 

而有的migration(尤其是自动生成的migration)很可能包含了一些原生SQL。同时,由于ORM是对数据库操作的抽象,而再强大的抽象也不如原生的SQL来得强大和方便,所以很多时候,开发者仍然会选择在一些地方使用原生SQL语句的片段或者语句来实现一些功能。虽然SQL本身是有标准的,但是各个数据库厂商实际上自己实现了很多新功能,很多时候我们本以为理所当然的功能,实际上并不在标准中,而是数据库厂商自己实现的,一旦手写SQL而没有意识到有的SQL实际上只在部分数据库中兼容,就可能会在不兼容的数据库中遇到问题。

举个例子,wakapi的某个版本需要在数据库的users表中新增一个has_data列,而对于已经存在的users表中的数据,这个列需要被设置为TRUE。代码中用于执行此次migration的代码使用如下SQL语句实现了这个功能:

UPDATE users SET has_data = TRUE WHERE TRUE;

看上去是个很简单的人畜无害的SQL语句,对吧?但是这个SQL语句在mssql中中是非法的,因为mssql中并没有TRUE, FALSE常量!sqlserver中没有boolean类型,所有这些类型都是使用一个字节的tinyint来表示的,而TRUEFALSE就对应使用10来表示。但是,10在sql server中并不是一个合法的boolean表达式,所以它们并不能直接用在WHERE中。所以,在MSSQL中,以上SQL语句就必须重新成以下的样子:

UPDATE users SET has_data = 1;

SQL语句片段

另一个情况是SQL语句片段。虽然ORM的一大作用就是将软件代码映射为SQL语句,减少我们手写SQL可能带来的错误,但是为了实现的灵活性,ORM常常同样也允许在自己的API中编写一些SQL的片段,而ORM会尝试将这些SQL片段嵌入进去。但是这些SQL片段可能也会有不兼容的情况!例如,下面的代码

result := db.Table("summaries AS s1").
			Where("s1.id IN ?", faultyIds).
			Update("num_heartbeats", "3")

上述gorm数据库仓库将会被映射为类型以下的SQL语句。注意TableWhere方法的参数与下列SQL语句中对应的语句的对应关系。

update summaries AS s1 set num_heartbeats = 3 where s1.id IN ?

是不是感觉很简单?但是MSSQL仍然不支持!MSSQL不支持在update持语句中给表新增一个别名,所以以上SQL语句中的AS s1必须被去掉。

重用Dialector的逻辑,为关键词加上引号

再看一个SQL语句片段:

r.db.Model(&models.User{}).Select("users.id as user, max(time) as time").

这段SQL语句一般会被映射为:

select users.id as user, max(time) as time from users;

有问题吗?有!user在MSSQL中是关键词,要作为标识符使用必须使用""或者[]将它包裹起来!而user在mysql等数据库引擎中都不是关键词,因此可以随意直接使用。

这个将关键词作为字符串使用的过程实际上编程中很常见,被称为escape,或者更简单的叫quote,加引号。但是更坑的是,不同的SQL引擎所使用的加引号的方法不一样。MSSQL使用的是""或者[],但是mysql使用的是`。如何通过一段代码来为不同的数据库加上正确的引号呢?

其实,这个加引号的过程实在是非常常见,只要ORM需要将程序员所写的名字(表名、列名等)映射到SQL,那么就需要考虑给这些名字叫上引号,防止这些名字和SQL自己的关键词冲突。如果你写一个名字叫select的表,没有这段逻辑,那么这个表就没法通过ORM来映射成SQL了!

因此,根据Don't Repeat Yourself原则,ORM为数据库系统的适配器中肯定会有对应的逻辑。而我们与其自己编写这个处理逻辑,最好的方法当然是调用适配器中已经写好的逻辑了。

gorm使用Dialector接口作为ORM支持不同数据库系统的适配器的接口,所有gorm支持的数据库都有一个对应的实现了Dialector接口的Dialector。所以,第一步就是去看看gorm的Dialector里定义了哪些接口。

// https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/interfaces.go#L12C1-L21C2
type Dialector interface {
	Name() string
	Initialize(*DB) error
	Migrator(db *DB) Migrator
	DataTypeOf(*schema.Field) string
	DefaultValueOf(*schema.Field) clause.Expression
	BindVarTo(writer clause.Writer, stmt *Statement, v interface{})
	QuoteTo(clause.Writer, string)
	Explain(sql string, vars ...interface{}) string
}

从名字判断,这个QuoteTo方法似乎就是我们要的接口。再随便点开一个dialector实现的代码浏览一下,就能确定,QuoteTo就是加引号的方法。

可是这个方法看上去和我们想象中的不太一样:我们预期这个功能是一个(string) => string的函数,这里怎么是一个(clause.Writer, string) => void的函数,这个clause.Writer是什么玩意?

由于拼接SQL说到底,就是要生成各个SQL的片段,然后将这些片段的字符串拼接起来。在绝大多数编程语言里,string都是不可变的,所以拼接字符串实际上是创建了第三个字符串,然后把两个字符串的内容复制进去。这个过程如果进行太多次,就非常浪费时间和内存。所以编程语言常常会提供一个组装字符串的工具类。这个工具类可以理解成是一个可变的字符串,你可以直接在这个_字符串_的后面增加新的字符串,而不需要每次操作都创建一个全新的字符串对象。而这个Writer接口,就是gorm定义的一个这样的能够组装字符串的工具类所需要实现的接口。这个Writer接口定义如下:

// https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/clause/clause.go#L13
type Writer interface {
	WriteByte(byte) error
	WriteString(string) (int, error)
}

很简单,很直白:WriteByte:在后面增加一个字符;WriteString,在后面增加一个字符串。

熟悉Go的同学可能会说了:啊,这个接口看上去非常熟悉,原生的strings.Builder就是用来做这个事情的,而且也有这两个方法!是不是可以直接用一个strings.Builder来作为这个clause.Writer

正确!所以我们可以直接创建一个*strings.Builder来作为clause.Writer(用指针的原因是这个strings.Builder的这两个成员方法是使用的指针接收者而不是值接收者,所以只有对应的指针类型才算实现了这个接口。具体关于指针接收者和值接收者的区别我们就不在这里介绍了,有兴趣的可以学习一下Go语言),并在调用后获取这个builder最终的字符串,这样就直接调用了dialector中实现了加引号逻辑。

func QuoteDbIdentifier(db *gorm.DB, identifier string) string {
	builder := &strings.Builder{}
	db.Dialector.QuoteTo(builder, identifier)
	return builder.String()
}

然后,我们将此函数用在所有需要在SQL片段中使用自定义的名字的地方,这样,我们就重用了dialector的逻辑,用同一套代码兼容了所有的数据库。

r.db.Model(&models.User{}).Select(
  fmt.Sprintf("users.id as %s, max(time) as time"), utils.QuoteDbIndentifier(r.db, "user")
)

重写不支持的功能

而在进一步查找并修改原生SQL的语句中,我找到了一段如下的原生SQL语句,直接让我眼前一黑。

with projects as (
			select project, user_id, min(time) as first, max(time) as last, count(*) as cnt
			from heartbeats
			where user_id = ? and project != ''
			and time between ? and ?
			and language is not null and language != '' and project != ''
			group by project, user_id
			order by last desc
			limit ? offset ? )
select distinct project, min(first) as first, min(last) as last, min(cnt) as count, first_value(language) over (partition by project order by count(*) desc) as top_language
			from heartbeats
			inner join projects using (project, user_id)
			group by project, language
			order by last desc

这段SQL语句就这样赤裸裸地写在代码里。经过以上几个例子,是不是以为这个SQL在MSSQL中必定问题巨大,不重新写一份根本跑不起来?

我一开始也是这么想的。而我自己对SQL Server并没有那么熟悉,所以聪明的我,在给一些名字叫上引号后,直接把这段代码扔给了GitHub Copilot,让它帮我改成SQL Server能用的。反直觉的是,这段代码实际上问题没那么大:

copilot的结果

具体来说,除了第三点去掉了不需要的引号后,有两个问题:

  1. 不支持join using,需要使用常规的join on代替
  2. 不支持limit offset。查找了一下,mssql可以使用offset ? ROWS fetch next ? rows only来代替,当然limitoffset的参数顺序是反过来的

啊,原来这么简单!由于需要修改的地方并不多,于是我就简单的通过if判断,将其中SQL Server不兼容的部分修改为SQL Server兼容的SQL语句,这段SQL语句也就完成了。

把同一个go类型在不同的数据库中映射为不同的列类型

修改了这些SQL语句后,程序仍然运行不起来,仔细一看,报错如下:

mssql: A table can only have one timestamp. Because table 'users' already has one, the column 'last_logged_in_at' cannot be added.

简单翻译,一个表只能有一个类型为timestamp的列,而users中有多个列都是timestamp的类型,所以SQL Server报错了。

去代码中一看,users表对应的Userstruct确实有好几个字段都通过gorm的field tag功能type:timestamp,确定了在数据库中这些列需要映射为timestamp类型。

// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/user.go#L17
type User struct {
  // ...
  CreatedAt           CustomTime  `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
	LastLoggedInAt      CustomTime  `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
  // ...
  SubscribedUntil     *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
	SubscriptionRenewal *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}

timestamp有什么问题呢?MySQL不都是使用timestamp来代表时间类型吗?去查找sql server timestamp,我才发现,SQL Server里的timestamp的含义和别的数据库中的含义不一样。在别的数据库中,timestamp就是一个表示时间戳/时间点的类型,而根据这个stackoverflow回答sql server中的timestamp主要用于乐观锁的情况(具体不在这里阐述),只是用来标记本行上一次被修改的时间,所以每个表只能存在一个timestamp的列。而在SQL Server中如果要表示一个时间戳,需要使用datetimeoffset

所以现在问题就变成了:如何将一个类型在不同的数据库中映射为不同的列的类型

type:timestamp这个tag肯定是不能用了,于是我们就需要查找有没有其他更动态的方法,可以自定义一个go字段映射到数据库中的类型。经过一番查找,我们找到了Customize Data Type功能。gorm允许定义一个自定义的struct,并给这个struct实现GormDBDataTypeInterface接口,来返回这个类型的字段所真正对应的数据库类型。

type GormDBDataTypeInterface interface {
  GormDBDataType(*gorm.DB, *schema.Field) string
}

这个函数的第一个参数就是gorm.DB对象,指向当前操作的数据库对象,可以获取到当前正在使用什么类型的Dialector,我们就可以根据Dialector类型,来输出对应的数据库列类型。另外,这个CustomTime正好是代码中所定义的一个新的struct,我们可以直接在CustomTime上实现这个接口。

看来比较简单,但是为了以防万一,我们再全局搜索一下type:timestamp,看看有没有什么其他的用法。果然被我找到了!

// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/heartbeat.go#L27
type Heartbeat struct {
  // ...
  Time            CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
  // ...
}

这里用到了timestamp(3)。根据mysql的文档timestamp(n)中的n是代表精确到秒后面的第几位浮点位,3代表精确到毫秒,而6代表精确到微秒。而SQL Server的datetimeoffset也支持类似的标记(文档),datetimeoffset(n)中的n也同样表示精确到秒后面的第几位浮点位。由于我们不能直接使用typetag,但是我们还需要一个tag用来表示这个精确的位数(根据sql server的文档,将这个位数称为scale),所以,我们可以定义一个全新的timeScale的tag,其值就是scale的值。而一个字段上具体有哪些tag,正好可以通过GormDBDataType的第二个参数获取。

于是根据这个思路,我们实现了CustomTimeGormDBDataType方法:

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/shared.go#L44
func (j CustomTime) GormDBDataType(db *gorm.DB, field *schema.Field) string {
 
	t := "timestamp"
 
  // 如果使用的是SQL Server Dialector,则将其类型设置为datetimeoffset
	if db.Config.Dialector.Name() == (sqlserver.Dialector{}).Name() {
		t = "datetimeoffset"
	}
 
  // 如果一个属性有TIMESCALE的tag,那么给类型后面增加(n)参数
  // gorm将所有gorm下的tag的key转换成了全大写
  // 参考:https://github.com/go-gorm/gorm/blob/0123dd45094295fade41e13550cd305eb5e3a848/schema/utils.go#L35
	if scale, ok := field.TagSettings["TIMESCALE"]; ok {
		t += fmt.Sprintf("(%s)", scale)
	}
 
	return t
}

同时,我们将所有的timestamp都直接去掉,将type:timestamp(n)修改为了timeScale:n

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/user.go#L17
type User struct {
  // ...
  CreatedAt           CustomTime  `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
  LastLoggedInAt      CustomTime  `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
  // ...
  SubscribedUntil     *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
  SubscriptionRenewal *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
}
 
// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/heartbeat.go#L12
type Heartbeat struct {
  // ...
  Time            CustomTime `json:"time" gorm:"timeScale:3; index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
  // ...
}

这样就解决了这个问题。

避免创建递归的级联修改外键约束

没想到的是,到这里仍然无法启动程序。报错如下:

[2.938ms] [rows:0] CREATE TABLE "summary_items" ("id" bigint IDENTITY(1,1),"summary_id" bigint,"type" smallint,"key" nvarchar(255),"total" bigint,PRIMARY KEY ("id"),CONSTRAINT "fk_summaries_editors" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_machines" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_projects" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_summaries_languages" FOREIGN KEY ("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE)
2024-01-19T19:29:35.607826413+08:00 [ERROR] mssql: Could not create constraint or index. See previous errors.
panic: mssql: Could not create constraint or index. See previous errors.

Could not create constraint or index. See previous errors.这个等于什么都没说嘛,只知道创建一个外键约束或者索引的时候失败了,而其他的错误信息被gorm或者gorm的sqlserver dialector忽略了。还

还好我能看到具体执行的SQL语句,所以我们可以把这段SQL语句手动输入到Azure Data Studio中,看看数据库引擎到底报了什么错误。

通过Azure Data Studio查看具体的错误信息

Introducing FOREIGN KEY constraint 'fk_summaries_operating_systems' on table 'summary_items' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.

看起来,是因为fk_summaries_operating_systems这个级联的外键索引会造成级联递归(cycles)。和外键约束有关,而外键约束是通过gorm定义,所以我们先去看看代码里涉及这个外键约束的两个表的定义。

// https://github.com/muety/wakapi/blob/fc483cc35cb06313da8231424d1d85b291655881/models/summary.go#L29
type Summary struct {
  // ...
	Projects         SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
	Languages        SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
	Editors          SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
	OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
	Machines         SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
 
type SummaryItems []*SummaryItem
 
type SummaryItem struct {
  // ...
	Summary   *Summary      `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
	SummaryID uint          `json:"-" gorm:"size:32"`
}

也就是说,SummarySummaryItem两个表是1:m的关系,这个关系映射到数据库中,也就是SummaryItem表中有到Summary的ID的外键summary_id,而这个外键的约束则是通过Summary表中的tag定义的。

转回头去看生成的SQL,生成的SQL里有5个完全相同的外键(fk_summaries_editors, fk_summaries_operating_systemsfk_summaries_machinesfk_summaries_projectsfk_summaries_languages)。由于这五个外键都是级联删除(ON DELETE CASCADE)和级联更新(ON UPDATE CASCADE),实际上确实是会存在一个级联递归:当一个SummaryItem被删除,会造成其对应的Summary被删除,而Summary被删除可能会造成这个SummaryItem也被删除。

那问题又来了,那为什么之前在MySQL、PostgresSQL等数据库里均正常呢?通过这篇Stack Overflow的回答,我们知道了这种问题在其他数据库中并不是问题:其他数据库会尝试解出一条级联路径出来。而SQL Server不会去尝试解,发现了这种循环,就会直接报错。

那如何解决这个问题呢?根据外键名和Summary中的属性的对应关系,我们可以推测,这五个外键是Summary中五个引用一一对应过来的。由于这五个外键实际上是一模一样的,只需要保留一个就可以了。所以,最终的解决方案是,只保留一个字段的外键tag,其他字段全部给一个-的tag,让gorm不要为这些字段生成外键。

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/summary.go#L30
type Summary struct {
	Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
 
	Languages        SummaryItems `json:"languages" gorm:"-"`
	Editors          SummaryItems `json:"editors" gorm:"-"`
	OperatingSystems SummaryItems `json:"operating_systems" gorm:"-"`
	Machines         SummaryItems `json:"machines" gorm:"-"`
}

规避gorm sqlserver的bug

至此,系统终于能启动起来了,并且大多数功能已经可以正常使用了。而在代码审核过程中,作者还发现了一个问题:代码中有一个Heartbeat表,它就是wakatime的客户端插件定期给服务器端发送的数据,其中包含了正在工作的项目、使用的编程语言等信息。而这个表中有一个字段为Hash,这个字段的值根据其他信息算出来,并且有一个unique index,通过这个字段,就避免给Heartbeat表插入多个重复的数据。

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/models/heartbeat.go#L13
type Heartbeat struct {
  // ...
	Hash            string     `json:"-" gorm:"type:varchar(17); uniqueIndex"`
  // ...
}

在插入的时候,作者使用了ON CONFLICT DO NOTHING的语句,这个语句的作用就是,如果当插入一行的时候,发现有些行违反了某个unique index的要求,则忽略这个问题,不插入这一行,也不报错。这刚好非常适合插入有可能有重复的Heartbeat的场景。

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/repositories/heartbeat.go#L52
// 下述代码将会被映射为ON CONFLICT DO NOTHING
if err := r.db.
  Clauses(clause.OnConflict{
    DoNothing: true,
  }).
  Create(&heartbeats).Error; err != nil {
  return err
}
return nil

这种行为被称为插入或者更新,又被称为Upsert(Update/Insert),其行为是一个简单的判断:如果一行已经存在,则更新这一行的内容;如果不存在,则插入这一行。而如何判断一行是否存在呢?则是通过unique index来判断。

可是遗憾的是,SQL Server不支持ON CONFLICT DO NOTHING。要想在SQL Server中模拟upsert的行为,有很多方式,一个比较常见的方法是使用merge into语句。具体如何编写比较复杂,这里就不再具体讲解。

由于这里是使用的gorm的API,并不是使用原生SQL语句,所以理论上来说,gorm是可以知道用户的意图,并根据不同的数据库生成行为相同的、但是兼容对应数据库的SQL语句的。根据这个issue,gorm的SQL Server库确实在很早之前就尝试通过生成一段的SQL Server兼容的SQL语句来支持这个功能,在gorm的文档里也提到了clause.OnConflict会根据不同的数据库生成不同的语句,而对于SQL Server,其对应生成的正好就是MERGE INTO语句。

但是,既然库都支持了,那为什么还会遇到这个错误呢?再次检查所实际执行的SQL,发现这一次Create实际上生成的SQL语句仍然是最普通的INSERT INTO,并没有不是正确的MERGE INTO,。

024/01/19 20:10:23 /home/ddadaal/Code/wakapi/repositories/heartbeat.go:54 mssql: Cannot insert duplicate key row in object 'dbo.heartbeats' with unique index 'idx_heartbeats_hash'. The duplicate key value is (d926be93ebcc4b6f).
 
[10.830ms] [rows:0] INSERT INTO "heartbeats" ("user_id","entity","type","category","project","branch","language","is_write","editor","operating_system","machine","user_agent","time","hash","origin","origin_id","created_at") OUTPUT INSERTED."id" VALUES ('ddadaal','/home/user1/dev/project1/main.go','file','','wakapi','','Go',1,'','','','curl/8.5.0','2024-01-16 02:16:18.954','d926be93ebcc4b6f','','','2024-01-19 20:10:23.378');

看来,要想弄明白这个问题,就得进到gorm的源码里来查看问题出在哪里了。使用VSCode启动一个调试版本的程序,给上面的代码的位置打上断电,使用curl连续插入两次同样的Heartbeat,根据断点往内查找,当进入到gorm的sqlserver的适配器的时候的代码的时候,我们发现了一些端倪:

查找到可能出现bug的位置

当前,我们正在图中的if hasConflict的位置,且hasConflict当前为true。根据下面的一段代码,如果hasConflicttrue的话,将会进入MergeCreate函数,而这个函数即是一个在MySQL实现类似行为的SQL的语句的代码。但是,实际在橙色块结束后,hasConflict被改成了false,所以最终并没有进入MergeCreate,最终的结果就是一个普通的INSERT INTO SQL。那么也就是说,橙色这段代码就是罪魁祸首!

这段代码干了什么事情呢?简单来说,它会检查要插入的行中的各个列中有没有包含主键。如果没有设置主键,则将会把hasConflict改为false;而如何设置了主键,才会进入MERGE INTO流程。回想前面,要想正确生成upsert行为,我们需要判断一行是否存在。而很显然,这里的代码将一行是否存在的判断标准等价为了主键是否存在,而忽略了其他具有的unique index的列。

当然,其他人也发现了这个问题,gorm-sqlserver的仓库中也有已经半年前打开的issue正好就是关于这个问题。而很遗憾,这个问题过了半年依然没有修复。

要想解决这个问题,有多种方法,而我最终选择了最简单粗暴的方法:不管!这个函数的目的是同时插入多个Heartbeatt,那我们就手动一个一个插入,如果一个行插入失败了,我们简单判断一下,如果这个错误是因为hash重复造成了,那我们就不管了!检查了一下代码中调用这个方法的场景,其实大多数情况下都是只插入一行,所以这样写对性能造成的影响是可以接受的。

// https://github.com/muety/wakapi/blob/1ea64f0397e5ee109777b367e9bd907cfdd59bdb/repositories/heartbeat.go#L34
if r.db.Dialector.Name() == (sqlserver.Dialector{}).Name() {
  for _, h := range heartbeats {
    err := r.db.Create(h).Error
    if err != nil {
      if strings.Contains(err.Error(), "Cannot insert duplicate key row in object 'dbo.heartbeats' with unique index 'idx_heartbeats_hash'") {
        // ignored
      } else {
        return err
      }
    }
  }
  return nil
}

总算还是完成了

终于,经过了一周的时间,本来以为只是一个简单的调库,结果却发现了这么多的问题。经过这个过程,之前连SQL Server用都没用过的我也知道了很多SQL Server的细节;之前没有接触过gorm,现在却连源代码都好好翻了一通。

而关于go,虽然我一直不喜欢go的语法,觉得它太简单,类型系统太弱,很多代码都花在固定的模板代码上(比如if err := nil),但是不得不承认的一点是,go确实非常的explicit。没那么多乱七八糟的反射、运行时黑魔法,控制流非常清晰,想知道什么代码调用了一个方法,直接Shift+F12就完事了,出来的结果不会多不会少;要想某个错误是在哪里处理的,跟着繁琐的错误处理代码总能找到。它确实缺少一些特性,但是它非常地工业化。

解决这些问题后,PR顺利合并进了主分支,成就感满满,这可能也是我第一个没那么简单的开源项目的贡献了。

https://github.com/muety/wakapi/pull/592

对我来说,设定一个目标是学习的最好的途径,在这次实践里再次得到了印证。

增加自制博客点击量统计

2024-01-07 23:55:00

为什么要加统计?

作为一个创作者,我还是很希望能够获取我的网站的一些统计和监控信息的,例如各个页面的点击量等。且不说这些数据到底有什么具体的作用,但是单纯地看着网站的访问量上涨,这对我来说还是非常有成就感的,说明我写的东西还是有人看的😂

但是在过去的5年里,我的博客一直没有部署一个稳定的统计系统。之前用过使用Google Analytics、友盟以及百度统计,但是最后均遇到了各种各样的问题没能使用下去。例如,Google Analytics的数据过于复杂,我甚至没搞懂怎么看某一页的访问量?友盟的信息只保存一年等。前几天看到一个Analytix的服务,看界面非常清新简洁,也没有提到要付费的情况,非常对我胃口,结果装进去发现报告数据的API有错误,仍然无法使用。

来到了新的一年,我打算这次一口气解决这个问题。我连简历的样式都是自己用CSS排的,写个统计功能还难住我了?用第三方功能总会有所担心,担心要收费、数据丢失,自己写的功能就没有这些问题了。于是花了一天完成了一个最简单的博客点击量统计功能,并正式上线。

如何收集访问者的信息?

绝大多数统计网站访问者的模式都是相同的:先在平台上注册一个账号并注册自己的网站,平台给予一个<script>HTML标签,这个标签需要被加到被统计的网站上。当标签加上去后,这个标签就会下载一段javascript脚本,这个脚本会将一些访问者的信息发送到平台上,平台收集数据后做数据统计,这样我们就获得了网站访问者的信息了。

这个脚本具体怎么写呢?在研究上文提到的Analytix为啥用不了的时候,我研究了它要求我们插入的脚本:https://analytix.linkspreed.com/js/script.js(点击直接查看)。它所做的事情,主要有两个:

  1. 加载脚本时,把当前页面地址page、来源地址referrer、屏幕分辨率screen_resolution发送到他们的endpoint
  2. hook history.pushState函数,当这个函数被调用时(即页面URL修改时),再次执行上面这个事件

这样,每次用户访问网站时,脚本就会把访问信息上报到API,并且通过history.pushState确保即使是单页应用,也能正确报告所有访问的URL。

收集了哪些信息?

要想分析用户的信息,最简单的方式莫过于把用户的IP地址收集上来。有了IP地址,我们就可以分析很多信息了,例如用户来源的分布等。但是,我们再仔细看看Analytix所收集的信息,只有三个:当前页面地址来源地址以及屏幕分辨率。是不是感觉有点少?没有IP地址,怎么去重,怎么分析哪些来访者是来自于何处?

我认为这很可能和GDPR有关。GDPR(通用数据保护条例)是欧盟的一项关于数据隐私的法律,是对所有欧盟个人关于数据保护和隐私的规范,所有互联网服务只要涉及到和欧盟的人或者公司,都需要遵守这个规定。这个规定非常复杂,我也没有信心完全读懂,但是其中以下几点非常重要:

  1. 要获取用户的个人信息,就需要获取用户的同意,并且这些信息还要满足大量的数据安全要求
  2. 所有能够识别到某个具体个人的信息都是个人信息,其中包括IP地址

把这两点连起来看,就是说网站不经过用户同意就能收集的信息实际上就非常有限了。Analytix可能也是考虑到这个因素,所以才默认只收集了URL以及屏幕分辨率信息,最重要的IP地址等均没有被收集。除此之外,脚本中还单独判断了navigator.doNotTrack,如果这个值为真,则不报告信息。根据MDN的信息,navigator.doNotTrack是个非标准的API,当用户浏览器设置了Do Not Track时,这个值为true。这也充分满足了用户的意愿,如果用户不愿意被track,就真的不会被track。这也是为什么现在很多网站在第一次访问时都会有一个很明显的弹出框等方式,里面会明确说明网站会使用cookie、记录访问者的IP地址,以及给用户说明如果不愿意收集可以明确被退出,要求用户显式地同意。这些也都是GDPR、以及其他国家后续类似推出的信息保护条例的要求。

www.jetbrains.com第一次访问时在右下角弹出的声明

作为一个负责人的网站开发者,我认为个人信息确实是非常重要。目前我也没有必要收集用户的IP地址来做进一步的分析。为了保护用户的隐私,我也选择了和Analytix一样的收集方式,目前只收集了这些和个人信息无关的信息。您可以访问https://services.ddadaal.me/monitor/script.js来获知访问网站时下载的脚本具体执行了什么代码,发送了什么信息。

如何开发和部署?

目前网站的统计逻辑基本上就是照抄Analytix,写一个信息上报地址、写一个脚本,然后在博客中加一个<script>标签。

项目本身我采用了我比较熟悉的Node.js,使用fastify HTTP库编写了代码。之后,我将其打包为Docker镜像,部署到了Azure App Service上,并将services.ddadaal.me解析到部署后的地址。Azure App Service使用的是最便宜的版本,每个月的费用预计为15刀左右。

App Service的价格。不得不说Azure服务的价格还是比较贵的

采集到的信息保存在Azure SQL Database上,主要原因是它有一个免费的offer,每个月32G存储和第一个100000核秒的计算是免费的,这些计算能力和存储对我来说绰绰有余了。

Azure DB Free Offer

它实际上是一个Microsoft SQL Server SQL服务器,Node.js下虽然有能用的mssql库,但是生态支持mssql的还是太少了,为了更方便地访问数据库,我使用了支持mssqlPrisma,正好尝试了一下这个全新的"ORM"框架。目前总体用起来,和传统的mikro-orm等用起来还是有不少的区别的,例如它的客户端是通过codegen生成出来的,编写schema也是通过自己的prisma语法,不是传统的用TypeScript来定义。总的来说,因为这个库是有公司在背后支持的,所以可用性还是不错的。另外,微软的Azure Data Studio甚至Prisma的Prisma Studio都可以访问MSSQL的数据,维护起来问题不大。

Azure Data Studio

后续

有了统计数据,之后第一件事肯定是数据分析。目前可以通过SQL语句来分析,但是这也太不直观了,后续可能我会开发一个简单的dashboard用来查看统计数据。

另外,这个博客是一个静态博客,所有的动态功能均需要单独部署服务来实现。而services.ddadaal.me会变成博客后端的服务的基础。后续博客的动态功能也会部署到这个域名之下。

博客集成AI文章总结功能

2024-01-01 10:20:00

一条朋友圈的提示

当昨天的2023年总结发出后,朋友圈有人对文章做出了总结,这启发了我,何不自己用AI给文章加个总结功能呢?正好也是第一次在真实场景中实装AI功能,看看目前在真实项目中集成AI需要什么步骤,体验怎么样,能做到什么功能。

朋友圈锐总结

选择AI服务

确定了要做这事,下一步就开始调研应该用什么AI服务。

第一反应肯定用OpenAI API,去年某段时间注册OpenAI账号不需要手机号,趁着这个机会直接注册了一个,至少最基本的ChatGPT可以用GPT 3.5模型聊天了。而当我想去用OpenAI的API的时候,发现要获得OpenAI的API需要验证手机号,而国内手机号当然验证不了的。除了手机号,支付方式也是一个问题,虽然我有国内银行的VISA卡但是似乎仍然不能使用。

国内手机号无法验证OpenAI API

于是接下来我想到了公司的Azure OpenAI服务。虽然Azure的服务都偏贵,但是由于公司每年都给员工送150刀的Azure额度,所以一点简单的应用还是可以开发的。可是真的去注册的时候发现Azure OpenAI Service并没有完全开放给所有客户,要注册必须填个注册表,且必须以公司身份注册。

Azure OpenAI服务并未面向大众开放

正当我在想“不会要去找国产服务了吧”的时候,我想到两个月前在Ignite上亮相的Azure AI Studio。仔细研究了下,发现这个功能本身只是一个集成平台,它是基于一些已经已经发布的Azure AI服务,而上面想尝试的Azure OpenAI只是这些服务内的一个。在这些服务中,有一个Language服务,正好用于处理自然语言的场景,而其中正好还自带了Summarization功能,其中的Abstractive summarization模式只要输入文本,它就能输出一段总结这段文本的文字。

一切都是正好。微软爸爸真懂我。

使用Azure AI Language Service

根据Quickstart文档,我们首先需要创建了一个Language资源,获取资源对应的endpoint以及key。

文章总结功能的一大好处是这个功能不需要实时交互。也就是说,我可以在后台把总结生成好,像文章内容本身一样作为静态资源放在仓库里。这样,我们根本不需要能够支持大量调用的AI服务,只要运行一次,就完全可以使用了。创建的资源的时候,每个subscription可以创建一个免费的资源,我们本来也就不到50篇文章,免费的资源就完全够用了。

创建好的Azure Language资源

接下来需要在项目中安装SDK来使用这个资源。我们的博客使用的是Node.js,于是我们选择Node.js继续进行下一步。使用npm install --save @azure/[email protected]安装SDK后,根据指示直接在项目使用SDK即可。

由于只要内容不变,总结就没有必要更新,所以最简单最直接的做法,就是直接放在本地写个脚本,读取文章的内容,然后把文章内容的markdown直接一股脑送给服务获得内容的总结,然后把总结的内容生成到文章对应的目录下,读取文章的时候顺便读取生成的总结,然后在UI中渲染就好。

ai/summarize.mts
// 总结文本
async function summarize(text: string, languageCode: string): Promise<string[] | Error> {
  const lro = await client.beginAnalyzeBatch([
    // 使用AbstractiveSummarization模式
    { kind: "AbstractiveSummarization" },
    // 根据文章的语言指定所使用的语言,实现中文文章输出中文总结,英文文章输出英文总结
  ], [text], languageCode);
 
  // 这是个耗时操作,等待耗时操作结束
  const results = await lro.pollUntilDone();
 
  for await (const actionResult of results) {
    if (actionResult.kind !== "AbstractiveSummarization") {
      return new Error(`Expected extractive summarization results but got: ${actionResult.kind}`);
    }
    if (actionResult.error) {
      const { code, message } = actionResult.error;
      return new Error(`Unexpected error (${code}): ${message}`);
    }
 
    for (const result of actionResult.results) {
      if (result.error) {
        const { code, message } = result.error;
        return new Error(`Unexpected error (${code}): ${message}`);
      }
 
      // 返回输出的结果
      return result.summaries.map((x) => x.text);
    }
  }
 
  throw new Error("No result");
}
 
async function summarizeArticle(articleDir: string) {
  // 获取此文章的所有markdown文件
  const mdFiles = (await readdir(articleDir)).filter((x) => x.endsWith(".md"));
 
  for (const mdFile of mdFiles) {
    const mdFilePath = join(articleDir, mdFile);
 
    // 读取并解析文章内容
    const mdContent = await readFile(mdFilePath, "utf-8");
    const { data: frontMatter, content } = matter(mdContent);
 
    // 计算并更新文章Hash,如果文章内容hash没有变,之后就不要重新生成总结了
    const contentHash = hashContent(content);
 
    const summaryJsonFilePath = join(articleDir, `${frontMatter.lang}.summary.json`);
 
    if (existsSync(summaryJsonFilePath) && (await stat(summaryJsonFilePath)).isFile()) {
      const existingSummaryJson: ArticleSummary = JSON.parse(await readFile(summaryJsonFilePath, "utf-8"));
 
      existingSummaryJson.hash = contentHash;
 
      if (contentHash === existingSummaryJson.hash) {
        log("log", "Content is not changed after the last summarization. Skip summarization.");
        continue;
      }
    }
 
    // 总结文本
    const summary = await summarize(content,
      azureLanguageCodeMap[frontMatter.lang as keyof typeof azureLanguageCodeMap]);
 
    if (summary instanceof Error) {
      log("error", "Error on summarizing %s of lang %s: %s", frontMatter.id, frontMatter.lang, summary.message);
      continue;
    }
 
    // 在文章目录下生成一个[语言id].summary.json文件,存放内容hash、总结以及相关操作信息
    const summaryJson: ArticleSummary = {
      articleId: frontMatter.id,
      lang: frontMatter.lang,
      lastUpdateStartTime: startTime,
      lastUpdateEndTime: new Date().toISOString(),
      summaries: summary,
      hash: contentHash,
    };
 
    log("log", "Write summary of %s of lang %s to %s", frontMatter.id, frontMatter.lang, summaryJsonFilePath);
 
    await writeFile(summaryJsonFilePath, JSON.stringify(summaryJson, null, 2));
  }
}
 

效果

最终效果嘛,你在打开本文章的时候应该就看到了。如果一篇文章能够成功生成总结,那么文章页面的一开头,以及右侧的目录部分就会有AI总结这部分内容。

效果图

为什么说“如果可以成功生成总结”呢?在具体操作中,2020年总结这篇文章死活不能生成总结。

一篇文章无法生成总结

而对应成功生成总结的文章,总的来说英文文章的总结效果显著好于中文文章。例如,An Infinite Loop Caused by Updating a Map during Iteration这篇文章的总结包括了问题描述、问题解决过程以及最终的解决方案,语言流畅,逻辑清晰;

The author encountered a problem during the development of the 2.0 version of [simstate], where an infinite loop occurred during the iteration of a set of ' observers' stored in a ES6 Map. The problem was initially confused due to the Map having only one element and remaining unchanged between and inside loops. However, after investigation, the author discovered that the root of the problem was the call to observer, which alters the Map itself during iteration. This led to the infinite loop, even when deleting and re-adding an entry during iteration.

而同样类型的问题探究类文章一次生产环境的文件丢失事故:复盘和教训,得出的总结就过于简单,而且也没有找到问题重点,得出的文本中甚至都是英文标点。

作者通过log、数据库数据等找到了受到影响的用户,通过邮箱、电话和短信提醒他们重新上传文件。

总结

这是我第一次在实际项目中运用AI,在充分的文档帮助下,整个过程花费了6个小时左右,其中集成这个功能可能花了1个小时左右,而最终的效果不能算非常完美,但是也是是差强人意。在2023年的最后一天完成这个过程,在新年的第一天给博客实现这个新的功能,也算是一个新年礼物了吧。在AI的时代,与其害怕AI替代自己,不如主动拥抱AI提高生产效率,而这对于从业者的我们天生就有优势。

2023年总结

2023-12-31 23:59:00

一段历史的终结

从2019年10月开始,在我对未来的所有计划中,2023年就是最后一年。我知道我会在2023年完成毕业论文并毕业,加入一个公司(在2022年确定是上海微软),再往后会发生什么,就彻底没有意识了。对我来说,2023是19年学生生涯的结束,也是我最熟悉的生活的终结。

没有干扰的毕业季

由于疫情,2020年的毕业季变为了宅家季,整个研究生期间的生活也受到了不小的影响,更别说在北京,以至于之前连出京看个牙有时候都是一种挑战。随着疫情防控的结束,生活总算可以回归正常。因此,我也在学生生涯的末尾再次体验到了一把久违的正常生活。

羽毛球比赛

作为一个经历了体重困扰22年的“减肥困难户”,我一直以为体育运动和我没有关系。可谁知道竟然能在研究生期间减重成功,结交了愿意一起运动的朋友,甚至还加入了院里的球队。生活正常了后,各种比赛也多了起来,在来自同学、球队的帮助下,我也有机会参加各种比赛。

3月在清华综合体育馆,第二个球就把腰闪了,坚持打完21分后在场馆边成为“球场流浪汉”;4月法学院组织的比赛,当时因为身体不太舒服没有上场,却与王适娴面对面,队友还获取了王适娴在球衣上的亲笔签名;5月和球队参加硕博杯比赛,可是却打出只获得3分、5分的惨烈对局,最后甚至还发现3分那一场的对手在朋友圈里;6月和老搭档的比赛前半局大比分领先,可后面却被逆转。这可能这是这半年最大的遗憾了吧,没能在实力接近的比赛中赢下一局。

羽毛球比赛

旅行

今年可能是我出游最多的一年。3月QQ火花1100天+的大学同学考研复试,在北京参观;4月大学同学回国飞深圳,3天时间在深圳闲逛,顺带拜访了港中深的同学;6月和研究生同学去了明孝陵和青岛、淄博和济南;7月大学同学回国飞香港,快十年后又一次出境游,回到国内后窝在民宿里,除了吃饭就是聊天,一个景点都没有去,还顺路在江门和研究生同学打了一场球;8月陪研究生同学游重庆。

旅行集锦

我不喜欢一个人旅游,对我来说,旅游的重点不在去哪儿,而在和谁一起去。有这么多愿意玩的同学朋友,我感到很幸运也很感激。

研究生期间的工作

随着负责的实验室项目迈入正轨,项目的事情也逐渐越来越多。从一开始的只要按照自己的写代码,到后面要去投稿、参加会议、和人越来越多的团队合作。虽然马上要毕业了,之后应该也与学术圈不会再有交集,但是在老师的支持下,在这最后半年里,浅浅体验了一下这条我之后不会再有机会经历的道路。

参加会议

另外,要毕业的时候,赶上了HackPKU Hackathon比赛的末班车。上大学以来总共参加和组织过5次hackathon,每次hackathon都是一次学习效率拉满的体验,这次hackathon更是在完全不同的情况下,在ChatGPT的支持下学习了一些WebGPU相关的能力,虽然真的很累,这种以兴趣驱动、有目标导向的体验真的难得。

HackPKU 2023 项目和证书

毕业

虽然三年前我们仍然能够回到学校,在学校度过本科阶段最后的日子,但是没有正式的毕业活动,总感觉没有真正的结束。还好,19年的学生生活有一个完美的结局。毕业典礼最后合唱燕园情的环节,不仅是对燕园、三年研究生生活的告别,也是对学生生活的告别。

毕业典礼

从激动到平淡的新生活

新的生活一开始是让人激动的,后面才意识到,它是复杂的。

从零开始打造理想中的生活

今年来租房的时候,根据去年经验,我和合租的同学定下了一个以下要求:不要老破小,通勤时间短,周边生活方便。可是,在到上海的前几天,预先看好的房源一个一个被订走,我们只好妥协对户型和地铁站距离的要求,最后租了一套在附近住房里离公司最近的、21年才交付的全新的动迁房,再购买了一辆二手电动车,从出门到公司电动车停车位停车总共8分钟。

通勤距离

入住后一番收拾,在客厅的一角把台式机打造成了工作站,设置好了厨房,后面还邀请了在工作中认识了几位南软的小伙伴,请他们在来家里一起吃我们订的螃蟹和做的饭。

做饭

由于是实习转正,所以入职直接进了去年实习的组,一切都是那么熟悉。办公环境没有变,工位和实习工位隔了2m,老板没有变,同事没有多没有少,工作仍然是接手的实习项目,熟悉到入职第一周就完成了一个功能。

园区门口

总的来看,一切就和当年想的一样进行着。

总会遇到新的问题

但是哪儿有完美的事情呢?

工作上,这四个月内我一直在做一个新的业务,具体的功能实现和节奏都由带我的同事和我自己掌握。而与此同时,我的在其他组的同学每天有具体的要求,还有从早到晚的各种会议,工作十分充实,这个对比让我十分不解:我的工作是不是太水了?和老板进行了一次6个小时的一对一聊天,了解到了组里的情况,组里目前还没有业务,分配给我的任务也是探索性质的,所以目前仍然是比较自由的状态。对纯粹混日子来说,这种组无疑是很合适的,但是毕竟公司主要还是以盈利为主,如果一个组一直不出业绩,组里分到的资源以及个人的发展前途肯定会受到影响,而这些事情是目前我一个小兵无法左右的。另外更具体地了解薪资和结构后,简单计算一下得知,即使是在升职顺利且不被裁员的情况下,收入前五年平均下来也只能维持几乎不变,距离在上海买房那还是差远了。

升职加薪没啥指望了,重点就要在生活上找乐子了。理论上来说,在上海应该不缺乐子。可是,如果要进市区的话,地铁出行一小时刚到徐家汇,开车由于经常堵车时间并没有本质区别,再加上没有搭子,即使去市区,等到了地方,也只能简单逛逛就得准备往回走,音乐会等最好多人一起参加的活动至今也还没有参加过。不进市区的话,周边都是工厂和农田,在冬天降温之前为了运动,倒是骑着共享单车把周边都转了一圈。

周末20km共享单车足迹

由于来了全新的城市,原来的朋友都在外地,而工作中的认识的同事以及现在仍然方便联系的朋友全部都已经有对象、有家庭甚至有小孩,都有自己要关心的更重要的事情,不太可能再像之前一样想约就约。公司倒是比较慷慨,组织了几次团建活动,迪士尼、甪直古镇,以及在市区一个酒吧的团建,但是主要也是为了大家单纯的放松和白嫖团建预算而已。

公司团建活动

11月的时候办户口,虽然户口可以网上办理,但是为了再和上半年的朋友们再见一次,我还是再次去北京待了3天,在可预期的未来最后一次见了北京的朋友们,三天见了6波人。之前做MBTI做出来结果为E,我自己都感觉不可思议,但是从社交是否能给我带来活力来说,这个结果确实挺准确的。

找寻新的目标

我从国内顶尖的学校毕业,进入了梦想的行业,在梦想的公司过着压力不大的、通勤10分钟的工作,我原来的目标似乎完全实现了。

自很小的时候在父母的单位上接触了电脑(从一张老照片上确认是2001年)后,我对计算机产生了兴趣。那个年代比尔盖茨的故事家喻户晓,我也认为计算机软件是我将会从事一生的行业。当别的小孩在外面疯玩的时候,我在家里鼓捣电脑,甚至还给小区里的邻居装系统、解决电脑问题。高考填志愿的时候,我果断地填了软件工程专业,在坚定的目标中度过了充实的七年大学和研究生时光。选择工作的时候,我也完全没有考虑目前很火的考公、国企,我喜欢我做的工作,虽然这两年裁员、降薪新闻一个接一个,但是我仍然选择了这个行业。而当大家都去国内大厂的时候,我却只盯着外企,以至于目前三段实习经历都是外企,最后也除了出于好奇往华为投了一份简历(然后面试通过后石沉大海)之外,一个其他企业都没有投,最后也顺利把握住了最近两年最后一批校招的机会,进入了微软。

但是,成年人的世界就是选择并承担后果。选择了Work Life Balance的外企,就要接受工资不高不低以及存疑的稳定性;选择了住在郊区,享受了极快的通勤时间和相对市区低廉的房租,摆脱了干什么都要排队的拥挤,就必须接受出行的不便以及看着像30年前小镇子的环境。

仔细想来,我完全不能抱怨环境。工作条件灵活,假期充足,老板不push,工作内容暂时压力不大,相处的同事能力强好沟通,甚至还能带我去俱乐部打球,可以说我可能很难再找到这样的环境。我也感觉一直非常的幸运,一直到今天,所经历过的事情、认识和结交的人都非常的nice。那我还有什么不满意的呢?归根结底,是我还需要回答这个最重要的问题:我到底想要什么

但这个问题有那么好回答吗?

工作之前觉得郊区生活成本低,通勤方便,房价低,有希望能留下;而现在却觉得,住在这么偏远,出行动辄1小时,也算在上海吗?同样一份Work Life Balance的工作,之前觉得工作和待遇取得平衡,现在却觉得待遇离能在上海定居差了十万八千里,晋升又慢,还要担心中美关系,混日子工作有什么意义吗?同样是居住,4个月前我唯一的念头就是离公司近,但是现在却萌生了去市区居住和体验的想法。又回想到2019年,从“坚持”本科毕业去工作到保研,这个转变也就发生在一个月内。

前段时间看到有人说,一个人成长的标志是感觉到以前的自己非常幼稚。我很高兴我仍然处于成长当中,但更重要的在成长中找寻新的方向。在过去的十九年中,总有一个“毕业升学”的目标围绕着我。而现在以及以后,永远不会再有这样一个固定的目标了,找寻新的目标成为了以后最重要的问题。明年,我将会更熟悉工作的状态,可能会和更多人合作,承担更多的工作;明年合租的同学离开,我将再有一次换居住环境的机会;可能会通过更多的渠道认识到更多的人。希望我能在不停的变化、体验、接触之中,慢慢地搞清楚我究竟想要什么样的生活。

博客的发展2:重写,重生

2023-06-17 22:10:00

鸽了4年的更新

4年前的博客的发展1中,我提到了当时博客的几个问题。后来,我通过一个非常hack的方式解决了中文字数统计的问题(修复gatsby-transformer-remark插件中文词数统计错误问题,但是最重要的重构样式和完善UI设计的问题一直搁置,并且随着时间和技术的发展,项目也遇到了不少的问题,例如

  1. 样式的混乱使得我一直使用老的bootstrap v4版本,无法升级到最新的bootstrap版本
  2. gatsby生态更新太快,很多组件我无法理解它们具体做了什么工作

5月底研究生答辩完后,本来计划好的旅行,在我出发的前一天被我二阳直接推迟了。阳了后基本上也就只能在宿舍呆着。呆着也是呆着,我想起来博客这个”我的门面“。这三年博客本身基本没有任何更新,基本属于年久失修的状态。于是我决定给我的博客来个大手术。

新博客的亮点

由于研究生期间的项目(PKUHPC/SCOW)是完全使用Next.js编写的,我对Next.js非常熟悉。而Next.js本身也是一个非常成熟的React框架,并且也支持导出为静态网站的功能,并且有很多网站均使用了Next.js来作为它们的主页、博客等信息发布平台,所以我在想是否能重用之前的经验,用Next.js来搭建新的网站。一顿操作下来,除了遇到了一些和Gatsby的思路不太一样的地方,整个体验还是挺不错的。

新的网站和原有博客在功能性、整体布局以及各个页面的URL方面都完全一致,原有的所有使用习惯和URL都可以直接使用,原来的所有功能现在都仍然支持,包括但不限于多语言页面、多语言文章、RSS等。这才是重写的真正含义吧:所有代码都完全重写了,但是不会影响任何已有的使用体验。

这是本次重写最重要的地方。我原来是CSS in JS的狂热爱好者,认为使用JS编写网站的所有方便是网页开发的最终目标。现在,我虽然仍然认为CSS in JS方案带来的灵活性是所有其他方案都不可比拟的,但是我也认识到很多情况下样式并不需要那么高的灵活性。另外,由于样式最终还是要到达CSS的层次,在把CSS in JS和其他第三方样式解决方案(例如之前用的bootstrap)集成的过程中,需要大量代码来将两套完全独立的样式系统整合起来。这也是之前样式代码极度混乱的根本原因。

例如,原来代码中的导航栏组件同时使用了bootstrap的Navbar组件,并通过styled-components在这个组件的基础上自定义了样式。在自定义样式时,还引用了TS中定义的样式变量。有的组件甚至为了使用bootstrap的定义在SCSS中的变量,故还引用了自定义的SCSS文件。而由于有的变量是定义在SCSS中,有的是在TS代码中的,所有很多变量(例如颜色)都需要定义两次。

import { Navbar } from "reactstrap";
 
const StyledNavbar = styled(Navbar)`
  && {
    max-width: ${widths.mainContent}px;
    margin-left: auto;
    margin-right: auto;
    padding: 4px 8px;
 
    transition: width 0.2s ease-in-out;
  }
`;
原来的标题栏实现部分代码

另外,我也认识到以传统HTML/CSS来布局和样式的一些优势,例如将UI与具体的开发框架解耦、更好的性能、以及甚至能在不启用JS的环境下展示页面等。当前,以tailwind为主的以传统的HTML/CSS为基础的样式方案非常火,这次我也直接采用tailwind以及基于tailwind的纯HTML/CSS组件库daisyui来编写新的博客,并体验到了前所未有的开发效率和开发体验。直接写语义化的类型名,确实比写JS代码要方便太多了。

tailwind自动完成体验

实现

完全采用Next.js App Router

Next.js的App Router功能可谓是万众期待,虽说有评论说这个功能(以及后续的Server Actions)把Next.js变成了PHP,但是不可否认的是,App Router极大地提高了开发体验和灵活度。

在新的博客中,App Router带来的各种优势里,让我最受用的是以下两点:

React Server Component (RSC)实际上是React的概念,在2020年就提出来了(Introducing Zero-Bundle-Size React Server Components - React Blog)。简单来说,原来的React的组件都是运行在客户端的。浏览器首先把项目代码下载下来,然后再浏览器中运行代码,这些代码将会通过浏览器端DOM API在浏览器上画出UI,并处理用户的交互。而React Server Component允许用户编写运行在服务器端的React组件。而Next.js 13第一次实现了这一概念。

这颠覆了传统的前端开发模式。代码在服务器端运行的,这就意味着组件可以直接执行在服务器端才能执行的代码,例如访问数据库等,而不再需要单独的一套API来实现客户端和服务器端之间的交互。

在新的博客中,所有博客的内容都是以本地文件的方式存放在contents目录下。所有的页面会去读取自己所需要的数据,之后将这些数据渲染出来。

假设我们的网页不是一个静态网站,而是一个传统的React+后端的模式,那要实现这个功能,我们首先需要设计一个API来获取后端的数据,在后端,我们编写一个服务器实现这个API,然后在前端,我们通过fetch调用这个API,拿到数据后在UI上渲染出来。

// 后端,编写API
const app = express();
 
app.get("/articles/:id", async (req, res) => {
  const content = await readContent(req.params.id);
 
  res.send(content);
});
 
app.listen(5000, () => {});
后端
// 前端,通过fetch API获取数据
export const Page = ({ id }) => {
  const [data, setData] = useState();
 
  useEffect(() => {
    fetch("http://localhost:5000/articles/" + id)
      .then((x) => x.json())
      .then((x) => setData(x));
  }, []);
 
  return data ? (
    <ArticleContent data={data}/>
  ) : <Loading />;
前端

然后通过RSC,我们可以直接使用React来实现这个需求:

export default async ({ params }: Props) => {
 
  const data = await readContent(params.id);
 
  return (
    <ArticleContent data={data} />
  );
};

这区别实在太大了。不再需要一个单独的后端项目,不再需要复杂的API设计、管理、调用、维护,从获取数据到渲染UI的过程非常直观。甚至说如果这个ArticleContent组件不需要用户交互的话,用户甚至不需要下载这个组件的代码,浏览器不启用JS就能访问网页。

从某种角度来说,App Router确实是把React变成了PHP这类传统的服务器端渲染的方案。但是,毕竟Web前端是JS的世界,PHP等不能直接使用后端语言编写前端的交互逻辑,只能做一些简单的模板替换的功能,一旦涉及一些复杂的逻辑和交互,就不能不重新使用JS,而这就要求两套不同的语言,两套不同的工具链以及两套不同的生态,以及前后端之间的交互。而Next.js是以前端为基础,用一种非常自然的方式将前后端融合在一起,用同一套生态编写从前端交互到后端逻辑整个链条,实际上是一套和传统完全不一样的方案。

在原来的pages目录下,每个文件定义了一个页面。例如/pages/test.tsx/pages/test/test2.tsx就分别对应/test/test/test2两个路径。但是,在绝大多数情况下,一个页面中的代码都不能在一个代码中完全写完。对于一些公用的组件,例如布局的header, footer等,我们可以把这些代码放在类似layoutscomponents的目录下,这些组件不涉及任何业务逻辑,可由具体的业务页面引用并组装。

现在博客下,会由多个页面使用的组件

但是还有一些组件,它只在某个特定的页面下有用,例如为了完成某个特定的业务逻辑的组件。这种组件一般来说又是过于复杂,不能把它直接写在页面文件中,但是如果把这些组件直接放在页面组件文件的旁边,那么它们会被当成一个新的页面。

由于在原来的事件中,我会创建一个pageComponents目录用于存放这种位于真正的基础组件(components)和页面(pages)之间的组件。例如在下图中,pageComponents/admin/AllUsersTable.tsx就是一个比较复杂的、涉及到业务的组件,它只会在pages/admin/users.tsx中被使用。

pageComponents

除了这一方案,我也看到了一些项目采用的是和Angular类似的Module概念,把某个功能相关的代码都放在一个modules/模块名目录下,然后在pages目录下引用模块下的页面组件。

但是不管是什么方案,实际上都是在为一个文件=一个路径这一概念打补丁。这一概念看着很美好,但是只要项目复杂度稍微高一点就会遇到上述的问题。同一个功能,有的代码在pages下,有的在pageComponents下,这会使得文件非常混乱。

App Router解决了这一个问题。在App Router下,路径由目录(而文件)定义。每个目录下,只有一些特殊的文件会被Next.js处理(例如page.tsx为这个页面的组件,layout.tsx为这个路径下的公共布局,其他文件Next.js直接忽略,都由自己组织。这就使得我们可以把一个页面所需要的组件拆分出来,放在和页面相同的目录下。

例如在现在的项目中,app/articles/[[...params]]包括了文章列表页面的定义,其中就需要一个文件列表页面的布局的组件ArticleList。这个组件很明显需要被拆分出来。在原来的实践中,这种组件就应该被放在pageComponents或者components里。但是这个组件实际上只会在这个路径下被使用,所以使用App Router后,我们就可以把这个组件放在这个页面的文件(page.tsx)的旁边。这样,我们保证了所有这个页面相关的业务逻辑(公共组件不包含业务逻辑)都存放在这个路径下,这对代码后续维护、多人合作开发等方面都有非常多的好处。

现在博客下,文章列表页面布局

App Router所带来的优点远不止这两点。由于本博客是个静态的博客,且整体布局较为简单,所以并没有用上使用Next.js的动态功能,但是在我其他的项目中,App Router的嵌套布局Nested Layout)、Server Actions带来的直接在前端代码中调用后端逻辑的能力都极大地提高了网站开发的效率。

Next.js静态生成

静态网站

我之前使用Next.js的项目都是传统的前端应用,也就是编译为前端+一个提供服务器端渲染(SSR)能力的Express后端的传统的Next.js项目。但是Next.js一直还支持直接生成只包括HTML/CSS/JS的静态网站的能力。

传统的单页应用(SPA)会把整个应用编译为一个(或者多个)JS bundle以及一个实际上并不包含真正UI的模板HTML。用户访问任何路径时,都会下载这个HTML。这个HTML的唯一作用就是提供一个根DOM组件以及引用编译好的JS Bundle。JS Bundle将会被自动下载,通过浏览器的History API在浏览器端实现路由功能,并负责通过DOM API渲染用户的UI。

vite build构建出的项目

而Next.js生成的静态网站和Gatsby, Hugo等静态网站生成器相同,会在编译时对每个路径获取这个路径所需要的数据,并将这些数据渲染成HTML。渲染出的结果中,每个路径都有对应的HTML。比如在下图中,about/me.html就对应了/about/me路径,并且其中包含了在服务器端渲染后的UI。用户访问路径时,会直接获取这个HTML,并直接就能渲染出已经渲染好的内容,无需等待下载和执行JS Bundle的过程。

next build构建出的项目

和Gatsby的区别

在我上次重写ddadaal.me时,我选用了Gatsby,因为当时Gatsby的生态更加的成熟,有大量现成的模板、插件和教程可供使用和参考。几年后的现在,Next.js的静态生成功能也是非常成熟了,并也提供了很多的API来实现静态网站渲染的功能。但是和Gatsby项目,Next.js提供静态网站渲染的API的思路有所不同。

Gatsby主要通过GraphQL让开发者访问数据(Gatsby and GraphQL)。开发者在页面中可以声明这个页面所需要的数据的GraphQL查询,并在页面中通过props访问读取到的数据以及渲染UI。在编译时,gatsby将会负责运行这些查询,并将数据传递给需要数据的组件。而可以访问到的数据,则可以通过插件或者自定义gatsby-node.ts脚本来向后端的GraphQL服务器中增加数据节点。

// https://github.com/ddadaal/ddadaal.me/blob/57fe926eb0/src/pages/slides.tsx
// 声明需要的数据
export const query = graphql`
  query Slides {
    allSlide(filter: {type: { eq: "dir" }}) {
      nodes {
        name
        html_url
        type
      }
    }
  }
`;
 
interface Props {
  data: {
    allSlide: {
      nodes: { name: string; html_url: string }[];
    };
  };
}
 
const Slides: React.FC<Props> = (props) => {
  // 通过Props读取获取到的数据
  const { data: { allSlide: { nodes } } } = props;
 
  // 使用这些数据渲染UI
};

当然,要想更灵活地访问数据和创建页面,开发者还可以通过gatsby-node.ts编写编译时在node端执行的脚本。这个脚本是在编译器在Node.js中运行的,故可以访问任何本地数据。Gatsby还提供了大量Gatsby Node API来帮助用户创建页面、GraphQL数据等。

createPage({
  // 生成页面的路径
  path: "/articles/" + pageIndex,
  // 页面所对应的React组件
  component: indexTemplate,
  // 组件所需要的数据
  context: {
    limit: pageSize,
    skip: pageIndex * pageSize,
    pageCount,
    pageIndex: pageIndex,
    ids: notIgnoredGroups
      .slice(pageIndex * pageSize, pageIndex * pageSize + pageSize)
      .map((x) => x.frontmatter.id),
  },
});

总的来说,Gatsby通过GraphQL和Gatsby Node API将UI数据完全隔离开来。用户定义各个页面所需要的数据类型,一方面编写脚本或者插件将各类数据源转换为GraphQL等页面需要的数据,另一方面编写React代码将这些数据渲染成UI。

而在当前使用App Router的Next.js项目中,获取数据以及渲染页面的方法有所不同。Next.js的路由一直是基于文件路径的,没有类似Gatsby Node API的API以及gatsby-node.ts的脚本可以用来手动创建各个页面。取而代之的,是

例如说,我的博客中/about路径下包含了/about/me/about/odyssey/about/project三个路径,分别对应3篇文章。要实现/about路径,我需要

可以看出,通过Next.js,我们不再需要GraphQL将数据和页面分割开来,而可以直接使用RSC同时完成读取数据和渲染UI的功能。通过generateStaticParams列举了所有可能的路径,然后对每个路径渲染它对应的RSC,生成了每个路径的页面,从而编译出了整个网页。

自定义的markdown渲染流程

在之前使用Gatsby时,我直接使用了一些现成的gatsby插件(如gatsby-plugin-remark)来帮助我完成把markdown渲染成HTML的过程,故我对markdown渲染的流程几乎没有了解。但是在Next.js中没有这些插件了,所以我就需要自己去学习markdown渲染的知识,并自己完成markdown渲染的工作。

当前,项目中是使用remarkrehype生态实现markdown的渲染的。remark是一套把分析并转换markdown的生态,包含由大量插件。它可以分析markdown文件并将其转换为AST,并支持通过各类插件对这个AST进行分析和转换。而rehype类似remark,只不过rehype是针对HTML的。整个渲染流程可以通过unifiedjs连接起来。

现在,博客在渲染markdown时,经历了以下的步骤:

了解了markdown的渲染过程给我带来了几个好处:

第一,我可以自己自定义渲染的流程了

之前,对于一些已有插件不支持的功能,我是通过一些比较hack的方式完成的。例如文章的Table of Contents,我是通过在渲染后通过DOM API分析页面中各个h1/h2/h3等元素来动态生成的。而现在,我可以自己去寻找可以解析TOC的插件(@stefanprobst/rehype-extract-toc),并将它插入到渲染的流程中,并在最后得到结果并自己完成渲染的过程。又比如,我想给渲染出的标题的前面增加一个图标,点击这个图标就获取到跳转到这个标题的URL。通过rehype-react,我可以很简单的实现这一点。

src/components/article/ArticleContent.tsx
.use(rehypeReact, {
  // ...
  components: {
    // ...
    // 使用自定义的React组件渲染h1/h2/h3组件
    h1: ((props) => <HeadingWithLink element="h1" props={props} />) satisfies
      ComponentType<JSX.IntrinsicElements["h1"]>,
    h2: ((props) => <HeadingWithLink element="h2" props={props} />) satisfies
      ComponentType<JSX.IntrinsicElements["h2"]>,
    h3: ((props) => <HeadingWithLink element="h3" props={props} />) satisfies
      ComponentType<JSX.IntrinsicElements["h3"]>,
  },
})

标题链接

第二,我可以完全自己控制RSS的渲染过程了

之前我采用的是gatsby-plugin-feed插件,通过定义GraphQL以及一些自定义的参数来生成RSS流。由于当时无法控制markdown的渲染结果,所以在生成RSS的时候感觉非常不自然。另外这个插件还不支持在开发时运行,所以我在开发时不能测试RSS的编译结果。现在,我可以自己创建一个app/rss.xml/route.ts的Route Handler,并和渲染文章页面一样,手动创建RSS的信息,以及各篇文章在RSS中的渲染结果。

静态生成图片

在编写整个网站的过程中,最大的挑战是如何生成博客内容所需要的图片

在所有能查找到的使用Next.js编写博客网站的文章和项目中(如Next.js官方blog-starter模板),都是通过public目录来引用图片等静态文件的。在编译时,public目录下的文件将会直接复制到构建目录下,在部署后,这些文件将可以直接通过/访问。

但是这并不能满足我的需求。因为我的博客中,博客文章和图片都是放在contents下的同一个目录下的。而contents目录不能被公开访问。

博客文章和图片存放在同一个目录下

<!-- 在Markdown中通过和md文件的相对路径访问 -->
![图片注释](./decompile.png)

一个简单粗暴的解决方案是编写一个脚本,在编译后把所有静态文件从contents复制到public下,并在编译markdown时,修改所有的图片路径到编译后的路径中。但是这个做法也太不优雅了,有没有什么更好的、不需要自定义编译流程的方案呢?

答案是Route Handler

Route Handler可以使开发者对某个路径编写自定义的处理逻辑。通过Route Handler,我可以定义一个专门用于获取静态文件的路径。我定义了一个/articles/asset/[...path]的route handler,当使用GET方法访问这个路径的时,handler将会去读取这个路径的对应的文件的内容,并以流的形式返回。Route handler同样支持generateStaticParams。通过这个方法,我遍历contents下的所有静态文件。这样,在编译时,Next.js将会把contents下所有文件的路径都传入这个Route Handler并运行,并将handler的结果(也就是文件内容)存放在/articles/asset/contents/{文件相对于contents的路径}下,发布后可以通过/articles/asset/contents/{相对路径}这个URL访问到图片。

src/asset/[...path]/route.ts
export async function GET(request: NextRequest, { params }: { params: { path: string[] }}) {
  const fullPath = params.path.join("/");
 
  const fileStat = await stat(fullPath);
 
  // 读取文件流并返回
  const stream = createReadStream(fullPath);
 
  // @ts-ignore
  return new NextResponse(Readable.toWeb(stream), {
    headers: {
      "Content-Type": lookup(fullPath) ?? "application/octet-stream",
      "Content-Length": fileStat.size,
    },
  });
}
 
export async function generateStaticParams() {
 
  // 遍历所有路径
  const paths: { path: string[] }[] = [];
 
  async function rec(dir: string[]) {
    const dirents = await readdir(dir.join("/"), { withFileTypes: true });
 
    for (const dirent of dirents) {
      if (dirent.isDirectory()) {
        await rec(dir.concat(dirent.name));
        continue;
      }
 
      paths.push({ path: dir.concat(dirent.name) });
    }
  }
 
  await rec(["contents"]);
  return paths;
}

编译后的articles/asset路径,包含有所有的静态文件

现在图片有了,下一步是将markdown中对图片的引用地址修改到真实的编译后的地址。这实际上很简单,通过rehype-react将HTML的<img>使用自己的组件渲染,然后在自己的组件中把src属性修改为真正的图片路径即可。

src/components/article/ArticleContent.tsx
.use(rehypeReact, {
  // ..
  components: {
    // 使用自己的Image组件渲染HTML中的<img>
    img: ((props) => <ArticleImageServer article={article} props={props} />) satisfies
      ComponentType<JSX.IntrinsicElements["img"]>,

我们使用了Next.js自带的<Image>组件来显示图片(在ArticleImage中)。这个组件有很多友好的功能,例如通过指定图片大小防止Layout Shift,在支持的环境下支持先加载更小的图片以更快的显示界面等。通过把<Image>和自定义的markdown流程相结合,我们就实现了显示图片的需求。

多主题

本次更新在样式方面最大的可见的更新就是支持了多主题。目前网站已经开放了12个主题可供选择,当然,我的美学能力远远不够自己设计这么多的主题,所有主题都是由daisyui提供的

daisyui首先定义了一些固定的颜色变量,这样在编码时,所有元素的颜色都可以通过颜色变量,而不是写死的颜色值,来指定。例如下面的代码就指定了一个背景颜色为base-200、文本颜色为text-contentul组件。

<ul className="bg-base-200 text-base-content">
</ul>

之后,daisyui通过识别<html>组件的data-theme数据属性来获取到当前用户所选择的主题,并通过CSS选择器将对应的颜色的CSS变量修改为对应的主题的对应颜色变量的值。要想切换主题,只需要修改<html>组件的data-theme数据属性即可。

data-theme

由于各个主题的颜色风格大相径庭,我原来博客的代码背景就不可以使用了。新的主页背景必须得能够自适应完全不同的颜色风格。要想实现这一点,新的背景必须是使用CSS动态生成的。

于是我在网上找到了一个绝妙的网站,这个页面中提供了数十种纯用CSS实现的背景动画。我选中了其中的第三个Floating Squares。这个动画的背景色、以及各个方块的颜色都是由CSS的定义的,我需要做的,就是把其中的基础颜色替换为当前所使用的主题的CSS变量,这样,新的背景就能自动和当前所使用的主题相匹配了。

.area {
  /* 使用daisyui的颜色变量 */
  background: linear-gradient(to bottom, hsl(var(--p)), hsl(var(--pf)));
  width: 100%;
  height: 100vh;
}
 
.circles li {
  position: absolute;
  display: block;
  list-style: none;
  width: 20px;
  height: 20px;
  /* 使用daisyui的颜色变量 */
  background: hsl(var(--a));
  animation: animate 25s linear infinite;
  bottom: -150px;
}

总结

本来我一开始并没有打算完全重写整个项目,而是打算在已有的代码上做一些小修小补。但是当我真的打开了代码开始准备修改时,就发现了原来的代码中各个组件之间的依赖像是形成了蜘蛛网一样,完全无法下手。随便改任何一处已有的代码,都会涉及到巨量的其他代码,可谓牵一发而动全身。想起来2018年开始写Gatsby的ddadaal.me的时候,一大驱动力就是原有的屎山实在无法维护了。而5年后,当年的新代码变成了新的屎山,而被更新的代码取代之。果然,和世间其他万物一样,技术在发展,代码也是常用常新,需要不停地跟上时代的步伐。重写后的博客功能和以往一致,但是更轻、更快、更易维护,也算是一次成功的重构实践。

2022年总结

2022-12-31 23:59:00

转折点

又是一个转折点,是2021年总结的最后一节小标题。不出意外的,2022年确实是一个转折点

玩具项目开始“玩真的”

上半年,继续维持2021年下半年的状态,像是做个玩具一样,在一个人的宿舍没有压力地写着实验室的项目。

可是,像是被赶鸭子上架一样,五月份项目放到GitHub开源(仓库地址),在一个小会上做报告,六月份写了一篇论文投到HPC China会议上然后拿了满分评价和优秀论文提名,之后项目组多了好多学弟学妹、老师和同事。公开的demo集群(点击访问,用户名密码参考仓库README)上线了,潜在用户来找我们了:项目突然变得认真了。

GitHub动态

会议论文评分

飞书组织

从一个随便写着玩的小程序员到啥都管的“小老板”,带人给了我更大的压力。不同的人有不同的背景、不同的能力、不同的期望。应该把什么样的工作给谁做?我自己应该把事情做到什么程度?我应该提供什么样的支持?什么事情应该他自己思考?怎样高效地沟通?如何我的想法更高效、更准确地告诉其他人,如何让同事更好的告诉我他所想的?如何和一个有数年、甚至数十年工作经验的人合作?这些问题对不同的人都有不同的、且随时都在变化的答案。我的一切行为可能都会影响同事接下来的一段时间如何开展工作,一丁点没有考虑到的细节,一点工作需要的、但是没有写进文档的、而且我也没亲口告诉的知识点都可能让别人浪费数倍的时间。甚至,我连我需要什么样的人这样基础的问题,我都没法给出一个自信的回答。如何作为一个并没有实际工业界经验的小小研究生来说,我只能根据在课堂和网络上看到的以及短时间的实习中体验到的经验依葫芦画瓢,尝试建立一个尽可能高效的团队。目前看来还有很长的路要走。

meme

项目本身也有很长的路要走。项目复杂起来之后,CI/CD流水线、自动化测试、文档、协作工具、仓库管理、版本发布,甚至如何保证代码风格的一致性,这些繁琐的事情的重要性越来越高了。代码之外,用户究竟需要什么样的功能,如何让用户部署和使用系统更方便,什么样的需求该做和不该做,项目应该朝什么样的方向发展,虽然最终拍板的不全是我,但是这些问题都需要我的参与和讨论。这些问题都不是确定的问题,也没有一个固定的量化指标,但是对于项目来说,可能比写100个功能点更重要。

我现在才算摸到的真正的工程的冰山一角。我自认为我能够勇敢面对困难,但是这半年来,我经常想逃避,issue打开了就放在那里,随便挑点简单的功能点写写代码,而不去想真正紧急的问题,审PR有时候也粗心大意,出现过好几次有重大问题的bug的PR进了主分支的情况。工作后挑战就更大了,如何平衡工作、生活和项目变得格外重要。什么工作都有难点,我不想坐科研的“冷板凳”,但是也逃不过工程的不确定性和复杂度啊。

新的娱乐方式

5月份,北京疫情严重了,进入了准封城状态,而高校当然是封控急先锋。5月份封校了,早期我们住校外宿舍区的连宿舍区都出不去,被迫在万柳这个弹丸之地开发娱乐活动。扔沙包、跳广场舞、甚至放风筝,世界仿佛回到了我甚至还没有出生的80、90年代。

扔沙包

放风筝

这些室外活动随着学校开通燕园班车以及后续学校解封就消失了,但是室内活动却保留了下来。在工作之余,和朋友们一起吃烧烤、打麻将,还是非常惬意的。

打麻将

吃烧烤

下半年,认识了更多的羽毛球球友,高峰期打球频率甚至提高到了一天2小时。甚至第一次加入了院队,第一次获得了一件有自己名字的队服,第一次和队友参加了几次团体和单项的小比赛。虽然水平很一般,几场比赛都没出线,但是这确实是第一次的体验。我也希望能够在最后一个学期里和同学们多多练球涨球,起码比赛不一轮游一次吧。

队服

最后的比赛和实习

看我的简历就可以知道,我的本科和研究生生活就是用各类项目比赛撑起来的。2021年和本科老搭档参与了一个AI创新应用大赛,本以为已经烂尾,却突然复活并获奖。虽说只是个优胜奖,但是也足够为我这6年的小比赛生涯画上句号了。

获奖名单

去年末,面试并加入了之前完全没有听说过的alluxio,并在里面第一次体验到了一个外企背景的小技术公司的工作生活状态,并第一次给一个还算出名的开源项目做出了一点不是很简单的贡献(PR)。

PR

作为一个长期把微软作为第一择业目标的纯粹的微软粉,暑期实习选择了上海的C+AI大组,并在上海数十天的40度的天气、以及此起彼伏的疫情下onsite了整个暑假。这个暑假也是第一次和同学租房(在苏州时租的单人公寓),体验了一把老破小,过了两个月自己买菜做饭的居家生活。体验很不错,但是决定之后再也不租老破小了,高端小区买不起,多花几百块钱租一套体验一下也是很不错的。

做饭

房子外景

躺平生活

回到了原点的职业选择

已接受MS Offer

兜兜转转又回到了原点呀。

本科前两年半时,我拿定主意直接工作,却在最后时刻,抱着想看看有没有新的机会的想法,极限转弯踏入了研究生的大门。而研究生的前两年的体验让我认识到高校做工程不靠谱,回去看业界的机会时,发现实验室做的工作不能让我踏入一个所谓更“高阶”的工作,我能投向的业界工作和读本科时基本没有区别。后来,当我意识到,当前国内大环境下体制内的技术工作也并非一无是处,想把握北大应届毕业生这个机会了解一下体制内的机会,但却错过了这个时间窗口,最后仍然投向了微软,和三年前的唯一的区别也就是大组(C+AI vs STCA)和工作地点(苏州 vs 上海)的区别了。

说遗憾肯定是有点遗憾的,毕业时选择工作可能是最重要的人生选择。有些地方毕业时不进,之后就再也不进去了。而很多人都向往的北大学历最后在找工作方面并没有发挥什么用处。但是这几年让我认识到,人是会变的,现在的想法只能代表现在,不能代表未来。甚至有几个被项目的事情困扰的晚上,我还在认真考虑现在去科研是不是还来得及。考虑了这么多,最后只会发现,一切都是有好有坏。在微软,起码短期内实现Work Life Balance、收入还可以的“小目标”实现起来还是比较简单的,而且还能接触到先进的软件产品和软件项目管理方式。虽然世界大环境对外企不利,但是世界变换得太快了,几年后的日子谁又能预测得到呢?

虽然终点一样,但是心态不一样了。在体制外,没有各种各样的限制,没人管的另一面就是自己为自己负责。这三年大环境的变化让我意识到,和体制内不同,公司是要赚钱的,我们和公司只是合作关系。发展得好,一起吃肉;发展得不好,分道扬镳。换公司(无论是自愿的还是非自愿的)对任何人来说几乎是肯定的事情。一定会发生的事情也没什么需要担忧的,需要做的只是为它做准备。公司的目标是为客户提供服务赚钱,那我们也得想办法增加自己作为个人(而不是作为公司的一员)对他人、对社会的价值。目前来看,实验室的项目似乎迈入了一个正确的轨道,采用的客户越多,越能体现我的价值。如果它真能发展起来,那对我个人的发展来说是一个大大的加分项。

迎接未知的新生活

这一年像是2019年的重演。升到毕业年级;暑假去外地微软实习;纠结了几个月后强势转向,2019年从就业选择了保研,2022年,从偏向体制内选择了上海微软。甚至直到疫情防控放开前,我还在担心明年会不会又像2020年一样,无法返校过最后一个学期。

但是有一点不同:现在,无论愿不愿意,都得迎接新生活了

高中时,所有精力都放在高考,“一分一千人”,似乎一切事情高考完就结束了;大学时,毕业就是一个看得见的里程碑,想工作的同学去实习,想科研的同学去科研,大家都为即将到来的新的生活而努力。而现在毕业了,工作了,没有看得到的终点了,反倒之前以学业为理由不想考虑的事情一下子都变得重要了起来,之前遇到了可以找家长、找老师、找学校帮忙的事情现在必须得自己花精力处理。大到找对象、结婚、买房、下一代、升职加薪、照顾父母,小到租房、做饭、做家务等日常琐事,都变成了一个一个无法逃避的事情。同时,现在朝夕相处的同学们也会有自己的生活,绝大多数时间都需要自己想办法度过。

2月和目前在国外的同学一起逛重庆,在南山上拍的照

站在校园生活的末尾才发现,即将挥手告别的生活可能是最简单、最单纯的生活,即将离开的地方将会是之后永远的回忆。

转折点之后,我们将迎来未知的新生活。新生活下,每个人都是自己生活的第一责任人。

2021年总结

2021-12-31 23:59:00

暴风雨之前的宁静

站在2021的年尾回看这一年,发现这一年着实有点波澜不惊。

这一年中没什么非常关键的里程碑事件。没有2018年的各种比赛和活动充实我的简历(以至于现在还在吃当年的老本);没有2019年决定保研,确定了未来三年的生活状态,以及实习,第一次进入梦想中的公司去工作;没有2020年的本科结束,告别至今对我最具意义的四年,以及研究生开学,迎接一段外表看上去光鲜、但是自己却不那么肯定的研究生生活。这一年发生的各种事件看上去都那么的日常,有时候日常地甚至连朋友圈都懒得发。

但是,可以遇见到,2022年将会是一个“腥风血雨”的一年。再次准备实习面试,再次暑期实习,参加秋招,确定出路,每个步骤都是一场不可避免的硬仗。用暴风雨之前的宁静来总结2021年实在是再合适不过了。

本年度的事件

项目和比赛

我一直认为,不被人使用的项目没有意义。所以,即使项目是大家都觉得没啥技术含量的CRUD,只要真的有人用,我仍然会接受并且愿意花心思和时间把它做好。所以上半年除了上课,很大一部分时间投入到了data-competition.pku.edu.cn的开发上了。这个项目之前写过文章详细介绍,我这里就不讲了。

5月份的时候参加中心主办的“新手友好”的CTF比赛,结果就会做娱乐题和web题,其他题一点思路都没有,说到底还是我太垃圾了。最后趁着大佬还在睡午觉没有发力、以及相关技术(JWT)之前开发用过偷了个web题的一血奖,本以为还能蹭个30名万岁,结果比赛DDL当天早上一觉起来被卷王卷下去了,行吧。

CTF

这学期年中的时候参加了学院组织的持续48小时的hackathon比赛,由于是线上,所以差点现场一起卷的感觉。但是瑕不掩瑜,4个人在48小时从0开始卷了个类似大众点评的小程序出来,也算是学了个新技术(虽然小程序其实可以说就是个阉割版和私有版的web)。

hackathon奖状

年末的时候还参加了个比赛,说好10月中旬结束的,结果到现在还没结束,日程表一改又改,也是无语了。

城市旅游/参观

和长距离的旅游相比,我更喜欢城市/城郊的旅游和参观,原因很简单:规划更简单、当日去当日回。加上疫情期间学校对进出京比较严格,以及北京作为首都,还是存在不少值得一逛的地方,所以这一年通过和同学和社区的方式,一起参观了北京城里和城郊的多处公园、博物馆等景点。

城里各个点的地图,不包含郊区的景点

可以发现大多数景点都较为冷门。有的景点的门票实在是约不上有空的时间,如故宫等;但是更重要的是我不太喜欢人太多的景点。人太多的景点,看到的人比看到的景还多,那还有什么意义?所以这些景点大多数我都尽量选择工作日去参观的,避开北京汹涌的人流。这也是作为学生的福利的了吧,以后工作了之后,想避开高峰都做不到了。

这些景点总结一下,

活动结束发的说说

运动和减肥

这一年可能唯一能称为标志性的事件就是我的体重人生中第一次降低到了标准值(BMI<24)。当前最低下过150斤(149.5,下图,是前一天晚上运动后第二天早上吃饭之前的结果,不具普遍意义),平时一般稳定在155左右。

当前最低体重

自从我记事开始我都是在超重或者肥胖的状态,且深受大体重的困扰,比如体力不行,运动成绩特别差,中考体育几乎全员满分的情况下只得了41/50分等等问题,没有得心理问题已经是万幸了。大学第一学期在一学期没有出过校、天天吃食堂的情况下降了30斤之后,之后的情况虽然比高中要好多了,但是仍然是在超重状态,且在正负20斤的范围内徘徊。直到研究生第一学年结束后,体重仍然离标准体重差了十几斤。终于有一天,我的体重终于降下来了!

其实根据这5年的经验,我觉得减重最重要的还是饮食,运动用处不大。

拿大一上学期和大三下学期举例:这两个学期,我的运动量基本都是一周2-4小时羽毛球的水平,但是大一上学期我每天吃20块的三餐,午饭晚饭基本就是一份鸡肉(因为鸡肉便宜,而且除了肉没很多骨头之类的其他配料)、一份青菜和2两米饭,完全没有出去吃过(那一学期就没有出过学校),除了有时候吃点水果,完全没有零食和奶茶等三餐之外的东西。而大三大四时对吃基本什么限制了,有时候晚上2两饺子+鸭削粉丝汤,中午30块的香锅,每周还出去吃2、3顿。这样的最终结果,就是大一上学期220斤起点少了30斤、大三180斤左右的起点最后甚至好像还重了5、6斤的样子。

研究生期间的情况其实也差不多。到目前这三个学期,第一学期2-4个小时羽毛球,第二学期没有羽毛球,平时基本没有其他运动,但是5、6月保持两三天一次<6配速的5km,第三学期4-6小时羽毛球,吃的情况差不太多,但是体重变化方面,前两学期每学期-10斤左右,第三个学期下降得比较明显,快20斤了,但是我觉得还是因为吃得少了:这学期因为懒得去学校,所以早上基本每天一碗纯麦片,中午吃万柳的比校内食堂种类和量少太多、但是价格贵不少的食,这样摄入得少,体重也就下来了。

跑步

现在既然体重已经回归正常体重了,所以我现在也没有继续控制体重了,想吃啥吃啥,有时候甚至还吃点零食,所以最近体重没有继续往下走了。啊,不胖的感觉真好。

技术、工作、职业发展

浪费掉的暑假

7月,因为自己懒,在实习和选择了回家过最后一个暑假这两个选择中,选择了后者,并给自己找了个借口:继续写yaarxiv([GitHub](https://github.com/ddadaal/yaarxiv)这个项目。

commit记录

这是我今年最后悔的一个选择。

这个项目的主要功能2020年9月就已经完工,可是这一年多时间了,一点推进的迹象都没有。上个学期整整三个月,这个项目就发生了这三个变化:加了个logo,注册了个域名,上了个HTTPS,没了。

我本应该从上个学年这个项目的情况推测出这个项目完全没有前途,结果仍然把宝贵的暑假时间投入到这个巨坑中,结果在开学之后发现认识的同学个个要么实习、要么学生工作、要么论文在投,各种方向做得有声有色(至少能往简历上写),感到十分后悔。后悔归后悔,想着赶紧推动另一个实验室项目,结果在整整一个月的时间内项目(又)一点的动静都没有,连个会都拉不起来,连个需求都理不出来,焦虑感直接拉满。那段时间找各种事情来做,做leetcode、做tidb的实验、各种看新技术,但是由于是焦虑驱动的,根本无法坚持下来(leetcode刷了几天没刷了),而且碰到难题无法静下来debug(比如tidb实验的一个bug到现在都没de出来),所以那段时间也可以说是一无所获。

为了缓解焦虑而做leetcode,无法坚持下来

直到项目终于开始之后,我像是抓到了一个救命稻草一样,把我研究生能否有任何值得一提的成果全部压到那个项目上去了,所以接下来几个月基本全身心投入到了那个项目的开发上去。做了2个月之后,项目终于初步可用并内部上线试用了。但是现在想起来,那个项目只是个业务逻辑稍微复杂、涉及外部系统有点多的CRUD,仍然只是个玩具,仍然不具有什么含金量,并不能拯救我的简历,焦虑如闪电般归来。

工作和职业发展

明年就要找工作了,上半年暑期实习,下半年秋招。最近刷校内的BBS,总能看到很多同学的求帮忙比较offer的帖子,现在大家一般都不会只看薪资,普遍更重视职业发展、稳定性等因素,这也造成选择一个比一个难。而我因为不愿意做行政工作,也不愿意加班,完全没有做过科研,选择面瞬间少了很多(比如本校同学热衷的选调对我来说就完全不是一个选项)而且我很讨厌(怕)面试,理想情况是面个1、2次、拿到不那么差的offer就停止了,所以从某种角度来说可能明年也不会那么纠结(当然这种拿着多个offer的纠结和拿着1、2较差的offer是两种纠结)。

在职业发展上,纯业务的发展路径是不可持续的:业务是可以复制的,而且目前互联网的发展明显出现了瓶颈,各种“优化”层出不穷就是一个证据。另外一条路是走纯技术路线,掌握并精通业界的实用技术,如分布式、数据库、UI等,虽然这才是做“技术人”的正途,但是其难度较大,门槛较高(比如应用规模必须大到一定程度才会遇到分布式的需求和对应的问题,但是做实验室的玩具项目基本就完全用不到这些问题;比如只有面向关注UI的用户才会对UI提出要求,才能倒逼去学习和实践CRUD之外的UI技术,但是一般内部的业务都不重视UI,能用就行了)。这种需求的培养的脱节已经被受到很多批评了,但是现实就是这样:工程确实不是学校的重点。

我之前的技术栈都在web上,但是受限于实验项目的需求和人力(毕竟只有一个人),而且我有种“用不上的东西就学不进去”的坏习惯,所以无法通过项目学习公司想要的高的技术难度上,而需要写没什么简单、但是量大的业务代码(写这些成果在找工作的时候基本没有用,面试官只会觉得没什么难度,大家都能做。其实到目前为止我手里的项目、比赛在找工作的时候基本没什么含金量)而如果换方向,因为我已经在这个方向上投入了这么多,现在距离找工作也只有几个月的时间了,贸然换方向存在极大的风险:很容易最后变成两边都是半吊子,两边的工作都找不到的问题。其实,我现在掌握的技能已经太过分散,什么都会一点=什么都不会,如果不背面经,连正经大厂实习都不好找。

除了开发,我仍然参与搭建和维护实验室的一些基础设施。我也认同管理、基础设施等看似和技术无关的东西对于软件项目同等重要。但是同样是因为当前手里的项目难度和规模上不去,这些管理和运维的经验在业界看来就是个笑话,还不如不写,省得到时候面试官想,就这?

说到底,这几年所做的工作基本对找工作没什么帮助。所以最后找工作,还是只能像本科生一样,靠算法题和八股文。算法题方面,由于我算法水平很差,所以在11月开始坚持每天一道LeetCode的每日一题,到现在能不能做出来另说,起码看到题不会慌了,也是一个好的进步吧。而八股文方面,还是在这个假期好好静下心来背,就当是一个更大的、更重要的期末考试了。

LeetCode

日常学习生活状态

一个正常的学习路径是入门的时候先学习掌握基础、个人技能,之后通过项目学习多人合作以处理更复杂的问题,可是我到现在的路径是反过来的:本科特别推崇多人合作,基本所有项目都是多人合作完成的;结果上了研究生之后项目却都变成个人做了,甚至实验室都变成了甲乙方关系,实验室只管提需求,我把需求分析、设计、实现、测试、部署、运维等事情全部搞定,甚至还得去push甲方,否则东西做完了,又没人用,重蹈覆辙。

在这个学期之前,我其实一直在找能够一起做项目的同伴,最好还有大佬可以抱,还不用自己动脑想做什么了。但是现在已经完全放弃了,或许一个人做也挺好的,毕竟每个人都有自己的想法,不能强求,能依赖的都只有自己。换个角度看,读研和工作也没什么区别,研究生三年可真就完全变成了用三年经验和工资的差价换一个研究生学历了。

由于就是一个人做项目,加上课在研一都上完了,所以我的日常生活也基本变成了“一个人的狂欢”。白天在宿舍/实验室一个人写项目,有问题去微信上问问甲方,饭点到了吃饭,中午到了午睡,正常情况下白天一句话都不用说。实验室也能不去尽量不去:宿舍又方便又暖和(或者凉快,取决于拒绝),平时又没有人非常自由,不去实验室还可以避开早晚通勤高峰,宿舍还有配置比实验室高的电脑,因此除了老师要我去实验室,以及宿舍区的食堂实在太难吃了,有时候还得去学校改善下伙食之外,实验室基本都看不到我。每天最开心的时候还是晚上有时和新认识的其他同学打打球,晚上11点后舍友回来了和室友聊天的时候。

虽然最近几个月的生活似乎就是copy-paste,几乎没有什么变化,但是这种确定性还是挺让人心安的。要是以后我的生活也能够像这样,每天稳定地做着自己喜欢的事情,我也已经很满足了。

又是一个转折点

有时候我在想,要是两年前我选择了工作,现在是怎样的呢?现在是股票翻倍,升职加薪,还是被优化了,现在正忙着各种找下家呢?有没有后悔当时没有保研呢?要是当时加入了AI的浪潮,现在是在读博做学术,还是也是在考虑找工业界的工作呢?现在是在拿着几篇论文意气风发呢,还是idea想不出来、实验卡住了,和现在一样、甚至比现在更焦虑呢?在做出某个选择的时候没感觉那么重要,可只有回头看的时候才知道人生的轨迹被改变了。这也说明,没有最好的出路,只有适合自己的才是好的。希望明年结束的时候,能够找到一个满意的出路吧。

一次生产环境的文件丢失事故:复盘和教训

2021-07-21 13:13:00

生产事故

我最近负责了一个比赛后台网站的开发和维护,在比赛临近结束、正在接受用户提交成果文件的时候出了一场生产事故,造成了丢失了一段时间内用户上传的文件。本文主要讲讲生产事故从发现到确定影响范围到确定原因的过程。在整个过程中,后台程序记录的日志起到了巨大的作用。

关于这个项目的总结,可以查看这篇文章:我的第一个真实项目:总结和经验

TL;DR

要素内容
表现丢失2021-07-16 09:00:102021-07-17 18:00:57期间所上传的文件
原因7月16日的一次提交,使得之后文件上传到了一个容器内部的、没有mount出来的目录,容器重启后,这些文件丢失了
补救进入容器,找回了最后一次容器重启后到问题发生期间上传的文件
后续处理通过log、数据库数据等找到了受到影响的用户,通过邮箱、电话和短信提醒他们重新上传文件
经验实现依赖业务,而非业务依赖实现;重视测试细节;log中记录尽可能多的信息

问题发现

项目基本情况是这样的。用户可以注册团队,一个团队有一个队长,队长可以提交一个团队的成果文件。管理员可以看到各个队伍的提交情况,以及也可以直接下载团队提交的文件。

19日下午4点左右,我用管理员登录了系统,本来只是在随便看看有哪些团队提交了成果,结果突然发现有的团队的成果点击下载下不下来。

点击下载提交的团队的成果发现出错

赶紧上服务器上看是怎么回事。打开服务器存放上传的文件的目录,grep一下发现对应的ID发现没有这个文件。

上传文件的逻辑是这样的:

  1. 获取上传的文件
  2. 把文件保存到upload/{团队ID}/文件名
    1. 保存之前,打日志Received file {文件名} from {用户ID}. Saving it to {完整路径}.
    2. 保存完成后,打日志${完整路径} saved successfully.
  3. 更新数据库,记录文件名(列名为filename)和上传时间(last_update_time

如果第二步失败,就不能进行第三步,不会出现保存文件失败但是写了数据库的情况。但是如果没有写数据库,我在后台就不会看到这个团队有成果,就不会能够点击下载了。

后端程序运行在docker中,但是upload是一个mounted volume,其中的文件和容器外的一个目录是进行了共享的,所以不管容器怎么重启,这里面的文件是不会掉的。

# docker-compose相关部分, /dist是容器中存放程序文件的根目录
backend:
  volumes:
    - "./backend/upload:/dist/upload"

马上翻了下日志,按上面说的格式搜一下日志(下面的队伍ID为实际的团队ID):

{container_name="backend"}|="Saving it to upload/队伍ID"

发现没有结果

这就奇怪了,于是把upload/队伍ID从查询语句中删除,直接看有哪些保存的日志,这一看就出事了。我看到了一堆这样的日志:

{"level":30,"time":1626528267191,"pid":36,"hostname":"2358983e224d","reqId":"req-29","msg":"Received file 文件名.zip from 用户ID.\n    Saving it to 团队ID/文件名.zip."}

文件被保存到{团队ID}/文件名下了!而{团队ID}这些目录是没有被mount的,容器重启就重启了,没了!

马上进入容器里运行ls,果然在容器根目录看到了一堆{团队ID}的目录。

确定影响范围和补救

首先,我把容器里已有的这些文件复制了出来,并定时检查有没有新的文件。还好,在排查问题的整个过程中都没有新的文件名上传。这些文件应该是在上一次容器重启之后才上传的。

之后,是找到哪些队伍的文件仍然丢失了,也就是数据库中filename不是NULL,但是upload下面没有这个队伍。这些队伍的文件应该是在上一次重启之前就上传了,上一次重启使得这些文件丢失了,应该是无法找回了。

通过各种查找资料,写出来以下的bash脚本:

# 查询filename不是NULL的团队的ID,写入db.txt
query="""
use data_competition_prod;
select id from team where filename is not null;
"""
echo $query | mysql -u root -h 127.0.0.1 -P 3306 -p密码 | sed '1d' | sort -n > db.txt
 
# 查询upload下的所有目录名,写入folder.txt
ls -d */ | cut -f1 -d'/' | sort -n > folder.txt
 
# diff两个文件
diff db.txt folder.txt

这个bash脚本得出filename不是NULL的、但是upload下没有对应的目录有11个。所以这11个团队的文件是丢失了。

但是除了丢失文件的情况,还有在出现问题之前就上传了版本,但是在这问题发生后更新了文件。这样,虽然在系统上有他们的问题,但是这些文件版本不是最终的版本。

为了找到这些团队,首先需要找到最近几次重启的时间,以及问题开始时的时间。容器重启的时候之前的容器会被强制退出,会打出有npm ERR的日志,通过这个ERR可以找到最近几次重启的时间。

{container_name="backend"}|="npm ERR"

最近几次重启的时间

可以看到图上有三次重启:

最后一次重启(19日的)是后面修改代码中的bug引发的重启,此时文件能恢复的文件已经恢复。而查询时间段之前的代码我知道是没有问题的。所以可以确定,t1-t3之间的数据是丢失了,而我之前从容器中补救出的文件是t3之后的数据。

为了确定事情是不是t1这次重启后发生,我又进行了一次确认。问题的原因是没有保存到upload/团队ID/下,而是直接保存到团队ID/下,可以通过正则表达式匹配Saving it to 数字的log,查看第一次出现这样的log的时间为2021-07-16 09:24:28,是在t1之后,所以可以确定原来的时间段是正确的,或者说,更详细的说是9点24-15点50之间的数据丢失了。

{container_name="backend"}|~"Saving it to [0-9]+"

第一次保存错目录的log

把查询时间设置在t1t3之间,运行以下语句,通过正则表达式,提取在这期间上传的团队的ID,获得了一个团队ID的列表。那么这些就是受到本次影响的团队了。

{container_name="backend"}|~"Saving it to [0-9]+" | regexp "Saving it to (?P<id>[0-9]+)" | line_format "{{.id}}"

后续处理

第一步当然是恢复还能恢复的文件并火速修改bug,并确认了一下在修改bug发布之前没有团队上传了新的文件。

由于这些文件无法被恢复,只能麻烦组委会其他人去通过电话、短信和邮件联系对应参赛队员,让他们重新交一份文件。

出错原因

这次项目我给后端项目写了测试,覆盖率已经达到了94.3%,对文件上传这部分也进行了重点的测试,但是为什么还是没有发现这个问题呢?

后端测试覆盖

因为之前我之前都是通过以下代码,将配置中的config.upload.path(取值为upload,就是之前根目录)和团队ID和文件名拼接起来,来获得一个团队的上传的文件的实际路径。

diff --git a/backend/src/entities/Team.ts b/backend/src/entities/Team.ts
index 38c381b..cf2957a 100644
--- a/backend/src/entities/Team.ts
+++ b/backend/src/entities/Team.ts
@@ -1,4 +1,3 @@
-import { config } from "@/utils/config";
 import { EntityOrRef, toRef } from "@/utils/orm";
 import {
   ArrayType, Collection, Entity, IdentifiedReference,
@@ -66,7 +65,7 @@ export class Team {
 
   get filePath() {
     return this.filename
-      ? urljoin(config.upload.path, this.id + "", this.filename)
+      ? urljoin(this.id + "", this.filename)
       : undefined;
   }

这次错误的commit为了重构下载的相关代码,修改了这个地方的计算逻辑,把拼接config.upload.path的代码移到了处理下载请求的代码中,而忘记了上传时确定文件保存的路径时也使用了这个filePath getter,造成了上传时使用的路径不包含顶级的config.upload.path,而是直接就是{团队ID}/文件名

而在测试中,判断上传之后的文件是否存在时,仍然是使了filePath getter获得路径,这使得其实不管filePath怎么写,测试肯定都能通过,因为测试代码和业务代码所使用的计算路径的代码是相同的,所以并没有发现目录计算错误这个问题。

// 测试文件是否存在
expect(path.basename(t.filePath!)).toBe(newFileName);

教训

  1. 实现依赖业务,而非业务依赖实现

之前把计算文件真实路径的代码放在业务模型里(filePath属性)这样的设计是有问题的。

按照DDD的思想,业务逻辑(保存用户上传的文件)不应该直接依赖实现逻辑(通过config.upload.path计算实际路径,然后使用fs直接操作文件系统),而是应该反过来,实现层面依赖业务逻辑。也就是说,应该

提供一个保存用户文件的方法,这个方法接受保存文件所需要参数(比如操作用户、文件名、文件流),这个方法来负责进行实际保存文件的工作。

用传统的OO和DI的说法,就是定义一个保存文件的接口,实现层实现实际的保存文件的逻辑,并通过DI注入给用户逻辑。业务逻辑通过这个接口完成保存文件的工作。

这样,既可以避免本文这种问题,还有利于以后进行扩展,比如不保存到本地,而是保存到云端的存储。

  1. 重视测试细节

虽然这次写的测试并没有发现这个问题,但是现在复盘发现,如果在运行测试的时候能够留意并重视一个细节的话,仍然也可以发现这个错误。

测试结束后的运行的代码(afterEach)中,指定了删除config.upload.path的目录。

afterEach(async () => {
  await server.close();
  // delete the test upload path
  await fs.promises.rmdir(config.upload.path, { recursive: true });
  // ...
});

但是由于错误的代码没有写到这个目录中,删除config.upload.path并不能删除测试上传所创建的文件,那么跑完测试将会有新的文件生成,就像下面这样:

  git status
On branch master
Your branch is up to date with 'origin/master'.
 
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        10/

现在我想起来,当时确实发现了这个问题,但是并没有引起重视。

  1. log中记录尽可能多的信息

从上面的发现问题的过程中可以发现,log相当重要。要是log中没有记录真实的完整路径,我可能需要花更多时间才能明白问题的原因。要是没有对每次文件保存都打log,我甚至都不能知道哪些队伍受到了影响。log记录了系统运行的整个流程,是分析系统的运行流程的最准确的工具。如果log打得好的话,能够从log中提取出很多没有记录到数据库中的数据。

所以应该在log里尽可能记录更多的信息。最基本的,错误必须打到log中,不能直接吞掉。而且在进行一些容易出错的行为时,也应该尽可能把相关上下文打到log里,以供后续查找。

我的第一个真实项目:总结和经验

2021-07-21 16:34:00

我的第一个真实项目

终于,从3月15日开始,我自己独立承担了一个完整的、真实的项目的开发和整个软件生命周期的维护工作:https://data-competition.pku.edu.cn ,第三届全国高校数据驱动创新研究大赛的后台系统(代码目前放在学校gitlab上,以后应该会把代码放到github上)。

虽然这还是一个常规的CRUD项目,但是毕竟是我第一个能够被真实用户使用的系统,我还是比较激动的。项目中途遇到了几次重构,最后出现了一次生产事故,其他的总体流程还是比较顺利的。

项目现在已经基本结束了,这里写一篇总结文,介绍一下项目的架构、演进、自己设置的一套前后端共用API的机制、以及在这个过程中的经验。

系统功能、架构、规模

下面是系统的用例图,主要完成一个比赛报名系统的用户管理、团队管理和上传提交文件操作,还对管理员实现了查看用户和团队信息以及群发邮件功能。

用例图

总的来说,系统是一个做了SSR的前后端分离的TypeScript全栈项目,下表为项目的技术栈。

部分技术栈
编程语言TypeScript
前端框架Next.js
UI库Ant Design
后端框架fastify
反向代理nginx
数据库MySQL
部署Docker + Docker Compose
监控Prometheus + Loki + Grafana
外部服务学校提供的SMTP服务器

下图是系统各个部分的架构图,监控部分其他的各个小组件就没有画了。

架构图

每个部分(包括nginx、数据库)都是使用容器运行,所有容器部署在一台2核2G内存的学校提供的虚拟机上。这个机器前面还有其他机器,负责SSL和开放到公网,所以这台机器本身是不能直接从公网访问的(连我上机器都需要使用VPN连接),这就带来了安全性:我可以使用最前面的nginx的来控制各个部分的可访问性。在我的配置中,nginx开放了80(前端)、5000(后端)和grafana的端口。grafana是否应该被公网访问我还不好说,但是由于grafana可以设置用户名和密码,应该安全性还好。

用docker运行的好处很显著:不管机器是什么样的,直接docker-compose builddocker-compose up -d就可以部署和更新了,以及loki也可以直接从Docker引擎中获取各个服务的日志。当然事实上没这么简单(数据库migration、volume mount等),后面要讲的生产事故也和docker直接相关。但是docker无疑是使得运维工作更简单的。

项目规模在12000行的TS代码左右。

➜  cloc . --fullpath --not-match-d="(node_modules|.next)"
     615 text files.
     610 unique files.
     296 files ignored.

github.com/AlDanial/cloc v 1.88  T=0.85 s (545.5 files/s, 92257.2 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
JSON                            22              1              0          41742
HTML                            96           1421              0          16873
TypeScript                     320           2470            440          12470
XML                              1              0              0           1390
CSS                              3             62             22            754
YAML                             6             26              3            260
JavaScript                       5             28             27            230
Bourne Shell                     1             12              6            164
Markdown                         6             76              0            162
SVG                              4              0              1            138
LESS                             1              2              0             14
PowerShell                       1              2              2              7
-------------------------------------------------------------------------------
SUM:                           466           4100            501          74204
-------------------------------------------------------------------------------

几次重构

Create React App到Next.js

项目开始得比较急,以及我一开始询问得知不需要做SEO,所以采用了CRA开发了系统的第一个版本。结果后面又说需要SEO,所以只好将项目迁移到next.js。但是现在虽然已经能够做到SSR(下图),但是搜索引擎似乎也不好搜索到网页中的内容(可能是流量太低,搜索引擎还没有索引?)。

SSR

非响应式到响应式

和上面一样,一开始说系统并不需要考虑手机上访问,所以设计的时候就完全没有考虑手机端访问的情况。结果初版上线没多久就说需要支持手机上的访问,所以需要对网站做响应式的改造。

由于除了主页以外,之前本来就完全没有使用绝对坐标,所以其实很多元素已经能够自己适应了(例如网页的主内容部分),整体工作量比我想象的小。主要工作有两块:

header整体是左边logo右边menu,但是右边的menu需要根据屏幕大小切换不同的布局。为了简单,我直接就对大屏幕和小屏幕分别写了两个Menu,然后在运行时根据当前的屏幕大小进行切换。

import useBreakpoint from "antd/lib/grid/hooks/useBreakpoint";
 
const { md } = useBreakpoint();
 
md
  ? (
    <BigScreenMenu
      links={renderedLinks}
      pathname={pathname}
      userStore={userStore}
    />
  ) : (
    <SmallScreenMenu
      links={renderedLinks}
      pathname={pathname}
      userStore={userStore}
    />
  )

左右布局借用了使用的Ant Design的Tabs组件可以显示在左边也可以在上面的功能,同样通过useBreakpoint hook获得现在屏幕的大小,然后通过大小判断Tabs的显示模式。

大屏幕下的界面:

大屏幕界面

小屏幕下的界面:

小屏幕下的界面

主页是比较麻烦的,因为主页有一些部分结构比较复杂,设计图的HTML里也主要是以1200px以上为基准,把一些复杂的结构使用绝对大小来表示。比如下图中的树状结构,就是通过绝对的margin值来确定位置的。

结构复杂的部分

这个我最后取了一个偷懒的做法,就是在屏幕宽度过小的情况下,把这些复杂的部分全部隐藏掉。由于原来是支持1200px以上的,所以最后在1200px以下就把这些部分直接隐藏掉。

@media (max-width: 1200px) {
  .part_04 {
    display: none;
  }
 
  .part_05 {
    display: none;
  }
 
  .part_06_01 {
    display: none;
  }
 
  .part_06_02 {
    display: none;
  }
 
  .part_07 {
    display: none;
  }
 
  .part_03 .moreOnPC p {
    display: block;
  }
}

小屏幕下提示

部署Prometheus/Loki/Grafana

一开始系统并没有部署任何的监控系统和日志工具,日志全部直接打到stdout里,要查日志的时候通过docker-compose logs {container name}来查。这种方式有以下几个问题:

另外在一开始,机器的硬盘很少(20G),由于我没有镜像仓库,docker镜像都是直接拉到机器现场build,产生了巨多storage layer,占用了不少空间,有一次部署因为硬盘不够直接build失败了。

所以我觉得需要部署一套监控和日志系统,用来在问题出现时更方便地寻找问题。后面选择了Prometheus和Loki和Grafana这一套组合。

网上抄了一些dashboard的配置,比如下面的Node Exporter Full,nginx等,但是可能由于我这个系统比较简单,请求压力也不大,这些机器只是看着炫酷,实际没有起到什么作用。

Grafana Noded Exporter Full

Grafana Nginx

真正起到作用的是loki,通过使用Loki的Docker Driver,loki可以直接收集容器产生的日志,并且支持在grafana中进行搜索和查询。这些日志在后面诊断生产事故时发挥了巨大的作用。

通过Loki查看和搜索日志,右上角还可以修改时间

前后端共享API定义

前后端分离中,接口是非常重要的。为了减少接口层次的不一致,这次系统使用了我在yaarxiv中采用前后端共享API定义文件的设计。只需要在共享的shared项目中使用TypeScript写API相关的定义(request、response、权限等),前后端就可以使用同一份定义文件进行API的调用和实现。只要对每个接口写一个文件,以下所有工作都可以自动化:

下面是一个真实的本项目中的API定义:更新团队成果信息来举例子,看看这个机制是怎么工作的。

定义接口

用户定义一个xxxSchema的interface,通过body定义request body的类型,responses中定义各个状态码以及对应的返回值。在定义接口的时候可以随意使用TypeScript的各种高级类型来减少重复。

定义好接口后,使用endpoint定义这个API的方法和URL以及对应的Schema类型,再使用props指定API的一些属性,例如要求的权限(requiredRoles),可以被调用的时间段(timeRange)等。

import { UserRole } from "../auth/login";
import { ApiProps } from "../utils/apiProps";
import { Endpoint } from "../utils/schema";
import { TimeOutOfRangeError } from "../utils/serverError";
import { Submission, submissionTimeRange } from "./models";
 
export type UpdateSubmissionModel = Omit<Submission, "teamId" | "leader" | "time" | "paper">;
 
export interface UpdateSubmissionSchema {
  body: {
    teamId: number;
    info: UpdateSubmissionModel;
  };
  responses: TimeOutOfRangeError<{ reason: "NotLeader" }> & {
    201: {};
    400: { reason: "DataUrls"};
    404: {}
  }
}
 
export const props: ApiProps = {
  requiredRoles: [UserRole.User],
  timeRange: submissionTimeRange,
};
 
export const endpoint = {
  method: "PATCH",
  url: "/submissions",
} as Endpoint<UpdateSubmissionSchema>;

这些都定义好后,在shared项目中调用npm run api生成一个json schema文件。后端将会使用这个json schema自动生成参数和返回值验证,自动对传入参数不合要求的请求返回400。

"UpdateSubmissionSchema": {
  "type": "object",
  "properties": {
    "body": {
      "type": "object",
      "properties": {
        "teamId": {
          "type": "number"
        },
        "info": {
          "$ref": "#/definitions/UpdateSubmissionModel"
        }
      },
      "required": [
        "teamId",
        "info"
      ],
      "additionalProperties": false
    },
    "responses": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "201": {
          "type": "object",
          "additionalProperties": false
        },
        "400": {
          "type": "object",
          "properties": {
            "reason": {
              "type": "string",
              "const": "DataUrls"
            }
          },
          "required": [
            "reason"
          ],
          "additionalProperties": false
        },

后端实现接口

后端对于每个这样的定义文件,提供一个实现。指定api的路径,传入api的Schema的名称,提供实现。通过req.body可以访问传入的参数,并且这些参数以及对应的类型都被自动推断出来的。响应使用{ 状态码: 响应 }的格式,如果状态码或者响应不对,编译器也会报错。

import * as api from "dc-shared/api/submission/updateSubmission";
 
export const updateSubmissionRoute = route(
  api, "UpdateSubmissionSchema",
  async ({ req }) => {
    const { info, teamId } = req.body;
 
    const team = await req.em.findOne(Team, {
      id: teamId,
    });
 
    if (!team) { return { 404: {} };}
 
    if (team.leader.id !== req.dbUserRef().id) {
      return { 403: { reason: "NotLeader" as const } };
    }
 
    team.abstract = info.abstract;
    team.title = info.title;
    team.subjectType = info.subjectType;
    team.subject = info.subject;
 
    team.lastUpdateTime = new Date();
 
    await req.em.flush();
 
    return { 201: {} }; b
 
  });

另外在写集成测试的时候,也提供了一些辅助函数简化测试的编写。比如,由于框架会自动检查HTTP传入的参数是否合法,我在测试中基本就不需要再写模拟传入错误参数的测试用例了。并且,我写了一个callRoute函数来进行接口的调用,能够在编译时检查传入的参数是否合法并提供自动完成,还能自动推断不同状态码的响应类型。

const resp = await callRoute(server, adminGetTeamsRoute, {
  query: { pageSize: -1 },
}, admin);
 
expect(resp.statusCode).toBe(200);
 
// 200的返回值
const json = resp.json<200>();

前端调用接口

shared项目中运行npm run client将会根据API的文件结构,在前端项目中生成一份具有相同架构的API调用对象。

下图是API的文件的结构:

API

下图是生成的API对象的对应结构:

API对象

前端代码通过这个对象来访问API,不需要手动输入方法、URL等参数。编译器会自动检查传入的各个参数的类型是否正确,还能自动推断响应正确([200, 300))时的返回值类型。

await api.submission.updateSubmission({
  body: {
    teamId: s.teamId,
    info: info,
  },
})

减少重复,提高效率

其实这也是我使用TS写全栈的原因:如果前后端采用不同的语言,那么在API层次肯定会有重复工作:两边重复定义接口数据结构、URL、HTTP方法,这很麻烦,而且维护不好还容易造成两方数据结构的不一致。

另外,如果要基于数据结构定义新的数据结构,传统的OO语言很难达到TypeScript的灵活性。例如上面出现过的UpdateSubmissionModel,就是在Submission类型的基础上删除OmitteamId等几个属性。TypeScript支持各种对类型的骚操作,很适合接口灵活多变的特性。

export type UpdateSubmissionModel = Omit<Submission, "teamId" | "leader" | "time" | "paper">

另外虽然有swagger/openapi等标准,但是这些标准需要手写JSON Schema有点太过反人类……这套机制完全使用TypeScript写,不需要学习新的语法,还可以享受到编译器和IDE的支持。

使用migration进行数据库初始化和更新

开发的时候,数据库的schema可以使用ORM框架自己产生,如果schema变了,直接删除原来的数据库重新创建就可以了。但是项目一旦在生产环境中开始使用了,就不能随便删除schema了。随便删schema不就变成删库跑路了嘛。

现在的ORM基本都提供了Migration机制进行数据库schema的更新。一个migration主要包含一个日期和一段更新的命令,有的框架还能提供撤销这次更新的命令。在执行时,ORM会根据日期判断当前数据库schema的版本,然后执行后续的数据库变更。

对于那些可以通过运行数据库命令就能完成的schema更新,通过migration机制能够有效地记录下对数据库的变更,并且能够在运行数据库变更之前检查变更是否是自己所需要的,防止破坏现有的数据。

当前的ORM框架也会常常提供很多方便的命令,例如

很多维护工作都可以通过migration完成。

本项目使用mikro-orm作为ORM库,也提供了内置的migration机制,下面是我的项目中所现有的migration。

migrations
├── Migration20210331031030.ts
├── Migration20210402023841.ts
├── Migration20210414064450.ts
├── Migration20210424142314.ts
├── Migration20210426143345.ts
├── Migration20210428092351.ts
├── Migration20210715123945.ts
└── Migration20210719091704.ts

下面是一个migration的例子,up为执行这个migration所对应的SQL,down是撤销这个migration的命令。up是自动生成的,而down是自己写的。

import { Migration } from '@mikro-orm/migrations';
 
export class Migration20210424142314 extends Migration {
 
  async up(): Promise<void> {
    this.addSql('alter table `team` change `file_relative_path` `filename` varchar(255) null;');
  }
 
  async down(): Promise<void> {
    this.addSql('alter table `team` change `filename` `file_relative_path` varchar(255) null;');
  }
 
}

我的工作流是这样的:

  1. 在项目开发期间,仍然使用框架自动生成数据库schema的方法,让程序自己去更新数据库schema
  2. 在项目开始投用之前,使用mikro-orm migration:create --initial命令,创建第一个migration,并禁止程序自己操作数据库schema
  3. 项目部署后,使用mikro-orm migration:up执行第一个migration,程序开始运行
  4. 每次数据库更改后,使用mikro-orm migration:create命令,生成一个新的migration。打开新生成的migration文件检查SQL是否正确,并手写down方法
  5. 提交到生产机后,在docker-compose up -d前,使用mikro-orm migration:up执行migration

当然migration也不能解决所有数据库变更的问题,migration也不能解决过于复杂的变更,所以在设计数据库schema时也需要谨慎、考虑到未来的可扩展性。

另外,前面说的记录尽可能多的信息在这里也有作用,记录尽可能多的信息也为以后增加使用新信息的新功能提供了可能。例如,我在实现群发邮件功能的时候,一开始没有在数据库中记录一次群发邮件的结束时间,只记录了开始时间。这时我已经群发了一次邮件了。而后续我又想增加记录和显示群发邮件的结束时间的功能,这时第一次群发邮件的信息就不能直接从数据库中获取了,丢失了信息。事实上,最后我是从log中获得第一个发送邮件的结束时间,然后手动修改数据库才填进去这个数据的。

记录尽可能多的信息

在比赛临近结束、正在接受用户提交成果文件的时候,系统出了一场事故,丢失了一段时间内用户上传的文件。文章一次生产环境的文件丢失事故:复盘和教训 详细介绍了这个事故从发现到确定影响范围到确定原因的过程。在处理事故的过程中,后台程序记录的日志起到了巨大的作用。

同时,不仅是log,包括文件、数据库条目等保存的信息越多越好,能保存的信息尽可能保存,涉及到任何删除信息/文件等操作都应该谨慎。反正现在存储便宜,多用一点空间的成本和一旦发生事故造成的问题相比还是无关紧要的。

例如,在本项目中中为了节省磁盘空间和恶意上传文件,我设定了每个队伍只能上传一个文件,之后的上传会首先删除之前的文件。但是现在想起来,如果又有其他地方逻辑出错,把用户上传好的文件删除了怎么办?如果系统响应到一半被中断了,只删除了之前的文件,没有写入后面的文件怎么办?一切皆有可能,连字节跳动的实习生都能删掉机器学习模型,更何况我们这种没人审核代码的独立开发者?而至于防止恶意上传文件,也可以采用给团队能够使用的空间大小进行限额的方式来防止团队使用过多的空间,而不是一刀切地只能使用一个文件。

尽可能避免删除之前的信息,使得意外发生时,能够恢复到之前的状态,并且更方便去诊断问题。

安全性

为了防止用户作恶,我针对系统的安全性做了一些有限的工作:

个人感觉这些措施应该是一些比较容易想到的措施了,但是当然,这些措施对一些真正的攻击也无能为力,比如注册和登录都可以随便请求,攻击者可以无限注册账号,可以无限猜测密码来登录,更别说什么DDOS攻击了。还好,目前据我所知还没有遇到恶意的使用者。

准备应对外部系统出的问题

后端和数据库之间的交互已经被做透了,一般也不会出问题,出了问题也有各种方案进行恢复。但是一个非玩具系统肯定会支持处理数据库之外的其他功能,而实现这些功能往往会使用到外部服务。这些外部服务总可能会遇到的一些问题,所以在功能实现的时候就需要考虑到外部服务器的稳定性,并做好外部服务出问题的准备,例如采用上面提到的记录尽可能多的信息的方法。

举个本系统的群发邮件相关的例子。系统需要支持向参赛人员群发邮件的功能,而学校提供了SMTP服务器。我当时考虑到发送邮件可能失败,就记录下发送失败的用户的用户名(下图中failedUserId)。当时前面发了几次邮件都没有失败,看起来学校的服务还挺稳定,当时也有点忙,就没有实现失败重发的功能,这个字段就一直没用上。

@Entity()
export class GroupEmailDelivery {
  // ...
 
  @Property({ type: new ArrayType((x) => +x) })
  failedUsersId: number[] = [];
 
  // ...
}

结果在16日的一次发送邮件中,系统突然报了不少的错误,一看都是邮件服务器报的错误。

2021-07-16 15:10:13
{"level":50,"time":1626419413258,"pid":33,"hostname":"7534d898d7e5","code":"EAUTH","response":"550 User suspended","responseCode":550,"command":"AUTH PLAIN","stack":"Error: Invalid login: 550 User suspended\n    at SMTPConnection._formatError (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:774:19)\n    at SMTPConnection._actionAUTHComplete (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:1513:34)\n    at SMTPConnection.<anonymous> (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:540:26)\n    at SMTPConnection._processResponse (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:932:20)\n    at SMTPConnection._onData (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:739:14)\n    at TLSSocket.SMTPConnection._onSocketData (/dist/node_modules/nodemailer/lib/smtp-connection/index.js:189:44)\n    at TLSSocket.emit (events.js:315:20)\n    at TLSSocket.EventEmitter.emit (domain.js:467:12)\n    at addChunk (internal/streams/readable.js:309:12)\n    at readableAddChunk (internal/streams/readable.js:284:9)","type":"Error","msg":"Invalid login: 550 User suspended"}

马上去找提供SMTP服务器的老师请求解决这个问题,在此期间做了重发邮件的功能,因为有了failedUserId字段,我可以直接从数据库中获得需要重发的邮件,实现这个功能就很简单了。

commit d4366a3c45261ead399940552bfc67b3547eb7e6
Author: Chen Junda <[email protected]>
Date:   Fri Jul 16 15:44:39 2021 +0800

    add retry failed email

当SMTP服务器的问题解决后,这个新功能也做好了,直接上线,点一个键,就能重发邮件了。

失败和重发

试想要是当时没有存failedUsersId,我还得又去从log中获得失败的用户ID,过程非常的繁琐和复杂。

还可以改进的点

这次偷懒没有做CI/CD,使得每次部署完之后我都得去服务器上跑一下docker-compose build && docker-compose up -d,由于机器只能通过校园网,没有校园网的时候还必须接个VPN才能进去,比较麻烦。

我理解的预生产环境,就是一个和生产环境完全相同的机器,使用生产环境的数据的某个历史快照,对新的代码进行测试,测试通过后,再将代码放到生产环境中运行

这次部署没有预生产环境,代码在本地测试通过之后直接就扔到生产服务器上构建和部署了,没有使用实际测试数据进行测试

这其实也是后面生产事故的一大原因,有缺陷的代码没有真实测过就扔到生产环境,造成了问题

这次项目的部署都是直接重启容器,如果此时有响应正在处理,可能会造成响应做到一半被强制中断。一个好的策略是graceful shutdown,在退出之前的服务时,停止接受新的请求并处理完正在进行的请求,这样可以避免运行到一半的服务(数据库操作有事务保护,但是处理请求可没有事务)。

另外,docker-compose up -d虽然启动速度较快,但是仍然有一定的downtime,如果对服务质量有要求,0 downtime的不中断服务的更新还是非常有必要的

本项目写了不少后端的测试,行覆盖达到了94%,但是没有写前端测试,这造成每次改前端之后,部署后都心惊胆战的,不知道在真实环境中会不会出现问题。

但是写测试也有它的问题:花时间

现在项目中后端的测试代码比业务逻辑代码要多,写测试代码花的时间也比写真实业务逻辑代码更多。而且由于测试代码也是代码,也会出问题(而且常常会出问题),有时候测试通不过,调试半天测试代码之后发现业务代码没有错,是测试代码的错……

而前端就更复杂了。前端的测试可不是1+1=2正确、=3就错误那么简单。定位UI元素、操作UI元素、等待UI元素的响应、判断UI元素的响应是否合需求,每一部都有一大堆API需要学校。另外,尤其是对于业务项目,很多前端其实并没有一个完整详细的设计文档,什么样的UI是正确的根本就不确定。目前,除了自己的两个前端库,我从来没写过正经的完整项目的前端测试。

我当前的想法是只针对一些复杂一点的业务操作写测试,具体什么样的业务算复杂,只能看实际情况了。

总结

这个项目比较简单,规模也比较小(12000行左右),用户量也比较小,什么优行操作都没有做,一个简单的前后端结构就能满足需求,但是中途还是犯了很多错误,最后丢失文件的事故也带来了比较多的麻烦。不管怎样,这个项目至少也让我自己完整走了一次一个项目从开发到上线的流程,学到了一些写玩具项目永远都不会用上的技能(比如grafana)。我终于有一个不是玩具的项目了!

现在纯业务的工作确实做得比较腻了,简单的CRUD做来做去也就那些东西。以后希望能够做点更偏架构上的工作,能够支持规模更大、要求更高的项目,也通过更大的项目去接触更高级的项目和流程。我也希望能够带团队或者加入一个能够参与项目全程的已有团队,挑战一下复杂度更高的项目,并从中练习一下团队合作和领导能力。

react-typed-i18n: 使用Template Literal Types实现强类型的i18n

2021-06-06 15:27:00

react-typed-i18n

最近学习了一下TypeScript最新的Template Literal Type特性,突然想起来可以使用这个实现一个使用字符串字面量的i18n库,以用来替换之前的使用了一些黑魔法的simstate-i18n库,也在这个新库中解决一些技术债,例如只使用React Context、加入测试、增加一些新功能等。

新的库名字叫react-typed-i18nGitHub)。花了一个周末将功能和测试写好发到了npm上,并很快把本网站用这个新库重写了,体验比较好。

于是通过此文章介绍一下i18n的概念、常见实现方式以及本库的亮点,其中介绍部分的内容使用了之前写过的关于simstate-i18n的文章 Strongly Typed i18n with TypeScript。要想更详细的了解一些i18n的内容以及当时写simstate-i18n的考虑,请查阅此文章。

i18n

国际化,英文叫Internationalization,简称i18n(因为首尾字符i和n中间有18个字符),是指让一个软件产品能够显示按多国语言显示。

比如你正在访问的我的博客就是支持国际化的网站,你可以尝试在右上角的下拉框中切换不同的语言(当前只中文和英文),切换后,整个网站的将会以被选择的语言来显示。

国际化的核心是将网站中的所有文本元素都拿出去单独定义,然后在原本应该硬编码文本的地方,使用对所有语言都一致的元素进行代替。在编译时或者运行时,这种元素将会被替换为实际显示的语言的对应的文本元素。

例如说,对于这个p元素<p>Hello World</p>如果我想将它支持多语言显示,那么应该做以下的工作:

  1. 不能直接硬编码文本,而是应该用一个ID之类的东西(例如helloWorld)替代硬编码的文本
  2. 在其他地方,用不同的语言定义ID和文本的对应关系。例如,定义helloWorld对应你好,世界!(中文)或者Hello World(英文)
  3. 使用一个机制,在编译时或者在运行时将界面中的ID替换为当前语言的文本

定义ID和文本的对应关系

这种ID和文本的对应关系可以有很多种,最简单的就是KV,一个ID对应一个文本。iOS (示例) 和 ASP.NET Core MVC (示例)其实就是这种对应关系。

简单的KV对应虽然很简单,但是当文本信息多起来之后,ID可能会非常长,例如:

app.header.userIndicator.loggedIn.dropdown.login.button.text
app.header.userIndicator.loggedIn.dropdown.username.button.text
app.header.userIndicator.loggedIn.dropdown.logout.button.text

UI基本都是以树结构组合起来的,所以,我们也可以以树结构组织我们的ID,同一个页面、同一个组件下的文本信息组织在同一层级,每个层级之间使用.连接。

在JS中,我们可以使用对象表示这样的树结构的定义。例如以下例子,两种语言分别定义对象,其key为每个部分的ID,叶节点的value即是对应的文本信息。所有语言的这个这个对象具有相同的结构。那么,英文下的login和中文下登录这个文本信息对应的ID就是login.button.text

// en.ts, 定义英文的ID的文本对应关系
export default {
  login: {
    button: {
      text: "Login",
    },
  },
}
 
// cn.ts, 定义中文的对应关系
export default {
  login: {
    button: {
      text: "登录",
    },
  },
}

通过ID取得当前语言的文本

这个ID其实也是一个访问对象属性的路径。通过这样的ID,我们可以很简单的从当前语言的定义对象中取得当前语言的文本。加上一些状态管理的加持,动态切换语言也是很简单的工作。

// 这个组件通过ID获得当前语言的对应的文本。
function LocalizedString({ id }: { id: string }) {
  // 通过React Context等方式或者当前语言的定义对象currentLanguageObject
  // ...
 
  // 把ID以.拆开,然后一层一层访问就可以了。
  const value = currentLanguageObject;
  id.split(".").forEach((k) => {
    value = value[k];
  });
  return value;
}
 
// 一开始我们是把文本硬编码在组件里……
<Button>Login</Button>
 
// 现在使用LocalizedString来动态获取文本
<Button><LocalizedString id="login.button.text" /></Button>
 

强类型地输入ID

上述方式已经能够完成任务了,但是有一个很大的问题:写id的时候没有类型检查,容易写错

除了字符串,也有一些其他方式可以来定位,其他方式在我的simstate-i18n的文章中中有介绍,最后还是认为上一节的字符串和组件的方案具有优势。

所以现在的问题变成了如何强类型地生成ID。我之前的库simstate-i18n使用Proxy来截取对对象的访问路径来生成ID。这个点子比较新奇,也能很好地完成工作,但是总感觉有点奇技淫巧,而且使用Proxy总会对性能产生一些影响。

使用Template Literal Type计算所有可能的ID

Template Literal Type的出现使得我们可以直接使用字符串字面量作为ID,并通过一些类型计算,使得TypeScript可以确定出所有可能的ID,并在编辑器实现自动完成和错误检查。

TypeScript支持字面量类型(literal type)联合类型(union type)

而新支持的Template Literal Type简单来说,支持了字符串字面量类型的拼接。并且,在拼接的时候,如果有一个操作数是联合类型,将会运用分配律将字符串展开:

type A = "A1" | "A2" ;
type B = "B1" | "B2" ;
 
type C = `${A}.${B}` // type C = "A1.B1" | "A1.B2" | "A2.B1" | "A2.B2"

在这个功能的基础上,再加上一些TS的高级类型(例如Mapped types, keyof等等等)我们就可以实现从对象字面量推断出所有可能的ID了。

在JS代码中,我们要生成这样的ID是很简单的:对对象做一个DFS就可以了

const a = { login: { button: { text: "Login" } } };
 
const dfs = (obj) => {
  if (typeof obj === "string") {
    // 已经到了文本,下一层的key就是""
    return [""];
  }
 
  // 保存至这一层的所有ID
  const ids = [];
 
  // 遍历obj的所有key
  for (const key in obj) {
    // 获得下一层的所有key
    const nextLevel = dfs(obj[key]);
 
    // 将本key加到所有下一层key的前面
    ids.push(...nextLevel.map((x) => `${key}.${x}`));
  }
 
  return ids;
};
 
// 按上述方案写出来的ID最后会多出一个.,使用这个函数把最后的.去掉
const removeTrailingDot = (str) => str.endsWith(".") ? str.substr(0, str.length-1) : str;
 
dfs(a).map(removeTrailingDot); // ["login.button.text"]

当然,我们可以使用一个更FP的方式写这个dfs函数:

const flattenArray = (arr) => arr.reduce((prev, curr) => {
  prev.push(...curr);
  return prev;
}, []);
 
const dfs = (obj) => typeof obj === "string"
  ? [""]
  : flattenArray(Object.keys(obj).map((k) => dfs(obj[k]).map((sk) => `${k}.${sk}`)));

现在我们要做的,就是在类型的层面、借助TS的类型系统,完成同样的工作。这里先放代码:

type ValueOf<T> = T[keyof T];
 
type Concat<T, U> = `${string & T}.${string & U}`
 
type LangRec<D extends string | Definitions> = D extends string
  ? ""
  : `${ValueOf<{[k in keyof D]: Concat<k, LangRec<D[k]>>}>}`
 
type RemoveTrailingDot<T extends string> = T extends `${infer U}.` ? U : T;
 
export type Lang<D extends Definitions> = RemoveTrailingDot<LangRec<D>>;
 
type A = Lang<{ a: "2", b: { c: "4" } }>; // type A = "a" | "b.c"

这段代码看起来很唬人,但是如果我们把类型看成函数,类型参数看作函数参数,就可以看出来,这个代码基本就是JS代码的直接翻译。其中有几点可以提一下:

LangRec是一个递归类型,可以理解成递归函数,其中LangRec是函数名,类型变量D为函数的变量。为了使得递归类型能够递归结束,必须需要使用条件类型 conditional type判断当前D的实际类型,并当它是字符串(即已经到达对象的叶节点)时,结束递归

ValueOf<{[k in keyof D]: Concat<k, LangRec<D[k]>>}>处运用了mapped type,将D的每个key(k in typeof D)映射成一个新的类型Concat<k, LangRec<D[k]>>

本来mapped type只支持映射把对象映射到对象(如{a: string; b: string}{ a: number; b: number; }),但是我们只关系值的类型(映射后的类型),不关心原来的key的类型,那么可以使用ValueOf取得所有值的类型。

在原来的JS代码中,下一级生成的key是一个string[],而本级的key是一个string,要将本级的key加到所有下一级key的前面,就需要使用map方法,并在最后使用flatten方法,把string[][]打平成string[]

对应到在我们的类型代码中,下一级生成的key是一个联合类型("a" | "b"),本级的key是一个字符串字面量"a"。但是,借助分配律,我们可以直接得到拼接后的联合类型"a.a" | "a.b"

类型系统中没有.endsWith方法,也没有.substr方法,但是可以借助inferred type,看看原类型能不能推断(infer)出一个类型U,这个U类型加上一个.,就等于原来的类型T。如果可以,那么U就是删掉最后的点后的类型。

通过这个Lang类型和语言对象的字面量类型,我们就可以直接获得所有可能的ID,将它用在ID的类型上,我们就实现了强类型的文本ID。借助TS提供的类型信息,VSCode等编辑器可以给出自动完成,极大地提高编程体验。

const language = { login: { button: { text: "Login" } } };
 
type TextId = Lang<typeof language>;
 
// 这个组件通过ID获得当前语言的对应的文本。
function LocalizedString({ id }: { id: TextId }) {
  // ...
}
 
<LocalizedString id="a" /> // error
<LocalizedString id="login.button" /> // error
<LocalizedString id="login.button.text" /> // correct

自动完成体验

ID前缀

随着代码量的增长,UI树越来越复杂,对应的ID的结构也越来越复杂,深度越来越大,这就会使得ID越来越长,但是在同一个UI里用到的各个ID都具有相同的前缀,例如下面这个真实项目中的例子:

app.header.userIndicator.loggedIn.dropdown.login.button.text
app.header.userIndicator.loggedIn.dropdown.username.button.text
app.header.userIndicator.loggedIn.dropdown.logout.button.text

所以,我们可以将前缀提取出来,然后在真正使用的地,就可以直接输入后面不同的部分就可以了。

当然了这个过程也需要有类型检查。本库提供了prefix这个helper function,在运行createI18n时可以获得。对于上面三个例子,使用prefix函数可以写出如下代码。

 
const p = prefix("app.header.userIndicator.loggedIn.dropdown.");
 
p("login.button.text"); // app.header.userIndicator.loggedIn.dropdown.login.button.text
p("username.button.text") // app.header.userIndicator.loggedIn.dropdown.login.button.text
p("logout.button.text") // app.header.userIndicator.loggedIn.dropdown.logout.button.tex

这是怎么实现的呢?

type FullLang<D extends string | Definitions> = D extends string
  ? ""
  : `${ValueOf<{[k in keyof D]: `${(StringOnly<k>)}.` | Concat<k, FullLang<D[k]>>}>}`
 
type PartialLangFromFull
  <D extends Definitions, FL extends FullLang<D>, L extends Lang<D>> =
  FL extends `${L}.` ? never : FL;
 
export type PartialLang<D extends Definitions> =
  PartialLangFromFull<D, FullLang<D>, Lang<D>>;
 
export type RestLang
<D extends Definitions, L extends Lang<D>, Partial extends PartialLang<D>> =
  L extends `${Partial}${infer Rest}` ? Rest : never;
 
const p: <TPartial extends PartialLang<D>, TRest extends RestLang<D, Lang<D>, TPartial>>
  (t: TPartial) => (rest: TRest) => `${TPartial}${TRest}` = (t) => (s) => t+s;

这看着比上面的获得所有ID要复杂不少,但是简单来说可以分为这几步:

  1. FullLang类型取得DFS过程中所有中间节点的ID
    • Lang只会取得到叶节点的ID,而FullLang会把途径的中间结果的ID也获得
    • 另外还有一个区别是这个得到的所有ID最终的.是没有去掉的
const language = { login: { button: { text: "Login" } } };
 
type D = typeof language;
 
type FL = FullLang<D>; // "login." | "login.button." | "login.button.text.";
  1. PartialLangFromFull类型把到叶节点的ID去掉
    • 对于FullLang的每个取值,如果它是某个Lang的取值后面加个.,那么这个其实是到叶节点的ID,不是前缀,是不应该取的(never)
    • 到这里已经获得前缀了
type PLFF = PartialLangFromFull<D, FullLang<D>, PartialLang<D>>; // "login." | "login.button."
  1. RestLang是通过前缀来获取可以接受的后缀
    • 这个也是通过infer的方式来实现的,看看是否某个Lang的取值可以是传入的前缀和后缀拼起来${Partial}${infer Rest},如果可以,就把后缀Rest返回
type RestLang = RestLang<D, Lang<D>, "login."> // "button.text"
  1. 最后,p函数的实现其实就是一个(t) => (s) => t+s,非常简单,运行时几乎没有什么开销。

总结

TS具有现在常见编程语言中最强大的类型系统,而这个类型系统如果能用得好,能够极大地提高编程效率。这个方案借助了TS的强大的类型系统,使得i18n的过程也能享受到较为完善的类型检查,能够在编译器避免很多因为string写错的带来的运行时bug。并且,在运行时就是普通的字符串,和之前的通过proxy截取对象访问路径生成ID的方案相比,不会在运行时产生任何开销。

这个方案也有一个潜在问题,即当语言对象很大很复杂时,编译器的类型运算本身可能会消耗较多运算资源,影响编辑器的流畅度。但是,当前我的网站具有151行的语言定义,ID的自动完成性能还算可以接受(基本不会有可见卡顿),所以对我来说性能已经够用了。

希望有更多用户可以来试试react-typed-i18nGitHub)。

wslg初体验:最佳Linux发行版?

2021-04-23 12:02:00

wslg

Build 2020上微软给大家画了个饼,说官方正在做WSL2的GUI和GPU支持。

Build 2020上WSL 2的GUI支持图片(来源:https://devblogs.microsoft.com/commandline/the-windows-subsystem-for-linux-build-2020-summary/#wsl-gui)

大家都知道一些常见开发工具在Windows上运行效率很低(如IDEA的启动速度在Windows上速度比Linux下慢了1倍(相关文章))。

要是GUI和GPU都能用了,那WSL 2和Linux用起来还有什么区别?开发工具在WSL2跑,其他软件在Windows上,best of two worlds,岂不美哉?Linux桌面受到致命打击。

没想到这一等就是一年。最近微软终于放出了官方的GUI解决方案wslg的预览版(GitHub),可以在Insider build上公开测试了。

GitHub仓库

当然了,我马不停蹄地格掉了一个移动SSD,装上了Windows 10 Insider Dev版,开始测试。

环境要求

仓库里说,系统版本要求为21362或以上,还推荐安装一个更新的显卡驱动。

环境要求

我本机是RTX 3070,于是就安装NVIDIA版本的。从NVIDIA官方网站可以看到,这个显卡驱动允许了WSL2使用GPU跑CUDA,使得需要GPU的计算任务(比如机器学习)也可以直接在WSL2上跑。这使得大家以后可以在Windows下跑机器学习了,Linux桌面再次受到致命打击。

CUDA on WSL驱动

直接从Windows Insider下载下来的ISO是21354版本的,还需要更新一次系统才能到21362+。更新之后为21364,可以正常运行wslg了。

21364版本

接下来都是在截止2021/4/23能够获得的最新的Windows版本(21364)和wslg版本1.0.17上体验的。由于还是预览版,所以很多功能不能用都是正常的

安装WSL2,运行GUI程序!

现在安装WSL2很简单,不需要去控制面板里自己打开各种功能,只需要运行wsl --install即可安装WSL2的所有部件(已经安装好了没有截图)。

我们先拿使用X11 Forwarding在WSL 2中运行GUI程序中提到的几个软件试试水。

安装饼直接运行xclock,一个时钟出现了!

xclock

ohhhhhhh!无缝体验,不需要再去折腾什么DISPLAY环境变量,Windows的X Server,直接运行就可以了!

然后我们再用个复杂一点VSCode进行测试,也能正常运行。

VSCode

把VSCode的titleBarStyle设置为custom后,使用vcxsrv的方案不能拖动窗口,但是wslg方案可以正常拖动。

拖动窗口

再启动一个网易云音乐,可以发现音乐也可以正常播放。根据wslg的主页,wslg也通过对音频输入输出有完全的支持。

网易云音乐

剪贴板也是双向同步的。

剪贴板同步

看起来很promising啊!

发现的问题

兴奋没一会,就发现了不少问题。这些问题使用官方的Ubuntu发行版也没有解决。

高DPI

我的显示器4K分辨率的,平时开的150%缩放,直接打开应用可以发现是糊的,看起来并不支持DPI缩放。

去Issues里查,确实有相关Issue(#23),里面提供了一个打开这个功能的解决方案,但是结果确实更糊了……

糊了的xclock

上面的拖动VSCode的动画打开这个功能后录制的,也可以看出VSCode已经糊得看不清了。

这看起来应该是个bug,因为已有的vcxsrv可以很完美地实现缩放。

Windows IME支持

我在文章中提到IME支持的问题。Linux下配置输入法也是一件比较麻烦的事情,如果能够直接使用Windows这边的输入法在Linux程序中输入程序,就会极大提高用户体验。

很遗憾的是看到当前版本的wslg仍然不支持直接使用Windows的输入法在Linux下进行输入。不知道微软有没有发现非英语国家的开发者确实有这个需求,也可能技术上有一些困难,如果以后可以做就更好了。

不能直接使用Windows输入法

但是实在想要IME的话,还是可以直接在Linux下配置IME,虽然有点小问题(#8),但是总比没有用好嘛。

QQ截图使wslg崩溃

这是个很奇怪的bug。看下面gif,一开始使用Windows自带的Win+Shift+S截图没有问题,但是如果使用QQ截图,就会使得当前Linux程序崩溃,无法重新启动,即使重启WSL都不行,需要重启整个Windows才能解决……

QQ截图使wslg崩溃

其他发现

除了这些问题,我还有一些其他发现:

  1. 现在的WSL2内核已经升级到5.10了,这比之前的4.19提高了不少。虽然用起来并没有什么不同,但是对于版本强迫症的我来说是一个极大的安慰。

内核版本

  1. 开始菜单里每个发行版的文件夹里会显示所安装的程序,个人猜测是读取desktop文件的。另外,Ubuntu里的程序是有图标的,Arch里的没有。Ubuntu不愧是有官方背书的(

Arch目录 Ubuntu目录

总结

可以发现,现在的wslg的功能和普通的X Server差不多,甚至目前有的方案还不如(比如HiDPI支持)。

从GitHub的README上可以看到,这个项目并不是通过X11 Forwarding实现的,而是借用了Wayland的Compositor以及RDP协议,看起来架构复杂不少,带来的优势其实我暂时并没有发现,希望有大佬科普科普。

但是即使这样,由于wslg有官方背书,用户体验比自己折腾X Server和DISPLAY变量的X11 Forwarding方案好一些。再加上开始菜单的集成、GPU计算等等功能,可以说有了wslg,Windows朝着最佳Linux发行版又跨了一大步。通过WSL2和wslg,Windows完全兼容了Linux生态,Linux能做的Windows都能做,而反过来现在不行,由于Windows闭源的特性,可能以后也永远不行。

借助Docker,把VPN当作HTTP代理来用

2021-02-08 10:13:00

需求

虽然放假了,但是学校还有活要做,而且学校的活的代码仓库在学校内建的内网GitLab上,在外网需要VPN才能访问。但是,大家都知道连接VPN会让整个系统里的所有流量都走VPN,但是很多流量其实是不需要走VPN的,比如聊天、看视频网站等,VPN是有带宽限制的,把所有流量都走VPN会使得不需要走VPN的网络请求变慢。

VPN方案下网络流量走向

那么,有没有办法可以让需要访问内网资源的程序走VPN,不需要访问的程序不走VPN呢?

代理

代理服务器的基本行为就是接收客户端发送的请求后转发给其他服务器。代理不改变请求URI,並不会直接发送给前方持有资源的目标服务器。(维基百科

简单的理解,代理服务器(proxy server)就是一个程序,它会将它收到的流量转发到其他服务器上。这个服务器有可能就是真正的目标服务器,也有可能是其他代理服务器。

当前大部分程序都支持设置代理服务器。如果一个程序设置了代理服务器,那么它发送的流量将不会直接发送到真正的目标服务器上的,而是发送到代理服务器,由代理服务器进行处理后发送到目标服务器上。

代理服务器下网络流量走向

如果有在内网的代理服务器……

如果我们有一台在内网的服务器,并且这个服务器可以从公网被访问,那么事情就很简单了:

我们将在这台服务器上开一个代理程序,这个代理程序就是简单地把收到的请求再转发到真正的目标服务器上。 然后在我们本机上,把需要内网资源的程序的代理服务器设置为这个服务器的地址。

这样设置之后,我们本地的需要内网资源的程序的流量将会被发送到这个内网的服务器上,内网服务器收到了,将会把请求转发到内网的服务器上。这样,我们的程序就可以访问内网的资源了。

内网代理服务器方案下的网络流量走向

最终方案:把连接到VPN的服务器作为代理

那我们没有一台在内网的、可以被公网所访问的服务器怎么办呢?

这时,VPN的作用就出来了:连接了VPN的电脑的所有流量都会转发到内网去,那么这台电脑就可以被看作一个在内网的服务器。

我们可以在本机上起一个虚拟机或者docker容器,使这台虚拟机或者容器连接到VPN。同时,在这个虚拟机或者容器上起一个代理程序,工作就是简单地把收到的请求转发到真正的目标服务器上。

我们将需要走内网的程序的代理设置为这个虚拟机或容器,那么需要走内网的程序的流量将会首先发送到虚拟机或者容器。由于这个虚拟机或容器连接了VPN,那么由这个服务器发送的请求就会走隧道到内网,能够访问到我们需要的内网的服务。而没有设置这个虚拟机或容器为代理服务器的程序的流量将会不经过内网,直接连接到互联网服务。这样,我们的问题就解决了!

VPN作为HTTP代理服务器的最终方案

设置代理

最后一步,如何给一个程序设置代理呢?

这个需要根据程序而定,一般是如下的策略:

在我们的原始需求中,我们使用的git。所以,我们可以在仓库中使用git config http.proxy 代理地址git config https.proxy 代理地址来设置,使得这个仓库的pull和push操作都使用代理地址对应的代理服务器。我们只在仓库层面进行设置,只有这个仓库的操作会走这个代理服务器,其他仓库不会使用这个代理服务器。

git使用代理访问内网仓库

借助Docker的实现

根据这个原理,我编写了一套使用Docker容器来实现这个解决方案的脚本,可以在经过简单的设置之后,使用一条docker-compose up命令就可以简单地启动这样一个代理服务器,方便使用。由于使用了docker,所以Windows/*nix等支持docker的操作系统全部支持。

仓库地址为:https://github.com/ddadaal/vpn-as-http-proxy

感兴趣的同学可以进入仓库参考一下使用方法和实现原理。使用方法写在README文件中了。由于每个学校和组织的VPN地址、参数等都不相同,我也鼓励大家尝试找到连接自己学校的VPN的命令,并将命令模板通过PR方式贡献给本仓库,这样使得更多的同学能够更简单地使用这个解决方案。

2020年总结

2020-12-31 15:10:00

2020年的四个阶段

对我来说,2020年可以分为四个阶段:

其中前三个阶段都在我的上一篇文章:美好的回忆和未知的未来:写在研究生开学前中这篇文章中做了总结,所以这里就#include一下,前三个部分的内容可以参考上一篇文章。

本篇总结一下这三个月研究生生活,记录一下这一年里一些值得纪念的生活小事,并用一些对本年的经历的一些思考来结尾,以此来告别这个魔幻的2020年。

三个月研究生生活总结

课程

一般提到研究生课程这东西,大家都会习惯性忽略,说:研一主要就上课就行了。但是呢,研一这研究生课程这东西嘛,它虽然不重要,但是也挺闹心的。

研究生课程确实不重要,体现在成绩的高低上:

毕业只要及格,连评奖评优都只需要及格,只要及格之后,成绩项的得分就是75分的定值。而且老师也一般都不会太严格,一般的课一年挂的人也是1个或者2个。一句名言:

研究生课程最好的GPA是及格GPA。

但是它又很重要,这体现在能否及格上:

没有补考,只有重修,而且挂两三门直接退学?另外我导师说,他的第一个研究生就是因为课程考不及格而退学的。

退学?

更麻烦的是,这里的课程和本科的课完全不一样:之前总结了一下,本科的课一般只需要做两件事:背书做大作业。背书这个就不说了,大家都背,考试的题目和背的题目基本也大差不差;而本科的大作业基本都是我所喜欢和擅长的,因此我本科的成绩才能比较高。

而到这里就不一样了:信科是重理论重科研的,所以它的课也是重理论重科研的。

所以软件测试课上完基础知识,接下来是现场想论文框架?30分钟完成思考问题,提出解决方案,用实例验证,提出解决方案的不足之处的全过程,还要做PPT?上课这一点点时间开脑洞出来的方法我还真不信能用……我还以为是真的拿几个软件制品来真正测试一下呢。

所以一门叫做“海量图数据的管理和挖掘"的课程,一次三个小时的课讲20多篇论文的算法/方法,也不管大家能不能听懂?下图是某一次课上课涉及的内容的来源论文,前面一页PPT还有10个。

某一次课内容的来源

还有一门课,数据库原理与技术,说实话上课质量确实比较高,内容很充实,讲的很清晰,但是无平时作业,考核是通过读论文和最后的纸面考试实现的,而且考试的难度有点过分,是不可能背出来的……其实相比起来问题也不大,但是我觉得这种课至少应该写点代码吧……

我选择的最后一门课高级编译技术,是在UT Austin的CS 380C上进行的简化,去掉了不少内容(比如寄存器分配等)。虽然这课难度比较高,而且又有平时作业、又有大作业、又有考试,但是由于它的内容是经典的、成体系的,上课的节奏比较可以接受,可以说是我这学期最喜欢的课程之一了。但是一开学就布置的三个大作业我一直在上周(12月中)才知道,难度也是相当大(123),时间也很紧张,还是相当难顶的。截至发稿,三个作业也就做了1.05个(按得分记),而且已经遇到了很困难的、还没想到方法解决的问题……

这些课上课的内容让我觉得我本科根本不是学计算机的(好吧,软工的确不是计算机)。整个研一期间,除了上课和写作业,基本没做成什么其他事情,最近期末这段时间疯狂赶DDL,啥都不想干了。说好的研究生期间是给老师打工呢?现在看来,我倒是希望给老师打工了,至少打工的时候的工作能给我带来一些成就感和满足感。

DDL

学工

在上一篇文章中我提到研究生期间的人际关系将会变窄,所以在开学的时候我去申请了信科研会的职位。当时我已经意识到研一可能会有很多工作,所以在投志愿的时候选择了看起来较为轻松的部门,职位由于只能选择部长和副部长,所以我选择了副部长,准备尝试去划水和了解一学年。

其实这时候我已经觉得有点奇怪了:为什么招新会直接招部长级别呢?新生什么都不懂,直接负责一个部门的事务是不是风险有点大?而且更奇怪的是,面试的时候我当面强调不要部长,后面却直接给了我部长职位;更主要的是之后发现部门的工作确实不是我喜欢的(而且甚至有点不太乐意干的),而且感觉课程压力变大了,所以没过一个月,就找了接盘侠,把研会退了……

说到底,其实还是自己的原因,一开始没有仔细了解部门的工作,过于冲动了,所以造成后面的一些不方便的事情,现在见到之后的研会的同学还是有点小尴尬。

但是研会的机会让我认识了一位上一届的研会的部长,他现在在负责北大的微软学生俱乐部。南大微俱的三年让我印象深刻(2016至2019,和南京大学微软学生俱乐部一起成长)。在开学、申研会之前,我就想继续在北大微俱继续做事,但是在百团大战的时候我并没有发现北大微俱,以为北大微俱已经凉了,本来还有一点小遗憾。所以当时他问我要不要加入微俱的时候,我想都没想直接同意了。

之后发现,北大微俱去年断档了,甚至已经被取消注册了,今年的北大俱乐部基本可以说是“从头再来”。企业俱乐部本来就很难办,面向研究生的更难办,所以我本科时的“面向新生”的策略基本不能应用在年级更高的同学身上。所以其实在之后,北大微俱也就组织了一次去MSRA的参观活动,其他活动似乎很难办。

北大微俱组织的MSRA参访活动

所以其实说到底这一学期我根本没有任何学工活动。虽然没有学生工作让我平时少了很多事情,轻松了很多(可以把更多时间放在肝课程作业上……),但是这使得我失去了最好的认识其他朋友的机会,所以我现在人际关系其实是比较成问题的。

人际关系

人际关系方面,基本和我在上一篇文章中说的一样:

认识的人:当然,研究生和工作期间也会认识新同学新朋友,但是由于参加的活动将会减少,社交面会急剧变窄;而且,研究生期间的同学和以后的同事都是成年人了,都懂了自己想要什么,都有了自己的安排,都有了自己的人脉圈甚至家庭,不能像本科时的兄弟们一样时时刻刻泡在一起。

唯一值得庆幸的是,我和室友以及实验室同年级同学的关系还可以,和清华的本科同学关系更好了,还加了一个打羽毛球的小圈子,但是也就仅此而已了:这些就是我当前认识的所有圈子。而且,大家都知道自己想要什么,都在朝着自己的方向去做。所以除了约饭、上课以及偶尔出去看个电影之外,也找不到还能一起做什么,大家都很忙。

唉,每次想找人却找不到的人时候,就想念本科的兄弟萌,十一的时候和兄弟萌短暂的几天相聚,也当是重温我的逝去的美好青春了。

十一回南大玩

大环境是个更大的问题,简单来说,和其他同学像是两个世界的人

就像之前说的,信科主要注重科研,所以周围的同学也基本都是科研方向(或者选调方向),而我仍然想做工程。我的能力、兴趣、方向和他们不说是冲突的,至少是正交的。所以不像本科时大家方向都大致相同或者互补,现在我完全找不到能一起做事的人。这几个月实验室有两个工程项目,都只能我一个人做;想参加一些项目相关的比赛,找不到愿意参加的同学;好不容易有个课程有个组队的工程项目,一个简单的社交网站平台,结果身边的同学都完全没做过稍微正经一点的工程开发工作(比如项目或者工程任务的实习)……

核心问题并不是技能不匹配,有兴趣的话,工程相关的知识其实比科研所需要和使用的知识简单多了,但是问题是我和其他人互相对对方的工作没有兴趣:我没兴趣去想idea做实验,其他人没有兴趣做工程。我觉得从工作中认识同伴是最好的、最自然的认识的人的方式,而兴趣的不匹配加上上面说的学工的情况,从工作中认识同伴这条路基本已经堵死了。

其他生活中的小事

入音乐剧坑

这半年去现场看了一次《第一次约会》中文版(豆瓣),在去看之前专门先看了一下百老汇原版,总的评价是原版质量中规中矩,是一个很典型的爱情轻喜剧。中文版和原版相比,除了把台词和歌曲翻译成汉语、把一些梗改成了中国人更能理解的梗(比如男女主犹太教vs基督教改成了本地人和外地人),其他的(剧情、舞台设计、舞蹈等)基本没有变化,个人觉得这种翻译还是有点生硬的感觉,可能是因为先看了原版先入为主的缘故吧。

但是看剧体验并不佳,主要原因是世纪剧场的音效太垃圾了,二楼靠前的座位只能勉强听清台词,歌词就完全无法听清了,而且由于是中文剧所以没有字幕,所以这很严重地影响了对剧情的理解。比如犹太教和基督教的“冲突”点的歌《The Girl for You》在原剧里相当搞笑,但是在这次表演中由于音效的原因,除了知道剧情改成了外地人以外,改的词完全没听清楚在讲什么,只能看到舞台上热火朝天的舞蹈,感觉怪尴尬的。其他歌也基本是同样的问题,我看过原剧所以理解每首歌主要讲的内容,但是没看过的观众就比较尴尬了。我的票也是中档价格的票了,但是体验仍然这么差,只能给剧院差评了。希望明年去的剧院能好好考虑一下音效效果,连歌都听不清,音乐剧的效果也就大打折扣了。

剧票

本年度还看了不少电影和音乐剧,列表如下图。

本年度的电影和音乐剧列表

今年开始可以说正式踏入了音乐剧大坑,通过万能的B站看了20多部音乐剧录像。在《剧院魅影》、《猫》等经典之外,还有很多音乐剧通过歌声和台词精彩地讲授了一个或温暖、或悲伤、或欢乐、或引人思考的故事:

当然,这其中也有一些虽然称作经典,但是剧情和价值观都值得吐槽的剧(如《西贡小姐》(Miss Saigon)),很多剧也只是讲解一个简单的故事,也没什么更深入的含义。但是总体来说,音乐剧确实带来了电影不同的全新的体验,帮我解锁了一个电影以外的更大的世界,当然也解锁了更多的歌曲专辑。

由于疫情的原因,这几年可能都很难能够在国内看到原版的国外音乐剧(希望明年的《剧院魅影》能正常上演),还是稍显遗憾的,但是一些中文版的剧评价也相当不错,也值得专程去剧院欣赏。希望疫情能够早日结束,也希望国产剧能够越来越好。

希望能正常上演

成都之行

10月底和一名同学参加了对方一个看起来并不太想参加的比赛,所以有机会从学校生活中喘喘气,去成都玩了5天。最大的感受是和北京比,成都的物价是真的便宜,每顿都在外面吃,最后人均总共才200多一点:在北京在外面吃我就没吃过一顿人均低于100的。

成都之行

装机

从双十一开始买电脑配件,12月12日终于抢到了电脑的最后一个组件5900X,历时一个多月,终于装好了我的第一台自己装的电脑。配置如下:

组件型号价格渠道
CPUAMD Ryzen R9 5900X4099JD
CPU散热器雅浚ProArtist Gratify5 G5249JD
主板华硕 TUF GAMING B550M-PLUS WIFI749JD
内存G.SKILL Trident Z 3200 16G *2999JD
主SSD三星 PM9A1 512G PCIe 4 NVMe866淘宝
副SSD海康威视 C2000PRO 1T PCIe 3.0 NVMe749天猫
显卡映众 GeForce RTX 3070 冰龙版4199JD
机箱迎广 301 黑色459JD
电源长城 G7 750W 金牌全模组569JD
机箱风扇酷冷至尊 漩涡120 ARGB *5 + RGB和风扇集线器435JD
总价13373

从价格来说,其实这套配置的价格已经算比较高的了,如果要讲究性价比的话,有很多地方可以缩配,缩到12000以下应该是比较简单的,比如:

而且如果只是臭打游戏的话,5900X也很overkill,5800X应该能够非常好的满足需求,不需要加这上千块强上5900X(更别提5900X现在还在耍猴了……),这样甚至可以压缩到10000以内。再减1000换个什么3060Ti,噫,性价比爆表。

另外,本套是个mATX配置,机箱空间较小,操作并不如标准ATX方便,对我这种第一次独自装机的不太友好,所以花了整整两天才完成,还拆掉了一些不该拆掉的螺丝,造成现在侧面盖板无法完全合上,而且完全没有理线,所以现在看上去很乱……另外,其实宿舍空间相当充足,mATX既没有ATX的扩展性,也没有ITX的便携性,有点小亏,下次装机还是要么ATX要么ITX了,mATX在中间不伦不类的,两头不占好。

PC

不管怎么说,这台电脑最难得的是在这个耍猴的年代,CPU和显卡都是从京东原价抢到的,其他配件也是近期最低价,配置也是相当满意,5900X太强了,用了几天后用笔记本感到明显的反应迟钝,而且,24个框框看上去太爽了!

原价买到的5900X和3070

24个框框!!!!

总结

总的来说,研究生生活方面和我之前考虑的差别不大,除了课程的难度让我有点措手不及以外,其他方面基本大差不差。而信科从任何角度来说(包括但不限于课程设计、院系发展的方向、同学的基本情况),确实不适合我。可能这也是当时做选择的时候考虑不周到吧:只考虑了学校,没有考虑专业的匹配性。本院的学长学姐对清华和上交的偏爱确实也有他们的理由。人际关系方面比较受限,更别提其他方面了。而日常生活方面也是比较平淡,但是一些日常的简单的活动(如打球、看电影等)也可以暂时从现实生活中稍微找到一点乐趣。

对于选校,现在说后悔也没用了,幸运的是导师和实验室的氛围非常好,可能会涉及的工作可能也是北大所有实验室里我最适合的了。至于让人头秃的上课,顺利的话,只有研一会上课,之后都是以实验室为主。希望以后能够不这么挣扎,能够找回自己当初选这里的初心,真正写点实用的、能为他人带来帮助的代码。

另外,我发现,不管是日常生活中还是写代码的过程中,我似乎很久没有再找到大一大二时的激情了,那种可以为了一个问题可以花几天时间啥都不想就想深追到底的激情,那种电影《心灵奇旅》中的忘我的感受。我仍然记得软工2大作业中途有3天我满脑子都只有一个CI的问题,除了吃饭上课就是在电脑前尝试解决这个问题,其他什么问题都不担心。而现在不管做什么事,总是会担心各种各样的事情,担心DDL,担心考试,担心以后的工作,担心35岁被辞退,担心会一个人去面对未知的生活,一个人去感受生活中的乐趣而无人一起分享这份快乐,一个人去接受来自生活的挑战,而在遇到挫折时最多只能自己在床上大哭一场然后下床继续装作一个坚强的人。这是成为社会人的必修课吗?

美好的回忆和未知的未来:写在研究生开学前

2020-09-11 18:42:00

昨天晚上把重达100多斤的行李寄走了,标志着研究生生活的开始。在这个最长的假期中,我止不住地回忆过去四年的美好生活。而对于研究生以及未来的生活,我却充满了未知和迷茫。

最长的“假期”:为四年划上句号

毕设

一场突如其来的疫情打乱了本科最后半年的计划,从和在学校边和兄弟们享受人生边做毕设,变成了……在家里疯狂做毕设。

选课题的时候,学院老师给的课题中至少一半都是机器学习相关,剩下的看上去要么是搬砖要么是学术,完全不感冒。于是找了研究生导师要课题,没想到一要就是一个大项目,虽然也是搬砖,项目最后也是个玩具不投入实用,但是算了,没办法了,996地连续肝了2个多月(但其实每天真正写代码的时候只有8-9h小时),才最终肝完。

2月23日的编程时间

肝这个项目还是学了不少东西,试了不少新技术(比如ASP.NET Core、gRPC等),还顺便配置好了vim,但是学到的东西基本也都是些工业上觉得太玩具,学术上不care的东西,用处其实并不大。但是,我觉得我喜欢的东西基本都是这种工业上和学术上都用不上的东西(比如一些奇怪的没用的code trick(比如强类型i18n解决方案啥的……),这也挺矛盾的……

盼望开学、开学和毕业旅行

这段时间内,除了写毕设,还有一件重要的事是盼望开学。1月希望2月开学,2月希望3月开学,3月希望4月开学,最后直接拖到6月中旬,行吧。

确定开学的时候高兴死了

虽然没能在学校待到最后一个学期,但是最后在学校的这半个月也是相当充实和完美了,可以勉强称作一个完美的句号:

在这段时间我们还敲定了毕业旅行的计划。其实我并不是一个喜欢旅行的人,对旅行本身我其实并不太感冒,但是去西北听上去还可以,因为西北的自然风光确实是其他地方所没有的。

一些旅行照片

离别

其实离校的时候我并没有感到离别的伤心,但是在毕业旅行之后,我因为看牙又去了趟南京,在南京走过学校的时候,突然涌上来了一股伤感的情绪。这段假期内,我也每周和老乡同学在同一个商圈见面吃肯德基,而前几天当老乡同学开学走了之后,我也感到了一丝失落。

几个月前,我们还在畅想回校之后要做的各种事情,想到毕业旅行还能见面,而现在这些遮盖离别的美好的愿望已经实现了,剩下就只有离别了。当时和我一起玩耍一起欢笑的人已经离开了,一个人站在同样的地方,就有一种物是人非的感觉。

我记得,高考完当天晚上的饭局已经凑不齐所有人了。谁又知道,下次大学的兄弟们凑齐,又是什么时候呢?就算凑齐了,每个人有自己新的经历,有自己新的人际交往圈,我们也回不到这几年了。

本科四年:最美好的四年

每个人对自己的本科四年都有不一样的感受,对我来说,本科四年是我人生中最美好的四年,前无古人,可能也后无来者了。

认识的人

我很幸运能在大学期间认识这么多的朋友。

比如我的室友和"酒肉朋友",我大学第一次才住校,但是很幸运遇到他们,和他们呆在一起我感觉很放松,和朋友们出去出门、散步、吃饭,让我的大学生活完全没有了孤独。

比如比赛、项目的队友,和志同道合的人一起努力让我感觉非常地充实和有动力。

比如社团的同学。虽然我们微软学生俱乐部是个小社团,每年搞的活动也都是比较简单,也没那么多的乱七八糟的利益关系,但是还是非常高兴有这么多的同学能够一起,在繁忙的学习之余,为社团的建设出一份力。

比如一起打羽毛球的伙伴。虽然我羽毛球水平非常的业余,但是有人一起打球就是一件非常让人放松的事情。在大三下非常忙的那段时间,我们也基本每3-4天打一次球,这段时间里,打球就是肝各种作业之余最让人期待的事情了。

除此之外,还有认识的ingresser、外校的俱乐部伙伴等等。和各位同学和朋友一起学习、一起研究项目和题目、一起参加比赛、一起玩耍、一起欢笑,让我的大学生活成为我目前人生中最美好的一段回忆。

合影

体验的事

我依然能清晰地记起这四年经历的事情。很多在大学的经历,现在想起来也激动人心。

我记得:

这些难忘的大事之外,我却对一些看似不起眼的小事记忆犹新。

我记得:

这些小事的记忆有的完整有的模糊,但是只要想起这些小事,我就想回到了当时一样,又重新体验了一番。

自由

在拍学院的毕业纪念视频时,有一个问题是用一个词概括大学生活,我的回答是这样的:

自由。

整个大学期间,除了课内作业,我选择去做的事,大都是我自己想去做而做的。社团、志愿活动、各种比赛等等,都是我自己想去做而去做的。

这些自由的尝试和亲身体验让我真正知道了我喜欢做哪些,不喜欢做哪些,对我还是很有作用的。

很感谢学校和学院能够给我们如此大的自由度,去追求和探索自己真正想做的事情。

这没有yygq。

如果从功利的角度来看,我在大学期间其实缺失了很多东西:没有参加学生会团委等、没有去过实验室、没有做过科研、没有参加很多拿得出手的比赛和实际项目、没有参加三四个实习……

但是我能很自豪地说,我在大学期间做的事情,都是我自己内心想做的才去做的。

虽然这可能确实让我错过了一些机会,但是我在我想做的地方做到了do my best,也通过体验各种事情,“去追求和探索自己真正想做的事情”,而不是把时间花在自己不想做的、却“有意义”的事情上。

后无来者?

这大学四年让我回忆如此深刻如此难忘,不仅是因为它很美好,更因为可能这种美好以后也不会再有了。

体验的事:研究生期间以及工作后,绝大部分时间都会投到自己的学业和工作上,不会有太多时间和兴趣参加其他活动,尤其是一些“无意义”的活动。

认识的人:当然,研究生和工作期间也会认识新同学新朋友,但是由于参加的活动将会减少,社交面会急剧变窄;而且,研究生期间的同学和以后的同事都是成年人了,都懂了自己想要什么,都有了自己的安排,都有了自己的人脉圈甚至家庭,不能像本科时的兄弟们一样时时刻刻泡在一起。

自由:这就完全不可能了:毕竟研究生期间主要工作就是实验室工作,工作时也是老板想什么时候就得做什么,喜欢做得做,不喜欢也得做。

另外,本科这四年,我喜欢的事和我的目标是重合的。所以这四年中,我能够全身心地做我喜欢的东西,并且没有感到丝毫的后悔。可是从现在起,事情似乎发生了变化,这种完美的匹配似乎消失了。

研究生及未来:未知和迷茫

我从小时候开始对计算机感兴趣,一直到本科毕业时,我都有一个明确的目标:从事计算机行业。

高中的时候,我就确定了学软件工程,甚至把软件工程志愿填在了计算机科学之前(当然,反正也上不了计科);大学前几年,我甚至完全以工作为主要目标,完全没有考虑读研的事情。

有目标、有热爱的人是幸福的,不迷茫的。因为我对计算机的喜爱,我从初中的时候就开始折腾编程,并且乐此不疲,虽然没什么成绩,但是客观上打下了一些底子,对行业比其他人更早有了认识;因为有了从事计算机行业的目标,所以在做项目的过程中我能比996更夸张的投入,因为我知道我喜欢做这个东西。我一直想着,大四毕业后,能够加入微软,开始全身心地做自己喜欢的事情。用轮子哥的话说:有人给钱让我做我喜欢的事情。岂不美哉?

但是,在加入微软前一刻,我出于一些理由,偏离我畅想了十几年的生活,选择了读研。

虽然很多人都在祝贺我去了北大,绝大多数人都认为读研是一个必须的选择,大家都认为我有一个光明的前途。

但是,我第一次感受到了未知,并且感受了未知带来的迷茫。

逃避和防御性的读研理由。

下表是我本科时读软工的理由以及和读研究生的理由的对比(非一一对应关系)

本科时读软件工程的理由读研究生的理由
喜欢国际形势恶化,微软等跨国公司在国内的发展会受到限制
软件行业发展很快学历通货膨胀,本科学历在之后不够用
软件行业能够帮助我们生活得更好,提高生产力看看这行业有没有其他道路

可以看到,我读研的理由都是逃避和防御性的:我读研不是我想做什么,而是我不得不做什么

而这样的理由是不能支撑我继续本科时、甚至小时候的那种热情的。

这种逃避,也让我失去了目标,失去了方向。

我不知道……

我不知道我读研的目标。

我不知道我应该研究什么方向,选什么实验室。

我不知道以后去哪儿,去做什么。

这真的是我想要的吗?

刚过去的四年中,我有同行的同学和朋友,有(自认为)坚定的目标,有足够的动力去走我自己想走的道路。这四年,虽然不能说我毫不后悔,但是我能肯定地说,我过出了我自己想过的生活。

而现在的我,没有目标,没有研究方向,用单薄的防御性的理由就选择了读研。

未知和迷茫,这真的是我想要的吗?

使用X11 Forwarding在WSL 2中运行GUI程序

2020-06-04 16:15:00

WSL 2使用GUI

众所周知,WSL 2开始使用真正的Linux内核,所以理论上来说,我们已经可以在WSL 2运行几乎所有的Linux程序,包括带有GUI的Linux程序。但是目前来说,由于第一方X11 Server的缺失,WSL 2主要还是在命令行中使用。

Build 2020上,微软已经宣称开始做WSL 2的GUI支持,并已经给出了宣传图。

Build 2020上WSL 2的GUI支持图片(来源:https://devblogs.microsoft.com/commandline/the-windows-subsystem-for-linux-build-2020-summary/#wsl-gui)

但是,事实上,借助X11 Forwarding,我们现在已经可以做到类似的效果了。

如果你之前折腾过在WSL上跑GNOME等各种桌面环境,则这篇文章的原理和那些文章是一致的,但是由于不需要启动桌面环境,其使用更加方便,各种Linux程序窗口和Windows的集成更加无缝,个人认为实用性比启动一个Linux桌面环境再在里面启动程序更强。

下图为跑在WSL 2上的IDEA、通过IDEA启动的JavaFX程序与Windows计算器共存的截图,注意看到状态栏上的窗口图标的IDEA图标,完美与Windows进行兼容。

效果图

X11 Forwarding

X Window是目前Linux上使用得最为广泛的窗口系统。虽然它有各种各样的问题(包括不支持多个显示器不同DPI的硬伤),我在使用Linux桌面的时候也经常推荐Wayland作为代替,但是其X11 Forwarding的特性是相当的有用。

下图是X11的架构。

X11架构(来源:https://en.wikipedia.org/wiki/X_Window_System_protocols_and_architecture)

在X11系统中,显示GUI的程序(Client,包括各种浏览器、IDE等的程序)和用于真正负责显示、以及捕捉键盘、鼠标等输入的服务器(Server)是分离的,且二者之间通过网络通信的。

对于普通的Linux系统,其Server和Client是跑在一台机器上的。但是,由于二者之间是通过网络进行通信的,所以X11窗口系统不需要Server和Client运行在同一台机器上。

所以,我们可以在其他电脑(甚至不需要是Linux)上跑一个X Server,然后通过配置DISPLAY环境变量,让X Client和位于网络上的X Server相连,就能让在一台电脑上运行的程序的GUI显示在另一台电脑上。

这就是X11 Forwarding,将一台电脑的程序的GUI Forward到另一台电脑上去显示。

配置

X11 Forwarding的原理很简单,配置其实也很简单。其分为两个大部分:Windows端(Server端)和WSL端(Client端)。

Windows端

在Windows端需要安装一个X Server。

X Server有多个,我比较建议vcxsrv(名字记忆:vc x server),因为其功能和操作比较简单。

可以从上面给的github链接进行安装,也可以通过scoop进行安装,scoop安装命令如下:

scoop install vcxsrv

安装成功后,在开始菜单或者其他启动器启动新安装的XLaunch,会弹出一个向导以配置X Server的属性。在一步选择Multiple windows,第三步选择Disable access control,其他不变。

第一步:选择Multiple windows

第二步:什么都不选

第三步:勾选Disable access control

第四步:确定以启动

最后在状态栏可以看到一个新的图标,鼠标悬浮上去可以看到其地址,其格式是{电脑的hostname}:{地址},记住后面这个地址。例如说,下图中,其地址为0.0

状态栏图标和地址

WSL端

WSL端需要做两个工作:

设置DISPLAY

DISPLAY环境变量用来指定X Server的地址。由于WSL 2使用虚拟机,其IP和Windows不同,所以需要进行一些特殊方法来访问Windows上的X Server。

在.bashrc或者.zshrc里加入以下代码,然后重新进入WSL。

# 若安装了Docker for Windows,且启动了WSL 2后端
export WINDOWS_HOST="host.docker.internal"
 
# 若没有安装Docker for Windows,则可以从/etc/resolv.conf中读取Windows的IP
# 这个IP有可能会变,所以不能直接一劳永逸。
# export WINDOWS_HOST=$(grep nameserver /etc/resolv.conf | awk '{print $2}')
 
# 可以尝试使用Windows的hostname,未尝试过
# 但是我在Hyper-V虚拟机中使用hostname访问Windows有时候会遇到奇怪的卡住的问题,不推荐
# export WINDOWS_HOST={你的Windows的hostname}
 
# 下面的"地址"替换为之前记住的Windows上的X Server的地址,一般(以及上面的例子)是0:0
export DISPLAY="$WINDOWS_HOSRT:地址"

配置字体

由于WSL的distro一般都比较简单,可能没有安装字体以及对应的配置,所以需要自己手动安装字体。

这里以noto-sans-cjk举例,你可以自己安装自己想要的字体。

字体安装后不能直接启动GUI程序,现在直接启动会遇到奇怪的报错问题(忘记截图了,可以自己试试)。

可以安装一个简单的X程序来初始化,并验证X11环境是否已经配置成功:如xclock:

sudo pacman -S xorg-xclock

使用xclock测试是否能够正常使用

安装之后,确认DISPLAY变量已经设置后,输入

xclock

若弹出一个时钟窗口,则配置成功。

xclock

效果

现在你可以开始在命令行里启动各种程序了:

Intellij IDEA在WSL 2上启动IDEA比Windows上快多了,我正在考虑将整个Java环境迁移到WSL 2中。

Intellij IDEA

VSCode也可以以Linux窗口来启动,其启动速度也比Windows快多了:

VSCode

在设置中设置以下后VSCode可以隐藏标题栏,但是这样之后无法拖动窗口,最大化窗口后也无法还原,所以不建议。

{
  "window.titleBarStyle": "custom"
}

VSCode隐藏标题栏

一些其他细节:

限制

这样的解决方案虽然看上去很完美,但是有一些限制,但是可用性也非常高了:

后续启动

以后重启电脑后,只需要重新启动XLauncher,然后在WSL中启动GUI程序即可使用。

延伸和总结

这套配置也不仅限WSL,任何虚拟机和远端电脑上的Linux应用程序均可以使用SSH和X11 Forwarding进行连接。

使用SSH和X11 Forwarding可以解决很多远程桌面相关的问题。

我们很多时候使用虚拟机安装Linux系统,其实并不是要使用Linux的桌面环境,而是使用Linux的命令行和一些GUI程序。

对于命令行程序,使用SSH完全可以解决;

而对于GUI程序,我们也可以通过这篇文章介绍的X11 Forwarding来解决。

和正常的连接到Linux再进行操作相比,SSH和X11 Forwarding的优势其实很明显:

对于WSL来说,目前的解决方案仍然有一些限制,但是我们可以发现在Build 2020上的效果图已经没有的标题栏,说明微软也正在解决一些X11和Windows集成的问题。相信当微软的X11集成正式发布时,我们在Windows上使用Linux程序的体验,可能比在Linux上使用Linux程序更好了。

在小手机已经消失的时代,我选择了Galaxy S20

2020-05-15 15:30:00

小款手机已经消失了

我记得,当我第一次拿到一台HTC Desire HD(G10)时,那4.3寸的屏幕带给我的震撼。

我记得,当三星刚推出5.3寸的Galaxy Note时,众人对这“巨大”的屏幕的感叹。

我记得,当苹果第一次推出4.7和5.5寸的iPhone 6和6s时,网络上对未来的iPhone的调侃。

iPhone 20(图片来源:https://www.zhihu.com/question/25216924/answer/3035342)

可是,从2018年起,手机越来越大、越来越重的趋势无法阻挡了。

或者更准确地说,小款的手机已经消失了。

2017时,一种宽度在70mm以下,重量在160g左右的版本占据了手机市场的半壁江山。在16:9屏幕的非全面屏时代,这个尺寸大小的手机一般为5寸屏。而占据另一半的手机,宽度普遍在75mm左右,而除了iPhone的Plus款以外,大款的重量也一般控制在180g左右。在非全面屏时代,这个尺寸一般是5.5寸屏。

年份手机型号高度(mm)宽度(mm)厚度(mm)重量(g)
2017小米6145.1770.497.45168(玻璃后盖)、182(陶瓷后盖)
2017华为P1014569.36.98145
2017华为P10 Plus153.574.26.96165
2017三星S8148.968.18.0152
2017三星S8 Plus159.573.48.1173
2017iPhone 8138.467.37.3148
2017iPhone 8 Plus158.478.17.5202
2016一加3152.774.77.35158

而到了2020年,旗舰款手机的三围和重量却变成了下面这样。宽度除了S20已经没有低于70mm的了,而同样除了S20之外,所有手机的重量都在180g左右(P40)或以上。

年份手机型号高度(mm)宽度(mm)厚度(mm)重量(g)
2020小米10162.674.88.96208
2020华为P40148.971.18.5175
2020华为P40 Pro158.272.69209
2020三星S20151.769.17.9163
2020三星S20+161.973.77.8186-188
2020三星S20 Ultra166.9768.8222
2019iPhone 11150.975.78.3194
2019iPhone 11 Pro144.071.48.1188
2020一加8160.272.98.0180
2020一加8 Pro165.374.38.5199
2020OPPO Find X2 Pro165.274.48.8/9.5217/200

和2017年的手机相对比,可以发现,当时占据半壁江山的宽度70mm以下、重量160g左右的小款手机,已经消失了。

临界点

手机尺寸经过这么长时间的发展,最终稳定在75mm宽、重量200g左右这个水平,说明了一件事:

这个尺寸已经是大多数人能够接受的手机尺寸的临界点

这就和屏幕分辨率一样:

但是之后,只有三星仍然在坚持使用2K屏,其他厂商除了少许机器(如2017年Mate 10),主流分辨率一直被锁死在1080P,直到2019年一加7Pro采用2K90屏幕之后才激起人们对分辨率和刷新率的关注。而很多人通过实验已经说明,在手机屏幕的尺寸上,1080P和2K屏对于绝大多数人影响不大。市场固定在1080P这一事实也从市场层面说明了这一事实:1080P已经是大多数人能够接受的分辨率的临界点。

分辨率级别时间代表机型
800*4802010-2011HTC Desire HD,小米1(2011年发布)
1280*720/8002011-2013小米2,三星Galaxy Note
1920*10802013-现在首款为2012年的HTC Bufferfly,这个时间段的几乎所有手机均采用这个分辨率
2560*14402014-现在首款为2014年的OPPO Find 7,之后只有三星在主流机型上配置2K屏幕

小手怎么买手机?

很多人能够适应这个大小,所以大多数实惠的、为了走量的手机也会采用这个尺寸以满足更多人的需求。对大多数人来说,这是好事。

但对我不是。

我空有180cm的身高,但是手的大小却在身高对应的正常范围里偏小。我的手从中指指尖到手腕长约19cm,左右最宽只有10.3cm;数字可能不直观,但是在电子琴上,我最大只能按下9度:一个普通的170cm左右身高的人也应该能按下9度。

由于知道这个事实,同时因为我对单手操作一直情有独钟,所以一直以来,我的手机都尽量选择的是小款,宽度控制在70mm左右。而2016年一加3从反面证明:对于这样的手掌大小和使用习惯,75mm左右的宽度太宽了。

年份手机型号高度(mm)宽度(mm)厚度(mm)重量(g)
2013Google Nexus 5137.8469.178.59130
2014HTC One M8146.470.69.4160
2016三星S7142.469.67.9152
2016一加3152.774.77.35158
2017小米6陶瓷145.1770.497.45182
2019小米9 SE147.570.57.45155

下图两张图为小米9SE(宽度70.5mm)和iPhone 11(宽度75.7mm)在正常单手持机打字时的区别,可以体验到9SE单手打字会比iPhone 11轻松很多。两张图里手姿不太一样,这是我试验过后得到的握持两个手机最正常的姿势:若将9 SE的姿势用在iPhone 11上,确实可以更轻松地够到另外一边,但是那时候手机已经拿不稳了。

小米9SE(宽度70.5)轻松单手打字

iPhone 11(宽度75.7)有点吃力

2020年选择

2019年中,我的米6突然出了问题,电源键卡住,机器完全无法使用。正好,米6的夜拍确实比较垃圾,我正好有换手机的欲望。于是,经过一番对比,我在米9和米9 SE中选择了米9 SE,其中最重要的原因,就是尺寸:米9的宽度为74.67mm,9 SE的宽度为70.5mm。另外,我本来打算在2020年换5G手机,9 SE便宜的售价正好可以用来当作用一年的过渡机。所以当时走到了学校旁边的一个小米之家,现场提了小米9 SE。

(找了很久还是没找到图)

刚用9SE的时候还是比较舒服的,因为全面屏和6的16:9还是区别很大的;但是它垃圾的震动马达(官方说是线性马达,但是明显感觉有开始震动的过程,感觉就是转子马达)、羸弱的处理器和硬件配置(SoC是712,连米6米的835都不如,日常操作比米6慢多了)、以及升级MIUI 12的夸张的掉帧让我不得不换手机,并且确定只能买旗舰CPU的手机。

而2020年,满足我的手要求的宽度的要求的旗舰手机(其实可以去掉旗舰手机的要求,因为连红米等都早已没有了小手机),只有以下几款可选:

型号高度宽度厚度重量亮点缺点价格
三星S20151.769.17.9163轻薄小,堆料足较贵,电池较小,软件本地化比MIUI弱,且后续更新慢5150(港版,4月29日,12+128)
华为P40148.971.18.5175软件体验更贴合国内需求堆料太少,阉割太严重,无线充电、高刷都没有4488(8+128)
一加8160.272.98.0180系统简单,价格较便宜,外观还行没有无线充电3999(8+128)
iPhone SE 2138.467.37.3148很轻薄很小巧,而且还有A13处理器,还便宜不想进入苹果生态,除了处理器之外的配置都复古3799(128G)

在这几款手机中,对我来说P40、一加8的问题是一样的:它们都阉割了无线充电,并且华为P40连高刷屏都阉割了。

而根据我的使用习惯,无线充电能够给我带来很大的好处:我基本一直坐在电脑前,无线充电使得手机可以一直放在充电板上,随时保持满电,并可以随用随取,比插拔线缆更方便。所以自从2014年开始一直没能用上无线充电的我,这次再怎么说也得用无线充电的手机。

其实……P40也有无线充电?(https://www.zhihu.com/question/383288772)

另外,高刷屏确实是用了就回不去的那种东西,60Hz到90Hz的体验变化是巨大的,而90Hz和120Hz倒是可以不太在意。

而S20在这里面,堆料是所有手机里最猛的,无线充电、120Hz高刷屏、4000mAh电池一应俱全,并且更难得的是,S20的三围和重量还是这些手机(除了SE)外最小最轻的。

S20在国内的一个最大的劣势是价格:6999的起步价,对于一个Others厂商来说,确实有点过分了。

但是嘿,买三星为什么要买国行?港行刷国行固件,它不香吗?😄️

4月29日的价格,5月15日已经到5050了

Galaxy S20简单体验

大小对比

下图为S20和小米9SE对比,可以看到其实二者宽度差不多,但是高度上S20会高上一头。从数据也可以看出这个区别。

型号高度宽度厚度重量
三星S20151.769.17.9163
小米9 SE147.570.57.45155

小米9SE(左)和三星S20(右)对比

S20的宽度也保证了就算戴着保护套,也可以单手打字:

S20带保护壳单手打字

One UI

y1s1,确实没有任意一个Android UI可以和MIUI相比,尤其是当MIUI 12发布后,那动画效果、那设计、那功能,啧啧。

但是其实One UI也挺好用的,各种功能也比较完善,设计也比较质朴耐看,动画也和MIUI 11差不多(当然比不上MIUI 12),当作日常使用也是非常合格的,配上120Hz屏幕和865处理器自然是丝版顺滑。

One UI主界面

负一屏就比较让人意外了:可以快速打开微信小程序这个功能比我想像中的有用多了;还有快速打开扫吗、车票追踪、快递追踪等功能。Samsung Pay中还有各种公交卡、支付方式等。三星这种在国内都Others的厂商居然还做了这些贴心的本地化功能,让我感到很意外。

One UI快速打开微信小程序和快捷方式

One UI车票追踪

另外本来说One UI的照片应用可以使用OneDrive同步,但是国行没有,应该是被阉割了。但其实影响不大,只要关掉三星的备份、单独使用OneDrive应用备份就行。

链接到Windows

链接到Windows。是微软推出的同步工具,用起来比较折腾,首先电脑和手机都得翻墙、然后把Windows的区域设置到美国,而且功能也挺一般,只有同步通知、同步照片等功能,而且因为要经过互联网,所以同步速度堪忧。

小米9 SE使用你的手机和Windows同步

本来所有Android手机都可以用,但是微软对三星的手机开了小灶,额外支持同步手机屏幕和在电脑上打电话两个功能。

打电话可以直接将电脑当作一个蓝牙耳机,从而实现不拿手机打电话,有电话来的时候还有提示。

在电脑上接打电话

同步手机屏幕功能和华为的有差距,最大的问题就是同步手机屏幕输入的时候是调用的手机输入法而不是电脑输入法,这带来了体验的割裂。另外,我也只有一次成功了投影了电脑屏幕,后续都没能成功(手机端有提示已经投影,但是电脑无法显示),不知道为什么。

在电脑上控制手机

注意,国行的系统阉割了这个功能,国行三星要想使用这个功能,需要单独装一个APK(Link to Windows Service,apkmirror链接),然后再装Your Phone Companion。

说实话这个功能还是有点鸡肋的,通知同步还要过互联网就离谱,使用体验也不好。这个方面华为和苹果确实还是做的最好的。

5G

港版S20刷了国行固件后可以完美支持5G,正好我家附近有5G信号,于是体验了一下5G杀手级应用:测速,结果如下。对于联通,不办5G套餐不换卡可以直接连接5G,但是限速到300Mbps。

我个人认为5G之前吹得太过了,所谓低延迟啥的对普通消费者“感知不强”,而且使用5G而非4G对于我这种在手机上视频都不看的人来说都是”徒增功耗“,这一代的5G也是功耗杀手。所以,目前来说,看看就好。建议下一代再买5G手机,到时候肯定价格会下来(今年手机价格太夸张了),而且也能做的更轻薄更省电。

5G测速结果

电池

虽然S20有4000mAh的电池,但是在865和1080P120屏幕下还是不堪一击。

我的日常使用习惯是这样的:

在这样的使用习惯下,手机从100到0应该只能8个小时,其中亮屏2个半小时左右。

说实话,这个续航真的挺一般的。希望以后注销一张卡,以及日常把5G关掉可能会好一些吧。

当然普通用户如果不用Google Play、不用蓝牙、不用双卡的话,应该会好不少。

注意中间的陡坡,那时候一直在外面,亮屏时间只有1个小时左右

其实这也是这代5G的毛病,看现在几乎所有手机的电池都4000mAh左右或以上,高端机都一般5000mAh。做这么大,一个原因也是目前5G不够成熟,功耗太大。所以,不想尝鲜的同学还是明年再换机吧,等等党永远胜利。

其他细节

后续:卖了又买了

根据上文所说,S20似乎已经满足了我对智能手机的一切需求。那为什么我还是把它闲鱼出了呢?

问题就在硬件和软件支持上。

三星在国内的市场占有率一律下滑,这并不可怕,可怕的是三星在国内的硬件和软件支持出了问题,而且这个情况只会逐渐恶化,不会变好。

这几点是一个死循环:用的人越少,厂商对于软件和硬件支持的程度就会越低,这样又会让用户减少。更别提国产厂商的飞速进步了。

在用S20这一个月中,我一直在想这些问题。最终,还是让我在使用不到1个月之后,把它从闲鱼出了回血,等待明年新一代硬件出来之后再尝试换机。

转眼间到了2021年,我的小米9SE实在已经撑不住了,且不说2个小时的亮屏时间,一些正常的功能也因为性能不足运行缓慢甚至无法进行。例如北大需要在微信小程序上进行人脸识别的注册,注册时需要调用手机摄像头拍一张照然后回到小程序界面。但是在9SE上,拍完照之后回去发现微信小程序已经被杀了,又回到了初始界面,使得我完全不能使用手机摄像头注册。

趁着暑假,去京东线下店体验了一些小手机(一加8、OPPO Reno 5 Pro+、S21、小米11等)发现,小米11所谓的“轻薄”却仍然有74mm宽度的机身让我完全无法接受,72mm宽度的一加8和Reno5Pro+就已经超越了我可以掌控的范围了,而S21相对于S20的升级实在是太小,反而因为变宽了2mm和平面屏影响了手感(背盖塑料影响不大)。最后转了一圈下来,居然S20还是现在最香的产品……

再想到今年骁龙888差强人意的表现以及国产厂商仍然到稍小一点的屏幕的手机的需求的忽视,今年(甚至以后)可能都只有三星和苹果会稍微考虑一下小屏手机的用户了,所以反手从闲鱼上拍了一个3800的S20国行,重新回到S20用户的行列。

闲鱼

总结

在这个小手机消失的年代,总有一些“前朝遗老”想着回到那个单手就能掌控手机的时代。这个群体的人数,说大不大,手机厂商都不愿投钱做这个市场,上一个坚持这个市场的SONY Compact系列已经死了;但是这个群体的人数说小也不小,iPhone SE 2推出时,也有很多剁手党直接就剁手了。iPhone 12 mini也为小屏手机带来了一场讨论。

我也是这样的前朝遗老。如果要让我来确定我对手机的要求,那么我的要求如下:

  1. 宽度小于72mm,71mm以下最好
  2. 电池能用一天,亮屏5小时以上
  3. 带有无线充电
  4. 相机、震动马达等外围配件不要阉割地太过分
  5. 当年性能最强的、没有挤牙膏的次旗舰处理器

当前满足这些需求的产品有iPhone 12、iPhone 12 mini、三星S20和S21。很可惜,在现在和可预见的将来,这样的产品可能也就只有三星和苹果可选了。

安装Arch Linux并使用N卡玩Steam游戏

2020-04-25 13:01:00

切换到Linux

最近Windows在我电脑上不知为何响应很慢,非常影响我使用电脑的体验,甚至在用WSL2时,输入命令都出现了卡顿。

而一般来说,Linux相对于Windows,一个很大的好处更节省资源,并且在运行一些程序时效率更高,比如VSCode、JetBrains系IDE、emacs等等开发者常用工具在Linux上的启动速度和Windows完全不是一个等级的。

所以我产生了在实体机上装Linux的念头。但是由于主力笔记本有时候还是需要使用Windows下的一些软件的,所以我选择在家里的一台2013年的老电脑上安装Linux。

之前也折腾过很多次Linux,但是无一例外,之前的Linux要么是虚拟机,要么是Intel核显的笔记本。这台陪伴了高中三年的游戏生涯(咦)的老电脑有一块GTX 660 Ti,虽然以现在的眼光看660 Ti很弱鸡,但是在当时(2013年),它还是一个可以稳稳玩战地4这种“新”“显卡危机”游戏的显卡呢,前段时间还用这台电脑通关了Ori and the Will and the Wisps。同时,查询资料发现,已经有一些开源项目在尝试在Windows下的应用和程序运行在Linux下,并在Steam的努力下,Linux已经可以玩一些Steam游戏了。

所以,这次我决定自己试试,看看Linux下玩Steam游戏,到底怎么样。

安装Arch Linux和配置

自从上次尝试了Manjaro后,我就算踏入了Arch邪教大坑,用过Arch的官方库和神器AUR之后就完全回不去Ubuntu等Debian系的包管理了,什么东西都能直接sudo pacman -S从官方库安装,如果官方库没有,那就yay -S从AUR安装。再加上极度丰富的Arch Wiki,Arch系Linux发行版的使用起来真的是舒服。之前Manjaro只是浅尝则止,这次折腾之前想了想,为什么不试试正版Arch Linux呢?

很多人都说Arch Linux难装,其实最大的障碍是Arch Linux的ISO没有一个图形化的安装程序,只提供了命令行界面,这就让很多Linux用户很为难。其实,Arch Linux官方是提供了一个安装教程的,其实只要随着这个安装就可以了,实话说,安装过程还不如后面配置过程耗时间。

这里记录一下安装过程中的一些教程里没有提到的事情以及容易错的东西(适用于UEFI系统):

我选用的一些重要软件包如下:

screenfetch截图如下:

screenfetch信息

驱动

安装完后,我尝试直接用内核自带的开源驱动启动Steam下的游戏,但是没有启动成功,点了开始后没有反应。但当时我是在Wayland下使用的,不知道是驱动的原因还是Wayland的原因。但是由于当时本来就是试试,发现不行就直接准备上NVIDIA的闭源驱动。

都说Linux上的闭源N卡驱动比较坑,但是根据我的测试,在单个显示器的情况下,在Arch Linux上其实问题不是很大。万能的Arch Wiki不出任何人所料地有一篇关于NVIDIA显卡的Wiki,安装上面做一轮,一般就能解决大部分问题了。

简单来说,对于一个纯NVIDIA显卡的系统(即不是笔记本平台的Optimus),其实只需要做以下两步:

  1. 先在/etc/pacman.d/mirrorlist里开启multilib以装32位包:去掉这两行的注释:
[multilib]
Include = /etc/pacman.d/mirrorlist
  1. 运行以下命令安装包,安装完成后重启电脑。
sudo pacman -S nvidia lib32-nvidia-libgl nvidia-settings

第一个nvidia包就是臭名昭著的NVIDIA的闭源驱动了。理论上来说,装了这个驱动后重启应该就可以用了,但是……

第二个lib32-nvidia-libgl是必须装的,不装的话,重启之后会发现虽然驱动正常加载、系统可以正常进入,但是会发现一些奇怪的问题,比如:

使用控制台启动Steam的话可以看到如下报错:

libGL error: No matching fbConfigs or visuals found
libGL error: failed to load driver: swrast

网上查了一些资料后发现了这个lib32-nvidialibgl包,装上问题就解决了。

第三个是NVIDIA控制面板,和Windows上的NVIDIA控制面板比较类似,装上后可以通过GUI查看一些信息和进行一些设置等,截图如下:

NVIDIA控制面板

驱动安装后最大的变化是:不能使用Wayland进入GNOME了,只能使用X11。对我来说Wayland相对X11最大的优势并不是性能,而是Wayland可以对不同显示器设置不同的DPI,而X11只能有一个全局的DPI。X11果然还是太老了,DPI只能全局这个XX设定就很离谱,尤其是对需要连接外接显示器的高分屏笔记本,基本就无法使用。但是对于台式机还好,如果没有不同DPI的显示器的话,X11其实也能用。

X11

BTW,根据我最近的体验,Wayland在GNOME上的体验已经非常好了,完全可以日常使用,个人认为要是没有游戏需求的话,建议能用Wayland就用Wayland,X11还是太老了。

另外,Manjaro,Ubuntu等一些发行版设置自带了管理显卡驱动的工具,如果不是执意要Arch Linux的话,完全可以选择这些自带管理显卡驱动的工具的发行版。这些工具似乎还可以很方便地配置Optimus双显卡的情况,非常值得推荐。

Steam和Proton

Valve虽然一直不会数3,但是在Linux游戏领域还是做了很多贡献的。之前推出的SteamOS虽然目前发展状况不佳(我连ISO都找不到?),但是它推出的Proton工具还是非常给力的。

Proton是一个针对游戏的、Linux下的Windows的兼容层,基于Wine、DVXK(在Vulkan上实现的Direct X)等开源项目。它能够让一些Windows独占的游戏跑在Linux上。虽然Wine做了这么久,还是效果不怎么理想,但是Proton的发展却异常不错,很多游戏已经可以Linux可以运行了。虽然效率有损失,但是可以运行就是一个胜利嘛。

Proton甚至可以有一个网站ProtonDB,记录了各种游戏在Proton下的兼容情况,可以发现其实很多流行游戏:例如CSGO、GTA5等已经可以在Proton下较好的运行了。

ProtonDB

所以,我安装好Steam后,第一件事就是去设置里打开Steam Play,让所有游戏都可以尝试在Proton兼容层下运行。

开启Steam Play

重启Steam之后,所有游戏都可以进行下载并尝试运行了。作为测试,我下载并打开了红色警戒3(中学时期一直是RA3粉,最近看了一些新MOD和解说后又想继续玩了)。

刚打开时,会有一个Steam Play提示说游戏运行在兼容层里。

Steam Play提示

确定后,Steam会装一些DirectX等游戏依赖库,然后就开始运行游戏了。RA3在点击运行到游戏中途启动用了好几分钟,当我差点失去耐心后,游戏突然蹦了出来!

RA3

设置分辨率,打开分辨率,选择阵营,进入游戏,可以正常游玩,并且性能并没有可见的损失。

RA3遭遇战

我让我很惊喜。退了RA3后,我从我的Steam库里找了几款游戏并尝试运行,运行情况如下:

游戏情况
Red Alert 3完美运行
Call of Duty Modern Warfare 3无法运行。打开运行后过段时间自动退出了
Ori and the Will of the Wisps完美运行,而且启动速度比Windows快
Halo: Master Chief Collection可以运行,但是性能比较低,开启垂直同步后都仍然有画面撕裂的问题
Crysis 2可以运行,但是性能损失似乎比较大,而且窗口运行退出后,GNOME侧边栏、顶栏等失去了鼠标响应,键盘可以用,重新登录可以解决

Ori and the Will of the Wisps

Halo: Master Chief Collection

Crysis 2

总结

虽然Proton的支持仍然比较初级,但是这么多的游戏已经可以运行,开源社区的力量真的非常强大。

游戏之外,总体来说Linux的运行速度确实比Windows快,打开IDEA、VSCode、Emacs等应用比在Windows上快了不知道多少,资源占用也更少。我甚至可以在Linux上同时运行我的毕设系统、一个3台虚拟机组成的OpenStack系统、VSCode和多个浏览器,同时保证系统仍然可以流畅的响应。之前在Windows上这么运行,系统早就卡的不能用了。至于生态,在深度等公司的努力下,一些国内的毒瘤应用,例如QQ、微信等,也可以在Linux下运行地比较好,国内的Linux生态其实早已没有那么贫瘠了。

最后,不管一个产品有多好,只要它垄断了,它早晚都会做出一些不利于消费者的事,Windows也不例外。所以作为一个普通的用户和消费者,即使可能长久Windows,但是Linux在游戏、生产力等各种领域的发展和补足也会让微软感受到压力,从而推动整个行业的进步。

使用PowerShell脚本让UWP应用使用localhost上的系统代理

2020-04-25 23:40:00

问题

众所周知,国内微软服务的连接性很成问题,并且这个问题越来越严重了,所以必须要让这些应用通过梯子才能正常使用微软服务。

梯子一般都会设置系统代理,其原理是在本机localhost的某个端口上监听网络请求,并将请求发送到服务器。绝大部分普通的Win32应用都会遵循系统代理的设置,包括OneDrive桌面应用等,所以这些以Win32应用形式存在的应用程序可以很简单的解决。

但是微软的很多应用,例如自带的Windows商店、邮件、照片等,都是UWP应用,而UWP应用禁止了将网络流量发送到本机(localhost),所以UWP应用是不能直接使用这种系统代理的。

手动解决方案

微软同样自带了一个工具CheckNetIsolation.exe(可以直接在powershell里运行),可以允许选定的UWP应用解除网络隔离,使用localhost的系统代理。

听起来问题解决了?

如果查看这个工具的帮助界面的话,会发现这个工具需要应用的名称(-n)或者ID(-p),但是这些信息需要从注册表中获取,非常的麻烦。想要这样进行操作的用户可以尝试少数派的这篇文章

➜ CheckNetIsolation.exe LoopbackExempt -?

用法:
   CheckNetIsolation LoopbackExempt [operation] [-n=] [-p=]
      操作列表:
          -a  -  向环回免除列表中添加 AppContainer 或程序包系列。
          -d  -  从环回免除列表中删除 AppContainer 或程序包系列。
          -c  -  清除环回免除的 AppContainer 和程序包系列的列表。
          -s  -  显示环回免除的 AppContainer 和程序包系列的列表。

      参数列表:
          -n= - AppContainer 名称或程序包系列名称。
          -p= - AppContainer 或程序包系列安全标识符(SID)。
          -?  - 显示 LoopbackExempt 模块的此帮助消息。

完成。

自动化方案

巨人的肩膀

我们作为一个合格的程序员,看到这种事情的第一反应,应该是:我太懒了,我们需要自动化!

所以第一件事就是去网上找,果不其然找到一个dalao的文章,里面给出了一个Python脚本,可以自动做这个事情。

但是反正这个工具都是在Windows上用,为什么要用Python?用PowerShell,什么依赖都不要,它不香嘛?

进入这个大佬的GitHub,可以在它的这个仓库中发现原来这个大佬也写了一份PowerShell脚本用来做这个事情。

但是当我尝试使用这个PowerShell时,发现了一些问题:

站在巨人的肩膀上

不能就这么放弃了!

于是我检查了一下这个PowerShell工具,解决了这两个问题:

针对这两个问题,我对脚本做出了改进,并加入了一个根据名字进行筛选应用的功能,以方便让用户应用列表中中快速找到想要设置的应用。

改进后的脚本如下:

param(
    [string] $Contain
)
 
$BASE_PATH = 'HKCU:\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppContainer\Mappings\'
# 获取相关注册表信息,并进行筛选和排序
$mapping = Get-ChildItem -Pat $BASE_PATH | Where-Object {$Contain -Eq "" -or $_.GetValue('DisplayName').Contains($Contain)} | Sort-Object {$_.GetValue('DisplayName')}
 
if ($mapping.Length -eq 0) {
    Write-Output "没有包含字符 $Contain 的软件包,请重试。"
    exit
}
# 格式化打印 APP List
$mapping | Format-Table @{label='Num'; expression={$mapping.IndexOf($_)}}, @{label='DisplayName'; expression={$_.GetValue('DisplayName')}}
$input = Read-Host '回复序号并回车提交(若只有一项,输入0),添加指定应用到排除列表中'
$id = $mapping[$input].Name.Split("\") | Select-Object -Last 1
Write-Output $id
CheckNetIsolation LoopbackExempt -a -p="$id"

将这段代码复制粘贴到本地一个以ps1为扩展名的文件中,假设这个文件名字为check.ps1

-> .\check.ps1

Num DisplayName
--- -----------
 ...
 54 @{microsoft.windowscommunicationsapps_16005.12624.20368.0_x64__8wekyb3d8bbwe?ms-resource://microsoft.windowscommun…
 ...

回复序号并回车提交(若只有一项,输入0),添加指定应用到排除列表中: 54
S-1-15-2-...(剩下的ID)
完成。
-> .\check.ps1 -Contain communicationsapps

Num DisplayName
--- -----------
    @{microsoft.windowscommunicationsapps_16005.12624.20368.0_x64__8wekyb3d8bbwe?ms-resource://microsoft.windowscommun…

回复序号并回车提交(若只有一项,输入0),添加指定应用到排除列表中: 0
S-1-15-2-...(剩下的ID)
完成。

另外,这里给出可以用来筛选几个常见的需要进行代理的MS自带应用的筛选参数,方便大家直接复制运行(直接作为-Contain参数的值,运行脚本时输入0回车即可):

软件筛选参数
应用商店WindowsStore
邮件communicationsapps
OneNoteOneNote
照片Windows.Photos (注意不要选择后面有DLC的那一项)

运行完脚本之后,关闭重启对应应用即可。

终于可以愉快地使用MS应用和服务了!

最后非常感谢这份PowerShell脚本的原作者!

可靠性、辅助功能和多屏协同:我对智能手表的体验和看法

2020-04-13 18:00:00

引言

我是个智能手表的老用户了。从13年开始购入我第一款智能手表Pebble开始,我的左手手腕一直都被智能手表占据着。在这篇文章中,我想简单讲讲我购买并使用过的智能手表的体验,我对智能手表的分类、作用、问题和发展趋势的分析,以及最后我对智能手表的期待的三个层次:可靠性、辅助功能和多屏协同。

我用过的智能手表

本节以下照片除了有提到引用来源之外,均为我自己实拍,标题括号里为我的购买时间。

Pebble(2013年12月)

Pebble

智能手表其实出的很早,不提20世纪那些电子表类型的“智能手表”,21世纪的第一个十年也已经有一些智能手表问世。而如果你现在去看各种智能手表的榜单(如这篇文章),Pebble绝对会在里面有一席之地。

为什么?

我认为,这是因为Pebble在智能手表的功能和使用体验上找到了一个绝妙的平衡。

Pebble的功能不是最多的。Pebble没有彩屏,没有触屏,没有Wi-Fi,没有心率检测,没有高性能的CPU,没有运动检测,没有GPS……但是Pebble有:

Pebble上玩Flappy Bird

作为一个智能手表的辅助,Pebble在保证手表的功能性的基础上,提供了消息通知、短信/电话提醒等实用的功能。即使在现在看来,这些特性也完美满足了我对智能手表的大多数幻想,并且在屏幕、续航、系统稳定性等很多方面比现在的一些主流智能手表做的还要好。

所以在当时,我对Pebble一直爱不释手,有事没事就去应用商店找有没有新的应用和表盘可以体验,它的极高的稳定性和长续航让我不用随时担心它是不是又停止了工作。Pebble也养成了我目前的智能手表的使用习惯:静音+手表消息通知。这一点将在后文详细介绍。

Pebble Time (2016年6月)

Pebble Time

Pebble公司在Pebble成功后,还推出了Steel(钢制机身)、Time(这款)、Time Round(圆形机身)等产品,从机身材质、屏幕等方面对Pebble进行一些改良。

我个人对钢制机身和表带、圆形屏幕等并不感冒,但是对Pebble Time引入的彩屏非常感兴趣。于是在2016年6月在闲鱼上花612块淘来了这个玩意。

Pebble Time引入的彩屏也算解决了Pebble的黑白屏幕这一痛点,且彩屏并没有影响反射性屏幕的可见性和续航优势,也完全没有改变Pebble的简单可靠的操作逻辑和使用逻辑,可以说是一个没有代价的升级。生态中也有一些彩屏表盘和应用出现。

Pebble Time还有一个我后来换成其他表之后才意识到的优点:线性震动马达。Pebble Time在15年已经用上了线性震动马达,在手腕上的震感非常的舒服。用过线性震动马达的手机用户应该知道线性震动马达对震感的提升是非常好的,在手腕上感觉更甚,甚至因为手表提醒用户就是通过震动马达实现的,一个好的震动马达对消息通知体验的提升比手机更为显著。当时以为各个智能手表设备都应该换成线性震动马达了,但是直到后来才发现,很多设备直到现在都在用廉价的转子马达。

Pebble公司在Time之后,Pebble还推出一些产品,如加入运动检测功能、产品外观更现代的Pebble 2, Pebble Time 2等,这些产品的Kickstarter众酬活动也不断打破着当时的记录,似乎前景看起来非常光明。但是很可惜的是,2016年12月,Pebble被Fitbit收购,从此没有再推出新的产品,已有产品的生态也组建萎缩。

从我个人角度来说,Pebble的被收购是一个非常遗憾的事实。直到现在我都一直认为,Pebble的产品是最适合我的:它维持了手表最基础的功能:看时间,并在功能性、稳定性和续航上找到了一个极佳的平衡点。虽然不知道为什么公司要选择在风头正劲的时候卖身,但是这对我们这些智能手表用户,甚至整个智能手表行业的发展都造成了很大的影响。

Microsoft Band 2(2015年12月)

Microsoft Band 2

微软在2014年10月推出了Microsoft Band系列,并在15年推出了第二代:Microsoft Band 2。作为一个软粉兼可穿戴式设备爱好者,我当然不会错过这个系列的产品,所以在2015年12月,从淘宝海淘了Microsoft Band 2。这款产品有几个优点让我非常感兴趣:

朝内的佩戴和交互方式(来源:https://www.engadget.com/2015-10-29-microsoft-band-2-review.html)

除了常规的消息通知、邮件提醒、通话提醒之外,这款手环还有:

Microsoft Band的应用,左为睡眠检测,右为步数检测

最重要的是,这款手环也是当时唯一可以和Windows Phone配对和正常使用的可穿戴式设备。由于我当时使用的是Lumia 830,可以和Windows Phone配对使用也是我买Microsoft Band 2的重要原因。

这款手环的功能相当全面,从办公相关的邮件提醒,到运动相关的运动、健康检测,可以说几乎适合任何场合、任何人群(可能应该去除当时作为高三学生的我)。并且,当时微软在通过Windows 8和Windows Phone 8构建一个独立的Windows生态系统,并且在当时看来势头良好,搭载有Cortana的这款产品将是这个Windows生态系统的一个重量成员。

这款产品的产品素质非常高,但是它较厚重,续航较短,只有1-2天;完全不支持中文,连中文字体都没有;

无法显示中文

以及最重要的,不知道是不是普遍现象的,质量问题。我的Microsoft Band在买来第二个月(2016年1月),就出现了无法充电的问题。找到店家寄回美国返修,花了一个月,结果到手后一个月后,又再次出现了同样的问题。这让我对微软产品的质量相当失望,还好信仰足够坚定,让我自己吃下了这个大亏。

另外,随着Windows生态圈的全面溃败,这款产品的“潜力”也大打折扣。Microsoft Band 3一直没有推出,其对应的在线服务Microsoft Health也在2019年5月31日被停止且删除所有资料

光从产品来说,Microsoft Band 2确实让人眼前一亮,曲面屏的设计、特殊的佩戴方式和交互方式非常有趣,其和Windows生态圈的集成也至少让我们这些软粉畅想了一波Windows生态圈的未来。但是,Windows生态圈的失败断送了产品作为生产力工具的未来;在失去了Windows生态圈后,这款产品的核心:硬核健康功能也很难吸引普通消费者花大钱(我买成1700)去购买这款产品;这款产品作为一个手环,也没有一些高价手环可以作为装饰品的作用。这样,微软手环的结局可能也不是非常让人吃惊了。

LG G Watch(2016年3月)

LG G Watch运行Ingress

LG G Watch是2014年谷歌推出Android Wear后第一批首发的智能手表,同一批首发的还有备受人关注的Moto 360。Android Wear作为当时Google的穿戴式设备的尝试,其独特的卡片式设计、与当时Android相同的Material Design、和Android的集成也是非常吸引人,开放API和应用商店也似乎预示着一个丰富的生态系统。

但是和其他可穿戴一样,Android Wear的后劲明显不足,大厂对Android Wear的支持要么比较初级,要么根本没有;小厂的Android Wear应用也很多不堪大用,再加上硬件几年没有大幅更新(高通最新的Snapdragon Wear 3100芯片组还是28nm的A7,真是连牙膏都不挤),以及Google自己对这个平台的忽视,所以即使现在Android Wear改名为Wear OS by Google,即使已经发展了这么多年,现在的Wear OS体验仍然非常一般。如果关注YouTube上的各种手表评测的话(比如MrMobile的),不管产品本身质量怎样,只要涉及到Wear OS的体验,都会说一般都会说Wear OS拖了整个产品的后腿。

我在2016年花了350块从闲鱼上收了这个表来体验体验,果然体验非常一般。滑动界面都能顿卡,Play里确实有app但是质量低下,Ingress的App看上去挺酷炫,但是其实并没有什么用。国内的app和Google体验就不用说了,就像没有一样。再加上一天不到的续航,这表用来体验体验还是不错的,日常使用还是别想了。

Amazfit Watch(2017年6月)

手表在学校,这里放一张和GTS的对比图充数

高考之后,我一直都是戴的Pebble Time,但是由于Pebble公司没了,Pebble的体验也原地踏步,有点厌倦了。于是当时把目光投向了华米的初代Amazfit Watch。

Amazfit Watch有几个和Pebble很像的优点:

但是也Amazfit Watch更注重运动检测功能,自带了心率检测器和其他一系列传感器,软件功能也倾向于运动检测,而不像Pebble一样倾向于一些日常功能。另外,Amazfit Watch没有开放API,只能用自带的功能。

这个表日常使用其实和Pebble感觉差不多,因为Pebble虽然能装第三方app,但是一般都不会经常用那些app。至于运动功能,由于我不怎么跑步啥的,用的比较少,这里就不评价了。

但是Amazfit Watch有个非常严重的问题:系统和触屏响应延迟大。尤其是当我想用手表的时候,按下手表侧边的按键,手表要等好几秒才会唤醒,唤醒之后用手去触控,系统也完全不会响应,得尝试很多次、等待一段时间后系统才能正常使用。这种情况不是偶尔发生一次,是每次都是这样。这个问题使我几乎都不碰手表了,完全不与手表进行交互,完全不使用它内置的功能,因为它的响应实在太慢了。

一个有趣的一点是Amazfit Watch在后期加入了搜狗地图,但是由于其恐怖的触控延迟以及低下的性能,搜狗地图基本处于无法使用的状态,不知道为什么华米会加这么一个功能。

Amazfit Watch GTS(2019年9月)

Amazfit GTS

其实我很早之前都有换掉Amazfit Watch的想法,因为它看上去比较丑,比较大,系统响应极慢,以及用了一段时间后电池续航下降明显,但是当我浏览了一圈之后,发现Pebble之后,并没有完全满足我的需求的手表,Amazfit Watch变成矮子里的高个,就一直这样等下去了。一直等到了2019年8月,华米发了GTS,虽然GTS也不满足我对智能手表的所有需求,但是它较为轻薄小巧、方形屏(我更偏好方形屏,在显示文字时屏幕利用率更高)、续航较强、功能足够,于是就下手了。

这款手表相信所有人看到它的第一眼都觉得它抄袭Apple Watch。确实,从外形和表盘来看,它确实和Apple Watch一模一样,但是我个人觉得这一点并没有什么大问题,国内厂商抄袭是一个连新闻都不用上的事情。而这款表除了和Apple Watch像之外,作为一个“大号手环”,其实几乎没有什么缺点,而且市场上也少有类似的竞品(华为表是一个,但是我是华为黑,以及华为表价格几乎是这个的两倍),再加上价格较为便宜(800块),所以我对它比较满意。

这块表和初代Amazfit Watch的最大的不同点,除了更轻薄更现代的外形,就是屏幕了。这块AMOLED屏幕分辨率和PPI达到了视网膜的标准,看着非常舒服,另外最重要的是这块屏幕和系统响应速度非常快,完全没有Amazfit Watch那种卡顿和缓慢的感觉。这块屏幕一个劣势也是来自于AMOLED,不像反射性屏幕,它需要自己发光,虽然它亮度足够在白天正常阅读,但是从它被点亮到光线感应器感应到外界亮度调整屏幕这个过程需要2 3秒的时间,不像反射性屏幕一样即抬手即用。另外由于我一直开启常显,在晚上的时候手腕总有一束光,看着比较奇怪。

对于屏幕这个点,在AMOLED和反射性屏幕之间选,我还是更倾向于反射性屏幕,因为阳光下可读性我认为是手表一个非常重要的特点:出门后我连时间都看不到,为什么要戴个表?但是市场已经做出了它的选择,所有厂商基本都使用的OLED屏,作为一个普通消费者也做不了什么,只能从命啦。

其他体验过的设备

Fitbot是另一家智能运动穿戴厂商,也在2014-2016年左右当时红极一时,主要产品手环和手表都非常注重运动相关功能。2015年托朋友的福,体验了一下Fitbit手环,具体型号忘记了。其实它和现在的小米手环并没有什么大区别,就是做运动检测,但是对我这种不做跑步这种运动来说基本并没有什么作用。另外,我当时体验的Fitbit产品没有显示屏,连时间都看不了,果然算了。

Ticwatch也是一个比较老牌的智能手表厂商,也是从Android Wear出来后没多久就开始做智能手表。托室友的福,我体验了一波Ticwatch E。它比较接近于Android Wear形态的手表,功能比较丰富,开放SDK,可以装各种应用,但是续航较短。这款手表对我来说最大的问题就是之前提到的阳光下可读性:这款AMOLED屏幕亮度太低了,在阳光下完全不可见。再加上这块表的续航太短(不足一天),功能虽然多但是实际上有用的没几个,所以很快就还给同学了。

智能手表的分类以及各自优劣

其实读了上一节的你可能已经发现了,虽然这些设备都叫做智能手表,但是它们之间的区别还是非常大的。有的重续航,有的续航只有一两天;有的可以自己装app,有的只能用自带的app。一般把现在的智能手表分成两类:大号手环和真正意义上的智能手表

大号手环指的是那些功能简单、不支持自己安装应用的手表。这些产品一般以运动检测功能为主,续航较长(一般在3天以上),较为轻薄。由于这些手表的功能实际上和那些手环产品差不多,所以被称为大号手环。大号手环例子有上面提到Amazfit Watch和GTS,以及华为和荣耀表系列。

而真正意义上的智能手表指的是那些功能丰富强大、支持自己安装应用的手表。这些手表一般也包括运动检测功能,但是续航较短(一般一两天),且由于需要性能的支撑较为厚重。真正意义上的智能手表的例子有Apple Watch、Android Wear(或者说Wear OS by Google)系列(上面提到的LG G Watch)、小米表等。

二者的优势和劣势其实也是相当明显。

大号手环续航长,较为轻薄;但是由于不能扩展,功能受限制;并且由于这种表为了追求续航,性能一般都比较低,一般内置的功能也都是较为普遍的运动检测等功能,诸如备忘录、地图等功能,我到现在还没有看过有大号手表搭载。

而智能手表正好相反。其能够扩展功能,如果生态跟得上的话(例如苹果表的生态),能够使用到很多功能。但是由于其对性能和电池的追求,其机身一般不会很轻薄(苹果表已经算是个好的例子的,可以去参考机身厚度超过10mm的小米表),并且即使这样其续航一般都不会超过2天。

有的智能手表还支持4G,支持独立通话和网络通信。这个功能可能对于有的用户(例如跑步的用户)来说,支持独立4G让用户可以让手表脱离手机独立完成一些工作,但是对于绝大多数场景,手表还是更应该当作一个手表的附属。道理很简单:就那么小块屏幕,即使支持4G,能干什么?除了打电话之外,发短信、聊QQ/微信、看网页、玩游戏等智能手机日常操作都完全不能做,开了4G还更加影响续航,电池本来就只能支持一两天,开了之后,续航就更惨不忍睹了。

当前各类智能手表的发展趋势

我对智能手表的发展趋势是比较悲观的,因为电池技术没有突破,手表必须在续航和功能之间二选一。而功能性上,智能手表较为低下的性能也影响了智能手表的使用体验以及可以完成的功能,除了运动检测以外,智能手表厂商现在仍然没有找到适合智能手表完成的工作。

现在市场上的手表几乎已经可以完全分成上述提到的大号手环和智能手表了。下面我就分成这两类来讲讲我对智能手表的发展趋势的分析。

智能手表

智能手表目前苹果表最好,没有之一。苹果表具有更好的硬件、更好更统一的生态,从任何角度来说都比Android这边的智能手表产品有更好的发展趋势。其他厂商的智能表,小米表一出来就被全国人看笑话,Wear OS by Google半死不活。

苹果表之外的智能表总有两个致命问题:硬件弱鸡生态垃圾

就像上面所说的,高通最新的可穿戴芯片组Wear 3100还在用28nm的A7架构。在手表这种功耗敏感的设备上,采用如此古老的制程工艺,一开始就输了。功耗降不下来,影响手表的续航;性能低下,影响手表的功能的开发,也严重影响了手表的流畅度。

高通骁龙3100的芯片组配置。图片来源:https://www.qualcomm.com/products/snapdragon-wear-3100-platform

而生态垃圾就不用说了。Wear OS by Google发展了这么多年,还有Google爸爸的亲自背书,都发展成这个样子,就更不用提Ticwatch、三星等厂商的智能手表生态系统了。另外,三星自己搞一台Tizen生态系统,其实也分流了开发者,加重了开发者的负担,进一步恶化了智能手表的环境。

更严重的是,硬件弱鸡+生态垃圾这个问题是负循环的:硬件差,功能和使用体验无法提高,影响生态的建设;生态建设不上去,吸引不到用户,硬件就没有动力持续改进,只能停留在几年前的地步,软件厂商也不去给手表做应用,软件体验也没什么改进。

大号手环

而对于大号手环,现在的新厂商都在这里发力。华为表系列、Amazfit系列、小米手表Color等,新款层出不穷,似乎欣欣向荣。

但是如果你仔细看的话,其实这些大号手环除了外观之外,几乎都是一样的:一样的AMOLED屏幕、一样的健康、运动、睡眠检测功能、一样的NFC和卡包,连续航时间都差不多(不开常显2周或者3周,开常显减半),一样的……没有其他功能。其实严格来说,连外观也都差不多,除了Amazfit GTS之外,都是圆形。即使华为表系列加入了麦克风和语音通话功能,华米部分手表有与小爱同学交流的功能,但是其实都不是主要的功能(语音助手确实还是不是那么有用)。其核心功能和手环并没有什么变化,仿佛,就是在手环上加了一个较大的屏幕而已。

我并不是说屏幕没有作用:相反,这块屏幕是我买智能手表而非手环的最重要的原因:起码我能够用智能手表看时间。但是大号手环的同质化简直太严重了,并且,3年前的初代Amazfit表和现在的各类大号手环,从功能性上看,其实也差不多。

我对智能手表的期待

我对智能手表的期待,可以分成三个层次:可靠性辅助功能多屏协同

可靠性

可靠性是我对所有手表甚至工具的基础要求。一个工具应该在任何我需要的时候,都能可靠地高效地完成的我的工作,关键时刻响应慢甚至掉链子都是不可忍受的。

在可靠性上做的不好的智能手表产品其实很多:

其实续航短也可以算是可靠性上的问题,一个连一天都无法支撑下来、需要我时刻担心充电的“工具”,若不是对我生活有巨大的好处(例如智能手机),我宁愿不使用它,少担心一个事情。

这一点上,其实很多真正意义上的智能手表对我来说都可以排除了,因为就像之前所说的,现在很多真正意义的智能手表在基础体验的可靠性上都欠佳,系统流畅度、功能可靠性、续航上都不能达到“可靠”的标准。这样,即使一个设备功能再丰富,那又有什么用呢?

辅助功能

辅助功能指的是智能手表作为手机的附属,以它在手腕上、随时可触及的优势,给生活带来改进的功能。

其中最重要的辅助功能,对我来说,是通知提醒。通知提醒是至今为止智能手表给我生活带来的最大的提升和改变。

自从Pebble开始,我的手机就一直保持静音的状态,所有通知全靠手表提醒。当通知时,手表将会震动一下,然后把信息投送到手表上。用手表、而非响应或者手机震动提醒通知的好处实在是太明显了:

所以,对我来说,通知是否能够及时到达、通知内容是否能够高效显示、通知震动的感觉是否恰到好处,这些通知提醒的体验是非常重要的。

通知提醒之外,智能手表还可以提供一些功能,以提供一些我们平常不经常用的、但需要用的时候感觉非常有用的功能,比如:

BTW,辅助功能也是我偏好方形屏幕而非圆形屏幕的原因,因为方形屏幕在显示文字的时候天生比圆形文字更有优势,而对我来说智能手表最重要的通知提醒功能就非常依赖文字阅读。

多屏协同

最后,多屏协同是我希望手表未来的发展方向。

多屏协同其实是个很古老的话题,从Windows Mobile、到现在的智能手机和平板,如果能把各个设备的优点结合起来,数据共享,将会产生1+1>2的效果。多屏协同在硬件层面上早已没有问题,但是在软件层面上,由于各个设备之间的厂商的壁垒,多屏协同对绝大多数来说都还是一场梦。

微软自己的共享功能,由于缺乏了手机这一重要一环,只能在UWP和Windows电脑之间用;而微软的手机和电脑的连接工具Your Phone,做了这么久却没有开放中国区的使用,且国内的连接稳定性堪忧;DELL等厂商也做了一套和Your Phone差不多的软件,却只预装在自己的电脑上;第三方厂商的协同工具,例如Airdroid,虽然功能强大,但是仍然无法解决第三方软件厂商的软件。

这个方面做的最好的,莫过于苹果和华为了。我没有使用过苹果生态链,这里就不多说;华为的手机、平板、电脑和电视之间的多屏协同虽然较为处于一个原始的阶段,但是也已经达到非常可用的状态。更重要的是,华为认识到了多屏协同的优势,并且做出了比其他厂商都要完善的工作,这已经是非常难得的。

说到手表上,其实我认为在多屏协同中,手表也是一个非常重要的一环。因为手表屏幕较小,操作不便,但是手表又有一直在手腕上可以方便与之交互的巨大优势,多屏协同的重要性完全不低于电脑、平板和手机。

举例,我希望手表能够和其他设备交互,实现:

当我走在路上,在手机上的地图选择一个目的地后,手表可以给我显示路线,并在到达路口的时候提醒我转弯。这样,我就可以不用一直打开手机拿在手机。

之前有的版本高德地图在息屏后可以通过通知提示路线,手表可以接收这个通知,这已经非常接近了。但是,如果手表能够实时显示地图,并用图的形式显示地图,那将是再好不过了。

目前有的手表有地图软件,但是这些地图软件还是需要在手表上设定目的地,手表上屏幕小,操作不便,我希望的是能够在手机上选择一个目的地后,手表能够作出提示。

当我在手机上设置了闹钟后,在响铃时,手表也同步震动。手机系统也可以在设置闹钟时选择是否在手表上响,以免打扰其他人。

现在很多手表都可以设定闹钟功能,但是在手表上设置闹钟仍然有操作不便的问题。Amazfit支持在手机上设置手表闹钟,但是在应用里设置闹钟和我们已经习惯的在系统里设置闹钟的体验是割裂的,并且应用里的闹钟设置较为简单,不支持例如MIUI的工作日响铃等高级功能。

Amazfit应用内设置闹钟

现在的消息通知都不支持图片,并且若没有软件厂商的支持,也无法进行进一步操作。理想情况下,手表上的通知应该能够显示聊天工具发来的图片,并也应该能对通知进行简单的进一步处理,例如发送一些简单的回复等。

Wear OS by Google是可以对符合Android标准的应用进行处理的,例如回复短信、邮件等,但是在现在这个不遵循标准的第三方聊天工具(说的就是QQ和微信)支配的时代,没有对应厂商的支持,这个功能基本是无法实现的。而腾讯对第三方的支持,呵呵。

Wear OS by Google回复消息(来源:https://developer.android.com/training/wearables/notifications/creating)

其实这些功能在硬件层面早已没有问题(可能蓝牙传送图片有带宽的限制),软件实现方面也没有涉及到人工智能等目前看来较为玄学的技术。这些功能的实现最重要的还是来自各个软件厂商之间的合作和数据共享,而这可能也是最难的。

总结

我一直无法忘记2012年Google Glass推出给我带来的震撼,那种科技与现实交融的感觉充满了未来感。而在现实中,可穿戴设备和智能手表就是最接近这种未来感的设备。

我一直是个可穿戴设备的爱好者,希望尝试各种可能给我的日常生活带来改变和提高的的设备,也希望科技能够一直发展,持续给我们的日常生活带来方便。

从VSCode到Vim到……两个都用?

2020-04-04 22:00:00

题外话

最近3个月博客都没更新,不是因为我弃坑了,而是因为这几个月一直在忙着搞毕设。本以为毕设只是个j2ee,没想到朝9晚9搞了2个多月。现在终于搞完了,博客也(应该?)开始恢复正常更新了。幸运的是,在做毕设期间我积累了很多可以用来写博客的材料(本篇文章也是其中之一),接下来的几个月将慢慢更新。

VSCode

当大家一提到编辑器还是Sublime, Notepad++时,我就开始用VSCode了,也很喜欢VSCode的优点:

因此,我在用VSCode做尽可能多的事:前端开发、新语言(Rust, Golang, Haskell等)的学习、写Markdown等。本博客的代码和所有文章也都是在VSCode下写完的。我也借用各种插件把VSCode打造成了一个“完美的编辑器”。

但是VSCode有个很大的劣势:性能

可能你会说,VSCode起家不就是靠快吗?如果和Atom比,VSCode当然是快的(消费过气编辑器)。但是根据我的体验,Windows下的VSCode的性能问题体现在两个地方:启动速度编辑延迟

启动速度

VSCode的启动速度虽然已经经过多次优化,但是不能做到秒开。尤其是当加了插件后,随便打开一个文件,都需要等待可见的几秒才能开始编辑。下面的动图展示了使用VSCode打开Rime的配置文件时的速度。请注意,由于使用了Vim插件,即使在VSCode已经显示文件后,仍然不能编辑,只能等到vim插件已经准备好后(光标从|变为方块状),才能开始编辑。从右键选择使用VSCode打开到可以编辑,耗时7s左右。

VSCode打开Rime配置文件

Rime配置文件是个很小的、很简单的纯文本(yaml)文件,打开尚且需要等待7秒。

而VSCode的打开时间大致是随着文件和项目的大小而增加,和项目打开的文件和文件大小有关。打开我10000行左右的毕设前端项目需要10s左右。我记得在微软工作期间,打开我组的20万行左右的前端项目,需要时间应该在15s以上。

讲道理,这个速度对一个文本编辑器来说,有点太慢了。

编辑延迟

在以下情况下,VSCode的LSP提供的自动完成Code Actions(即自动修复,Ctrl+.)等功能经常出现较为严重的延迟的问题。

等等。

VSCode自动完成的延迟

另外,更严重的是,当系统资源不足时,或者项目更大、连续编辑一个项目更久时,VSCode连打字都会出现延迟。即已经输入了字符,但是在屏幕上字符需要等待一段时间才能出现。

这些编辑中出现的问题极大地影响了编辑的效率和感受。尤其是打字延迟我认为是无法忍受的:作为一个编辑器,打字响应应该是第一优先级,在任何情况下都应该首先保证打字的响应速度。JetBrains系列IDE就是一个很好的例子:JetBrains占用资源大,启动慢,但是启动完成后,打字和自动完成几乎都可以保持瞬时响应。

可能的原因

VSCode出现这些问题的原因可能也很好解释。

Electron应该背主要的锅。Electron的本质是一个Chromium浏览器。VSCode使用了Electron,除了一些性能攸关的组件外,VSCode的功能几乎都是用前端技术栈开发的。前端技术栈的效率一直就是个问题。V8引擎已经改善许多了,但是和原生技术相比,前端技术的效率还是差了一个数量级。不仅是VSCode,其他几乎所有用Electron的应用的速度都不敢恭维,如Postman、GitKarken等。

另外一个原因是Windows。Windows下的编辑器、IDE等程序的效率似乎没有Linux等其他操作系统下面高,例如根据JetBrains的Intellij IDEA 2019.3的更新报告,IDEA系列IDE在Windows下启动动辄10s以上,而在Linux下可以几秒内完成。我在Linux下也测试过VSCode的启动速度和编辑速度,确实遥遥领先Windows操作系统。

IDEA 2019.3更新日志中给出的启动时间图

第三个原因可能是插件的数量。但是,插件是VSCode立身之本之一,为什么要让用户在速度和功能之间做选择呢?

这些性能问题,电脑配置越差,插件越多,越明显。在我的X1C6上(i7 8550U + 16G内存),这些问题非常明显。对于Electron和使用它的应用,我现在都尽量不用,因为被已有的这些electron项目整怕了。

Vim

Vim的鼎鼎大名大家应该都听说过,但是实际用vim的,我好像只在网上见过?不得不说入门Vim确实是地狱难度。我学vim也尝试了三次,最后一次是在微软实习时,使用VSCode的Vim插件,耗时1个多月,才最终能够较为熟练地使用vim,以至于到现在没有vim无法写代码,用word都随手ESC的程度。由于vim的使用全程不需要离开键盘,能够减少手伸到鼠标的次数,一定程度地提高打代码速度和体验。并且如果你经常折腾配置文件,尤其是在linux平台下,在终端里使用vim比使用nano等工具效率会高不少。所以如果有时间我还是推荐大家试试vim,但是在练习的时候一定要有耐心,"vim不是一天炼成的"。

扯远了。在做毕设期间,由于被VSCode的性能问题折腾的有点疯,以及用了vim后对效率和速度越来越敏感,所以我开始尝试配置vim,想把vim打造成一个IDE。

我在2月14日左右,连续干了10几天毕设后,我停下来休息了3天。在这3天里,我开始折腾vim。我用的是Neovim,一个vim的fork,据说比原版Vim更精简、有更多的功能,但是使用体验和原版Vim并无二致,并随着vim 8的加入,二者的差距其实也变得比较小了。

装好了neovim,找各种教程、插件,再加上不断的尝试和调整(这几个月的休息时间几乎都投入到vim配置上了),终于配置出了一个比较舒服的vim环境。

下图为我的neovim的环境,使用了包括Leaderfcoc.nvim等插件。

neovim展示

使用nvim最大的原因,也是之前提到的VSCode最大的问题,那就是

打开文件的速度快。

很多人是在终端(CLI)下用Vim,而由于Windows平台一般不在终端工作,以及Windows Terminal目前还有一些问题(例如不支持鼠标输入等,有个版本说支持了,但是在我这里测试仍然不能支持,不知道是什么原因),所以目前我主要在GUI下用Vim,主要用neovim-qt(GUI的事等会还要谈)。

用neovim-qt打开一个项目和文件的速度相当的快。下图为打开和之前同样的Rime配置文件,可以看到neovim-qt在3秒左右就已经打开了,并且立刻可以开始编辑。对比VSCode的7秒,这确实是一个相当大的提高。即使是打开项目,neovim-qt的速度同样可以维持几乎不变的水平。

neovim-qt打开Rime配置文件

编辑的速度同样很快。

Vim本身就以速度闻名,也基本做到了打字响应优先级最高。并加上现在的neovim和vim 8都加入了异步的功能,即使在处理复杂任务(如获取自动完成的信息)需要耗时,打字的速度仍然可以不受影响。

自动完成和LSP支持我使用的是coc.nvim,它不仅为neovim提供了LSP的支持,还提供了一个node.js环境,让更多的功能可能实现在neovim平台上(如coc-explorer,功能很强大的文件树浏览器)。它的标语是Make your Vim/Neovim as smart as VSCode,我认为它确实达到了这个目标。

VSCode能支持的语言,它都支持,并且由于都是用LSP协议,功能基本都一样;VSCode能做的功能,vim一般都能做;vim不能做的,coc.nvim也基本都支持了。

另外讽刺的是,用的同样的LSP Server,vim的自动完成和Code Actions的响应速度比vscode快,而且长时间编辑后,当vscode已经慢的不能接受时,vim的响应速度几乎没变。基于这个原因,毕设的前端项目我基本都是用neovim开发的,开发体验比vscode强了许多。

neovim编辑ts文件获取自动完成

纯键盘操作

另外用vim对我最大的影响,是纯键盘操作

虽然VSCode也可以使用键盘完成很多的工作,但是在VSCode下,死活想有伸手用鼠标的感觉。这有几个原因:

一是VSCode的键盘并不能完成所有工作

比如,在不安装插件的条件下,即使装上vim插件,并不能在文件管理器只使用键盘管理文件,如重命名、新建文件等。即使使用File Utils等插件,也并不能做到像ranger(一个Linux下运行在CLI中的文件管理器)直观地、快速地管理文件。并且正由于VSCode并不能使用键盘完成所有工作这一事实,每当我想使用键盘做一个事时,总得先试试VSCode到底能不能用键盘做这个工作,然后这又浪费了一些时间了。

二是VSCode、以及一些传统的IDE的快捷键,我认为键位不够科学。

例如,VSCode的打开侧边栏的各个功能的快捷键总是三个键,过于复杂,如Ctrl+Shift+E打开文件浏览器,Ctrl+Shift+F打开搜索等。要按这样的组合键,总得变换一下手姿。

另一点是Ctrl键,基本所有快捷键都依赖Ctrl,但是按Ctrl要么变化手姿,要么使用小指这个最力量最弱最不常用的手指,这样总会影响一点效率和舒适度。

而使用vim就可以比较好的解决这些问题。

对于问题一,使用vim可以确保键盘一定能完成vim能完成的所有任务。且由于vim生态中所有插件都以纯键盘操作为核心,所有插件的操作也非常键盘友好。例如,下图为使用leaderF进行文件搜索和跳转,使用NERDTree进行文件创建、重命名和删除操作,都可以非常直观的使用键盘进行操作。

neovim使用插件进行文件跳转和管理

对于问题二,我的解决方法是自己定义快捷键。为了避免使用Ctrl键,所以把很多快捷键都绑在了Alt,例如Alt+A打开和关闭文件管理器、Alt+,Alt+.在buffer(即打开的文件)中切换,Alt+h/j/k/l在各个pane(分屏)之间切换,Alt+q关闭当前buffer等。

map <A-j> <C-W>j
map <A-k> <C-W>k
map <A-h> <C-W>h
map <A-l> <C-W>l
 
" Leader A to open coc-explorer
map <A-a> :CocCommand explorer<CR>
 
" <Alt>-, and <Alt>-. to switch between buffers
nmap <A-,> :bprev<CR>
nmap <A-.> :bnext<CR>
 
" :Bc or Alt-q to close current buffer and show the previous (p) buffer
command! Bc :bp | bd#
map <A-q> :Bc<CR>
 
 

另外,vim还有leader key的概念,即一个特殊的按键(记为<leader>,我设置成了空格SPC),按下这个按键后,接着按其他按键,可以进行更多的操作。这相当于新增了一个快捷键的命名空间,避免了一些重复的(比如VS的Ctrl+K Ctrl+D)、又或者是复杂的(比如Ctrl+Shift+?),可以定义更多的快捷键,既方便使用,也方便记忆。

比如说借助CamelCaseMotion这个插件,<leader>w可以按大小写分词来移动,即|camelCase|代表光标),按下SPC w后,就变成了camel|Case。这在浏览代码的时候非常有用。又比如,coc.nvim在提供的示例配置里,把<leader>qf绑定为了LSP的**快速修复(quick fix)**功能,又好用又好记。

当然,在各种编辑器和IDE里,也可以自己绑定快捷键,但是这会破坏IDE自己定义的快捷键本来有的助记性(Mnemonic)。比如虽然VSCode侧边栏各个功能的快捷键比较复杂,但是它们是有规律的:Ctrl+Shift+E(Explorer),Ctrl+Shift+F(Find)等。这虽然不是什么大问题,但是我个人不太喜欢,而且重定义快捷键也是额外的工作。

VSCode也有一个对VSCode的各种功能模拟leader key的插件LeaderMode,但是由于VSCode的限制(对应vscode issue),这个插件和VSCode的Vim插件不能一起使用。

Vim的问题

一开始用Vim非常舒服,但是在稍微用的比较久一点之后,我也发现这套配置的一些问题。

插件的质量、功能性和兼容性

由于Vim的很多功能都由插件提供,所以插件的质量就非常重要。虽然Vim有很多高质量的插件,但是不可否认的是,也有很多插件的质量、以及插件之间的兼容性是很有问题的。

比如vim-polyglot,一个支持多种语言、给多种语言提供代码高亮等支持的插件,和coc.nvimcoc-tsserver,即typescript的LSP支持就不兼容,indentation会出错(比如应该indent 2个空格的会indent 4个)。且vim-polyglot提供的TS语言支持其实不太正确,复杂的语法高亮会出错,所以就必须关闭vim-polyglot的TS支持,而换用另一个typescript插件typescript-vim来提供typescript的高亮。

又比如coc-snippet,在coc.nvim平台上的code snippet插件,称可以支持使用UltiSnips,另一个code snippet插件的snippet定义文件,但是实际上在我这里测试,它不支持在自定义UltiSnips格式定义文件定义的placeholder之间的跳转。

coc-snippets无法支持自定义snippet的placeholder

另外,由于用vim的人比较少,所以也很少有一些官方团队会来vim上进行深度开发,造成在vim上这些库的体验不如VSCode等有专门开发的编译器。

比如vim-styled-componentsstyled-components的vim插件,只能在vim上提供CSS块的代码高亮,不能提供自动完成等其他特性。

又比如rust的下一代Language Server rust-analyzer,官方团队在VSCode上开发,对VSCode还有一些例如类似JetBrains IDE的、在变量旁边显示其类型的功能。而在Vim虽然可以正常使用LSP提供的自动完成、错误检查等功能,但是功能性仍然比不过VSCode。

其实所有开放的生态圈其实都或多或少有这个问题。整个生态圈由不同的开发者用爱发电的,那总会有插件不能完全满足所有用户的需求,我们也不能因此而责怪开发者,毕竟开源的东西嘛,你行你上啊。

高级编辑功能和学习成本

VSCode等编辑器或者IDE不仅有最基本的编辑功能,还有各种高级的、和语言有关的编辑功能,如查找所有引用(Find All References)、全局查找等,这些功能虽然少用,但是每当需要时也可以方便的从各种菜单中找到。

这些功能虽然在VIM下也能实现,比如使用leaderF可以调用ripgrep实现全局查找的功能,而且vim下可能功能更加灵活强大,但是要学习这些高级功能就要多学习一个程序的用法、或者多学习一套API,又或者需要多记一套命令,这些都是需要成本的,尤其是对于没有完全习惯CLI下操作习惯的用户。而IDE等默认提供了这些功能,学习成本很低。

Windows的支持和WSL

对Windows用户来说,VSCode一大优点就是对Windows支持很好,毕竟都是MS的亲儿子。安装时把用VSCode打开加入右键菜单,WSL和远程开发的无缝支持,这些都对Windows用户来说很方便。

而neovim的各种GUI,要想加入右键菜单得自己改注册表;虽然也支持远程连接,但是这种远程连接并不像VSCode的远程开发一样,能够保留本地VSCode的各种自定义选项,而需要在远端(或者WSL)里重新配一遍WSL。虽然可以通过各种方法(比如共享文件夹等)实现两边的配置文件同步,但这也远远不如VSCode默认自带的方便。

并且有的vim插件默认用户使用*nix系统,依赖外部依赖,在Windows下直接无法使用。比如leaderf可以与GNU GLOBAL交互实现类似Find All References之类的功能,虽然GLOBAL声称可以在Windows下用,我仍然一直没有配置成功,不知道为什么。

GUI

neovim有很多GUI前端,但是很讽刺的是,没有一个是完美的。

fvim是一个跨平台的.NET平台实现的GUI,界面渲染不错,性能也还行,问题是CapsLock没用。不管按没按下Capslock,出来的一定是小写。而我和其他人不一样,重度依赖CapsLock,打大写都偏好CapsLock -> 字符 -> CapsLock。

这是因为它使用的跨平台GUI库AvaloniaUI只给了应用程序raw key的响应(比如用户按了A键,应用程序就获得了a,并不会根据CapsLock按键进行转换),且没有实现获得当前CapsLock状态的API(对应issue)。

对很多本来就不怎么用CapsLock,甚至把CapsLock绑定到其他按键(fvim作者就是)的用户来说这是好事,但是这对我来说是一个deal breaker,完全没有办法正常使用。

neovide是一个Rust实现的前端,比较正常,但是它的问题一开始是无法输入中文,后面换用SDL2(一个跨平台的多媒体库)实现后,可以输入中文了,但是输入中文时无法弹出输入法的候选框。这个issue也一直在项目的issue里跟踪,看起来也很难解决,毕竟作者自己并没有输入中文的需求,且这个问题很有可能是SDL2库的问题,作者也无力解决。

goneovim是一个用Go实现的、以Qt为框架的前端。这个虽然自己有一些外置GUI,比如minimap、Markdown预览等,个人不太喜欢,我比较喜欢纯粹的、在不同平台和GUI上都统一的Neovim体验,不喜欢每个GUI自己的私货(当然这些可以关闭),但是功能较性能嘛,性能也不错。这个的问题是字体渲染比较难看(下图),并且当窗口在两个DPI不同的显示器之间拖动时,窗口内部的内容在新DPI渲染会出错,完全无法使用,只能拖回它启动的显示器里才能恢复正常。

goneovim字体渲染,与nvim-qt对比

nvim-qt是最接近完美的,也是我找了半天最后只能妥协下来使用的GUI。但是它不支持ligature,且标题栏不能隐藏(见上面的图),比较丑。

其他编辑器

当然了,我也尝试过一些其他的编译器,但是都是浅尝则止,这里说一下我短时间使用的感受。

Emacs

Emacs和Vim都是赫赫有名的编辑器,我学了Vim吃到了甜头,当然也必须试试Emacs,看它会不会也给我带来一些没有想到的好处。

我一开始准备尝试原生Emacs,但是由于不熟悉Elisp,而且也很不喜欢Emacs的默认快捷键(一个原因是重度使用Ctrl和连续按多个组合键),所以去试了下spacemacs。这是一个已经预配置了很多emacs的配置的、可以开箱即用、各种功能按需开启的、默认有vim插件的(evil)、适合小白的emacs“发行版”。但是我感觉有几个问题:

就放弃了,转而去用了doom-emacs,另一个发行版,比较轻量,默认有LSP支持,而且各种快捷键是以evil模式为中心。用了一些,且YouTube上有一些简单的入门视频,感觉体验不错,其可以用leader key(和vim中的leader key是一样的)操作emacs的几乎所有功能是相当棒的。但是试用下来感觉有以下几个问题:

另外,不管是哪个emacs,其实也会遇到和vim同样的问题:第三方插件的问题。第三方插件的质量、功能性和兼容性上都存在一定的问题。比如上面提到的styled-components,在emacs上甚至没有一个能用的解决方案。另外,emacs也是标榜可以在emacs里完成所有工作,但是真的需要在一个工具里完成所有工作吗?

Onivim 2

Onivim也是一个想在保留vim的特色编辑行为(称为modal editing)的基础上扩展vim功能的编辑器。其1代是在Electron上做的,看了上文的同学应该知道了我对electron的态度,所以就没怎么体验。

而去年的时候发现onivim团队开始开发Onivim 2了,不仅它是使用的原生技术栈开发的(Reason语言和原生跨平台UI库Revery),而且还声称要支持所有VSCode插件。这就激起我的兴趣了。

虽然Onivim 2是收费的,但当时Onivim 2还在第一个阶段“想付多少就付多少(Pay what you want)”,所以当时就就花了一点钱(具体花多少忘了)买了个license。

Onivim 2开发时间挺长的,到现在还在Alpha阶段,但是可以下载来试试。体验了一下,感觉速度确实不俗,但是界面和交互目前来看还是比较原始的,且功能还没完全做完,还不能支持日常使用。

对于这个项目我个人来说感觉是比较有特色的:原生技术栈和VSCode扩展支持,性能和扩展性都有了,确实很吸引人。但是项目本身并非完全免费的,非商业和教育使用免费,商业使用是收费的(README.md),这可能会影响其生态的建设。另外,这个技术栈极为小众(你听说过Reason语言吗?),不太清楚开发团队是否实际能否驾驭,按时完全项目的开发。

JetBrains IDE LightEdit模式(2020-4-10更新)

JetBrains系的IDE在2020.1更新后加入了一个LightEdit模式(官方博文)。就像名字所说,这是个轻量(Light)的编辑(Edit)模式,就像简单的文本编辑器一样,只提供一些简单的功能,但是保证较快的速度。

其实我一开始见到这个模式的时候不以为意,想我有VSCode、Vim,为什么要yet another editor呢?但是它更新后我尝试了一下,却非常惊讶。

已经打开IDE后,启动它太tmd的快了!

打开了IDE后启动LightEdit模式

速度和原生vim和神器记事本有一拼!

而且还有代码高亮、Markdown预览(虽然idea的Markdown预览太垃圾了)等常用的功能,和原生的vim相比,它对鼠标操作的支持更好(简单的文本编辑时鼠标定位还是比vim用键盘更快)。

但是这有个前提,就是需要先启动IDE,才能获得这么快的启动速度。如果不先启动IDE,则速度特别慢,感觉是得先启动IDE再启动LightEdit窗口。但是对于Java程序员来说,一般IDE都是一直打开的,所以缺点应该不太严重。

未打开IDE,直接启动LightEdit

总结:二者互补

有的人认为,应该在不同场景下选用最适合的工具。最典型的例子就是苹果:iOS给手机用,iPadOS给iPad用,macOS给电脑用,互不侵犯。Unix哲学也体现了这个观点:Do one thing and do it well.

而有的人认为,应该可以用一个工具就能完成所有的工作。UWP就是这个思想的典型代表:无论手机、电脑,还是Xbox,甚至HoloLens,所有平台都可以用UWP;Emacs也是,不仅能写文本,还能用IRC、电子邮箱等,被尊为“操作系统”,其也是想让用户在Emacs里就能完成所有的工作。

虽然UWP的愿景在现在看来凉得差不多了,但是其实我们也可以看到:iOS和macOS也正在互相融合;JetBrains的平台的本质其实也是想在各个语言下都能有统一的JetBrains IDE体验。

这个问题的讨论其实是无止境的:一个工具,到底应该尝试帮助用户做更多的事,还是应该只专注于一个领域呢?

尝试帮助用户做更多的事,用户能在做不同事情的时候都能有一个统一的体验,但是就必定面临着在某些场景下体验不完美的问题;

只专注于一个领域,用户能够就会面临着不同工具之间的质量的参差不齐和兼容性问题,以及对每个工具的学习成本。

目前看来,没有一个完美的解决方案,可能也永远不会有一个完美的解决方案。我们确实只能亲身体验,根据自己的偏好,在两者之间作出权衡,并从多处取长补短,让每个工具更好用。

目前,在我开发前端应用时,我仍然选择vim,因为它速度快,效率高,容易专注;而当我在调试由C#和Python写成的毕设后端时,我仍然会选择VSCode,因为它对鼠标较为友好,对多种语言的支持更加强大。并且,我从Vim中学到了Alt+h/j/k/l在各个pane之间跳转效率很高,所以把它抄到了vscode中。coc.nvim默认配置里使用[g]g在LSP提供的各个报错信息中跳转,我首先把它改成了g[g],更利于记忆(g代表go),然后也把这个快捷键抄到VSCode中了。

以后我也将在两端反复横跳,甚至可能不时再搞搞emacs,哪个好用就用哪个,然后再把更好用的按键配置抄到其他的地方去。一切以好用为主。

相关资源

我的dotfiles,有各种工具的(nvim, doom emacs等)的配置文件,以及还有一些配置Arch Linux, i3wm等的自动化脚本。

2019年总结

2019-12-31 21:40:00

前言

吸取了去年的年终总结从元旦咕咕咕到除夕的教训,这次我提前3天(2019年12月29日)开始写了,终于赶上了~

最后的课程(?)

这一年中,上了本科阶段最后的课,考了本科最后的考试,除了毕设,基本已经可以和本科做告别了。这一年的课程基本都是软院的典型课(=大作业+背书考试),其中zh的体系结构和实证软件工程、czy的测试课和人机交互给了我比较深刻的记忆。

zh

zh作为成绩杀手果然名不虚传:体系结构同一门课,两个班不同老师讲(其中一个是zh),卷子不同,总评平均分可以差10分。体系结构虽然讲的内容挺体系结构的,既有理论也有实践,但不可避免的是仍然成为了纸上谈兵的课程。三篇不知所谓的论文,外加8人团队2周做(赶)出来的项目demo+66页的文档+PPT,在最后的演讲中,可以明显感觉到大家对大作业的迷茫。

PPT的最后一页,虽说右边的类图很复杂,但是相信大家都很明白这只是一个纸上谈兵的东西

虽说如此,在这段经历中仍然能学到不少东西:界定一个项目哪些应该做哪些不应该做,把一个繁杂的项目拆成多个部分,每个部分分配给其他同学做,在这过程中做自己的部分+检查其他部分完成的大致情况,掌握项目的阶段以及各自的时间点……虽然这段时间很短,尽管有的事情可以做的并不到位,即使写这份文档并没有让我学到它实际上想让我们学到的东西(软院常规操作),但是进行这样一个项目至少让我知道了看到一个复杂的需求不要慌,divide and conquer,总是能够解决的~另外,为了堆这个垃圾的过程中,我还要kotlin堆了一个简单的DI框架,虽然并没有什么实用价值,但是用各种java等传统语言过程中不存在的概念写这样一个小工具也是个非常愉悦的体验。

暴力的DI核心源码

第三学期的实证软件过程(经验软件过程)却是另外一个从来没有在软件接触到的体验:收集论文,读论文,撰写报告,做报告。为了体验一把我以后不会从事的方向,虽然我一直没有做学术的体验,但是我还是把这门课选成了方向课。事实证明,这个决定让我第三学期变得更刺激了:

一天产出学术垃圾

通过zh老师发的论文、他之前的各种经历以及它做的方向,感觉它不像是传统的工程导向的本院老师,确实它也给本院带来了不同的体验。即使如此,还是推荐大家如果还在意成绩的话,选zh课的时候还是一定要慎重。

测试

测试课的第一个月还是挺硬核的:学的知识比较理论,还有难度比较大的机考,但是从第二个月(5月)开始,又恢复了本院常态,比如上课念API。值得一提的是最后的6月开始的大作业:当 时的大作业是二选一:机器学习测试移动应用测试。作为一个完全没有接触机器学习的、也不对机器学习有兴趣的同(guai)学(lei),虽然移动应用测试选的人少(最后200多人的院只有20几个人选),资料也少,还是毅然迈入这个大坑,在踩掉无数坑(10个应用6个坑)后,终于用简单的图+DFS算法外加人肉尝试出的各种暴力打洞hack(比如遇到哪个控件的时间不要继续进去遍历、遇到什么元素时让它最后再遍历到等),在10个测试用的APP中达成了比较好的效果。嗯,这很软件工程。如果你有兴趣可以试试这个玩意,万一它还真的有用呢?

遍历时的各种例外规则

人机交互

人机交互课在6月开始大作业之前都是比较正常的本院课。而大作业就完全变了一个样:不仅要写一个完整项目,还要录视频

项目的idea基于体系结构大作业(变废为宝),本来只想在体系结构课的demo上随便改改(那就是个javafx的几个默认按钮),但想到本院所有项目即使老师说界面不重要,最后还是基本只看界面的实际情况,还是以软工2的项目为基础(变废为宝x2),两周时间和另外3个同学搞了android端和服务器端,最后功能基本完整,完全达到预期,也有一些人机交互的设计,比如下图的及时反馈:当教师端关闭讨论功能时,客户端也同时禁止发布新消息。

即时反馈设计的演示

而为了拍视频,我们也投入了不少精力:

最后得出的视频效果其实不谦虚的说,挺不错的:D

视频截图

魔鬼月

测试+人机交互+俱乐部的事情一起堆到同一时间,也让我得到了5月底开始的魔鬼月。那段时间我每天早起早睡,一起床就去机房写项目,每时每刻都在想项目的事情。写完人机交互写测试,写完测试复习背书,复习完考试,考试完立刻火车卧铺去北京参加保研夏令营……可能这就是传说中的,黎明前的黑暗

5月底的俱乐部事项

最后

其实我一般不会对软院课程有很深的印象,但是今年的上半年是个例外。虽然这些课程本身还是没什么大用,但每门课我都投入了不少的精力,尝试了没有尝试过的领域(读论文、学全新的语言和开发平台、拍视频等),整个过程中也有给力的同伴/大腿们的助力,让我这半年的纯粹的学校生活变得有滋有味,虽然如果可以选的话,我不会想再来一遍~

实习,工作 => 保研

实习

这一年最重要的是,莫过于实习确定自己未来的方向了。

实习间断性地刷了几个月题,顺利地拿到微软苏州的offer。拿我说说里的内容来说,我的实习体验可以概括成:

虽然我很多时间在划水,但是FTE同事真的很厉害,工作的时候全神贯注,放松的时候尽情放松,真羡慕这种健康的、张弛有度的工作生活态度,还有良好的工作环境和人际关系,以及各种技术、娱乐和交流活动。技术栈上虽然和其他的公司不完全一样,但是也都是最新的技术,完全不用担心落伍,而且还有各种学习资料和学习的机会,想学都是有时间和机会的。

经过这次实习,我有个感觉,就是实习的时候相对于学习新技术,更重要的应该是感觉公司的环境和文化,包括工作环境、人际关系、技术文化等各个方面。在微软这三个月,虽然不能说非常完美,但是确实也让我感到了一个真的把人看做人、而不是工具的企业应该是什么样子。虽然在国内这种内卷成性、法律监管缺失的大环境下,这样的环境是持续不下去的,但是,嘿,人总得有个梦想吧?可能以后就会变好的吧?(虽然实际上来说是不可能的)

微软苏州大楼

工作之外,通过晚上和周末的时间,我还逛了下周边(965工作制至少每天还有时间做自己的事情,难以想象996的生活),用双脚和自行车参观了苏州工业园区的一些区域(图中外圈内的区域通过共享单车到达过,内圈内的通过走路到达过)。可以说苏州工业园区的规划除了地铁太少之外,简直是城市规划的典范:街道宽敞且整洁、绿化完善、遍布全园区的邻里中心让生活非常方便、每条街道都有的几乎免费的共享单车让通勤也问题不大(虽然有时候自行车太少)。相比到大城市里过着租着3000块的房子、每天通勤单程1个小时的生活,在苏州工业园区生活可能会是一个更好的选择。

地图

保研

对于未来的方向,就像我之前所说,直到4月份,我还是以就业为目标而进行打算的:选择去微软苏州实习也是为了直接转正。我甚至还拖了朋友问问上海转正的事项。但是由于大环境的变化等一些复杂的原因,最终还是决定再去研究生混三年,同时也可以多了几年更自由一点的时间,也能体验可能如果直接工作的话、以后不会再有机会经历的研究生生活。由于仍然对科研没有希望,所以还是在学术气氛浓厚的北大计科专业里选了一个工程导向的组。希望一年后、两年后的我不会为这个选择后悔。对夏令营感兴趣的同学可以看我的北大信科 | 上交软院 | 南大软院夏令营体验文章。

接受待录取

多尝试多准备

在一切尘埃落定之后的10月27日,我受邀和另外几个大佬一起,给大一大二的学弟学妹们做了一次关于未来规划的演讲(PPT)。在这次PPT里,我的核心观点只有一个,即:多尝试多准备,找到真正所爱,或给自己更多选择

多尝试,多准备

站在现在这个时间点,我感觉这也是我对我的大学生活最大的遗憾之一:从大一以来我就以工作为导向进行准备,一切和工作无关的事情都不进行尝试。现在想来,可能错过了一些可能痛苦、但也可能美好的体验,例如做志愿活动、和老师看论文做实验写论文、出国看看世界体验没有经历过的文化和生活。很多事情现在不体验,以后也不会有机会做了。所以,希望我能够在以后不计较得失,在有机会的时候尽可能多的体验新东西,多尝试,多体验,多准备,获得更丰富的经验。

俱乐部

在这一年里也彻底和微软学生俱乐部做告别了!

由于课程和实习的缘故,上半年我并没有给俱乐部做出太多的工作。俱乐部在上半年和腾俱举办了一次hackathon比赛由于实在找不到老师去做了评委,看了很多学弟学妹的有趣的创意和虽然不成熟(嘿他们才大一大二呢)但是能用的实现,真实感到长江后浪推前浪,年轻人真是一代比一代强!另外还有因为考试没能参加的联合春游,还有最后回到最初的起点又一次在校庆夜的面向全校的展台活动

在离别之后,还有幸在微软亚研院的官方微信上发了一篇文章博客版本)总结这三年俱乐部的点点滴滴。微软学生俱乐部是个小社团,但是他承载了我三年来的一些美好的记忆和体验,我也有幸能够为俱乐部尽自己的一份力。希望有更多人能够加入俱乐部,在里面提高技术、结识朋友,和俱乐部一起度过大学生活。

俱乐部特色钥匙链和卡贴

比赛

不像2018年,最后这一年里只参加了5月份学院组织的hackathon比赛,在大佬队友的帮助下,借助一个斐波那契项目(花旗杯(前面界面)+区块链(项目大噱头))拿到了奖。

核心功能截图

其实可以说这是我参加的第一个完整的hackathon比赛。之前的hackathon,要么是7天的长期项目(2016年微俱hackathon)、要么晚上根本没有熬夜(微俱夏令营,晚上九十点出去吃烧烤晚上正常睡觉你管这叫hackathon?)、要么是作为组织者进行参加,而在这次比赛中,我们四个人是真正的从0开始,去除中间3个小时左右的睡眠时间,连续写了20余个小时的项目。现在看来那一个晚上的工作量简直爆表,要让我现在按正常工作时间来做,可能几周都做不完。hackathon果然让人疯狂。

其实进了微软的时候,微软内部的Hackathon正在进行,我也和同学一起报了名,项目也已经开始,但是由于时间问题最后没能提交。即使如此,最后还是有幸到了微软hackathon展示的现场,看看各位微软员工做的工作,体验真·hackathon。其中一位参赛者都30多岁有孩子了,他做的产品是关于家庭的(具体忘了),且在介绍自己的产品时仍然非常激动,像自己的孩子一样给所有参观者介绍项目的亮点,这让我十分感动,可能只有在生活没有压力的情况下,才能保有这样的年轻的心态吧。

微软Hackathon 2019苏州现场

退休前最后一个无忧无虑的假期

10月18日实习结束后我没有像其他大佬一样继续实习,而是回到学校躺尸。仔细想想,可能这是退休前最后一个没有什么负担的假期了吧。

这几个月主要做了以下这些事:

本来还想把给博客写点新功能(比如之前博客的发展1里说的统计、评论等),但是一玩起来谁还想做正事啊……

总结

2019年结束了本科阶段的所有课程和考试,确定了未来三年的走向,进行了第一次实习,享受了一个无忧无虑的假期。

2019年是收获的一年,也是思考的一年。通过亲身体验和思考,我对以后我想过的生活有了不一样的认识。而这个认识是否正确,那就只能用2020年、以及接下来更长的时间来检验了。希望站在2020年底、或者以后的任何一个时间点回望这一年的各种事情、各种思考、各种决定,我能说,我已经做到最好了。

入手4K显示器:LG 27UL550

2019-12-21 17:30:00

现在的混乱的桌面

出手已有显示器

最近身边几个同学配了新台式机,而且都是3700X+1660Ti的特别香的配置,让我心里直痒痒 :persevere:,但是身为一个坚决的等等党 & 防止49年入国军 & 明年下半年要换个城市呆着,台式机还是等到明年到新学校后,配Zen 3和30系显卡比较好。

但是呢,同学装机,得要显示器吧,我的显示器(ThinkVision X24q)正好也用了3年多了,用起来虽然没什么大问题,但是以后换了电脑,用个2K60还是太浪费配置了,换是肯定得换的。要换新,就要卖掉旧的显示器,而从闲鱼出的话,得清洁、拍照、和卖家详细介绍、包装、快递的,更别提碰到杀价党。而同学装机,也需要显示器,这不正好是一个出手好机会?不用清洁、不用拍照、不用快递、不用当售后,直接抬到楼上就完事了,省事:thumbsup:

ThinkVision X24q

当时这显示器买成1800,是当时最便宜的2K显示器了,虽然没啥特色功能(高刷新率、FreeSync、HDR啥的),但是显示效果、色彩、外观都是一流,并且刚刚能放进学校的这又浅又矮的桌子,是非常值得推荐的显示器。

之前的混乱的桌面……

本以为这几年显示器可能变化不大,现在出个1000块应该还是可以的,没想到联想变了,这显示器全新只要999了,比一些1080P的显示器都便宜……

2019年12月21日的京东价格

于是没有办法,直接500块丢给了同学。然后,我就开始了选新显示器的过程。

2K144 vs 4K60

选显示器第一个是选分辨率和刷新率,具体来说,就是在2K144和4K60里选。

4K60和2K144的显示器差不多,都有1700-2000左右的选择(主要是AOC和LG的,只要不要加Type-C等功能,这俩厂的确实便宜),最重要的差别就是分辨率和刷新率。

京东价格,其中LG的(27UL550)天猫能到1999

这个问题让我非常的纠结:

144Hz

高刷新率的丝般顺滑确实能够显著提高包括游戏在内的所有日常工作的体验。而且我个人也是主玩FPS类型的游戏,高刷新率在这类游戏里很有优势。

我的游戏笔记本的内置屏幕只是一块75Hz的屏幕,但是当我第一次用它,也感觉到之前没有体验过的顺滑,并且在我在换用60Hz的X24q时,也感到60Hz不太流畅。75Hz都这样了,更别提144Hz了。

但是,根据我的观察,2K144普遍使用的是广色域的屏幕,但是不知道是不是广色域的原因,我在体验了一同学的2K144后,感觉颜色有点不自然

另外,从一屏能显示的内容来说,从2K60换到2K144,分辨率没变,一屏显示的内容也不会有变化,在生产力方面可能基本没有什么变化。

4K

但是4K显示器的清晰和生产力也是我想要的。

我的X1C自带的屏幕是1080P的,让我自己换成了2K屏,这个清晰度的提高又让我回不去1080P了。

之前室友有一台LG 27UK650,体验过一下,确实也感觉到了4K的分辨率优势,虽然体验的变化没有60Hz到144Hz那么大,也没有X1C从1080P到2K的变化那么明显,但是确实文字更加的清晰了。

并且,通过降低显示器的缩放比例(DPI)(之前我2K屏是用的100%缩放,4K现在用的125%缩放),也可以增加显示器一屏显示的内容,提高生产力。(4K屏默认是150%缩放,但是150%缩放下的4K屏显示的内容和2K 100%是一样的(2160/1.5=1440正好是2K的垂直分辨率,水平分辨率原理相同))。

下表显示了几个尺寸和分辨率的PPI值(计算工具),可以看到这PPI提高是比较显著的。这里需要注意的是,PPI越高越清晰,但是这也需要和眼睛和屏幕距离有关。下表显示的距离是我自己的习惯的实测,每个人的偏好都不一样。眼睛和屏幕距离越长,需要的PPI就越低。14寸的两个是笔记本。可以看到,虽然24'2K到27'4K的PPI变化(计入距离的减小)和14'1080P到14'2K差不多,但是主观体验上来说还是笔记本的分辨率提高带来的变化更大。

尺寸分辨率PPI个人的坐正工作时眼睛到屏幕的距离
24' 2K122.38~65cm(24寸能放进桌面,看之前的桌面图)
27' 2K108.79~56cm (27寸不能放进桌面,只能突出来,降低了距离)
27' 4K163.18~56cm
14' 1080P157.35~38cm(笔记本)
14' 2K209.8~38cm

高度

另外还有一些其他小区别:比如LG的全系都放不进学校的桌面(学校桌面高度42cm左右,LG的最低只能放到43cm-46cm,AOC的是可以的放到40cm左右的);LG的不带可升降底座的会便宜200块左右。

AOC U2790PQU的高度调节范围(来源:京东) LG 27UL550的高度调节范围(来源:某天猫店)

但是高度不需要太过在意,这个把位置挪挪就能解决,并且有个额外的好处:可以通过可升降底座提高显示器的高度,防止长期低头对脖子的伤害

从文章第一张图和之前那张X24q的桌面的对比,可以看到目前的显示器的高度比较高(测量桌面到屏幕显示部分的距离,现在是18cm-52cm之前是12cm-42cm)。这样我用电脑的时候头甚至会稍微抬一点。这样虽然一开始不习惯,但是用一段时间后我确实发现了脖子会舒服一些。在微软工作的时候我也发现我和身边的同事的显示器的高度都是比较高的。

微软工位上的显示器高度。显示器的高度和位置是可以自己调的,我发现这个角度比较舒服

选择

选择4K60,就意味着放弃了高刷新率的丝滑体验,并且花钱买了我不需要的东西(10bit面板、准确的颜色等设计师专用的),还有看YouTube要耗更多的流量了;

选择2K,就放弃了更多的一屏内容和更加清晰锐利的文字。

这真的是个艰难的决定。

最后选了4K60,购入了LG 27UL550。最终让我下手的原因其实会让你们感到哭笑不得:

怕用惯了144Hz后,对其他所有屏幕都感觉卡顿

嗯。这理由非常让人信服。

4K144

其实呢,俗话说世界上所有问题都是没钱的问题。截至写稿时,市面上已经有好几款4K 144的显示器了,其中最便宜的是宏碁XV273K,某淘宝店最低卖到4600块。嗯,2K144+4K60=4K144,不管从性能还是价格上来说都没有毛病:thumbsup:。

XV273K

过段时间的CES2020上肯定也会有新的更便宜的4K144出现,但是个人推测最低不会低于3000,低于3000我会立刻吐血,还是没有掏出49年国军的命运。

21:9 1440P 144Hz带鱼屏

其实个人现在觉得要生产力和游戏兼备的,除了4K144,21:9 1440P 144Hz带鱼屏也是个比较好的选择。左右分屏空间大,1440P保证了垂直高度也是比较够用的,从生产力角度来看可能比4K更有优势。而且也有高刷新率,最后价格也非常不错:小米的可以到2499块(JD)。但是1440P的带鱼屏(注意不要买成1080P的)都是34寸的,对我来说还是太大了,宿舍空间还是太小了。要是有充足的桌面空间的话,可以看看带鱼屏。

小米的1440P带鱼屏

DisplayPort和HDMI的各种版本以及HDR

4K60

在1080P甚至2K屏时代,一般不需要太纠结接口的问题,因为目前早已经烂大街的HDMI 1.4和DisplayPort 1.2(包括mini DisplayPort)的带宽都能支持2K。但是4K60就有点不一样了:HDMI 1.4只能支持到4K30,所以要想要4K60,只能使用HDMI2.0或者DisplayPort 1.2或者1.4。

X1C的HDMI只有1.4,只能支持4K30

我的游戏笔记本(MSI GT72VR 6RD)自带了一个mini DisplayPort和HDMI。对于HDMI,官网显示这个HDMI是支持4K60的,所以应该是HDMI 2.0。而mini DisplayPort并查不到其具体的版本,但是由于1.2版本的DP就已经支持了4K60,所以这个口支持4K60是没有什么问题的。但是这个DP口到底是1.2版本的还是1.4版本的,我并查不到资料。

GT72VR 6RD的配置

之前为了使用VR而空出HDMI,电脑上一直是通过一根mini DP转DP线接在显示器上的。所以拿到新显示器后,我直接把DP线插上去了,4K60一点压力都没有。甚至可以打开10bit颜色深度。

4K60 10bit

HDR

本来事情已经就这样结束了。但是当打开HDR的时候,显示效果突然就不太对劲了:

后面两个效果能用肉眼看出来,但是尝试过多次无法使用视频或者照片表现出来,但是第一点可以对比以下两张照片看出来(两张照片均为设备小米9SE手动模式拍摄,光圈f/1.75,快门1/30s,焦距4.77mm,ISO 100,显示器亮度没有变化,第一张开启HDR,第二张关闭HDR,两张照片的顺序真的没有反)

开启HDR 关闭HDR

这就非常奇怪,因为按理来说HDR的效果应该是黑色更黑白色更白(对比度提高),文字和鼠标移动应该不会有太大的影响。

之后我去网上找资料,发现DisplayPort 1.2是不支持HDR的(从1.4开始支持)。

DP各个标准的特征。来源:维基百科

如果我的电脑的DP是1.2的话,由于带宽不足。这个可以解释文字和鼠标移动的流畅性,因为带宽不足,开启HDR会损失画面数据,从而造成鼠标卡顿和画面劣化。

之后,我将显示器搬到了另一个同学的装有RTX 2080的台式机上,接上DisplayPort 1.4的接口和线进行测试,发现鼠标移动的流畅度恢复了,但是画面仍然不太正确,仍然存在对比度下降文字不够清晰的问题。

这说明画面劣化问题可能是面板,而不是显卡或者线的问题。这块面板应该不支持真正的HDR效果,但是LG仍然把它标在宣传语上,同时操作系统也认为这块面板支持HDR,所以开启后发现画面出现了明显的劣化。这也说明了我的电脑的DP口应该是1.2的,鼠标卡顿的问题确实可能是由于带宽不够而造成的。

对于HDMI,虽然我的电脑配置上写的支持4K60,但是插上显示器自带的HDMI线后,电脑并不能认到这个显示器,没有信号输出,我也无法测试用HDMI 2.0能不能正常显示了。

最后,经过这些查找资料和实验,我认为可以得出以下的结论:

最终,我还是将HDR关闭,使用4K 60帧 10bit色彩进行显示。虽然没有HDR是一个不大不小的损失,但是4K60的显示效果也足够惊艳了。

FreeSync和G-Sync Compatible

G-Sync可以用来解决画面撕裂, 即屏幕不同部分显示的内容不一样,造成画面撕裂。下图是维基百科screen tearing词条上的示例图片,可以很明显发现撕裂点。

画面撕裂

导致这个问题的原因是显卡发给显示器画面的发送频率快于显示器自身的刷新频率,使得显示器还没有渲染完上一帧时,显示器就收到了并开始渲染下一帧,导致显示器上同一刻显示了两帧不同的画面。游戏里常常会有垂直同步选项可以用来解决这个问题。其工作原理大致解释为当显示器绘制完当前帧之前,显卡不能渲染下一帧。这解决了撕裂的问题,但是也浪费了显卡的处理能力,当硬件的刷新率低于显示器的刷新率时,会很大程度影响画面帧数。

而NVIDIA在2016年左右推出的G-Sync通过让显示器的刷新帧率和显卡同步来解决了这个问题,但是这要求显示器上有一块专门的芯片来动态修改和同步显示器和显卡的刷新帧率,提高了成本。我的笔记本的内置显示器带有G-Sync功能,打开后确实既能获得不开垂直同步帧数,也能避免画面撕裂。AMD也推出了类似的方案FreeSync,由于FreeSync是免费的,很多厂商支持的是AMD的FreeSync方案。但是当时,FreeSync只支持AMD的显卡,但是AMD的显卡确实还不够Yes,所以适用面比较有限。

以上对画面撕裂、垂直同步、G-Sync和FreeSync的介绍比较粗略和不准确,想知道更详细、更准确的关于这几个名词的知识,可以参考这篇我认为比较通俗易懂的中文文章

2019年,NVIDIA宣布了G-Sync Compatible,其对支持FreeSync的显示器也开启了部分G-Sync的功能。所以从现在开始,FreeSync的显示器也可以使用N卡获得G—Sync类似的功能。这是一个非常好的消息。所以到手后我就从NVIDIA控制面板把这个功能打开了。软件里面说“所选显示器”未被验证为G-Sync Compatible,是指的这个显示器不在NVIDIA官网上列出的验证过可用的显示器的列表中。但只要显示器支持FreeSync,这个功能也可以被打开。

G-Sync Compatible

这里需要注意的是,不要选择以窗口和全屏模式启动。开启后一些应用(例如Windows Terminal)会帧数下降。GitHub上也有一个issue在跟踪Windows Terminal的开启G-Sync后帧数下降的问题。其实G-Sync Compatible真正用处在游戏上,而大多数也是全屏打游戏的嘛,所以就以全屏幕模式启动就没问题了。

如果开启了以窗口和全屏启动,那么一些窗体上会出现这个G-Sync的标志

至于效果,我体验了一把BF5,确实完全没有发现画面撕裂的问题,帧数也稍有提高,可以说还是有点效果的:thumbsup:。

超频

虽然放弃了144Hz选择了4K,但是我仍对高刷新率耿耿于怀,仍然想最后超频一下试试,看看能不能在降低分辨率的情况下提高刷新率。

显示器也有超频的概念,即提高显示器的刷新率。显示器的分辨率是不能提高的,因为像素点就那么多,没法提高。但是理论上来说刷新率是可以被提高的,比如很多75Hz的显示器其是60Hz的屏幕超频而来。

显示器的超频比较简单,通过NVIDIA控制面板,按下图的步骤就可以尝试。修改刷新率后,点击确定,如果画面正常,可以继续尝试修改;但如果画面出现问题,等待20s,系统会自动恢复之前的设置,所以还是比较安全的。

超频

我在之前的ThinkVision X24q上尝试过,面板本身可以支持到70Hz(即到70Hz的时候还可以正常显示),但是那时候显示器上出现了一个一直显示的提示信息,说输入信息超出标准,请改回2K60。所以没有办法,只能调回去。

这次入手显示器后,我也想尝试进行超频。但是很遗憾的是,这块显示器在4K、2K和1080P分辨率下,最多只能提高1Hz的频率(即到61Hz),再提高就会直接不能正常显示。所以没办法,只能等着4K144白菜价时,再体验高刷新率的顺滑了。

不能正常显示

总结

买一个显示器真够纠结和折腾的,但同时也学到了和体验到了很多的东西,比如目前市面上有哪些显示器、各种显示器的价位区间、各种接口有哪些不同、4K显示器真正用起来是怎样的、HDR效果到底是不是噱头、超频到底可不可能等。这显示器目前接在1060上还是比较浪费的,3A作品还是只能开1080P才能流畅玩。等到了明年下半年,接上3070或者3080,开着4K光追60帧玩着游戏,那才叫爽吧:laughing:

暗色模式上线

2019-11-18 10:41:00

为什么一直没有暗色模式?

我是个暗色模式的粉丝。不管是操作系统、还是各种应用,只有有暗色模式,我肯定都使用暗色模式。更别提IDE了。因为我感觉使用黑色比较的护眼,没有浅色背景那样的刺眼。

但是讽刺的是,我的博客一年多都没有暗色模式。这是由于我的博客的主题是直接用的bootswatch提供的主题,而我认为这个网站提供的那套Darkly主题不太好看(主要是一些配色的问题,比如我不太喜欢绿色的链接、按钮啥的……),而在当时我很很弱鸡,不会修改主题提供的颜色选项。所以就退而求其次,选择了它提供的浅色的Flatly主题。

而前段时间,我终于又想开始把网站变成黑色模式。由于这一年中我的前端技术有了一定的提高,所以这次再看的主题的时候,发现修改颜色其实是比较简单的:其实就只需要修改修改SCSS中的一些变量即可。所以,这次我成功地把暗色模式应用到了网站上。

有哪些修改?

其实网站的主题方面大概来说变化不大。主题色没有改变,标题栏和footer等组件的样式没有变化。主要修改的是背景色文字颜色以及代码块的颜色

背景色、文字颜色

对于背景色和文字的颜色,你可能会感觉这是个很简单的工作:暗色模式嘛,背景纯黑文字纯白就可以了。但这样的效果其实并不好:黑白对比度太高,白色文字显得过于刺眼。纯黑的背景和网站的其他部分的颜色也不是很搭配。

黑背景白文字

所以,有的应用的暗色模式,不是使用的真正的黑:

因此,通过一些尝试和主观感觉,在我的网站中,背景和文字颜色分别选择的色号是#222#ced4da

当然了,如果读者对颜色选择有一些意见,欢迎在下面评论框留言。

代码块的颜色

本网站使用prism.js对代码块进行高亮处理。这个插件负责把代码处理成一个一个的token,然后使用CSS对各种token进行样式处理的。之前代码块使用的是类似Visual Studio的样式(地址),如下图:

VS代码高亮样式

看起来还是蛮不错的,但是它是浅色的,放在黑色背景下很瞎眼。所以这让我去找暗色的配色方案。

通过寻找,我也成功找到了一个类似VSCode默认的暗色配色(dark+)的方案。把它下载下来后,经过一些魔改(设置背景色、字体优先Cascadia Code等),就成功用到网站上了。效果如下(下面是张图片):类似

VSCode Dark+ Cascadia Code

不足

其实我的本意是像其他很多网站一样,可以在暗色模式和浅色模式中切换的。但是由于我的样式是使用的SCSS,各种颜色是使用的SCSS的变量功能,而SCSS其在网站构建的时候被编译成CSS(就像TypeScript在构建的时候被编译JavaScript一样),这些变量的信息就丢失了,编程出来的CSS里就是写死的颜色。要做到可以动态切换,有两种办法:

  1. 使用CSS Variables功能(MDN),即使在SCSS中,首先把所有的SCSS变量设置成CSS variables,然后使用单独的CSS类来设置各个CSS Variables,以此来实现动态切换颜色
  2. 写多份SCSS文件,每份文件设置一个颜色主题,然后编译的时候产生多个CSS文件,然后通过引用不同的CSS来实现的颜色切换。这个只是理论可行,实际代码上怎么写没有试过。

其实第一种方法的可行性挺高的,但是就像我在之前博客总结的一样,我的博客的样式方面的代码就是一团乱麻:有的样式是写成scss的,有的样式是用styled-components写的,而二者的互操作性又基本为0,所以造成了样式同步的问题:

styled-components无法使用scss的变量,使得有的常量定义在SCSS中,有的定义在ts文件中(variables.scssvariables.ts)。但是有点常量又是公用的,所以只能在ts和SCSS都定义一次,这又不能做到修改一处全局生效(例如这次修改暗色模式的之后,文章列表中每一个文章的标题仍然是黑色的,因为这些标签的样式是用styled-components,并不能和SCSS中定义的颜色相同步)

这次更新我也对代码做了一些很有限的重构(例如把文章列表的每一个文章项的样式的风格写成了SCSS (article-item.scss),并且在其中引用variables.scss中定义的颜色。这样,修改variables.scss之后,文章项的样式也能自动同步)。但是这是治标不治本的,并没有解决我的样式代码过于混乱的问题。我希望在以后能够完全使用一个统一的框架和规范来编写我的样式,从而不仅提高代码的可维护性,也能实现诸如主题切换这种功能。

修复gatsby-transformer-remark插件中文词数统计错误问题

2019-11-03 10:10:00

问题

本博客是使用gatsby搭建的,而gatsby官方提供了一个gatsby-transformer-remark插件可以用来将markdown转换成html。此外,这插件在渲染markdown之外,还提供了一些很贴心的小功能,包括词数统计(wordCount)和阅读时间(timeToRead)。这两个小数据放在博客里也能让用户能够简单地知道一篇文章的大致篇幅,从而提高用户体验。

但是呢,这两个功能在之前都不支持非拉丁字符!,让这两个功能形同虚设。并且,插件也没有提供设置自定义的计算函数的方法,所以这两个功能对中文等非拉丁字符用户来说是比较鸡肋的。

难道这功能就这样闲置下去吗?不!我在这里提供几个可以用来在一定程度上修复此插件在中文环境下失效的问题的方法。

推荐方法:使用timeToRead

Gatsby的PR #18303已经修复了timeToRead无法统计中文字数的问题。虽然有人在下面说这个PR没有合并进去,但是实际上是可以用了的。

这个修复是在原来的数据上,再将文本使用/[\p{sc=Katakana}\p{sc=Hiragana}\p{sc=Han}]/gu这个正则表达式作为模式,统计文本中符合这个正则的元素的数量,把中文的数量加到原来的数据上。而对于中文,这个正则表达式简单来说就是把每个中文字符统计一个单独的元素。通过这样,用来计算timeToRead的字数数据就基本完善了。

timeToRead的计算方法就是词数/平均WPM(average word per minutes, avgWPM),平均WPM在代码中硬编码为265,所以这样得出的timeToRead也是基本准确的。相关代码可以参考下面的链接:

https://github.com/gatsbyjs/gatsby/blob/3aa41fb8dbf7fe294f35a706424c6b2b11345881/packages/gatsby-transformer-remark/src/extend-node-type.js#L586

另外,根据这个公式,也可以通过timeToRead估算词数,即是timeToRead * avgWPM (265)就可以了。但是由于插件返回的timeToReadMath.round过的整数,直接乘265的结果的误差在±265左右。其实这个误差不算大,但是由于265是5的倍数,这样估算出来的得出的结果有点太假(全是5的倍数,好巧),所以请看情况使用这个方法。

这里需要注意一下,265字/秒这个速度对英文来说可能比较合适,但是对中文来说是比较慢的。但是对于不同种类的文章来说,WPM是不一样的,比如读小说和读专业书的速度肯定是相差几倍,所以WPM取多少没有一个固定值。根据网上查到的资料(其实知乎上的某相关问题……)和自己的体验,在我的网站中对中文文章平均WPM取的值为500

2020-4-11更新:gatsby的PR #21312对中文字符和日文字符的timeToRead算法进行了一些改进,目前的结果较为正常,可以直接使用,不需要像上一段一样使用不一样的WPM重新计算timeToRead了。

替代方法:移植timeToRead统计算法

wordCount.wordstimeToRead中词数的算法是不一样的:

所以timeToRead修复了,不代表wordCount也修复了。实际上,源码中也提到了wordCount支持非拉丁字符是个TO-DO,追溯到remark的对应issue,发现这个问题是2017年就提出了,而且看讨论的进度在短时间之内是不会修复了。

如果你不使用wordCount query中的其它项而只使用wordCount.words,那么可以考虑以下把timeToRead的词数统计方法移植出来,成为一个单独的field。具体有以下几种方法:

根据gatsby官方文档( https://www.gatsbyjs.org/docs/creating-a-local-plugin/ ),gatsby是可以从你本地项目的plugins目录中使用插件的。

所以你通过进行如下操作,给allMarkdownRemark这个GraphQL query中增加一个wordCountChinese字段,这个字段使用timeToRead中的统计算法来统计文章字数:

  1. 在项目根目录创建一个plugins/gatsby-transformer-remark-fixed目录
  2. node_modules/gatsby-transformer-remark中的内容复制到plugins/gatsby-transformer-remark-fixed目录中
  3. plugins/gatsby-transformer-remark-fixed/extend-node-type.js文件中,接近文件末尾的wordCount项之后,增加如下代码(如果wordCount最后没有;,记得加一个):
wordCountChinese: {
  type: "Int",
  resolve(markdownNode) {
    return getHTML(markdownNode).then(html => {
      const pureText = sanitizeHTML(html, {
        allowTags: []
      });
      return (
        _.words(pureText, /[\s\p{sc=Han}]/gu).length
      );
    });
  }
}
  1. 然后在本地的gatsby-config.js里,将gatsby-transformer-remark替换成gatsby-transformer-remark-fixed
  2. 之后,在项目里就可以在每个node上query wordCountChinese啦!

这个方法得出的数据比较偏大,在两篇文章上的结果如下表:

文章语言原插件wordCount.words的结果这个方法的结果Microsoft Word的结果
折腾Linux:从实体机到Win10中文58752325211
A React Form Component Performance Optimization with Profiler英文153219391653

这个方法的问题就是:你需要跟随上游gatsby-transformer-remark的更新而更新本地的插件。这个更新是比较麻烦的,而且随以fork也是社区的分裂的根源之一,所以不建议。

参考项目:gatsby-transformer-remark-chinese-word-count

如果你非要获得一个较为真实的词数,但是又不想向上面一样的自己做(伸手党?),那么根据之上同样的方法,我弄了一个gatsby-transformer-remark-chinese-word-count项目,用来简化自己从node_modules中提取和修改插件这个流程。使用方法可以参考此项目的GitHub链接。

但是这个项目仅供使用替代方法1时的参考,个人不推荐使用在项目中这个插件,我也没有把插件发布到npm上去,理由如下:

  1. 这个项目不会随着上游的更新而更新,所以很可能这个插件会是过时的
  2. 使用这种插件会造成社区的分裂

而这种计算方法由于太过简单,并且也只覆盖了中文这一种情况(对于其他语言情况太过复杂,我也不甚了解,所以也没有能力去支持其他语言),gatsby团队应该也不会接受把这个算法合并进主项目。

总结

尽量使用timeToRead以及使用timeToRead来估算总词数。如果非要获得较为准确的词数不可,考虑自己修改gatsby-transformer-remark插件并维护。gatsby-transformer-remark-chinese-word-count项目仅用来作为修改插件时的参考。

折腾Linux: 从实体机到Win10

2019-11-02 20:20:00

从三年前Xubuntu开始

想当年大一的时候,由于当时所有预算都投入到游戏本上了(这仍然是我目前最后悔的一个决定,要是当时能直接上台式机,性能能提升很大一个层次),所以外出只能带一台三代i3的联想Yoga笔记本来应付(要是没记错的话,应该是Yoga 11s,2013年的产品了)。虽然这是台预装Windows 8的标杆型翻转屏设备,但是在低压三代i3+4G内存+128G SSD这可怜的配置下,做点普通的工作,性能和存储空间都有点捉襟见肘。于是,当时我萌发了装Linux来省空间、以及让系统运行地更快的想法。当时我安装了Xubuntu,默认安装轻量的Xfce桌面环境(而不是当时Ubuntu默认的Unity),运行速度确实比跑Win8要高一个档次,同时能准备拿这机会熟悉熟悉Linux。当时计基的作业我还是拿这台装有Xubuntu的电脑去检查的,感觉逼格满满。但是后面没多久,这电脑实在有点支撑不了在外面写作业编程的需求,所以假期之后就换成了一台稍微能用一点的低压五代i7的一台联想扬天,后面也没拿实体机装Linux玩了。

这个假期,闲来无事,看着都已经有点审美疲劳的Windows 10,我又想开始折腾了。

Manjaro好

装Linux的第一步当然是选择发行版。对于很多人来说,Ubuntu一定是他/她第一个接触和尝试的Linux的发行版。但是我还记得在当年用Ubuntu的日子,装软件麻烦(什么PPA什么的)、容易挂(你敢相信我装了系统第一次升级之后,系统就各种冒出各种内部错误的提示吗?)等问题让我对它的印象不怎么好。

通过找搜索了下资料,我发现目前网络上对Manjaro这个发行版评价非常高,尤其是对它继承于Arch Linux的pacman包管理工具和AUR,大家都表示好评。这不正好解决了我的第一个麻烦了吗?至于容易挂这个问题,通过了解,虽然Arch是滚动更新(即不像Windows, Ubuntu, macOS等主流的系统会每隔一定时间发布一个大更新包),但是只要更新及时,一般也不会造成太大的问题,并且Manjaro也通过把Arch的包延迟2周来尝试解决滚动更新的问题,所以我对这个问题也暂时没怎么考虑。

选择了Manjaro后,我又发现Manjaro提供了很多预置的桌面环境,官方支持的都有KDE, GNOMEXfce三种桌面环境。经过了解,KDE似乎是从功能到界面最完善的桌面环境;而Xfce就和当年一样,注重轻量级和效率;GNOME介于两者之间,但是似乎有不太好的名声。想到我目前的电脑也不弱(X1C 6th),于是就选择了KDE,看看目前Linux的“最好”的桌面环境怎么样。

于是,8月份,我选择了Manjaro KDE作为几年来我第一个尝试的Linux发行版。

Manjaro KDE

KDE的第一印象还是非常不错的。界面确实比较好看,功能也比较完善,甚至还有手机的KDE Connect App可以把电脑和手机相连,体验了一下功能还是可以用的(MS的Your Phone……不知道为什么一个连手机的App还要限制区域)。各种应用也比较齐全,Arch的软件包管理确实名不虚传,只需要配置一下源、安装一下keyrings就可以直接pacman -S安装很多软件;不够的话,还可以用yay等一些工具装AUR上面的各种各样的软件;还可以用archlinuxcn源装一些国内的应用,比如网易云音乐、QQ什么的,确实非常方便。对于HiDPI也有不错的支持。其中QQ的配置需要强烈感谢 https://www.lulinux.com/archives/1319 ,有兴趣的同学可以去看看这篇文章。

KDE桌面截图

KDE HiDPI设置和国产软件

但是HiDPI也有问题:deepin分发的QQ不知道为什么依赖了gnome-settings-daemon这个GNOME桌面的依赖,要想在KDE上运行,则需要单独安装这个包。但是呢,这个安装这个包会影响KDE的DPI设置,造成KDE的字体设置会无效,让基本所有地方的字体都过小,且无法变大。

字体设置

QQ,注意看群聊名称下一栏

这个影响其实比较大的,因为这造成基本所有的地方的字体都变得非常小,很难看。这个问题也在GitHub有跟踪(Issue以及它reference的一个issue都是这个相关的),目前好像也没有什么好解决方案。

Manjaro GNOME

KDE其实感觉挺舒服的,但是似乎没有办法解决GNOME和KDE冲突的问题,机智的我突然想到:要是我直接用GNOME,不就没有冲突的问题了?

于是下载了GNOME版Manjaro。进了系统后发现字体太大,一进显示设置就傻了:居然只支持整数倍缩放……

100%和200%

当网络上都在喷Window HiDPI的时候,没想到GNOME,这个这么流行的桌面环境不支持非整数倍缩放……从网上搜索GNOME从2017年就开始做分数倍缩放(fractional scale),但是到现在都没能完全支持。网上有一些办法据说可以打开分数倍缩放的功能,但是我都没有成功过,不知道为什么。

放弃...一段时间

在折腾的时候,我发现Manjaro确实解决了之前我提到的软件更新的问题。软件安装方便,更新也是基本无缝了,没有再有什么更新一次就内部错误这种问题……而且整个生态环境也更加地完善了,感觉如果没有腾讯毒瘤的话,Linux作为日常和开发使用应该问题不大。

但是在折腾GNOME后,感觉有点累觉不爱了。这时正好我在实习,晚上一般都在主力机面前看看视频打打游戏啥的,也不想再折腾Linux了。

直到……

平铺式窗口管理器(Tiling Window Manager)

一个偶然的功夫,我听说了平铺式窗口管理器 (Tiling Window Manager)的概念。

根据我的了解,目前Windows, macOS, KDE, GNOME等绝大多数浮动式窗口管理器(floating wm),默认把各个窗口像一个一个卡片一样堆叠在桌面上,各个窗口可以随意拖动改变位置和缩放大小,之间可能有重叠。而平铺式窗口管理器把各个窗口平铺在桌面上,各个窗口之间没有重叠,以尽可能利用屏幕空间。并且,各个窗口间没有重叠,配上workspace(即虚拟桌面)的概念,也方便自己在打开的超多窗口中快速找到和切换到自己的想要的窗口。

上面出现过的KDE桌面,各个窗口之间有重叠

i3wm官网上的平铺式示例,各个窗口之前没有重叠,共同覆盖了整个屏幕空间

另外,平铺式窗口管理器一般还会有一个特征,即可以用键盘完成所有操作。例如在i3里,可以使用Win或者Alt+其他快捷键完成打开终端打开浏览器打开dmenu等任务启动器切换目前激活的窗口缩放窗口等所有操作。当时我刚学会用vim,感到纯键盘确实可以带来一些效率的提高,而这个纯键盘操作确实相当吸引我。

manjaro i3的默认壁纸会给出操作提示

Manjaro i3

i3是一种比较典型和简单的窗口管理器,它不仅有以上的所有特征,而且配置简单(全在一个配置文件里),功能也还算比较全,而且默认支持HiDPI。Manjaro i3也配了一些例如urxvt等辅助工具,安装好之后整个系统还算比较完整。

但是尽管这样,经过上次直接在实体机上折腾Linux的教训,这次我学乖了,先开了个Hyper-V虚拟机来配置下试试,看看效果。推荐一下这个YouTube上配置i3的系列教程,讲得非常清楚,跟着做确实也能系统配置得像模像样的。另外,也尝试了下QQ等软件的效果,虽然是平铺窗口管理器,但是也可以手动、或者根据配置文件对对应app切换到浮动式显示,这些软件在i3的显示效果也还是可以接受的。

于是,一狠心,我就把Manjaro i3装到实体机上了。复刻了一些配置,以及安装一些软件之后,整个体验确实很不错。甚至FreeOffice标榜和MS Office无缝兼容,Office在Linux上也能正常编辑了。

装逼专用neofetch, zsh, htop

浮动式的应用

firefox + vscode

FreeOffice Word and PPT

QQ

但是呢,一旦到了QQ,事情又变得难受了起来。

10月24日,时隔上次更新10年后,QQ居然更新了Linux QQ。虽然界面上看上去挺原始的(北京申奥成功了!!),但是还是一大进步,而且根据“有原生用原生”的理念,我第一选择是看看原生QQ在i3上体验怎么样。

不装不知道,一装吓一跳:不支持HiDPI消息弹窗被遮挡(注意右下角,把QQ的图标移到最左侧可以解决这个问题,但是也是比较麻烦的)忽略了消息屏蔽字体和背景颜色等问题让我几乎不能正常使用QQ……果然是10年前的代码拿出来小改就用了……

QQ

唉,原生QQ效果这么差,那试试之前尝试的deepin容器的QQ?结果在i3上遇到了KDE同样的问题:字体大小又被影响了(注意下图右侧的控制台字体)。另外,这些窗口的样式似乎也被改变成了GNOME的样式。注意下图Firefox的顶栏样式(和之前的图片进行对比)、包管理工具的窗口样式、以及右下角的图标,完全就是GNOME嘛!

运行了gnome-settings-daemon后的效果

这些效果只要一运行gnome-settings-daemon就会出现,而且必须重启系统才能恢复,但是不运行gnome-settings-daemon又不能运行QQ,这就让人很为难了。

麻烦的配置

另外配置的过程也是非常的麻烦。虽然很多软件只需要修改一个配置即可成功配置,但是架不住软件多啊!每个软件配置的风格不一样(JSON, key-value等),而且各个软件配置更新后,让新配置生效的方法也不一样,记住这么多不一样的配置方案也是挺累的。

而对于一些我们理所当然的东西,比如输入法、音频、网络等,很多也要自己配置才能正常使用。输入法fcitx是比较简单的,音频、蓝牙等这些本来以为理所当然桌面会给处理的工具,现在都要自己安装和配置,一不小心就是乱码、报错。这不,想把alsa升级到pulseaudio,一不小心,pulseaudio又挂了,而且archwiki也找不到解决方案。之前我还在没有安装其他命令行模拟器的情况下把urxvt给配置炸了(字体设置错了),结果终端直接无法启动了,没法改配置文件;而不能改配置文件,终端也启动不了,这样陷入了僵局。

pulseaudio...

i3虽说比较灵活,配置起来也比较简单,但是很多事情也是需要自己动手的。例如说像之前说的让一个软件自动变成浮动而不是平铺,虽然只需要在配置文件里增加一行,但是对于每个软件都得自己处理,也是一件非常耗时间的事情。但是要是不处理呢,很多软件显示的效果……不说了,自己看下图。

平铺QQ窗口的后果

纯键盘+平铺式一定是最有效率的吗?

另外一点是之前说的最吸引我的一点:纯键盘操作。不可否认的是vim类似的键盘快捷键给敲代码提高了很多的效率。但是键盘也不是在所有场合都是效率最高的。在一些日常场景下,键盘+鼠标更能提供效率,例如:

操作蓝牙

另外,即使我们认为用户都能记得这么多命令和参数,但是在使用命令行操作时,需要自己输入各种命令、参数、选项,在整个操作的过程中,人的精神是紧张的。如果操作都使用命令行进行操作,用户很容易感到疲惫的。更别提用户第一次操作时完全不知道使用GUI操作时,需要--help, man, archwiki等在各种浩如烟海的文档中找到自己需要的操作在命令行中应该怎么写,这也是非常耗时间和疲惫的(这里吐血推荐tldr,短短几行告诉你命令怎么打,大多数情况下就不需要去几十页的man里找到自己要的几个字母了)。而使用GUI操作的时候,即使效率不如键盘,但是在整个操作过程中用户只需要操作鼠标以及简单地输入少数字符,整个用户体验会比命令行舒服,用户也不容易感到疲惫

tldr

当然了,在那些需要效率的地方当然命令行更能提高生产力,但是用电脑也不是要一直要保持高效率吧?而在i3这种平铺式窗口管理器上,只使用鼠标几乎什么都做不了,这就对我这种懒人造成一些挑战了。

对于平铺式窗口管理器的批评,我个人认为这个YouTube视频 Tiling Window Managers suck. Here's why 里说的几个点还是比较有道理的,感兴趣的同学可以看看。

Arch Linux

大家都说Arch Linux装一次要几个小时,甚至认为Arch Linux不提供图形安装界面就是为了筛选用户……在折腾i3的过程中,我也尝试过直接安装Arch Linux,并且我在虚拟机上成功了。确实Arch Linux的ISO不提供图形安装界面,一进去就是命令行,分区、引导啥的都得自给自足(fdiskgrub),确实有点劝退,但是跟着archwiki上的官方的Arch Linux安装教程走,基本可以走完安装全过程,再配上比如这个教程查漏补缺,走完安装,进入系统其实问题也不是很大。但是后面的各种折腾就因人而异了……Manjaro装完起码提供了X11,Arch装完连X11都没有,什么都得自己弄,遇到问题也得全网搜(比如我的这个系统,X11性能特别差,鼠标移动都有延迟,我也最终没有找到能用的解决方案)。Arch确实很适合一些什么都要自己操作的用户!

Arch Linux + i3 + urxvt

Windows 10

最终我还是回到了Windows 10,毕竟目前最好的Linux发行版是Windows 10(误。在Windows 10上通过WSL也能享受到和Linux差不多好的命令行体验,即使有一些IO等问题,但是使用体验已经非常接近了。并且等到20H1发布后,就有了完整的Linux内核了,那时候,Windows == Windows + Linux,使用一个系统就能享受到了两个系统的好处,岂不妙哉?

Arch on WSL

总结+建议

你可能会觉得我会感慨一下然后以后就再也不折腾了?naive。折腾是不能停的!吸收各家所长,扬长避短,才是最吼的。所以以后呢我还会继续折腾各种各样的Linux发行版(甚至试试BSD :joy:),但是可能以虚拟机为主。毕竟Windows作为一个更加成熟的操作系统平台,更能满足我各种各样的需求。

另外,如果你也想折腾Linux,不管是虚拟机还是实体机,这里给三个建议:

如果你学会用vim,操作Linux中各种各样的配置文件将会变成很简单的事情。并且学会vim也能帮你打代码的时候提高效率。当然了,学vim的副作用就是以后做什么事情都想用vim的快捷键 :joy:

archwiki真的是个好东西,其内容之详细、实用让我叹为观止。很多不仅限于Arch的Linux相关的问题都可以archwiki上得到解答。

毕竟Linux就不是给懒人准备的。多查资料、多尝试、多写自动化脚本,尝试找到一个让自己用起来舒服的配置。

最后,这里放一下我的dotfiles。里面也有一些配置脚本,可以用来快速初始化一个manjaro, zsh或者Arch on WSL系统。

从viccrubs改名为ddadaal

2019-10-31 14:28:00

改名

标题越短,事情越大!

我决定将我在网络上的用户名从viccrubs这个至少用了5年的名称,以及daacheen这个用了半个月的名称(捂脸),改到ddadaal

原因

改名的原因有以下几个:

目前,在网络上我主要使用以下两个名字:smalldaviccrubs

前者是我最早使用的一个名字,其是我最早使用的中文网名小达的英文翻译。这个名字不能继续使用的原因很明显:我长大了 :joy:。这个名字在Steam和邮箱上仍然存在,所以有时候给其他人共享Steam账号和邮箱时都有点羞耻的感觉 :persevere:

第二个名字是我意识到前一个名字问题后(我觉得应该是14年开始用的)拍脑袋拍出来的另一个生造的名字Victor Crubs的缩写。这个生造的名字的姓甚至根本没有这个词!但是这么多年来也一直没找到一个好用的方案,于是在后来新的地方(例如GitHub等)都使用了这个新的缩写。但是由于一直对这个名字有点不太满意,所以最重要的邮箱没有改,仍然维持了smallda的名字。

这就造成了一些割裂感,虽然在绝大多数地方都是使用viccrubs,但是就像上面所说,邮箱没有改,对于强迫症来说这很不爽呀!使用一个统一的名字能够减少这种割裂感。

这个缩写主要有以下几个问题:

10月15日开始,我正式启用daacheen。为什么当时选daacheen而不是其他名字呢?

但是呢,经过这半个月的使用,我发现这个名称有几个问题,最终促使我使用ddadaal来代替:

ddadaal这个词呢,是达达的魔改,正好我更喜欢用达来表示自己(以及初中高中的时候大家都是这么叫我的);同时这个词具有上面所说的所有特征,所以感觉用ddadaal可能更加合适。

改名遇到的问题

就像在现实中改名一样,在网络空间更改一个使用多年的名字是比较麻烦的事情,但是反正要改,早改的影响总比晚改好

还好,最重要的GitHub和微软账号是可以改的,而且可以无缝切换,减少了很多迁移的成本。

由于之前一直都外面都是用viccrubs来表示的自己的,可能会有人对这个名字产生了印象。突然改名字可能会让已经记住我原来名字的人和第一次看到新名字的人感觉迷惑,影响ID的影响力。

对于这个的问题呢,我的看法是:

  1. 讲道理我的影响力应该不值一提,根本就没人记得我原来的名字;
  2. 我有一个非常有代表性的、仅此一家别无分店的头像,修改名字对我的影响力应该不大,从头像也能认出来
  3. 就像一开始所说,早改总比晚改好,越晚改,就有更多人对我原来的名字有印象,改名的影响就越大

改名所涉及的方面

改名将包括以下方面:

最后

如果没有什么大的特殊情况(10月31日:特殊情况),这个名字应该会用一辈子了。希望大家能早点适应我的新名字 :smile:

A React Form Component Performance Optimization with Profiler

2019-10-13 11:14:00

Note: This code is from an internal project. Measurements apply to protect confidential information, but the problem and methods mentioned in the article are universal and not limited to specific projects.

The Problem

I was working on a form that had very poor performance. To be specific, at every key stroke, the form responded pretty slowly. What's worse, the component and the whole website became completely unresponsive for seconds if several keys were stroked sequentially (like input a long number), which was, unfortunately, the normal use case for this field.

bad performance, especially for the first input field

Investigation

Validations?

From the gif provided above, you may find some details that are worth taking notice of:

Hmm...It looks like the validation might be the cause.

However, the validation process was merely some format check, and a request which would be cancelled if next key stroke had come before it completed. It did increase the delay of input, but should not be so significant.

Re-renders of the Whole Form?

The Challenge of Forms in React

The re-render of the whole form at every input might be the problem as well.

React has been known as not good at implementing forms because of its famous unidirectional data flow.

Source: https://gist.github.com/alexmingoia/4db967e5aeb31d84847c. See this page to get yourself familiarized if you haven't already.

Unidirectional data flow simplifies the data flow and fits seeminglessly with the concept of React. However, since each update to the state will trigger the component to re-render, it might impact the performance of the app.

Form is one of such cases, because a form consists of multiple input fields, and each input to any one of them would update the state, and re-render the whole form and every other fields. Therefore, writing forms in React has always a challenge.

Complicated Form

Looking at the code, the form was implemented in a very React way: the form contained all the fields, and handled every onChange event for each input fields.

part of fields

The form was complicated with dozens of fields, several sync/async and cross-field validations, dynamic placeholders and some unnecessary duplicate calculations (which could be improved with constants and cache).

It seemed pretty evident that the re-renders and the complicated calculations during the render were the cause to the problem.

No Easy Solutions

Having found the reason, what's left was to find a solution. However, the reason that form implementation is called a challenge in React is that it can not be solved easily. There are lots of React form library from the community (like react-form, formik) trying to solve the form as a whole by providing a complete solution to some common form challenges, including input management, async validations, and submission. They may be viable for many users and should be considered if you are having trouble building a form in your project, but I couldn't adopt them in this project because:

Maybe Not Re-renders?

Besides, my experiences told me that it was possibly not the re-renders that was to blame, since the updates of the form should not be so costly that would result in such a significant delay.

I have encountered situations where even a whole page re-rendered at every interaction, but React and modern JavaScript engines is more than capable to drive it smoothly without even a stutter. This form had several fields to be re-rendered at every input, but when taking the whole page into consideration, the number of re-renders inside this page was negligible.

Profiling, Profiling, Profiling

After wasting days on brainstorming the source of problem, I decided to diagnose the issue in a scientific way -- profiling.

React's official devtool of Chrome has a built-in profiling tool. By recoding a sequence of user interactions, profiler can provide performance metrics on each commit during the record, like what components are re-rendered, how many time they take to re-render, with which the developer can locate the components that have biggest impact to the performance, and optimize them accordingly.

Start the recoding, repeat the operations of the gif above, and I had the following result:

Ranked chart Flamegraph chart

The first chart (ranked chart) showed the time to re-render for each component that had re-rendered on this commit. The second chart (flamegraph chart) shoed the re-rendered components hierarchically: that is, the time to render the component itself, and each of its children.

As you could see, a Dropdown component took a significant time to re-render. In fact, all the graphes on all the last 10 commits had led me to the same component. By following the hierarchy provided by the flamegraph, it didn't take long to locate the component, which was the dropdown on the right side of the first field with slow responsiveness, and implemented as follows:

The dropdown

It looked like a normal dropdown with the exception of a duplicate calculations: generating an options array at every render. Providing different instances at every render is a common source of performance problem in React, because it signals React that the props have changed and a re-render is required, even the objects between renders are deep equal. The solution is straightforward: instead of generating a different instance each time, provide a constant or a class field that only changes when it is modified.

But unfortunately, in this case, after replacing the call to this.generateDropdown to a constant, the problem persisted.

Large Quantity of Options

The source of options of the dropdown came from a network request that was initiated when the page loaded. Another clue that I found during investigation was that when network errors happened, the input became very responsive, at which time the dropdown had no option. And after several intentional experiments, I found the correspondance between options and performance to be true (no option -> good performance, had options -> bad performace).

I was just a maintainer of the component, not the original authors, so I didn't take notice of the contents of dropdown during my previous investigation. Now, according to the correspondance, I checked out the options of the dropdown, only to find it had over 400 options.

>400 options

Rendering so many options at each render would definitely impact performance pretty hard, unless the component had thought of it and selectively re-rendered only the changed part.

This project used React Wrapper of Semantic-UI which was a famous component library and open source. By diving into the source code, I found that the dropdown did re-render every options every time without any memoing or caching.

https://github.com/Semantic-Org/Semantic-UI-React/blob/master/src/modules/Dropdown/Dropdown.js#L1275

// omitted
renderOptions = () => {
  // omitted
  return _.map(options, (opt, i) =>
    DropdownItem.create({
      active: isActive(opt.value),
      onClick: this.handleItemClick,
      selected: selectedIndex === i,
      ...opt,
      key: getKeyOrValue(opt.key, opt.value),
      // Needed for handling click events on disabled items
      style: { ...opt.style, pointerEvents: 'all' },
    }),
  )
}
 
renderMenu = () => {
  // omitted
  return (
    <DropdownMenu {...ariaOptions} direction={direction} open={open}>
      {DropdownHeader.create(header, { autoGenerateKey: false })}
      {this.renderOptions()}
    </DropdownMenu>
  )
}
 
render() {
  // omitted
  return (
    // omitted
    {this.renderMenu()}
    // omitted
  )
}

Solution

In this case, the options didn't update anymore after the request had completed. Therefore, there was no need to update and re-render the options every time the component had changed.

To stop the re-render, I extracted the dropdown out into a separate component, passed the options as a prop, and most importantly, made it a PureComponent which only re-rendered itself when the props had changed.

The dropdown was used in a uncontrolled way -- that is, not managing its value by the DOM instead of as a state of React component by not passing value to the input DOM field. I had to admit it was not the React way, but here, not passing value to it meant that the component would never update, which was exactly what I wanted.

Optimized code

The optimization worked like a charm: the render time of the whole form for each input had reduced from ~400ms to ~22ms, and there was no any unresponsiveness and delay at every key stroke.

After

Verdict

Form is React is indeed a challenge. Without careful designing and implementation, problems may occur at any time, anywhere.

The issue here was ultimately the re-renders, but not the form I originally thought of, but the dropdown with large number of options. It is important to app performance to understand React update strategy and control the component update with lifecycle functions, property hook usage, memoing and caching, especially when the the number of component to render is too large.

The profiler was the key to finding the component to blame by providing the data of time to re-render for each component. Therefore, when a performance issue is detected, stop wasting guessing which compoenent causes it, and let profiler take you straight to it.

博客的发展一:RSS,国内托管……

2019-09-26 21:46:00

前言

不知不觉,Gatsby博客已经上线了一年多。在这一年中,虽然从表面看起来博客变化不大,但是我一直在对博客做一些更新工作。特别是最近一个月,我对博客一些耽搁已久的问题进行了解决,使整个博客更加的完善了。这篇简单的文章就简地列举一下最近博客一些特别值得提到的更新,目前存在的问题,以及对博客的未来发展做一些展望和规划。

更新

RSS恢复支持

博客的RSS源地址是:https://ddadaal.me/rss.xml。欢迎订阅!

其实博客从很早开始(具体来说,从去年10月的e2e469开始)就已经加入了RSS支持,但是那时候的代码就是随便从网上抄了一段,没有对博客比较特殊的地方(例如说多语言的支持,存在不能显示在列表中的文章等)进行定制,后面对博客一些更新的时候也都直接放弃了RSS。这几天,我针对博客的RSS的功能进行了一些修复,包括:修复文章中不合法的日期串RSS项中的原文链接变成绝对地址而不是相对地址重新修改序列化方法等,使得博客的RSS功能基本上可以正常使用了。博客的RSS源地址也在W3C的Feed Validation Service中成功认证,对于大多数RSS阅读器来说已经可以正常使用了。

有效性认证

文章列表(Newsflow UWP应用)

文章内容(Newsflow UWP应用)

当然,根据认证服务的结果可以发现,博客的RSS还有一些问题,其中比较严重是文章内容的图片地址是相对地址,而不是绝对地址,这造成了包括Read(一个Android RSS应用,Google Play)等一些工具不能正确显示图片。但是这些问题可能在短时间内无法被解决,因为我目前暂时没有找到有效的、可扩展的方法hook进入Gatsby构建时markdown的渲染流程,并根据我的需求进行修改。

详细地说,目前,将MD编译成HTML是由remark,以及很多周边插件(例如Gatsby-transformer-remark)共同完成的。这些插件都有一些默认行为(例如图片地址是相对地址)等。

这些默认行为在大多数情况下都是合理的,让开发者能够开箱即用。但是,约定大于配置的反面就是配置常常不够完善,在遇到少见的需求的时候让开发者感到束手束脚。

之前,为了一些特殊需求(例如在markdown里插入React组件(我使用过MDX个人不太好用),给code元素加入一些特殊元素(例如显示语言、行号、复制按钮等)),我多次尝试过hook进remark的编译过程,在remark进行渲染的时候修改AST,使得进入Gatsby数据源中的htmlAst和html就是经过定制的,但是一直没能找到合适的方法。

后来,这些功能都实现了,但是不够优雅:在代码中,从Gatsby数据源中获取remark渲染后的AST,修改AST后再重新使用rehype-react渲染(对这里感兴趣的同学可以查看代码的ArticleContentDisplay组件)。

这样做虽然能够实现需求,但由于在Gatsby的数据源中,其htmlAst和html并没有经过定制(定制是在页面渲染的时候执行的),这也造成目前各个方法获得的HTML并不统一。

没有一个可靠的hook渲染过程的方法,也造成了我没有办法修改remark的默认行为,其中就包含图片地址为相对地址而不是绝对地址这个问题。

我正在努力想办法解决这个问题,但是看情况短时间内解决希望不大。

使用国内托管(腾讯云的CODING个人版

博客一直是托管在GitHub Pages上的。GitHub Pages很方便,零开销,在国外用的很广,网络上的教程也到处都是。但是它的问题是在国内速度非常慢,且非常不稳定。即使我的博客的相关文件已经比较小了,但是GitHub Pages在国内的速度也极大地影响了网站的用户体验。要是当某篇文章有图片,那体验就更糟了。

多亏了我的博客是静态博客,解决这个问题,本质上只需要把文件托管到某个国内访问速度快的类Pages服务上即可解决问题(点关于博客查看博客工作原理介绍)。我把目标投向了17年参加南京四校Hack.Christmas时赞助商提供的CODING.NET一年免费VIP账号。虽然那时候VIP基本已经过期,但是当时体验还行,并且也发现了它也提供类似GitHub Pages的功能(也叫Pages服务)。于是,我进行了以下操作:

经过测试,CODING.NET的体验还是非常良好的,在国内的速度也非常不错,于是:

这样,我的根域名就被解析到CODING.NET提供的Pages服务上。经过测试,网站的速度提高了非常多,国内用户的体验得到了很大的提高。

站长工具

多语言环境下文章地址改进

之前,为了支持多语言,本网站的所有文章的地址末尾都增加了语言(cn/en)为了表示这个文章的语言是什么,例如关于我就有两个版本,/about/me/cn(中文)和/about/me/en(英文),另外对/about/me这种根路径增加了客户端的跳转(即在浏览器执行了JS进行跳转,而不是通过服务器发送301(Moved Permanently)的响应。对于静态博客,服务器发送301是不可能的,因为服务器端不能执行这么“复杂”的逻辑)到本文章的所有版本的第一种语言版本(知道你对第一种语言版本感到疑惑,继续往下看)。

这样做有2个问题:

  1. 对于所有文章,包括占大多数的只有一种语言版本的文章,其地址栏最后都有语言字符串,造成路径不必要的太长;
  2. 这个“第一种语言版本”的结果每次执行可能是不相同的,有可能造成根路径在每次更新后都出现变化(没有验证过,只是存在这种可能)。

我在最近也重新设计了路径的计算方法,较好的解决了这个问题。以id为an-article的、有两种语言版本(cnen)文章来举例子:

  1. 首先,对一篇文章的所有语言版本,根据lang的字典序进行排序,使得每次所有版本的顺序都是相同的,解决了第二个问题
    • 例子中,顺序为[cn, en]
  2. 选取中文版本(cn)的文章,或者如果中文版本不存在的话,选择第一个版本(由于所有版本的顺序是相同的,第一个版本也总是相同的),生成根路径/articles/${articleId}的页面
    • 例子中,选择了中文(cn)版本生成/article/an-article的页面;
  3. 生成/articles/${articleId}/${上一步生成的版本的语言}/articles/${articleId}的客户端跳转
    • 例子中,生成了/articles/an-article/cn/articles/an-article的跳转
  4. 对其他所有语言,生成/articles/${articleId}/${语言}的页面。

通过这样,可以保证博客中大多数单语言的文章都生成在根路径,不再有碍眼的多余的语言字符串,同时也保证了多语言功能和向前兼容性。

举几个例子:

文章语言之前的路径现在的路径
关于我cn/about/me/cn/about/me
关于我en/about/me/en/about/me/en
2018年总结cn/articles/summary-for-2018/cn/articles/summary-for-2018
Simstate and Whyen/articles/simstate-and-why/en/articles/simstate-and-why

最后提一下,一般来说,网站要支持多种语言,目前大家通常使用以下的两种方法实现,但是由于他们都需要特别的处理,也为了能够热切换语言,最后采用了以上这个比较简单的方法(即在路径最后增加语言表示)实现:

方法解释例子问题
二级域名每个语言的子网站都有自己的二级域名淘宝本质上是两个网站,当一种语言的页面不存在时,需要做跳转,懒得维护
子页面每个语言采用类似https://domain.com/{zh-CN, en-US}/path/to/page的方法,通过路径的第一个部分区分不同语言微软官网(切换语言的页面处理路径时(例如导航栏高亮),需要单独切分掉pathname的第一部分;当一种语言的页面不存在时,需要做跳转

使用自己开发的simstate进行状态管理

博客一开始是使用unstated进行状态管理的。这个库非常的简单易用,很符合react的理念,于是当时我非常喜爱这个库。但是随着后来hooks的推广和unstated迟迟没有跟进hooks等原因(其实已经跟进了只是当时我的不知道……),我认为是时候写一个自己的状态管理库了。这之后故事可以查看Simstate and Why文章,这里就不说了。

在v3.0时,我重构了simstate,完全抛弃了class的概念,完全使用React原生的hook就可以完成状态管理。而博客就是第一个完全采用simstate v3.0的项目。

使用simstate设计Store(左)和使用Store(右)

在使用新版本的时候,我也积累了一些在项目中正确使用此项目的经验,例如说:

这些常见问题也写到了simstate项目的README中,也是给未来自己(和其他人,如果有人在用的话)带来方便,帮助解决这种常见问题。

其他值得提到的近期的修正(太远的我也记不住了……)

问题和后续计划

去年Gatsby博客上线的时候提到:

博客的大厦已经基本建好,接下来只需要小修小补即可。

其实这一年中博客的发展和变化还是比较大的,已有的功能继续完善,没有或者缺乏的功能也正在加入。在接下来,我会在以下几个方面继续完善我的博客:

  1. 文章数量和质量

毕竟博客的本职工作的写文章(而不是像我这样大多数时间在写网站本身……),以后我会带来尽可能多尽可能高质量的原创文章。

  1. 重构样式和完善UI设计

目前博客是使用大名鼎鼎的bootstrap的组件库进行设计的。虽然很方便,bootstrap也还算比较耐看,但是,由于要将使用SCSS编写的bootstrap和我偏好的CSS-in-JS方案(例如styled-components)组合起来使用,造成了一些问题:

5月份的时候,我尝试在styled-system的支持下写自己的UI库vicui。组件基本都完成了,但是在使用的时候遇到了很奇怪的问题,造成我完全无法在其他项目中使用。

同时,我在项目中采用了将Rebass这样的primitive UI componentsCSS混用的方式,使得不仅在保证在React中使用方便和可扩展性,同时在以后迁移到React生态圈之外时,已有的CSS的可以重用,但是在无法避免全局样式类名对用户公开多大自定义样式的能力等问题上也存在一些问题。

对于样式,这个前端”永远的难题“,我还需要更多的学习和实践。

  1. 补充功能

静态博客无法单独实现所有依赖于服务器的功能,包括访客统计、评论、点赞等功能。我已经使用gitalk解决了评论问题,解决地还算满意;使用CNZZ解决访客统计功能,虽说是能统计,但是它甚至没有提供API,不能编程实现网站访客的显示,更别提分文章的统计了;至于点赞,这还是算了吧……

要解决这个问题,可以自己写后端,将这个博客变成一个SPA。目前使用场景非常适合微服务,即每个功能之间是的独立的。例如,对访客统计写一个服务,对点赞写一个服务,各个服务之间相互独立,独立开发、渐进部署。我也正在学习这个方面的技术,期待早日能有一个可靠的、可扩展的、博客专用的基础设施和服务能够上线。

  1. 一些其他问题:例如中文字数统计不正确、之上提到的自定义MD渲染过程等

感谢!

博客已经走过一年了。在这一年中感谢所有支持我的博客的读者和网友,感谢你们对我文章的支持!我也会在之后继续完善博客的内容、设计和功能,让博客更加地完善、丰富和易用!

2016至2019,和南京大学微软学生俱乐部一起成长

2019-09-07 16:00:00

前言

2016年9月到2019年5月,我在南京大学微软学生俱乐部呆了三年。我从一个初升大学的萌新,变成现在马上就要毕业的大四老狗,其中南京大学微软学生俱乐部给了我许多。新学年的南大微俱的招新就要开始了,我想是应该总结下在我在俱乐部三年的成长和变化,希望有更多学弟学妹们能够加入俱乐部,和俱乐部一起成长。

2016年9月:一张海报开始了俱乐部的旅程

上了大学后,很多同学都加入了各种俱乐部或者院系组织,而不擅长交际的我却还窝在宿舍。一个偶然的机会,我看到了微软学生俱乐部的海报,感觉非常好奇:为什么一个公司也会在大学里运营俱乐部呢?

带着这份好奇,加上一直是一个微软粉的缘故,2016年9月6日,我参加了宣讲会,选择了技术部,通过了面试,加入了南京大学微软学生俱乐部。当时的我没有想到的是,这个“名不见经传”的小俱乐部,将成为我大学期间的不可或缺的一部分。

俱乐部2018年招新海报

Hackathon:7天影响了3年

很快时间到了12月1日,南京四校hackathon比赛开始报名了。刚听说这个比赛的我了解这种比赛的规则后,觉得这个比赛很酷,便不顾没有做完和作业和不久之后的期末考试,在组队群里拉了另外两名同学组好了队,凭着一股“初生牛犊不怕虎”的干劲,参与了这个比赛。

7天之后,我们的项目拿了个“超级实用奖”。

2016年hackathon奖状

这不仅是我大学以来的得的第一个奖,更重要的是让我有了自信,敢于用代码将自己心中的蓝图实现出来。另外,在接下来的两年中,我和这次比赛中的两名队员(以及另加的一位同学)一起,共同参与了院里组织的一次比赛,共同进行了2个各持续一个学期的课程大作业,都拿到了非常满意的成绩。我们也成为了很好的朋友和合作无间的伙伴。而这一切,都是因为2016年南京四校hackathon比赛。

hackathon比赛前的合影

夏令营:在俱乐部“春晚”中大开眼界

2017年7月暑假某天,刚非常荣幸成为南京大学微软学生俱乐部技术部副部长的我突然收到了微软学生夏令营的邀请函,参加了这个微软学生俱乐部的“春晚“。作为一个大一的萌新,这四天的“高强度”活动中,我大开眼界。

观看了编程之美决赛,看到了各个参赛队伍优秀的脑洞、强大的技术能力、当然还有邹欣老师“横扫全场“的澡堂问题;一天听了六场技术讲座,对微软的技术有了更深入的认识;和各位owner和俱乐部伙伴一起集思广益,对俱乐部的未来发展献计献策;当然还有最后的hackathon环节,和小伙伴一起完成一个策划。

左到右上到下:编程之美决赛,技术讲座,活动策划,hackathon答辩

这四天的夏令营的行程不仅让我对微软有了更深入的认识,对俱乐部接下来的发展有了更清晰的规划,更重要的是认识了来自全国的对俱乐部抱有发自内心的爱的微软学生俱乐部成员。南京四校在夏令营之间的提出的hackathon规划,也在接下来一年成为了现实,我们四所俱乐部之间的联系越来越紧密,合作越来越顺利。

2017年微软学生夏令营合照

俱乐部运营:和俱乐部一起,改变俱乐部

从夏令营回来之后,我们新一届的部长团开始了2017-2018学年的运营。在这一年中,我们购入了一台HP Windows Mixed Reality Headset混合现实头设,在俱乐部内部的简单测试后,在2018年5月20日晚南京大学116周年校庆夜上,面向全校举办了MR体验活动。而这次活动出乎意料地受欢迎:在4个小时左右的展台时间中,我们的展台前一直门庭若市,来自全校各个年级各个院系的同学都积极参与体验,甚至吸引了一些小朋友。

2018年校庆夜展台

作为一个在以文理科见长的学校的技术性社团,想让全校的同学都参与了解我们俱乐部、参与俱乐部的活动是一件比较困难的事情。而这次活动极大地增强了俱乐部在全校范围内的知名度,也为我们以后的运营指出了方向:降低技术门槛,增加活动丰富度,让更多同学参与进来。

2018年6月,我当选为南京大学微软学生俱乐部主席。2018年-2019年这个学年,在全俱乐部所有成员,特别是各位部长团同学的努力下,南大微俱发生了一些变化。

活动

在这个学年中,俱乐部的活动频率显著提高了,从之前的一个月一次,提高到平均两周一次、甚至在第一学期保持一周一次的频率。

2018-2019学年活动时间轴

并且,在传统的技术相关的活动(例如说技术讲座、hackathon)之外,我们组织了多次技术门槛不那么高的、有趣味性的、全校所有专业的同学都能进行参与的活动。例如,我们将MR体验、小冰游戏以及黄金点游戏带到了12月南大社联组织的**“冬至未至”和5月的校庆文化夜**中,吸引了全校同学来了解和体验最新的技术,提高了俱乐部的知名度。

上:2018年冬至未至;下:2019年校庆文化夜

在19年4月,我们联合东南、南航和南理工微软学生俱乐部一起,举办了**“南风微语”系列春游活动的第一站:南大鼓楼**。来自南京四个学校的小伙伴齐聚南京大学鼓楼校区,进行各种活动和游戏。本次活动的参观、游戏将融合在一起,采用定向越野的方式完成。定向越野中的每一个地点都会设置一个团体合作完成的小游戏,我们的工作人员将在设置的地点协助来访同学完成游戏。通过这次轻松的活动,我们加深了和南京另外三个学生俱乐部之间的交流和联系我们也希望此次活动成为系列活动,以后继续办下去。

“南风微语”春游活动南大站

另外,为了让同学们能够更加了解微软,我们在2018年7月和2019年1月两次举办了参观微软苏州的活动,也在2018年12月以及2019年4月两次举办了微软实习经验交流活动。通过参观微软苏州,同学们更加深入的了解了微软和微软的招聘项目,通过工程师的介绍同学们也对微软的技术也有了一定了解,最后在微软苏州的几位南大校友也来进行了相关的分享并为同学们答疑解惑。通过邀请MSRA、苏州STCA和微软CSS部门实习的学长学姐来为同学们介绍微软实习的情况,让同学们更加了解在微软实习和工作的体验,也为想要去微软实习的同学提供了交流的渠道。

两次实习交流活动

2019年1月参观微软苏州

2019年参观微软苏州合照

技术

在技术方面,我们考虑到南大微俱的几乎所有成员都是来自大一大二,所以我们在技术活动方面也以入门向为主,以提高成员的兴趣为中心。

在学年伊始(10月),南大微俱以2018年微软学生夏令营中的黄金点游戏为基础,组织了一次俱乐部内部的黄金点AI赛。这次比赛给了新生一个非常好的机会去学习和了解AI和计算机领域,并且通过这个比赛将他们所学的知识加以运用,并且能获得良好的反馈以促进他们继续学习下去。最终获得最好的成绩的队伍,正是一支来自大一同学的队伍。他们在短时间内学习到包括算法、网络等各类知识并取得如此好的成绩,让人刮目相看。这次活动所使用的平台也是由俱乐部技术部自行开发的,其也被用在了之前提高的两个展台游戏上。在冬至未至活动中,我们也将本次比赛中成绩最好的程序作为一个黄金点游戏的玩家,让它和大家一起体验黄金点游戏的乐趣。

黄金点游戏决赛现场,投影上实时显示得分情况

我们还公开举办了5场技术讲座,分别是科学上网和如何正确地问问题、Git讲座、和创新工厂合作的AI技术讲座、Office讲座以及和南京大学腾讯创新俱乐部合作的Unity ECS架构的讲座。根据我们俱乐部成员组成的实际情况(绝大部分同学来自大一大二),所以技术讲座以普及实用技术为主。

左到右,上到下:AI技术讲座、如何正确地问问题、Office讲座、Unity ECS架构

对外联系

在这一年中,我们也和很多其他组织建立了联系,合作举办了各种各样的活动。不仅提高了俱乐部的知名度,通过和其他组织的合作,也为我们以后举办规模更大、更有意义的活动打下了基础。

在2019年3月,我们和南京大学腾讯创新俱乐部合作,举办了第一届南大微俱-腾俱hackathon比赛。本次hackathon的主题是Hack for NJU,希望参赛者能够在七天之内做一个对学校生活有所帮助的工具。Hackathon比赛和南京大学最大的公益社团:IT侠达成了合作,最终吸引了近70名同学、18支队伍报名参赛,包括来个各个年级(大一到研一)、各个院系(计科软院商院到中美中心)的同学。这次比赛是我们第一次和其他社团合作,取得了非常理想的效果,不仅提高了俱乐部的知名度,也让微俱和其他社团(腾讯俱乐部和IT侠等)建立了合作。我们也希望能够将此比赛延续下去,做成和南京四校hackathon一样的系列活动;并且,我们也希望能够继续加强和友社之间的联系,合作互补,办更好的活动。

比赛现场

合照

南京四校hackathon今年已经是第四年了,今年我们将范围扩大到了七校,和南理工、南航、东南、中科大、山大和苏大一起,举办了华东七校联合创客马拉松。本活动有约200名同学报名参赛,请到了来自南理工、东南、南航和南大的一些老师和京东的相关人士作为评委,在过程中拉到了京东的赞助,以及南京校园直播平台南播玩、苏媒(中国(江苏)高校传媒联盟)和中青在线的媒体和宣传支持。

2019华东七校联合hackathon海报

全俱乐部的努力

在这一年中,俱乐部的同学都为俱乐部奉献出了他们的力量,正因为有了俱乐部成员的参与和支持,我们俱乐部才有动力举办活动。特别想感谢我们的部长团们。由于这一年中我在老校区,离俱乐部活动的新校区有40分钟的地铁的车程,所有的活动,从策划、宣传到执行,部长们的全程参与和贡献是最重要的。可以说,没有他们,这一年的俱乐部就不会有这么大的改变。今年南大微俱获得了品牌活动奖,这是对全俱乐部成员的努力的最好的肯定。 s 品牌活动奖奖状

展台前忙碌的部长团和部员们

未来:和俱乐部一起成长

三年前,刚上大学的我看到了南大微俱的海报,参加了宣讲会;三年后,我结束了在俱乐部的旅程。回想起这三年,俱乐部让我得到了合作无间的队友认识了全校、全南京甚至全国愿意为了俱乐部付出的同学,让我得到了我人生中诸多的第一次:第一个比赛,第一个奖项,第一次主持讲座、第一次举办大型活动……可以说,俱乐部改变了我的大学生活。俱乐部,一个当初默默无闻的小俱乐部,现在有了更多更好的活动,有了更实用的技术活动,有了更强的对外联系,有更多人愿意参与进来,也希望能够给更多的同学带来乐趣和帮助。

我相信,在现任部长团的努力下,我们俱乐部一定能更加活跃,更加技术,更加外向,让更多同学能够与俱乐部一起成长,度过大学难忘的时光。

2019年5月24日,南京大学微软学生俱乐部新老部长团和马欣姐和昊哥的合影

北大信科 | 上交软院 | 南大软院夏令营经历

2019-08-07 22:06:00

怎么又要保研了?

其实我从大二开始就开始准备直接去工作的。根据我的规划,项目比赛刷题实习转正,之后就是直接踏入职场了。而这个规划以外的、很多同学大学期间做的工作,例如说找老师、进实验室、做科研、发论文,我完全没有想过。

到2019年4月,这个规划进展非常顺利,项目、比赛都有能拿出来的成果,刷题也勉勉强强足够应付MS的要求,实习Offer也顺利到手。

结果没想到这个时候发生了一件影响颇深的事,那就是贸易战。由于个人无法接受996,所以外企几乎成为了唯一的选择。但是我认为根据这几年的国际局势以及国内996的趋势,外企在中国的发展前程是比较受限的。所以,趁着这么好的机会,读一个研究生,不仅在以后找/换工作为自己增加筹码,还可以避开这几年的“乱世”,多观察多体验,找到真正属于自己的道路。虽然很可能最后我可能还是会外企,待遇比本科出来只低不高,但还是决定现在去读研。由于最终目标还是工作,所以在找夏令营时有几个条件;

我的个人情况可以参考我的简历。在这个暑假中,我参加了北大信科上交软院南大软院的夏令营。

北大信科

事项
时间见下表
机考和结果百练OJ的比赛,AC 3/8,排名 97/225
面试情况计算中心,网络、数据库和高性能计算方向
结果优秀营员

日程

时间活动
5月16日-29日网上报名
7月4日上午签到,领取营服
7月4日下午到7月5日下午讲座
7月4日晚上练习题
7月5日晚上机考
7月6日开始至闭营面试

报名

本院每年都有几位同学去清华,但是已经有几年去北大的一个人的都没有。研究了后发现,可能是因为我们是软件(而不是计科的)专业的,所以研究生也更倾向于去软件相关的学院和专业(而不是计科)。而北大的软院(即软微)的情况比较特殊,北大信科是传统意义上的计科,所以最后没人去北大了。

其实我也一直是这么想的(去软院),且由于北大信科要三年,而且(一般来说)学硕可能也不会太允许实习,所以我也一直没啥兴趣去。只不过到最后DDL想了想还是可以去北大了解了解,再加上4年前也去过一次北大的信科夏令营(给高中生的),所以感觉可以试试。最后成功收获几个极限操作:网申在DDL前2小时完成找老师要推荐信在和材料投递在DDL前4小时完成,以及获得被入选夏令营的最后一个报名号

申请编号 我的编号

另外,北大的报名不需要寄送材料,29日晚上前在网上填报,31日晚上上网上提交扫描版申请表、成绩单、自述和其他奖状什么的就可以了。

推荐信也把我搞了一波。首先,29日结束的网申不需要推荐信的任何材料,31日结束的材料提交中,需要确认有哪两位老师进行推荐,并且需要他们的联系方式等信息,不需要实际上拿到推荐信。至于推荐信本身,只需要等到夏令营报道的时候才需要提交。而我网申根本就没有找老师,而是等到30号才开始找老师,直到31日晚上才确定推荐的老师,之后6月底结果出了才去找老师要的……找老师的过程比较艰辛,问了6、7个本院的给本科生上过课的老师,最后在最后一天才确定下来……所以如果像我一样之前和老师除了上课再无交集的同学,可以早点去找老师,并且胆子要大一点,人在屋檐下不得不低头嘛。

最后今年本院只有我一个人去了,其中一大原因是7月4日本院有课考试,而夏令营报名期间考试安排还没有出,所以几位大佬也直接没有报名。而我经过胡乱分析.jpg,认为我4号有考试的几率比较低,搏一搏单车变摩托。最后几位大佬果然有考试,而我果然没有考试。另外,认为北大只招一个是不对的,因为在我夏令营的那几天中发现其他学校的好像都去了好几个,只有我校就去了2个人(加上一个电子方向的同学共3个,夏令营共400人,注意是南大总共就去了3个),另一个小伙伴还是去叉院夏令营的。所以如果学弟对北大感兴趣的话,可以勇敢一点报名,有考试也不用怕,鸽了不就完事了,夏令营鸽了一点影响都没有。

讲座

前两天都是白天讲座,白天讲座还是蛮重要的,因为信科还是个比较大的院系,下属了很多的实验室(见下面日程安排表)。每个老师在讲的时候会讲讲自己实验室的基本情况,比如研究方向、导师的情况、过往学生、招生计划等,对北本之外的同学来说是一个非常好的了解渠道。

日程1

日程2

我印象比较深刻的包括软件工程和系统软件方向的讲座。这个实验室(软工所,官网)最大的特点是它有3名院士19名博导,而且保研只招直博生……从时间也可以看到这个实验室讲的时间是最长的(接近1个小时),因为他们老师多方向多,中间还穿插讲解了之前毕业的一些大佬学长学姐的情况、一些他们的项目情况等。他们的名额也非常多,今年似乎有19个直博生的名额。这个实验室的人员配置和做的方向真的是让我叹为观止,感觉真像是他们自己说的“一个实验室和其他学校一个系进行竞争”的情况。

另外,其实听讲座期间我还是比较失望的,因为我还是对学术真没什么兴趣,但是绝大部分实验室做的都是学术工作。并且,很多实验室的博士名额比硕士多(软工所的19比0是最极端的……从今年的优营名单也可以发现直博119多于硕士73),对只想上硕士的我来说有点不对口。

听到后面我甚至有点不想去了,但是最后一个计算中心的老师讲的激发了我的兴趣。计算中心(官网)严格来说不是信科下面的研究所,而是一个机构,管理整个北大的所有信息设备的机构。只不过它也培养计算机应用技术专业网络、数据库和高性能计算方向的研究生。听完讲座并了解一番后,我的感觉是:

  1. 中心做的工作比较实
  2. 中心只招硕士,招的人数比较少,每年保研3个人,考研1-3人,而硕导有7位,师生比比较高;
  3. 中心几乎没有科研压力,并且只需要学位论文就可以毕业了,所以应该也不会“被压榨”(最多也就是学不到什么东西,但是反正也没怎么期望……)

所以即使发现中心的资料在网上几乎完全找不到,知乎、导师评价网等完全没有相关信息,并且看情况中心也有一些问题。但是想来反正读研的目标并不是做研究,这种条件可能还更适合我,所以决定去试试,实在不行就当刷面试经验了。

下午的座谈其实就是答疑,老师在各个教室里,自己对哪个是实验室有兴趣就可以去找老师聊聊和问问。由于我对计算中心比较有兴趣,所以下午2点就去计算中心那儿问了一些情况。3点左右人比较多之后,老师带大家参观了北大的一些机房和设备,包括他们的未名一号超算。整个2个小时就来了10个左右的同学来了解,说明中心还是确实比较冷门的……

在座谈机考之前有一个小时通过问卷星填志愿,分一二志愿,理论上来说一志愿没有满足会才会再考虑第二志愿。而我就直接填了计算中心的一志愿。

机考

基本情况:

罚时计算例子

练习题(题目)让我心态爆炸,8道题只做出2道题,日历题还是在同学的指导下做出来的……但是其实仔细看通过和尝试人数就可以发现其实绝大多数人也都只会2道简单的题……

练习场实拍

正式机考的题目难度比练习题要低,但是还是可以大致画出三种题:

机考题目情况

由于我只做出第一类,这里简单说说每个题的思路(我做的代码由于我忘记给的账号的账号密码了所以提取不了了……),题目本身可以点进链接看看。

A: 数和字符串

定义一个字符串序列 s[1]="1",s[2]="2", ..., s[n] = 数字 n 转化成字符串后的结果

输出 s[1] 到 s[n] 中,(字典序)最大的那个字符串

暴力:直接把所有字符串相比较,比较出最大的(C++可以直接用大于小于运算符比较string对象)。

B: 打印月历

输入为一行两个整数,第一个整数是年份year(1900 ≤ year ≤ 2099)/,第二个整数是月份month(1 ≤ month ≤ 12),中间用单个空格隔开。

输出为月历表。月历表第一行为星期表头,如下所示: Sun Mon Tue Wed Thu Fri Sat 其余各行一次是当月各天的日期,从1日开始到31日(30日或28日)。 日期数字应于星期表头右对齐,即各位数与星期表头相应缩写的最后一个字母对齐。日期中间用空格分隔出空白。

题目中给出了1900年1月1日是周一。的提示,所以可以先用暴力法(一天一天地加),算出从1900年1月1日到给定月份第一天有多少天,模7算出是周几,然后注意格式直接打印就可以了。

D: 上楼梯

小S在玩一个叫上楼梯的游戏。楼梯一共有n层台阶。因为腿长的限制,小S每次最多只能上k层台阶。小S是一个迷信的人,所以他不希望自己某一步走的步数的数字里有"4",(比如4,14,44都含有数字"4")。现在,小S想要知道,有多少种走完这n层台阶的方案?

动态规划入门题上楼梯(Leetcode 70)的变种。方程:f(n) = sum(f(n+i), 1<=i<=k 且 i不含4),直接递归+记忆就可以了。

机考总的来说是很两极分化的。普通人的题目的难度和Leetcode的Easy题差不多,所以准备过实习的同学做出做过算法题的会的题目的难度不大,但是其他题却比hard还要难……所以没有练过算法竞赛的同学其实可以直接把简单题做完就走人了,不用挣扎了。但是由于这样,普通人做出来的题目数量其实差不多(这次有70多名同学都是3 AC,占1/3),所以可以注意一下时间,尽量一次AC,不要被罚时。我三道题40+分钟都全做出来了,所以排名比较高。另外,北大机考的硬要求是做出一道题就可以参加面试(有的实验室可能有额外要求,但都不会明说),所以可以不用太把机考看得太重,面试还是更重要的。

面试

能不能面试也没通知,反正就到了时间就去教室外面等着,有志愿者会叫人进去面试。面试排名不知道是怎么排的,但是比较幸运的是,我是第一个面试的。在进去的时候瞥到似乎只有5个同学把中心选为第一志愿(且似乎有一个同学是北本),但是选中心为第二志愿的同学并不少。有北本的同学来是个好现象,说明这个中心的名声应该不会太差的(否则不应该有北本的同学来)。而且5选3的录取率,还是非常可观的(比整个夏令营的录取率1/2差不多,留意此录取率,后面要考)。

面试时长为20分钟,全程录像,有6-7个老师都在现场,流程基本是自我介绍根据简历问问题一点专业知识以及一些研究方向的考虑。我还记得的我的问题如下:

计算中心的面试没有英文,没有笔试(因为我听说网络所的面试一开始有个1小时的笔试考微积分、线性代数、概率论什么的……),面试题都是根据个人陈述或者简历来的,所以就和找工作什么的一样,个人陈述和简历里写的东西一定要滚瓜烂熟,不会的千万不要写,不要逞能。之外的一些基础知识可以先根据面试的实验室方向进行一些复习。另外注意最好在面试前打印10份简历左右,因为在场好像就我一个人没有打简历……

之后

面试之后,我在食堂尽可能多的用了用饭卡,然后中午交了牌子和饭卡就坐复兴号溜回南京了。之后一直没消息,本以为凉了,结果在下一周的周二公布的优营名单中看到了我的名字,还是比较惊喜的(后来听说北大接到电话或者短信意味着要么调剂要么凉凉,没收到信息才是稳的,这操作也是非常迷)。计算中心没有要求联系导师。

南大软院

事项
时间7月8日上午面试,下午机考
机考和结果一道和抽象工厂模式相关的代码填空,一道算法,AC 2/2
面试情况没分方向
结果不知道

南大软院相较其中学校有一个极大的好处:2年毕业,且第二年实习!!! 简直是找工作的同学的天堂。想想2年就能拿到一个硕士,而且一进社会就有一年了工作经验,这简直是实习工作两不误。当然了,对于想搞学术发论文的同学就不要考虑南软啦。但是怎么说呢,在这里呆了三年,里面是什么情况还是比较清楚的,由于各种原因,还是决定最多体验下保个底,能不去最好还是不要去啦~

本院夏令营对本院和外院是分开招的:本院GPA前50%都能去(应该有100出头个人),其他学院招了300人,而且笔试面试的时间都不一样(本院是7月8日,外院是7月17日开始似乎),所以多我一个少我一个其实并没有影响。本院我的基本没怎么准备,因为就算我想认真也认真不起来,因为7月7日才从北京回来,7月8日就笔试和面试,所以就随性了。同样是由于分开招的,外校同学的流程和情况这里就不太清楚了,这里就简单讲讲针对本院同学的笔试和面试。

面试

面试抽签是早就搞了,所以我们这次直接去就完了。上面说了本院有100人左右去了,但是面试教室只有两个,而因为刚从北大回来知道夏令营面试一般是20分钟,算了算这不得面到晚上,还考个啥的机考……最后面试的时候发现,老师可能也没有认真计时,刚考试的面试的同学普遍超过20分钟,越到后面时间普遍越短,我个人可能就进去了2-3分钟就出来了……

面试题目其实倒是大同小异,就是自我介绍、介绍做的项目、深入问问项目中的一些具体情况、可能有一些小的知识点、然后用英文说点技术相关的内容,听说有的同学被面到了诸如伊朗总统是谁这种政治相关的知识点,这……问到了答不上就答不上吧,不然还能怎么样呢……

个人被问到的题目和北大的差不多的,首先介绍做的项目,然后问了问区块链的特点和局限,最后用英文说了一段什么(具体忘了),然后就出去了。

机考

笔试题有两道,一道和设计模式相关的代码填空,一道算法,需要用java写,算分方式都是跑测试用例,分公开的和隐藏的测试用例。隐藏的测试用例实际上是打成jar包放在本地,所以理论上来说你是可以想办法看到所有测例是什么然后打表的。但是实际上不太可能,因为机考环境是封闭的,用的机房的电脑而且(应该是)不能上网,所以除非你能直接读字节码,所以其实也不能知道测试是什么。

第一题是一个小项目,差不多10个文件,总共加起来可能也就两三百行(这是java,实际上有用的代码可能不到100行,对不起我又黑了一下)。题目不会涉及到任何算法,只需要理解每个类是做什么的、并且他们之间是怎么交互的,然后把没有实现的方法写好,并使输出符合测例的要求就可以,并且类和方法的定义都是写好了的。这题目比较简单,耗时间的部分主要是看测例(对,是测例,不是文档……)理解应该输出成什么样子,然后StringBuilder……

第二题是一道简单的算法题,题目大致是在一张图中找两个点的一条路径使其某个指标最小,数据量不大,直接暴力DFS就可以解决了。

笔试题难度不高(但是比北大的普通人能做的题要高),尤其是如果之前有准备过其他学校的夏令营或者实习刷过题的同学,这两个题应该都比较简单。但是之前不太熟悉java或者用不惯eclipse的同学可能很多时间是在和语言和环境做斗争(或者是和外设和网络做斗争……我在写第一道题过程中不小心按到了键盘上的关机键,然后电脑就关机了……然后等它开机后发现连不上网,万不得已只能换台电脑重新做),但其实也不用太担心,因为听说对外校同学应该是有C++的选择的。总之,笔试题的难度比较低(高也高不到哪儿去了因为本院的算法课和普遍的算法水平……),找过实习应该就没有什么问题。

上交软院

事项
时间见日程部分
机考和结果题目看这里,成绩还不知道
面试情况智慧应用-1,选题是边缘计算/微服务,共7人面试,录取人数可能是2-3人
结果专硕,拒了

似乎本院很多人都喜欢投上交,而且上交也特别喜欢招本院的同学……听说去年有20个本院去的夏令营,最后也留下了接近10个人去了上交读研,某一个实验室里就有4个本院的15级学长学姐……另外在报的时候对上交还是非常有好感,因为可能更想在上海发展,且上交的研究生只有两年半,对找工作还是非常友好的(后来又听说不准实习,最后确认是一般是达到毕业要求才能实习,这点还是有点劝退的其实)。并且上交的软院是南大以上几个学校中比较少的不需要的推荐信的,对于不想去/不好意思去找老师要推荐信的同学(比如我)还是比较友好的。

上交整个夏令营都比较奇怪:时间安排、笔试、面试都和其他学校不太一样,下面简单讲讲。

日程

时间活动
7月14日签到
7月15日上下午讲座
7月15日晚上机考和现场打分
7月16日-17日参观实验室,交流
7月18日面试

这个日程最大的槽点就是参观实验室的16、17两天,参观实验室需要花2天?上交没有包住,让外校的白白多掏2天的住宿钱也太强了(还好闵行够偏酒店便宜)。于是我和室友就在酒店呆了两天,让我之后再也不想住酒店……

讲座其实没什么特别的,就是每个实验室从高层次讲讲自己实验室做的工作和招生的情况。但是讲座的时候给出了一个招生情况的数字:夏令营入营(本校加外校)一共113人,软院总体研究生录取15-17人,录取率15%……还记得之前让记住的北大的招生比例(50%)吗?北大夏令营的时候室友说上交喜欢招一大波人来考试结果根本不怎么招人(所以他鸽掉了上交准备去西交),诚不欺我也……

机考

题目和我的代码看这里

机考是另外一个槽点。上交的机考是15日晚上6点到9点,之后立刻现场检查给分,最后到晚上11点多才完全结束。

上交的机考题和其他的学校的不太一样,其他学校一般是做算法题,而上交的机考是做一个完整的系统。在夏令营前,上交发了一封邮件中让我们准备预先安装好并学习使用GUI库、图表库等第三方库,因为机考题会涉及这些方面。这三年的上交的机考题确实都涉及到GUI的绘制,而这两年增加了使用图表库根据数据画图的部分,这就要求本科没怎么做过项目的同学快速学习使用GUI。更要命的时候机考的时候不能上网,所以文档等信息都需要先下到本地。

今年的题目应该是个灾难。今年的题目看上去人畜无害:

  • 用不同的内部算法实现两个hash map
  • 对比两种实现的性能表现并画统计图
  • 可视化第二个算法的执行过程

实际写起来才发现,算法本身如果之前没有实现过,是比较麻烦的,有时候发现测试跑不过,很难发现问题所在,相信刷过题的各位都应该有过同样的感觉。另外,可视化算法的执行过程这个需求也是非常耗时间的,动画相关的API什么我也完全没有接触过。从零开始构建GUI、图表等本身就非常耗时间,再加上实现看上去不难、实际上比较坑的算法,3个小时简直是杯水车薪。

最后我只实现了线性探测算法,通过了小数据量的测试,大数据量完全不知道为什么无法通过(de了接近半个小时的bug,其他的实在没有能力做了,就只能de这个bug)。另外一种算法几乎直接放弃,只简单写了写查找和删除,连小数据量都无法通过。画图倒是都实现了,但是由于算法本身是错误的,图像其实并没有什么意义。最后,可视化部分就把GUI的几个按钮文本框画出来就直接放弃。我的版本是使用JavaFX + Kotlin实现的,图表选用的JavaFX自带的统计图表,感兴趣的同学可以点开题目链接查看我的代码。

为什么说这次的机考题是个灾难呢?因为大部分人的得分都极低(内部传闻及格(60分)的人数一只手都数的过来),如果严格给分,我的版本只能得20几分,而大多数人的进度也就这样。据同学说,中途还有同学心态崩掉中途直接离场了。当然了也不缺乏大佬,据学姐说有本校大佬拿了90+分,这可真的不得不佩服了。总之,可能是因为出题的大佬学长没有料到我们这么菜吧,机考成绩最后的分布应该是比较可怜的。

做题时的电脑屏幕(X1C截屏键的蜜汁位置(右alt和ctrl之间)让我图库多了不少的截图……)

面试

上交的面试也不太一样:一般面试应该是老师根据学生的情况问各种的问题,而上交的面试要求是首先在给予的面试题中选择一个题目,按实验室要求进行预先准备,之后再进行面试。例如说

面试准备的要求

问题在7月5日就通过邮件发给大家了,学校也给予了在听完讲座或者考完机考后换方向的机会,但是这样的话就只能在参观实验室的两天中做准备(可能那两天就是用来做这个的吧……),可能会比较辛苦。准备的时候倒是比较麻烦,因为很多内容(尤其是IPADS实验室这种偏向底层的实验室的题目,或者人机交互VR相关的文章)本科完全没有接触过,要想读懂论文的内容需要看很多相关材料(例如人机交互的需要几天内速成计算机图形学的基础知识),所以看上去准备时间比较长,但是还是非常紧张的(当然了,也可以像我一样选一个简单的一点的方向(微服务/边缘计算的理解),这样就可以轻松一点)。我的PPT可以在[这里]找到(https://github.com/ddadaal/Slides/tree/master/20190718-%E4%B8%8A%E4%BA%A4%E8%BD%AF%E9%99%A2%E4%BF%9D%E7%A0%94%E7%AD%94%E8%BE%A9%20%E5%BE%AE%E6%9C%8D%E5%8A%A1%E5%92%8C%E8%BE%B9%E7%BC%98%E8%AE%A1%E7%AE%97)。

面试是在18日开始的,同样是每个人20分钟,内容除了PPT,老师们同样会根据简历来问问题。当时共有5-6个老师,应该就是这个方向相关的实验室的所有老师了。我的流程如下:

其中没有包括PPT的内容,可能是因为我PPT做的比较high-level,没有涉及什么具体的细节,一位老师也指出了这个问题。所以以后可以具体一点,不需要面面俱到,可涉及一些具体的代码实现等,以让老师知道自己确实是进行了一些研究的。

面试问题其实都大同小异,都是根据论文和简历来的。因为简历里写了区块链的项目。三个学校都问了区块链相关的问题,没想到我不仅靠区块链恰了上万块钱,还升了学……

之后

上交的夏令营的结果理应会在他们的平台上公开,但是不知道为什么电院的到现在都还没有公开,动作和zh老师出分一样慢啊……在7月22日收到了上交的预录取邮件,其中要求签一份协议,其内容是说保证推免时一志愿填上交。当然了这种协议没什么法律效力,到时候填其他学校鸽它,它也没啥办法。但是想着老师在面试时问了那个尖锐的问题,想着鸽对其他同学和下一届学弟学妹不太好,后来经过充分思考认为最后去北大的可能性比较高,就拒掉了这份offer,把机会让给需要的同学。

PS

中间两天在一个朋友的帮助下参观了下微软上海紫竹园区,感觉环境非常不错,面积比较大,但是楼比较矮(和苏州相反,苏州就一栋高高的楼;零食角也比苏州的大(难受)),然后整个园区就东北角一栋楼其他全是草坪了,MS还是财大气粗~

东北角的微软标志

草坪

零食角

后续

从上交回来后,我就去苏州实习了,夏令营也因此告一段落。因为外出实习需要一些时间来适应生活(加上更重要的本人比较懒的因素),这篇文章鸽得有点久了,目前正在考虑是否参加**北大软微(对工作非常友好)清华软院(更硬核)**的九推,如果不出意外按目前的情况可能比较倾向北大。

使用MVC、MVP、MVVM和FRP实现Android局域网群聊应用

2019-06-05 21:30:00

0. 作者

学号姓名
161250010陈俊达
161250002蔡蔚霖
161250060李培林
161250199张凌哲

1. 简介

在本报告中,我们使用MVC、MVP、MVVM和FRP分别实现了一个Android局域网群聊应用,并通过分析在使用这四个架构实现应用功能的过程中每个架构所表现出的特点,来分析每个架构自身的优点和缺点。

2. 项目说明

项目地址:https://github.com/ddadaal/android-chat-in-4-patterns

项目可直接使用Android Studio打开,需注意Android Studio应安装有lombok插件(安装教程)。本项目分为3个模块 :app, server, shared。三个模块的说明如下表:

模块作用启动方式
appAndroid应用部分使用模拟器或者实机打开
server聊天服务器端直接运行nju.androidchat.server.ChatServer
shared包含有服务端和客户端公用类不启动

2.1 应用需求

为了展示各个架构在实现功能的过程中的特点,我们将需求分成基础需求扩展需求

2.1.1 基本需求

服务器

  1. 建立与多个客户端的连接
  2. 接受任何一个已连接的客户端发送的消息,并发送到其他所有客户端

客户端

  1. 发起到服务器的连接
  2. 接受用户输入文字信息
  3. 发送文字信息到服务器
  4. 接受服务器文字信息并显示

2.1.2 扩展需求

功能编号实现的功能点简介功能详细介绍使用架构使用本架构的代码实现相对于上一个架构的优点使用此架构实现本需求点的不足之处更好的架构更好的实现
1增加消息记录云备份功能增加一个界面,界面中有一个按钮,点击按钮后将本地的消息记录发送到某个云服务器MVC增加一个Activity,Activity连接一个Controller。当用户点击备份按钮后,Activity将备份请求转发给C,C转发给M,M发起网络请求,且在结束后通知V修改界面。当按下按钮后View无法及时响应(M还在等待响应,没有通知V数据变化)MVPP能够控制V(对比C不能控制V),所以当V(Activity)将备份请求给P时,P可以控制V进行及时的、恰当的显示(比如显示备份中字样)
2撤回消息某个客户端的用户选择一条消息撤回(只能是自己发的消息),其他用户的客户端上,如果该消息存在于浏览页面,这一消息会被置换为“该消息已被撤回”MVP在P和V中都增加处理逻辑,P中当接受到撤回消息的请求时,调用V的撤回消息方法;V的响应方法中,将对应消息文本框的text设置成“已撤回”需要同时修改P和V。当业务逻辑复杂时(例如说一个消息可能具有很多状态(撤回也可以考虑成一个消息的状态)每个状态都需要在UI上进行特殊显示时),P和V可能需要增加很多处理函数。MVVM将消息状态和界面进行双向绑定,修改消息ViewModel的状态就会同时更改界面中对象消息的状态,不需要单独的逻辑处理。同时也可以把界面上的输入框和某个属性进行双向绑定,减少UI事件处理的代码。(这个其实可以算一个单独的功能)
3过滤脏话当客户端接受到信息时,根据预设的脏话列表(表现为正则表达式数组)匹配信息内容,如果匹配到信息里包含脏话,则信息修改为***MVVM修改在VM和UI组件的绑定处理函数中(就是,当VM改变时UI应该怎么改变),增加判断逻辑。若包含脏话,则将显示的信息设置为***需要修改逻辑。若逻辑更加复杂,则可能使此绑定函数过于冗长,难以维护。FRP将接受到的信息看作一个流,增加判断逻辑只是在这个流上增加过滤函数而已;增加逻辑也只需要增加过滤函数而不是修改已有逻辑
4限制用户发送消息频率限制用户在1s中只能发出一条信息,消息发送后1s内不允许发送。FRP将用户发送信息也考虑为一个流,使用throttle对发送消息函数进行节流只需要增加流处理函数,不需要修改已有逻辑,也不需要手动写计时器

2.2 实现说明

  1. 使用Java语言
  2. 所有客户端和服务器运行在同一台机器,使用Socket通信

在实际实现中,我们将采用以下形式:

  1. 首先使用MVC,MVP,MVVM和FRP四种架构分别实现基础需求
    • 初始设计中考虑所有功能点的前置条件
      1. 功能1:客户端本地已经保存消息记录
      2. 功能2:需要项目配好junit,MVP中P对M和V应该是依赖接口,而不是依赖具体类
      3. 功能3:需要区分不同的请求类型;每个接受到的信息可以独立修改
      4. 功能4:倒是没有特殊的前置条件
      5. 功能5:倒是没有特殊的前置条件。
      6. 作业1:每个接受的的信息用单独的容器显示,而不是在一个TextBox里修改其文本(和功能3比较类似)
      7. 作业2:同上
      8. 作业3:这个让他们自己改吧
  2. 复制1次MVC的原始代码(称为MVP-0),在复制的MVC代码中实现功能1
    1. 实现了功能1的MVC代码编号为代码MVC-1
  3. 复制2次MVP的原始代码(MVP-0),在第一份MVP代码中实现功能1,第二份实现功能2
    1. 实现了功能1的MVP代码编号为代码MVP-1,和MVC-1形成对照
    2. 实现了功能2的MVP代码编号为代码MVP-2
  4. 复制2次MVVM的原始代码(MVVM-0),在第一份代码中实现功能2,在第二份代码中实现功能3
    1. 实现功能2的MVVM代码编号为代码MVVM-2,和MVP-2形成对照
    2. 实现功能3的MVVM代码编号为代码MVVM-3
  5. 复制2次FRP的原始代码(FRP-0),在第一份代码中实现功能3,第二份代码中实现功能4
    1. 实现了功能4的FRP代码编号为FRP-3,和MVVM-3对照
    2. 实现了功能5的FRP代码编号为FRP-4

2.3 应用使用

系统使用配置文件确定在登录后要进入哪个Activity(称为目标Activity)。

要修改登录后进入哪个窗口,修改app/assets/config.properties文件中chat_activity为对应的类名。可选的类名有如下格式:

nju.androidchat.client.{架构: mvc|mvp|mvvm|frp}{编号: 1|2|3|4}.{架构: Mvc|Mvp|Mvvm|Frp}{编号: 1|2|3|4}TalkActivity

例如:要进入之上提到的实现了功能3的MVVM代码,应将其修改为

nju.androidchat.client.mvvm3.Mvvm3TalkActivity

要启动应用,设置好目标Activity后,应首先启动Server。等Server启动完成后(显示下图的字样即启动完成),再使用Android Studio运行多个客户端。

当客户端启动成功后,输入用户名后点击登录按钮进行登录。要注意多个客户端不能使用同一个用户名,否则登录时将会报错。

进入主界面后,输入信息点击发送即可将信息发送出去。本机发出的信息显示在右边,其他用户发送的信息显示在左边。

通过修改目标Activity,可以尝试各个功能的效果。各个功能的效果gif图可以在下文介绍实现每个功能时看到。

3. Android代码和界面交互

首先,我们得搞清楚在Android中,逻辑代码是如何与界面进行交互的。

在Android中,逻辑通过Java代码进行编写,而界面中的元素通过XML进行定义。在Java代码中,可以通过findViewById方法,找到某个元素对应的对象,并进行操作。

例如说,以下XML代码在界面中定义了一个EditText组件,并通过android:id属性定义其ID为et_content

app\res\layout\activity_main.xml, 57-68

<EditText
    android:id="@+id/et_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/message_shap_chat_bg"
    android:imeOptions="actionSend"
    android:maxLines="1"
    android:minHeight="36dp"
    android:paddingStart="13dp"
    android:textSize="13sp"
    />

这样,在代码中,我们就可以通过findViewById(R.id.et_content)获得此控件的实例,并进行交互(例如订阅事件功能)。

app\src\main\java\nju\androidchat\client\mvc0\Mvc0TalkActivity.java, 43-45

// Input事件处理
EditText editText = findViewById(R.id.et_content);
editText.setOnEditorActionListener(this);

在代码中也可以通过直接实例化控件的实例,来生成一个新的界面元素,并通过其他控件暴露的方法(例如说LinearLayout提供的addView方法)将新生成的界面元素增加到界面中。

app\src\main\java\nju\androidchat\client\mvc0\Mvc0TalkActivity.java, 57-71

LinearLayout content = findViewById(R.id.chat_content);
 
// 删除所有已有的ItemText
content.removeAllViews();
 
// 增加ItemText
for (ClientMessage message: messages) {
    String text = String.format("%s", message.getMessage());
    // 如果是自己发的,增加ItemTextSend,并传入撤回请求事件处理
    if (message.getSenderUsername().equals(model.getUsername())) {
        content.addView(new ItemTextSend(this, text, message.getMessageId(), this));
    } else {
        content.addView(new ItemTextReceive(this, text, message.getMessageId()));
    }
}
 

这样,我们实现了代码和界面的交互操作。

3.1 一个Activity的职责

一个典型的Activity需要负责以下两个部分的工作:

app\src\main\java\nju\androidchat\client\mvc0\Mvc0TalkActivity.java, 115-118

// 界面操作:发送按钮点击事件处理
public void onBtnSendClicked(View v) {
    // 界面操作,隐藏键盘
    hideKeyboard();
 
    // 业务逻辑操作,发送文本
    sendText();
}

3.2 问题

如果我们把所有的事件处理代码和业务逻辑操作都堆在Activity中,很容易就造成Activity的代码堆积。

为了解决这个问题,人们提供出了Model-View-Controller架构,即MVC架构。

4. MVC架构

在MVC架构中,界面相关的代码业务逻辑相关的代码被分开,分别放在View和Model中。而在Android开发中,View即我们之前提到的Activity。他们三者之间的关系如上图所示,即

具体来说,如果我们通过MVC架构实现发送消息这个功能,其数据流应该如下图所示。其中,左边(后缀为1的MVC)为发送者,右边(后缀为2的MVC)为接收者。

通过这样,

4.1 问题

根据MVC的数据流图,我们应该注意到一个问题:界面控制不灵活

在传统MVC中,V只能因为M变化而变化(C只能控制V的跳转)。当M的操作很快就能完成的时候,V也能很快获得响应;但是如果M的操作不能很快完成(异步操作,耗时操作)呢?

4.2 扩展功能1:消息备份

我们尝试使用MVC架构实现扩展功能1:消息备份。

消息备份是一个全新的界面,在这个界面中,上面有一个大按钮备份。当点击备份后,系统将会发送一个异步HTTP请求(在真正的实现中,用Thread.sleep)代替。在请求进行中,View的界面请求结束后,将会在界面中显示上次更新时间。

根据MVC架构,我们可以很容易地画出如下的数据流图。

看起来非常的正常,但是我们思考一个问题:当Model正在进行3. 备份操作时,View如何进行响应?

就像我们之前所说的,在MVC中V的改变只能由M引起。但是当M没有被修改时,V也无法改变。对于耗时操作,这样就不能及时给用户进行响应,严重影响用户体验。

你可能会说:我们可以将此“正在备份”的状态也考虑为一种数据,然后在Model中,在发起HTTP请求之前,我们先修改这个数据,引发View改变。根据这个思路的代码实现可以参考代码中mvc1下的Mvc1Backup(Activity|Controller|Model)三个类,在报告中就不再赘述。

这样的实现的问题在于:

这个“正在备份”的数据和界面相关,却放在了应该只处理业务逻辑的Model中

现在,我们发现界面似乎并不能直接和业务模型进行一一对应:界面中的数据和业务模型的数据是不太一样的。所以,人们提出了MVP架构。

5. MVP架构

在MVP架构中,负责界面的View和负责数据操作的Model已经不再有直接的联系:他们之间的操作均通过一个专门的Presenter进行转发和处理。Presenter取代并加强了原来Controller的地位:Presenter现在除了要转发View层的操作,还能够直接控制界面层进行界面的修改,还负责给View提供数据进行显示。除了Presenter的引入,View和Model的职责和MVC一致。

使用MVP架构实现的消息备份功能的数据流图如下。注意,在发起3. 请求备份操作之前,P已经控制V显示正在备份的数据。

5.1 优点

通过设置一个专门的Presenter,之前所提到的MVC的界面控制不灵活的问题得到了很好的解决。Presenter在进行耗时操作之前,可以首先控制View修改界面,这样减小了界面的控制粒度,使控制界面更加的灵活。

同时,Presenter的引入也让界面和数据完全地解耦,使一方的修改可以不影响另一方。

最后,如果我们将MVP之间的控制也全部接口化(即Presenter是控制View接口,而不是实际控制某一个特定的View),我们也让View和Presenter的重用变为了可能。在我们的MVP0代码中,所有M,V,P都是通过接口调用而交互的,而这样一份MVP的接口定义又叫做一个合约(Contract)。这样,界面和数据进一步解耦,增强了可重用性和可测试性。

app\src\main\java\nju\androidchat\client\mvp1\Mvp1Contract.java,8-28

public interface Mvp1Contract {
    interface TalkView extends BaseView<TalkPresenter> {
        void showMessageList(List<ClientMessage> messages);
    }
 
    interface TalkPresenter extends BasePresenter {
        void sendMessage(String content);
 
        void receiveMessage(ClientMessage content);
 
        String getUsername();
 
        //撤回消息mvp0不实现
        void recallMessage(int index0);
    }
 
    interface TalkModel {
        ClientMessage sendInformation(String message);
 
        String getUsername();
    }
}

5.2 扩展需求2:撤回消息

我们再引入一个新的扩展功能:撤回消息,并使用MVP架构来实现它。这个功能的需求是这样的:

  • 长按一个消息,弹出撤回确认框;
  • 撤回消息后消息内容被替换为“已撤回”。

使用MVP架构实现的此功能,其数据流是这样的:

其Presenter的代码是这样的,请留意我其中的注释,再想想我们MVC尝试解决的问题是什么。

app\src\main\java\nju\androidchat\client\mvp2\Mvp2TalkActivity.java,40-57

@Override
public void recallMessage(UUID messageId) {
    // 操作界面
    List<ClientMessage> newMessages = new ArrayList<>();
    for (ClientMessage clientMessage : clientMessages) {
        if (clientMessage.getMessageId().equals(messageId)) {
            newMessages.add(new ClientMessage(clientMessage.getMessageId(), clientMessage.getTime(), clientMessage.getSenderUsername(), "(已撤回)"));
        } else {
            newMessages.add(clientMessage);
        }
    }
    this.clientMessages = newMessages;
    this.iMvp2TalkView.showMessageList(newMessages);
 
    // 操作数据
    this.mvp2TalkModel.recallMessage(messageId);
}

5.3 问题

Presenter既负责操作界面,又负责数据操作。

你是不是对这句话感到很熟悉?我们MVC架构不就是来解决这个问题的嘛!为什么它又回来了?以下代码是使用MVP架构实现的消息备份界面的备份功能的代码,你能在这里面看到Presenter是如何既操作界面,又操作数据的。

app\src\main\java\nju\androidchat\client\mvp1\Mvp1BackupPresenter.java,12-23

@Override
public void backup() {
    // Presenter首先修改界面显示
    this.backupView.editBtnStatusAndText(false, "正在备份");
 
    // 再进行数据操作
    this.backupModel.backup();
 
    // 数据操作结束后,将界面改回来
    this.backupView.editBtnStatusAndText(true, "备份");
    this.backupView.editTextView(this.backupModel.getLastUpdated().toString());
}

这就造成了,如果我们需要在一个数据被修改的时候同时操作多个地方的界面,那么Presenter中就必须**过程式地(imperative)**一条一条地描述要做的工作,当逻辑复杂时,非常容易出错。

另外,虽然Model和View没有互相耦合了,但是Presenter耦合Model和View,这就造成Model和View被修改(如果MVP之间是使用接口通信的话,那就是接口被修改),那么Presenter也必须进行相应的改变。

5.4 数据和界面的关系

我们现在考虑一下MVP和MVC中数据界面的关系:

架构界面数据来源产生的问题数据修改如何反应到界面上产生的问题
MVC业务模型数据界面控制不灵活观察者模式,自动修改界面
MVP经过Presenter处理的数据Presenter过程式(imperatively)地修改Presenter职责过于繁重

我们自然能想到,如果有这样一个架构,那么我们就同时有了MVP和MVC的优点,并避开了它们各自的缺点:

架构界面数据来源数据修改如何反应到界面上
?某个经过处理的数据(称为界面数据自动

幸运的是,我们确实有这样一个架构,它叫做MVVM。

6. MVVM架构

在MVVM中,VM和M的交互则和M和P中的交互没有变化。但是我们使用了一个叫ViewModel的东西替代了原来的Presenter。ViewModel包含某个View需要的数据,需要处理View业务处理请求。除此之外,也是最精彩的地方,

ViewModel和View进行双向绑定:当ViewModel改变的时候,View对应的界面元素自动更新;当View改变的时候,ViewModel的数据也自动更新。

请注意上文段中的两个自动。因为数据到界面界面到数据的同步过程都是自动的,我们的业务代码中就再也不需要考虑修改界面这个工作了!当VM中的数据被修改时,MVVM框架自动帮助我们修改对应的界面。

当我们使用MVVM来实现消息撤回时,其数据流是这样的。

特别注意在第二步,和MVP去直接去修改界面中元素属性不同,ViewModel只修改了数据,界面的修改是自动的。

app\src\main\java\nju\androidchat\client\mvvm2\viewmodel\Mvvm2ViewModel.java,62-71

private void recallMessage(UUID uuid) {
    uiOperator.runOnUiThread(() -> {
        messageObservableList.stream()
                .filter(message -> message.getMessageId().equals(uuid))
                .findAny()
                .ifPresent(message -> {
                    // 修改数据的状态即可
                    message.setState(State.WITHDRAWN);
                });
    });
}

6.1 定义映射关系和观察数据变化

稍微深入一点,MVVM是怎么知道这个状态的改变需要怎么改变界面的显示呢?MVVM又是怎么知道这个状态发生了改变呢?

先说第一个问题:在XML中,我们可以定义和界面有关的数据,并定义界面和数据的对应关系。

app\src\main\res\layout\item_text_mvvm2.xml,16-18,61-69

<variable
    name="messageBean"
    type="nju.androidchat.client.mvvm2.model.ClientMessageObservable" />
 
<!-- ... -->
 
<TextView
    android:id="@+id/chat_item_content_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@{messageBean.direction.equals(Direction.SEND)?@drawable/message_text_send:@drawable/message_text_receive}"
    android:text="@{messageBean.state.equals(State.WITHDRAWN)?@string/recall_message:messageBean.message}"
    android:textColor="#333333"
    android:textSize="14sp" />
 

在以上XML中,variable定义了这个界面和一个ClientMessageObservable对象有关系,在这个XML文件中这个对象被称为messageBean;而在TextView的android:text定义了这个TextView的值,应该和这个messageBean的state属性有关:当其为State.WITHDRAWN时,就显示@string/recall_message(资源文件中定义的字符串,目前为“已撤回”);若不是,就显示这个messageBean的message属性的值。

你可能已经猜到了,一个ClientMessageObservable对象就对应着我们的一个消息。当我们每接受到/发送一个消息时,我们就新增一个这样一个ClientMessageObservable对象,并将其加到一个列表。这个列表的修改又会造成界面上的新增一个界面元素……

app\src\main\java\nju\androidchat\client\mvvm2\viewmodel\Mvvm2ViewModel.java

ClientMessageObservable clientMessage = new ClientMessageObservable(serverSendMessage);
uiOperator.runOnUiThread(() -> {
    messageObservableList.add(clientMessage);
    uiOperator.scrollListToBottom();
});

你可能又发现了,这个类是以Observable结尾的。这说明,这个类是一个可观察的对象,即它的改变,能够被其他对象观察到,并进行相应的操作。在实现中,这个类继承了BaseObservable。这个类是androidx.databinding包提供的,用来定义可观察的(observable)对象的基类。在一个类里的标注有Bindable的属性,其变化是可以被观察的

app\src\main\java\nju\androidchat\client\mvvm2\viewmodel\Mvvm2ViewModel.java,5-6,29,39-41,48-51

import androidx.databinding.BaseObservable;
import androidx.databinding.Bindable;
 
// ...
 
public class ClientMessageObservable extends BaseObservable {
    @Getter
    @Bindable
    private State state;
 
    public void setState(State state) {
        this.state = state;
        notifyPropertyChanged(BR.state);
    }
}
 

在message对象的setState方法中,在修改完可观察的属性state后,其调用notifyPropertyChanged方法。虽然我们不知道这个方法具体做了什么,但是从它名字我们可以猜到,它通知(notify)了属性(property)的改变(changed)。当这个被触发时,MVVM框架中所有依赖了这个对象的这个属性的界面元素(例如上面提XML中定义的TextView)就会被自动刷新。

这样就实现了数据到界面的同步。至于界面到数据的同步,读者可以自己去尝试。更进一步,读者可以尝试一下如何将一个输入框的文本和一个VM中的某个文本属性双向绑定起来,使得当输入框改变时,文本属性也自动改变;文本属性改变时,输入框中显示的文字也自动改变。

6.2 优点

虽然我们在5.4节已经说过了MVVM的优点,但是在这里我再想强调一下MVVM最重要的特点:数据和界面的完全解耦

通过MVVM的双向绑定,我们再也不通过findViewById获得界面元素并手动操作界面元素;再也不需要再修改某个界面的定义时,在各个代码中搜索并修改所有操作过这个界面元素的代码;甚至,我们也不需要测试数据改变,界面是不是也被如预期地被改变了。我们的代码只需要修改数据,将我们从繁琐的UI操作中解放了出来,极大地增强了代码地清晰度可扩展性可维护性

6.3 缺点

虽然MVVM的优点非常显著,使得MVVM以及其思想被广泛运用于各个UI开发领域(WPF,JavaFX,Angular...),但是MVVM也有几个不可忽略的缺点:

  1. 基础设施复杂

为了在Android中使用双向数据绑定,我们必须引入整套androidx.databinding库,也必须重写XML文件以定义数据到界面的绑定关系,我们也必须重新定义我们的数据结构,以使数据的变化能够通知到MVVM框架。

  1. 不能处理非数据和界面非一一对应,业务逻辑复杂的情况

我们之前的情况,都有一个共同的特点:数据和界面是一一对应的。有数据,就有其对应的界面元素。如果业务逻辑非常复杂,情况不是这样呢?

6.4 扩展需求3:过滤脏话

过滤脏话的需求很简单:当发送内容包含脏话时(在实现时,提供了一个boolean containsBadWords(String)方法来判断),不显示在界面上

根据MVVM的思想,我们会很快在一个问题上陷入困惑:如何把一个数据绑定到没有元素上

除了一些耍流氓般的什么可见性、透明度,我们发现我们只能在将数据和界面绑定之前,把这个数据提前过滤掉。

app\src\main\java\nju\androidchat\client\mvvm3\viewmodel\Mvvm3ViewModel.java,59-71

public void sendMessage() {
    if (Utils.containsBadWords(messageToSend)) {
        uiOperator.sendBadWordNotice();
    } else {
        LocalDateTime now = LocalDateTime.now();
        UUID uuid = UUID.randomUUID();
        String senderUsername = client.getUsername();
        ClientSendMessage clientSendMessage = new ClientSendMessage(uuid, now, messageToSend);
        ClientMessageObservable clientMessageObservable = new ClientMessageObservable(clientSendMessage, senderUsername);
        updateList(clientMessageObservable);
        AsyncTask.execute(() -> client.writeToServer(clientSendMessage));
    }
}
 

我们发现,在这种数据和界面非一一对应的情况下,我们无法避免在绑定进行之前写逻辑代码。当这样的、必须发生在绑定发生之前的逻辑变得更加复杂的情况下,我们的代码又变成了过程式的(imperative)的意大利面条式的代码,MVVM对此完全无能为力。而随着业务的扩张,功能的增加和变得越来越复杂,这样的逻辑越来越多,为了避免代码质量的雪崩式下降,我们必须想一个办法解决掉这样的问题。

7. FRP

FRP,全称Functional Reactive Programming,函数式响应式编程,是最近一个非常火的概念,被广泛运用于各个UI开发领域,很多使用者说它能解决很多UI开发中固有的问题。那么这到底是个什么东西呢?

在FRP中,对事件的处理被抽象为一个流(Stream)

处理一个事件的方法,和之前地写事件处理函数不同,是在这个事件流(Stream)上增加各种处理函数。为了提高效率,并防止处理流的过程中意外修改系统的上下文或者全局状态,这样的处理函数应该是纯函数(Pure functions),即输出只和输入有关的函数,例如说常见的映射(map,将一个数据变为为另一个数据)、过滤(filter)、节流(throttle)等。这个通过连接纯函数进行数据处理的概念,称为Functional。

而在FRP中,当事件发生时,其数据被放入流中,流的各个处理函数被自动按顺序触发,直到被流的消费者消费掉(subscribeOn),此时流事件结束。这个自动触发的概念,称为Reactive。

当然,流也可以被分支(share)成多个流;数据在经过分支时,生成多个副本被多个流分别处理;多个流也可以合并(merge)成单个流:当多个流的数据到达时,按顺序进入被合并的流中进行处理。

通过这些看似复杂的概念,我们将复杂的事件处理流程分解成流的分支合并以及各个事件处理函数的附加和删除,降低了复杂度,提高了代码可读性和可维护性。

7.1 使用FRP实现消息发送和接受

让我们来看看FRP是怎么处理消息发送和接受的过程的。

我们将整个系统考虑为两个流:输入流输出流

当用户确认发送一个消息时,这个事件的数据(用户要发送的数据)被放入输入流。输入流在中间被分支为更新UI流发送给服务器流。一个用户要发送的数据的副本被发送给服务器流的消费者通过Socket发送到服务器,而另一个副本通过更新UI流被更新到UI界面上。这样,我们完成了用户发送消息这个事件的处理。

当客户端接受到一个消息时,一个接受到消息事件被触发,被接受到的消息作为数据放入接受流中。接受流会根据消息类型的不同分支为多个分支流:在图片中,接受流被分支成了其他用户发送的消息流服务器错误信息流。对于后者,其消费者将会在界面上显示一个Toast消息;对于前者,将会合并到更新UI流中,和输入流的消息一起被更新到UI界面上。

代码实现如下:

app\src\main\java\nju\androidchat\client\frp0\Frp0TalkActivity.java, 68-120

// 1. 初始化发送流
this.sendMessages$ = this.createSendMessageStream().share();
 
// 2. 初始化接受信息流
this.receiveMessage$ = this.createReceiveMessageStream().share();
 
// 3. 将接受信息流分为多个流,分别处理
// 3.1 错误处理流
this.errorMessage$ = this.receiveMessage$
        .filter(message -> message instanceof ErrorMessage)
        .map(message -> (ErrorMessage) message);
// 3.2 服务器发送消息流
this.serverSendMessages$ = this.receiveMessage$
        .filter(message -> message instanceof ServerSendMessage)
        .map(message -> (ServerSendMessage) message);
// 3.2 撤回消息流
this.recallMessage$ = this.receiveMessage$
        .filter(message -> message instanceof RecallMessage)
        .map(message -> (RecallMessage) message);
 
 
// 4. 处理每个流
// 4.1 处理错误流
this.errorMessage$
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe((message) -> {
            Toast.makeText(this, message.getErrorMessage(), Toast.LENGTH_LONG).show();
        }, Throwable::printStackTrace);
 
 
// 4.2 处理发送流,将每个消息写到服务器
this.sendMessages$
        .observeOn(Schedulers.io()) // 发送消息网络要在 io线程做
        .subscribe((message) -> {
            Log.d("send", message.toString());
            this.socketClient.writeToServer(message);
        }, Throwable::printStackTrace);
 
 
// 4.3 合并发送流和服务器接受消息流,并更新UI
this.addToViewMessages$ = Observable.merge(
        this.serverSendMessages$.share().map(message -> new ItemTextReceive(this, message.getMessage(), message.getMessageId())),
        this.sendMessages$.map(message -> new ItemTextSend(this, message.getMessage(), message.getMessageId(), this))
);
 
this.addToViewMessages$
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe((view) -> {
            log.info(view.toString());
            messageList.addView(view);
            Utils.scrollListToBottom(this);
        }, Throwable::printStackTrace);

7.2 过滤脏话

有了这样的流结构,要实现过滤脏话,我们只需要在发送流上增加一个filter函数即可。这样,包含有脏话的消息数据,就不会继续被输入流的后续函数和消费者进行处理,被“过滤”掉了。

代码实现:

app\src\main\java\nju\androidchat\client\frp3\Frp3TalkActivity.java, 65-68

// 1. 初始化发送流
this.sendMessages$ = this.createSendMessageStream()
        // 在这里加一句filter就可以过滤脏话了
        .filter(message -> !Utils.containsBadWords(message.getMessage()))
        .share();

7.3 扩展需求4:限制用户发送消息频率

限制用户在1s中只能发出一条信息,消息发送后1s内不允许发送。

在普通的MVC/MVP/MVVM中,为了实现这一功能,我们不得不手动使用计时器,并通过一个标志位来控制用户是否能够发送。整个代码冗长又易错,如果再扯上多线程……

而使用FRP的思想,我们可以尝试截断这个流,使其一定时间内只能通过指定次数。幸运的是,这个需求很常见,我们可以直接使用内置的节流函数(throttleLatest)就可以实现这个功能。这个函数顾名思义,使这个流在指定时间内只能通过一次。

代码:

app\src\main\java\nju\androidchat\client\frp4\Frp4TalkActivity.java, 67-70

// 1. 初始化发送流
this.sendMessages$ = this.createSendMessageStream()
        // 1秒只能发一条
        .throttleLatest(1, TimeUnit.SECONDS)
        .share();

7.4 优缺点

FRP的思想和传统的命令式编程思想有很大的不同,所以它对程序员思维的影响也是比较大的。这里,我们总结出FRP的优缺点供大家参考,但是也更希望大家能够自己亲自尝试一下,自己体验FRP的特点,并根据自己的业务需求来选择或者不选择使用FRP。

FRP和传统的事件处理范式有很大的不同,这使得FRP在处理复杂逻辑时,能有效避免传统事件处理范式所造成的意大利面条式代码的问题,使复杂的事件处理流程变得更加的清晰和可维护。同时,FRP的一些库也提供了很多的帮助函数(例如Map, filter, flatMap, throttle, reduce, first)等,非常适合应对来源多样数据处理复杂多变等情景。

整个应用使用FRP进行构建,只使用一个类,仅173行代码,并且不像其他架构一样有很复杂的依赖和调用关系,使得代码非常的清晰和间接。

要想真正用好FRP,必须能够懂得FRP的概念,并且能够正确地用FRP的思想来考虑问题。而正如之前所说,FRP的思想和传统的编程思想也很大的不同,这也为使用FRP设下了很高的门槛。

目前被广泛使用的FRP库(例如ReactiveX)都会提供非常非常多的函数供用户使用,虽然这增强了FRP的能力,但是却也让新手程序员望而却步;就算是老手程序员,其中一些函数从字面上可能并不能猜出它们地区别,也很容易地就用错了函数(例如下图这两个函数(地址),对于很多用户来说,仅从函数名上可能很难理解他们的区别)。要精通FRP的使用,还是需要很长的学习时间。

FRP在数据来源多样数据处理复杂多变的情况非常好用,但是对于不满足这些条件的情况下,可能并不能带来什么好处,反而会因为其概念和使用的复杂性降低了工作效率。所以,如同前面所说,我们还是建议大家自己去尝试并评估FRP对自己的项目的利弊,并选择对开发最有利的工具。

8. 练习

三选一

  1. MVP:在MVP中,除了之前提到的便于测试之外,由于将业务逻辑和View分离,增加功能也只需要增加一个Presenter和少量修改View即可实现。尝试在MVP的原始代码的基础上实现以下功能:

当所发送的信息为![]({图片地址})这样的格式时(即markdown的图片),将信息显示为对应的图片。

  1. MVVM:当消息过多时,有的消息可能没有在当前屏幕上显示出来。尝试在MVVM的原始代码的基础上实现如下功能:

显示不在当前屏幕中显示出来的消息数量,并随着用户滚动消息列表、发送和接受消息实时更新。(Tip:考虑绑定消息列表的当前位置属性)

  1. FRP:使用现有的XML界面文件,使用流从头实现基本需求,并实现功能3(撤回消息)

9. 总结

在这篇文章中,我们通过实现一个Android端的局域网群聊应用,介绍了MVC、MVP、MVVM和FRP的特点、优缺点以及使用建议。希望读者能够从这篇报告中对这四个常见的Android客户端架构有更加深入的理解。如果有什么问题,请随意指出。

2019年春微软实习面试经验

2019-04-21 10:30:00

介绍

经过几个月的准备、投简历、面试和(最让人难受的)等待,终于在4月16日拿到了微软苏州STCA SE的实习Offer。所以在这里记录一下这几个月来所经历过的实习相关的事情,并想发表一下关于实习、关于未来计划的一些思考。

面试前

面试前最重要的当然是刷题选择职位投递简历。在这次夏季实习之前的2018年底和2019年初,我还经历了一些和微软相关的面试经历。而且,这次夏季实习生的投递简历的过程也是一波三折,在上海和苏州的职位中纠结,最终还是选择了苏州。下图为投递过但最后撤回的职位。

申请列表

2018年底:微软冬季实习

2018年10月份的时候看到了冬季实习的消息(当时的微信推送),感觉非常激动,于是很快就把简历投了上去,选择的职位也是微软苏州STCA的SE Intern。

等了一个月,终于在刚下课的时候等到了来自苏州的一个电话,一接果然是HR,心里非常激动,以为和SAP一样要电面了,可没想到就问了一个可以去的时间,我报了1个月(寒假嘛)后,HR就随便说了说如果有的话过几天会有HR来通知的就挂了。然后从同学那里听说这种实习一般都得需要3个月😔,所以就放弃了,最后果然也没有下一步。

2019年1月:MSRA和上海Blockchain

之后,在俱乐部邀请两位去过MSRA的学长学姐来介绍实习经历(俱乐部微信推送)后,学长表示可以内推。心里没忍住,就让学长帮忙内推了MSRA,接下来的事在这篇面经中讲的比较清楚,这里就不再说了。这次是我第一次接受比较正式的面试,成功体验到从自信到自闭的全过程,最终还收获了宝贵的我拒了MSRA的经历。

其实,在这段时间里,我除了投了MSRA,还投过了上海某个区块链相关的职位(上图中最后一个)。在拒了MSRA之后我心灰意冷,向和继续在你院浪费半年时间的残酷命运低头,所以过了几天区块链这个职位的HR打来电话问面试时间,我就直接拒绝了面试。

之后迎来了愉快的、本来准备刷题但是最后在家连LeetCode都没打开的寒假。

2019年2月:夏季实习投递简历

在寒假后第一个工作日就看到了夏季实习生开始投简历的消息(当时的微信推送)。因为刚开学,所以时间比较充足,所以在这次网申开始后,我花了一段时间更新简历,之后立刻开始投简历的。投简历经过了以下阶段。

一开始以为可以投多个职位,所以浏览了一下实习列表,除了一定会投的苏州STCA,还发现了上海的两个职位。本来以为上海微软就只有支持,结果发现其实也是有开发岗的。其中普通的C+AI应该和STCA比较相似,但是另一个C+AI Open Source的职位吸引住了我。仔细了解后发现这个工作主要是给VSCode写Java扩展……我是VSCode(当然还有其他微软产品,除了Surface)的忠实用户,对这个开源和开发扩展的工作非常感兴趣,于是也把它选上了。

后来听说只能投一个职位。我纠结再三,最后还是感觉在在MS写开源在MS写Java听上去非常酷,而且这样也能提高自己混开源社区的能力,也可以避免把自己螺丝钉化,而且还可以去体验上海的生活,所以最后留下了上海Open Source的职位。

结果又没过几天就听说有学长可以帮忙内推。这可难办了:内推可以直接免掉笔试,而我之前对MS的笔试(好吧那是2015年……)的印象是:不会做。而且不仅当时的我不会做,现在的我也不会做啊……虽然刷了一段时间的题但是还是深知自己算法有多么的弱鸡(不管是从自己做题的要死要活的感受以及和同学比较),所以免笔试对我来说是个巨大的诱惑;但是学长只能内推STCA,而上海的是C+AI,这是两个大组,不能互推。又纠结再三,追求稳妥的我最后还是选择了先上车再说,留下了苏州STCA的申请。有关苏州还是上海这一点,文章最后还会继续讲。

刷题

当然了,投简历不重要,在面试前的这段时间里最重要的是刷题准备面试。而MS的面试=做题(这一点请看下面面试部分),所以面试前的准备就是两个字:

刷题

刷题的感觉每个人都不一样,对我来说刷题就是两个字痛苦。几乎每遇到一个题我大脑都是空白的状态,即使刷过50、100、150题以后也是如此;给自己安排的配额是每天5道,确实发生过很快就能解决5道的情况,但是那是少数,大多数时候都是2小时2道medium,每道题一段时间后想不出来就忍不住看Solution,之后心态爆炸准备找3道easy水过,结果被easy难住……;其他同学刷个几十道甚至几道就能在面试场上游刃有余,拿到新题至少有个思路不会惊慌失措,而我甚至在刷了下图这么多题后看到新题仍然是一脸懵逼,心里发慌;更别提其他人刷题就是放松、“找到真正的快乐”,而对我就是折磨……

我的Leetcode情况

总之,这段刷题经历让我终身难忘,也是我最后选择先上车再说的原因:我确实是不想再经历刷题的痛苦了……

PS: 在4月的时候看了下微软笔试题,发现当时选择内推是正确的,也证明了我对自己的认知没有错。

面试

3月1日结束了投简历的环节,然后就边刷题边焦虑地等面试。终于在3月12日收到了一封现场面试邀请,确认自己有空的时间后在3月18日收到了3月20日面试的通知。生日面试可真刺激。第一次面试包含两轮面试,两轮都基本就是在白板上做算法题,每轮1个小时,听学长说,只要过一轮就会又三面。

第一轮

第一轮的面试官把我从楼下带到面试房间后,随便寒暄了几句就开始做题。第一轮的题目是这样的:

n个人,以i,j元组代表i关注j. 输入关注关系的数组(例如[(1,2), (2,3)]表示用户1关注用户2,用户2关注用户3),且认为每个人自己关注自己。计算所有人的被直接关注和间接关注的人数(比如i关注j,j关注k,那么i间接关注k,k的被关注量为3)

这个题目的做法是DFS,即对每个用户(比如说i),寻找(k, i)出现的个数,然后对每个(k, i),寻找k的关注量。用一个Boolean数组表示一个用户是否被访问,从而解决环的问题。最终写出来的算法和图的DFS遍历比较相似。

看起来很简单,都知道是DFS,但是当时不知道脑子怎么抽了想的是带回溯的DFS……然后卡了40分钟没想出来怎么处理环,最后面试官(应该是看不下去了才)来提醒了一下不带回溯,并指出来哪些行要改,但是我还是直到最后一分钟才做出来……由于是在MS办公楼一个小房间里里做的,当时卡住的时候紧张地满身大汗,话都不想说了……这种情绪还是得控制一下才好。这轮根据面试官的反应和个人感觉应该是挂了的。

第二轮

面完第一轮已经一个小时了,于是休息了几分钟就迎来了第二位面试官。和第一位不太一样的是,第二位面试官一开始还问了20分钟左右的项目经历。我趁势推广了我的博客,面试官在我博客上看到了An Infinite Loop Caused by Updating a Map during Iteration这篇文章,就饶有兴趣地让我讲讲这个bug以及怎么发现它的。这个部分我个人感觉讲的不错(毕竟算法题这么屎的我还敢面试MS的原因是我项目经验应该还不错吧……),面试官的反应也不错,这让我稍微放松了一些。

之后,还是得做题。这轮面试第一题是这样:

在二叉搜索树中,按照节点值的大小顺序,找一个节点的上一个节点。

这个题比较简单,因为对二叉搜索数进行中序遍历,其结果的顺序就是从小到大的。所以这个题最简单的做法就是先做一个中序遍历,将结果存储到数组中,然后在数组中查找要查找的节点。

很明显这不是最优的,果不其然面试官要求进行优化。一个简单的优化是只记录前一项(而不是保存之前所有的节点),在中序遍历中,首先检查当前节点是不是要查找的节点。如果是,则返回前一项;否则,将当前节点记录下来,然后继续遍历

以上两个思路可以参考LeetCode 173。之前的两个写法都是用递归的,所以面试官到这里又继续问,能否将递归改成循环。这个也是个比较套路化的做法,背下来就可以了,也可以自己写几个递归算法然后尝试用栈把递归改成循环,可以参考LeetCode 94的Solution。值得一提的是我不知道在这里又出什么问题了,又以为碰上了第一轮一样的错误(环),然后又开始纠结,紧张地满身大汗,话都不想说了。当时心里全想的是完了,然后又立刻安慰自己没过还有其他出路……不过还好,稍微冷静下来后仔细看了下代码发现自己纯粹是没事找事,所以就直接给面试官看了。

这个题到这里就结束了。之后面试官又出了一道题:

二叉树中,输入两个节点,检验两个节点是不是同深度但异父母。

这个题最简单的思路就是寻找从根节点到两个节点的路径,然后比较路径的长度和路径的倒数第二项(即节点的父节点),也可以采用BFS的思想。

这个题做了之后就让询问面试官一些问题后就结束了。

结束一二面后

这一二面还是比较刺激的,随便两道题就将我算法弱鸡的本质毫无保留地暴露出来。这时的心情还是比较复杂的:一方面第一面表现实在是太差了,但另一方面个人感觉第二面地表现应该还是可以接受地,面试官的反应应该也比较正面,再加上两面过一面就可以三面,所以还是有希望的。

令人喜悦的是3月21日就收到了三面的安排邮件,确认时间后在3月29日收到了4月4日三面的邮件。还是从学长那里了解到三面的形式不一,可能又会做题,但也可能只聊天。但是我当时已经松懈了,再加上考试的压力,我就没有继续准备了,准备自由发挥,认命了。

三面

4月4日前去三面,是和另外一批内推的同学一起面试。他们一天面完三轮,而我们先面了两轮的,这次去只用面试最后一轮就可以了。

见到面试官后,面试就直接开始了。一开始它看我简历,就直接问我React的一些问题,例如

后面还看我简历上提了ASP.NET Core,又问了ASP.NET Core中Request进来到Controller总共经历了哪些步骤。这谁顶得住啊.jpg……只好在脑子里扒两年前用ASP.NET Core时的一些技术细节,然后支支吾吾说了个Filter……还好面试官接受了我ASP.NET Core就xjb用用的说辞,就没有继续往下问了。

然后又进入了喜闻乐见的刷题环节。这次两道题都是LeetCode原题。第一题LeetCode 26,题目是:

删除排序数组中重复元素

这个的思路就是双指针,如果两个指针的值相等,就把第二个指针后面的所有数据往前移;如果不相等,两个指针都+1

写出来之后,还要写几个测试用例。由于当时还没经过czy老师的教导,就只能凭感觉瞎写了几个。面试官看来也不准备继续在测试用例耗时间,就出了第二题:LeetCode 10

带. *,不带括号的正则表达式匹配。

看到这个我又惊又喜:惊的是这个题在LeetCode上可是Hard题啊;喜的是这个题我还是认真做过,现在还记得思路。根据心里还记得一些思路,用最简单的递归(没有DP)将代码写了出来。面试官似乎有点不太熟悉这个做法,他说他本来想让我写状态机。他一提到状态机,我就想到了编译原理的词法分析的过程,想到了正则表达式中序转后序->后序表达式转NFA->NFA转DFA->使用DFA分析字符串的过程,心里一阵发毛:谁tm能现场写这么多,这谁顶得住啊.jpg * 2。之后就和面试官说,这个用状态机比较复杂,然后就DFA、NFA、闭包啥的吹了一通,面试官可能看时间有点来不及了就没让写了)。

最后还是问了问问题,了解到这个面试官是在MS做Microsoft 365企业应用的,前端正好就用的React + TypeScript(可能这是为什么他来面试我的原因把),后端使用的是ASP.NET Core,他们还在用Microsoft Bot Framework做一些聊天的应用。正好我都有点熟悉,于是和面试官聊了聊这几个技术,现场气氛十分愉悦。

三面后

三面结束后感觉还是比较放松的,毕竟题目都做出来了,聊天也没啥问题,感觉没啥理由挂我)。中午微软包了饭(果然还是微软特色:盒饭),没想到居然遇到了二面面试官……闲聊了几句,比如什么面试情况,微软的工作时间,听说微软的工作时间一般945或者1055的时候感觉这也太棒了吧.jpg,尤其是在现在到处996的情况下,真的是一股清流。

吃完饭等了一会,下午1点多HR找到我说面试官的反馈还是比较positive的,虽然不能肯定但是offer应该是稳了。非常膨胀,以至于忘了第二天清明节还不提前买票,然后(再次)体验了一把4个半小时的汽车。

等车时的抱怨说说

Offer

清明节后第一天收到了面试通过邮件,又过了一周正式收到了Offer,又过了几天收到了分组名单,我分在了Office 365的部门。

感想

到这里我的春招应该就结束了。接下来要做的事情比较简单:

讲道理,SAP真的是良心企业,不仅是955,各种岗前培训也表明了SAP是真心实意把候选人当作人才来培养的,就这样走了还真的感觉有点对不起SAP)。希望还有学弟学妹们把握住机会。

czy,zh,4门课3个组队4个大作业,zh四个pre五个报告,测试上三节课就机考,微笑。

但是每个阶段都有不一样的问题:面试的问题解决了,一些之前搁置的问题又重新变得突出了:

这个问题纠结了我很久。读个研,用两年时间换个学历,不知道到底有多么重要。学历对我来说倒不是为了什么得到更好的工作(读了研最后不还是这些工作?),也不是薪资的问题(有的公司研究生起薪比本科生高,但是微软不是),而是退路问题。

一直说计算机行业不怎么看学历,但是个人感觉这只不过是计算机行业发展前期供小于求的劳动力供应情况造成的。这几年计算机行业人才井喷,劳动力供应飞速增长,可是随着行业结束野蛮发展的状态,劳动力需求增长减缓,甚至不增反降。作为一个硬指标和门槛,学历的重要性肯定会提高,甚至不排除发展成金融行业那样的情况。如果以后微软凉了(外企护城河再深,效率再高,国内企业在各种政策、情怀和奋斗比加持下,并不是不可能动摇外企目前看似坚固的业务的根基;再加上国内越来越加深的“国产情怀”,以及国内各种保护政策,微软等外企在国内业务凉掉的可能性并不是不存在),得重新出去找工作的时候,有可能在学历关就被刷掉。这就非常难办了。

但是从个人角度来说,由于各种原因(特别在你院的三年),我确实不想再呆在学校,更加期望能够投入工作,做一些真正有用的东西。就像在我的关于里说的那样,我还是希望我的工作能够有利于对其他人有用(这也是我喜欢微软的一大原因,Make Other People Cool),能够帮助他人提高工作效率,并且做一些自己真的想做的事情,而不是在学校里(这里省略一些字)。

感觉目前来说这个问题还是只能先去实习,亲身感受下工作的环境后再定。

之前更加喜欢苏州,并不是因为苏州的自然人文环境等(个人确实不太care这些),而是因为苏州的生活成本和最重要的房价。除了这个方面,在其他例如发展前景(不太看好苏州发展前景,毕竟有个上海,纯个人想法请不要因为这个点撕我)、新事物的应用机遇资源(比如说对后代)等其他方便,上海不仅现在更好,以后也只会更好不会更差。

但是对于生活成本这一点,最近查了查上海和苏州房价的对比,发现苏州并没有想象中的那么便宜。当然上海整体会更高,但是上海稍偏远一点的地方的房价和苏州贵一点的地方的相比也并没有离谱到哪儿去。再加上上海的薪资会比苏州高,所以其实这个选择还不是那么容易做的。

目前的打算还是先在苏州实习,在实习过程中更多地了解一下两个城市地情况和转正上海的事情。

总结

不管怎样,我的微软实习申请部分到现在就结束了,现在最重要的工作是在这学期的课程轰炸下存活,同时也要在以后找个时候重新开始算法题,毕竟转正面试也是要考算法题的嘛。

Strongly Typed i18n with TypeScript

2019-04-17 09:30:00

i18n and The "Raw String" Problem

Internationalization, also known as i18n (18 characters between the leading i and trailing n), is about making a product friendly to global users, and one of the most important work is to deliver common contents using different language according to user's preference. For example, for the same text content login, our application should show login for English users, whereas 登录 for Chinese users etc.

Text Placeholder (Id)

One typical technique to achieve it is to use placeholder (or id) in the places of text contents. During compilation or runtime, these ids will be replaced with localized strings which are usually defined in dedicated files, each one of these files consisting of all the texts in one language.

Defining the Mapping from Id to Content

There are many ways to structure a file, and one of them is nested object, where the id of a value is all the keys in the path joined with dot(.). The nesting structure works as index for a database, which would be beneficial to maintain the file, especially when the number of entries grow.

// en.ts, which contains all the texts in English
export default {
  login: {
    button: {
      text: "Login",
    },
  },
}
 
// cn.ts, which contains all the texts in Chinese
export default {
  login: {
    button: {
      text: "登录",
    },
  },
}
 
// both language files are isomorphic,
// i.e. have the same structure
 
// the id for the text content `Login` is login.button.text

Another form that is commonly accepted is plain key-value mapping, like iOS (example) and ASP.NET Core MVC (example), which is just simply, well, an id-to-text mapping.

Applying the Id

After the mapping has been defined, one way to use the id is to define a custom component which receives an id and returns the text content according the current language setting.

// This component receives id and returns the text content.
function LocalizedString({ id }: { id: string }) {
  // get the currentLanguageObject (the object defined above) from anywhere,
  // like React Context, redux store, mobx store, simstate store, or just import it
 
  // For simplicity, use Ramda to safely get nested value
  return R.path(id.split("."))(currentLanguageObject);
}
 
// Use the component in the place of raw text content
<Button><LocalizedString id="login.button.text" /></Button>
 
// Previously...
<Button>Login</Button>

Benefits

It looks promising. By using id, the followings can be easily achieved, which are left as assignments for readers :smile:.

The "Raw String" Problem

The biggest problem of this solution is raw string id. Without external support from compiler or IDE (like Angular Language Service whcih supports Angular templates), using raw strings in code is harmful and should be avoided.

// typo
<Button><LocalizedString id="login.buton.text" /></Button>
 
// long key
<LocalizedString id="app.header.userIndicator.loggedIn.dropdown.login.button.text" />
<LocalizedString id="app.header.userIndicator.loggedIn.dropdown.username.button.text" />
<LocalizedString id="app.header.userIndicator.loggedIn.dropdown.logout.button.text" />
 
// What if a key in the path is renamed?

Make it Strongly Typed

By making the call strongly typed, we can avoid raw string in our code and enable the refactors, error checking, autocompletion and other features provided by type inference. However, the following table lists some ways as well as respective weaknesses.

SolutionExplanationWeaknesses
Callback<LocalizedString id={(ref) => ref.login.bottom.text} />- Affecting performance
- Verbose, especially multiple LocalizedString components
Accessing the object directly<Button>{lang.login.bottom.text}<Button/>- Losing the abilities to fallback, observable, string interpolation...
Accessing object with component wrapper<LocalizedString id={lang.login.bottom.text} />- Losing the abilities to fallback
Accessing the store (or context) containing language object in the componentcontext.language.login.bottom.text- Verbose
- Unnecessarily coupled

The third solution is best of all, but since it accesses object directly, it is impossible to intercept the chaining call to enable fallback. Besides, designing such a global variable(lang) that satisfy the need is not an easy work.

Strongly Typed Id

It seems that using id is the best choice, so is it possible to generate id by accessing object? With Proxy introduced in ES6, it is!

// Definition is the type of language object
import { Definitions } from "./definition";
 
// The wrapper class recording the keys that have been accessed
export class Lang {
  constructor(public paths: PropertyKey[]) { }
}
 
// The Symbol to access the keys
export const GET_VALUE = Symbol("__get");
 
// factory function creating proxified Lang object
function factory(langObj: Lang) {
 
  // The proxy intercepts the object access
  const obj = new Proxy(langObj, {
    get: (t, k) => {
      // if the key is GET_VALUE symbol, return the keys joined with "."
      if (k === GET_VALUE) {
        return langObj.paths.join(".");
      }
      // if not, record the current key and generate another proxified Lang object
      // making the Lang object immutable
      // more on that later
      return factory(new Lang([...t.paths, k]));
    },
  }) as any;
  return obj;
}
 
// The root proxified Lang object
const lang = factory(new Lang([]));
 
// Lie to TS compiler
// that lang object is of type Definitions (the type of language object)
// and export the object
export default lang as Definitions;

There are also few modifications needed in the LocalizedString component.

function LocalizedString({ id }: { id: string }) {
  return R.path(
    // add access to GET_VALUE
    // highlight-next-line
    id[GET_VALUE]
    .split("."))(currentLanguageObject);
}

The core idea is to record the key at every access using Proxy. By lying to TS compiler about the actual type of the lang, all the type related features are naturally enabled, like the autocompletion showing in the picture below.

Lang object autocompletion

By making the proxy object immutable, we can introduce a common object containing the common prefix without sacrificing type inferenece, resulting in cleaner code, especially when multiple components are needed.

// Previously
<LocalizedString id={lang.app.header.userIndicator.loggedIn.dropdown.login.button.text} />
<LocalizedString id={lang.app.header.userIndicator.loggedIn.dropdown.username.button.text} />
<LocalizedString id={lang.app.header.userIndicator.loggedIn.dropdown.logout.button.text} />
 
// With root object
const root = lang.app.header.userIndicator.loggedIn.dropdown;
 
<LocalizedString id={root.login.buttom.text} />
<LocalizedString id={root.username.buttom.text} />
<LocalizedString id={root.logout.buttom.text} />
 

root object autocompletion

Drawbacks and Conclusion

The benefits of this solution is obvious: all the aforementional benefits with type inference. Therefore, it is wildly adopted in all my projects, including this VicBlog and my Citi Competition project. On the other hand, it is also worth noting that there are some drawbacks.

I believe that type information is important in every periods of programming, including designing, coding, debugging and maintaining, since it significantly helps programmer avoid stupid bugs and reduce the time and frustration in finding these bugs. Type inference also increases efficiency quite radically, since well-designed code can be used as never-out-of-date docs which are also invaluable in team collabration. As a result, personally I would prefer strongly typed programming languages and styles and try to use it anywhere possible.

A Kotlin DI Framework in 50 Lines and Thoughts on Kotlin

2019-04-06 15:48:00

Why

If you have got used to using DI framework (like Spring) to manage and inject dependencies, and now you have to start a new, relatively simpler, but not so simple project, you have 2 options to work with in terms of dependency management:

I encountered this sort of problem a week ago when I was writing the demo project for architecture class, which was only a simple JavaFX project that had a simple dependency hierarchy. What was different in this project from other simple and less designed project was that instead of using class directly, it used interface and implementation class pattern in order to match the class diagram and meet the course requirement. Using interface decoupled the interface and implementation, which was a good practice, but also introduced complexity in using this code, since it would be impossible to new an interface.

As mentioned before, excluding DI framework option, two patterns could be applied: object instantiation and factory pattern. Kotlin also supports singleton object declaration which was also a good choice. All of them, however, made the code less elegant:

// interface and implementation
interface SomeService
class SomeServiceImpl: SomeService

Pattern 1: object instantiation

import xxx.SomeService
 
// highlight-start
// Problem 1: have to import the impl class
import xxx.SomeServiceImpl
// highlight-end
 
 
class User {
  // highlight-start
  // Problem 2: have to specify the interface type and the implementation type
  // Problem 3: not valid if we want singleton
  private val service: SomeService = SomeServiceImpl()
  // highlight-end
 
}

Pattern 2: simple singleton factory pattern

// in the same file where Service and ServiceImpl are defined
 
// highlight-start
// Problem1: manual object instantiation
val instance = SomeServiceImpl()
// highlight-end
 
 
// highlight-start
// Problem2: manually write a factory function
fun createSomeService(): SomeService {
  return instance
}
// highlight-end
 
// in another file
import xxx.SomeService
 
class User {
  // highlight-start
  // Problem 3: manual factory function call
  // Problem 4: implicit and extra dependency to factory function
  private val service = createSomeService()
  // highlight-end
 
}

Pattern 2.1: simple singleton factory pattern using companion object

// in the same file where Service and ServiceImpl are defined
 
interface SomeService {
  companion object {
    // highlight-start
    // Problem 1: interface relies on implementation??
    val service: SomeService = SomeServiceImpl()
    // highlight-end
  }
}
 
class SomeServiceImpl: SomeService

Pattern 2.2: singleton using singleton object declaration

// in the same file where Service and ServiceImpl are defined
 
interface SomeService
 
// highlight-next-line
object SomeServiceImpl: SomeService
import xxx.SomeService
 
// highlight-start
// Problem 1: have to import the impl class
import xxx.SomeServiceImpl
// highlight-end
 
 
class User {
  // highlight-start
  // Problem 2: have to specify the interface type and the implementation type
  private val service: SomeService = SomeServiceImpl
  // highlight-end
}

All of these problems were of little importance, but to an extent affected my coding experiences. What I would like was minimal dependencies and minimal extra code.

Luckily, with the help of delegation and classpath scan capability provided by classgraph, we could achieve the minimality, with the introduction of only three simple APIs:

// annotate service interface with @Service
@Service
interface YourService {
  fun hello()
}
 
// annotate service implementation class with @ServiceImpl
@ServiceImpl
class YourServiceImpl: YourService {
  override fun hello() {
    println("Hello from YourServiceImpl")
  }
}
// in a random class that uses the service
import YourService
 
class AnotherClass {
  // declare a property with the interface type and delegate it with di(),
  // the function *where the magic is*
  private val yourService: YourService by di()
 
  fun hello() {
    // just call it
    yourService.hello();
  }
}
 
fun main() {
  // just new an instance as usual
  val obj = AnotherClass()
 
  obj.hello() // print out "Hello from YourServiceImpl"
}
 

How It is Done?

Here is the code with some comments to help understanding.

Before getting started, install classgraph in your project with Maven or Gradle to have all the Services and ServiceImpls automatically scanned and configured like Spring Boot would do.

// imports
import io.github.classgraph.ClassGraph
import kotlin.reflect.KClass
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
 
// annotations to annotate Service interface and Service implementation class
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceImpl
 
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Service
 
// the exception when an implementation is not available
class NotProvidedException: Exception()
 
// the container class
// `scanBase` is the base package that would be scanned
@Suppress("UNCHECKED_CAST")
class CADiContainer(scanBase: String) {
 
  // key as the Java Class object and value as its corresponding singleton instance
  private val map = mutableMapOf<Class<*>, Any?>()
 
  init {
    // scan specified package
    ClassGraph()
      .enableAllInfo()
      .whitelistPackages(scanBase)
      .scan()
      .use { scanResult ->
 
        // find out all interfaces annotated with Service
        val services = scanResult.getClassesWithAnnotation(Service::class.qualifiedName).loadClasses()
 
        // find out all implementation classes annotated with ServiceImpl
        val impls = scanResult.getClassesWithAnnotation(ServiceImpl::class.qualifiedName)
 
        // fill the instance map with service and service implementation
        services.forEach { service ->
            map[service] = impls.find { it.implementsInterface(service.name) }?.loadClass()?.newInstance()
        }
      }
  }
 
  // just look up the map and try to get the instance
  fun <T : Any> getInstance(type: Class<T>): T {
    return map[type] as T? ?: throw NotProvidedException()
  }
}
 
// instantiate a DI Container
val container = CADiContainer(scanBase)
 
// Here is where magic is:
// define a inline function with reified type parameter
// and return an anonymous ReadOnlyProerty object to be used as a delegate
// which uses the reified type parameter
// to look up and return the instance in the container
inline fun <reified T : Any> di(): ReadOnlyProperty<Any, T> {
    return object : ReadOnlyProperty<Any, T> {
        override fun getValue(thisRef: Any, property: KProperty<*>): T {
            return container.getInstance(T::class.java)
        }
    }
}

What's Good?

All injected instances are singleton, which is how DI is usually used.

You can also simply new an object (which should not be annotated with @ServiceImpl) if singleton injection is not enough, or context parameters are required when using a service. New-ed object can still use its dependencies. No extra learning needed.

Unlike commonly used constructor injection and setter injection, dependent object are fetched dynamically from the container when the property is being accessed. Nothing will be injected during the instantiation of objects. So there would be no problem to have circular and hierarchy dependency structure at all.

With other DI frameworks (like autofac and Spring), the whole application must be structured from the ground up and configured in guidance of the DI framework of your choice , which is time-wasting and totally a overkill if a simple application is all what you need.

Limitations

All services are instantiated randomly (to be more precise, in the order of time when it is scanned), regardless of their dependency relationships. For example, if A uses B, it is possible that B is instantiated earier than A, in which case access to B inside A's init block would cause NotProvidedException since at that time A instance has not been in the container. This problem can be solved with prior dependency relationship analysis or custom after-instantiation hook.

It should be emphasized that our 50 lines of code only targets to small application and is not for production, so advanced features are never considered. Use full blown DI framework if your application is serious.

My Thoughts on Kotlin

My first impression to Kotlin was a mixed bag:

It had lots of features that C# and Java did't, which does improve coding experience a lot: strict nullity check, pattern matching(which C# is introducing), delegation, function type ((Int, String) -> String, which is more intuitive than Action delegations in C#, and various and differently named built-in functional interfaces (like Consumer, Producer) in Java which are hard to remember), inline functons and more;

Some parts of language seemed confusing at first, but later was proved to be excellent. For example lambda in Kotlin needs to be wrapped with a brace({}) pair (like func(arr, {a, b -> a < b}) (just an example)), where Java doesn't (func(arr, (a, b) -> a < b)). However with the ability to write the last lambda parameter outside of parentheses (func(arr) {a, b -> x a < b}), the extra abilities it brings overweigh the disadvantages.

For example, with the help of TornadoFX, an excellent JavaFX framework for Kotlin, building JavaFX views can be done directly in Kotlin elegantly, which is even better than FXML, since strongly-typed-ness makes coding way less error-prone, verbose and intuitive than XML.

// define a vbox
vbox {
	// define a button as a child component, with "Button" as text, and an onAction function
	button("Button") {
		action {
			replaceWith<StudentCheckinView>(sizeToScene = true)
		}
	}
	// a list view whose data source is aList
	listview(aList)
}

Gradle also supports Kotlin as its DSL alongside Groovy, which also proves the benefits of this grammar are phenomenal.

Of course Kotlin has some problems, like the grammar of anonymous object is more verbose than Java's (where just a lambda would be enough) and some type system related problems thanks to the type erasure of JVM.

But the defects cannot obscure the virtues.

The features (including grammar sugars) Kotlin has have opened my eyes, showing me how a programming language that is modern, well-designed and has no history burdens can be like, how these features and grammar sugars can make coding much more productive and enjoyful, and most importantly, how our ways of thinking a problem can be different.

Everyone knows that programming languages are just tools: that's true, but tools can also influence how we look at problems.

A programmer who only knew procedural programming language would try to solve every problems procedurally, quickly being overwhelmed by exponentially growing complexity; A OOP only programmer would wrap everything into objects, resulting in piles of useless boilerplate codes and a class hierarchy so complicated that only god could understand it; A FP originalist would try to use FP on everything, ignoring the nature of the problem, the communities of selected technologies and his/her teammates, all leading to the failure of the project.

Learning different languages can bring different angles to view a problem and different tools to solve a problem, and that is valuable for any programmers.

7天Hackathon,Web前端极速入门指南

2019-03-08 19:30:00

前言

最近几年,前端技术发展地很快,各种新的框架和技术如雨后春笋般疯狂出现,让很多同学陷入了选择恐惧症。但是到目前为止,前端技术中最重要的仍然是老三件:HTML/CSS和JS。所以如果想做出一个网站,HTML/CSS和JS都是非常重要的。

我知道你现在心里在想什么:hackathon就七天时间,要掌握这么多技术才能写出个网站?我学个C/DLX都要学一学期呢,还这么多,那我退群算了。

这篇文章的目的,就是提供一些现有的框架/工具/教程,希望能帮助你在7天的时间里,拼装出一个好看又有用的网站前端

在这里强调一下,作者本人目前也还是一个前端小菜鸡,前不久被腾讯AlloyTeam怼得体无完肤,所以如有问题,可以在评论框中指出。本文的重点是根据我自己的经验,让无基础的同学快速入门使用框架的前端开发,能够使用各种框架,在7天的hackathon中,拼装出一个能用的Web前端。只看本文,只会框架,是不能算是会前端的。前端开发有其自己的复杂性,要深入也是一门很深的学问,要不然给普通前端开发开几十万年薪的公司都是傻吗?如果之后还想继续学习前端,可以参考各类面经、权威资料,在能够熟练使用框架的基础上,夯实HTML/CSS/JS基础,再加上多多练习和真实项目实践,相信你可以在毕业后拿到一个很棒的前端offer的。

第一步:学习基础的基础知识

学习顺序和重心

在这个部分,个人认为学习的顺序应该是HTML->CSS->JS,但是重心应该分配成JS>HTML>CSS

为什么先学HTML?因为HTML是可视化的。你写一个input,就能在页面上出现一个input;你写一个button,就能看到一个button。这种及时反馈对学习的重要不用我强调了吧?同时,HTML也是搭建目前网页布局的基础,不管用什么框架,最后的布局总是以HTML实现的。同样,CSS也是可视化的,所以也应该先学习。

但是呢,HTML和CSS都不需要学习过于深入。一是因为以下要学习的前端框架都会提供各种各样的简化HTML/CSS编写的东西。例如,你现在要让我写一个好看的按钮,我也写不出来,但是选一个好的前端UI库,只需要寥寥几行代码,就能看到一个好看的按钮;再加几个标签,还能给按钮变颜色、加动画。如果你要求不高的话,你甚至可能一行CSS都不需要写。一些UI库(例如bootstrap),还会提供可视化的HTML的布局工具,你甚至连HTML都不用写了。二是因为HTML和CSS过于艰深复杂,因为各种历史惯性和前端需求的复杂性,有各种反人类的知识点。例如说使用CSS居中一个元素看上去这么一个简单的需求,其实现方法比茴字的写法都要多,甚至有人还专门写了一个网站来帮人们做到这个需求。再加上第一条中的原因(UI库的存在),深入学习HTML/CSS在这么短的时间里是没必要的。个人认为,当你能够使用HTML/CSS搭建出一个你心目中的网站的大致布局时,就可以不要再继续深入了。

而JS不太一样。它第一眼看起来和你们比较熟悉的C/Java比较类似,但是呢其实是完全不一样的东西。建议以一个全新的编程语言的态度来学习它。JS在目前前端开发中占据极度重要的位置,而且也是以下将会讲述的前端框架的基础,所以需要非常重视。请直接从ES6的JS开始学习,不要学ES5之前的JS版本。个人认为,你至少需要了解并熟练使用回调函数,和一些ES6的新的特性(Promise,class等),才能算差不多了。

最后呢,还需要了解一些其他知识,例如说在浏览器中输入地址栏到显示网页这个过程中发生了什么HTTP request和response机制;如果你要使用下面将会提到的除jQuery以外的前端框架(推荐),你还需要了解Node和NPM是什么(nodejs是JS的本地运行时,可以理解成JVM一样的东西,目前前端大部分工具都是运行在nodejs平台上的;NPM是前端包管理工具,可以给一个项目管理和下载依赖包),前后端分离单页应用(Single Page Application)以及它和传统的多页应用的不同RESTful API及其设计(包括前端如何与后端交互)等知识,以让你知道你现在写的东西是什么,SPA的前端和后端的关系,以及方便对于前后端接口和后端扯皮。在学习这部分的时候,不要过分深究,看不懂就跳过,能够在不看书的情况下理解大概即可。想要理解地更加深入,需要在真正的项目中认真学习。

教程

以下教程只是抛砖引玉,如果你觉得这些教程有点看不懂/太简单,建议自己去多搜索,尤其是后面几个概念性的知识,建议多看看文章以从不同角度全面理解。

MDN的入门教程,专为啥都不知道的小白设计,快速了解HTML/CSS/JS基础

建议细看,特别是JS部分。

https://developer.mozilla.org/zh-CN/docs/Learn

W3CSchool,可以当reference用的教程,快速了解各种HTML元素和JS语法

看这个教程的时候,每看到一个知识点就自己写写玩玩,用各种HTML元素搭一搭网页。

http://www.w3school.com.cn/html/index.asp

JavaScript教程两则

https://www.liaoxuefeng.com/wiki/001434446689867b27157e896e74d51a89c25cc8b43bdb3000

http://www.runoob.com/js/js-tutorial.html

在浏览器中输入地址栏到显示网页这个过程中发生了什么

https://segmentfault.com/a/1190000006879700

HTTP请求和响应

主要看HTTP部分,网络实现部分可以忽略

https://www.jianshu.com/p/c1d6a294d3c0

前后端分离

https://juejin.im/post/5b71302351882560ea4afbb8

RESTful API

http://www.ruanyifeng.com/blog/2014/05/restful_api.html

http://www.ruanyifeng.com/blog/2018/10/restful-api-best-practices.html

Fetch API

https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch

第二步:选择并学习框架

组件库和UI框架

在这部分开始,我要把框架分为两个大类:组件库用户界面框架。从现在开始,我们把组件库就称为组件库,把用户界面框架简称为框架

为什么要这么分?因为他们要解决的问题不一样。

组件库,是来提供一些提前写好的UI组件的。在之前学HTML/CSS的时候,你有没有感觉到这些原生组件都非常丑?有没有感觉到一些看上去很常见的功能,HTML自己都没有提供(比如说弹出框?下拉栏?滚动条?)?你有没有感觉到每次都用CSS手动调样式调布局,非常地麻烦?而组件库,就能够帮你提供一些预先写好地、可供调用的、好看的、常见的组件。组件库就是一个工具箱,你想使用的话,找到其文档里所给出的使用方法,复制粘贴就可以了。

框架,是为了解决UI开发的一些常见痛点,提供一个写代码的模板(模式)的。举个数据同步的例子:当数据改变的时候,你会使用一些浏览器API操作网页上一些组件的一些属性,以让页面显示和真正的数据同步。比如说,你可能会使用document.getElementyById("id").innerText = "text";来修改一个ID为id的元素的值。那如果现在我想另一个元素的值和这个元素的值同步,你可能会想到提供一个修改值的函数,里面同时修改两个元素的值,如下

function changeValue(text) {
  document.getElementById("id").innerText = text;
  document.getElementById("anotherText").innerText = text;
}

当需求简单的时候,使用原生JS是最方便的:简单、直观、好调试。但是在真实项目里,需求更加复杂和多变,通过这样手动维护DOM的状态,是非常麻烦且容易出错的。例如说,在以上需求的基础上,想动态生成一个组件,当这个组件存在的时候,其值和text同步;不存在的时候,就不同步。这样需求的变化,都需要你重新设计一下代码;想想在一个真实的、动态的、数据流复杂的项目中,如果像这样一样随意设计,将会有多么容易出错。

这种常见痛点,除了以上提供的数据同步,还包括动画组件化(你做出了一个好看又好用的按钮,其不只是用CSS就能实现的。而你想在更多地方用它,难道要每次都复制粘贴一边吗?)等。所以,人们就根据这些痛点,开发出了一些前端框架,通过提供一些API、提供一个写代码的模式,帮你解决这些很多程序员都遇到并为之困扰的常见问题。

各个常见框架和组件库

所谓常见框架,这里指的是React, Vue, Angular这三个目前最火的前端框架,以及jQuery,这个老牌的、给行业做出巨大贡献的、但是随着时代的发展行将就木的行业老兵。它们具有目前最多的使用人数、最好的生态系统、最完善的文档(对于一个框架,使用的人越多,生态系统越发达,文档越全面你遇到的问题就更有可能更快地找到解决方案),对于各类开发者、各类需求都提供了比较好的解决方案。使用这些框架,不仅能避开一些前端(乃至整个UI开发)中的一些常见错误,还能借助其生态系统,在不了解其实现的前提下,也能快速拼接出自己想要的功能和界面。

以下将会从个人角度,简单介绍这些框架,适用人群和相关资料。目前这三个框架的官方文档都比较详细,个人认为学习这些框架主要以官方文档为主,然后在做项目的过程中,遇到问题就去网上搜索答案

对于组件库,一般来说,组件库不受框架约束。但是由于目前常见的框架都对常规HTML/CSS/JS的开发模式有所改变,一些组件库为了迎合框架的开发方式,会直接以框架为基础开发。这样开发出的组件库,可能不容易、甚至不能用在其他框架上。所以这里在介绍框架后,将会介绍推荐使用在这个框架上的组件库。

React(推荐)

我的主力,目前世界上使用最多的前端框架。React对常规HTML/CSS/JS的开发方式进行大的变革,通过引入JSX的语法以及一些社区的CSS in JS方案(即在,使得前端开发能够完全以JS为中心;通过单向数据流模式,解决了数据同步的问题。

个人觉得React有个重大的优势就是API少,概念简单,花在学习API的时间上少,能早点开始做东西。但是React的简单的另一面就是React实现的功能少,一些常见的功能都需要找库来实现(例如路由)。使用React就意味着需要使用React全家桶,例如webpackreact-router甚至redux等,可能会有一些选择恐惧症,以及可能会遇到一些库的坑(开源的东西嘛,你行你上啊)。所以在学习React的时候,建议不要一股脑就上各种三方库,先就用react自己提供的功能尝试实现(例如说,在React Context出现后,很多以前需要使用redux的使用场景,都可以使用Context,以更少的代码解决问题),实在感觉不上库不行的时候(比如说全局状态管理等),再尝试使用第三方库解决。

学习React的过程中还需要注意一点,目前React网络上的教程很多,但是很多教程都是过时的。当你在找React教程的时候,尽可能找新的教程,其中React的版本至少要是16以上。

比较新的中文文档,但是还是落后官方文档一个大版本

https://react.docschina.org/

官方文档

https://reactjs.org/

create-react-app,快速创建react项目

https://github.com/facebook/create-react-app

组件库:Ant Design

阿里推出的React组件库,组件覆盖特别全面,API也比较容易使用。而且除了组件库,阿里还提供了例如动画库、页面模板等很多资源,可以配套使用(在官网footer里找)

https://ant.design/

组件库:reactstrap,Bootstrap组件库的React包装

https://reactstrap.github.io/

Vue(推荐)

Vue是华人开发的一个前端框架,据说有简单易学易上手的特点,有中文文档而且是一直保持更新的,在国内具有极高的人气。其开发模式比较接近传统的前端开发,即模板语言(HTML)、逻辑(JS)和样式(CSS)分离。而且不像React对社区撒手不管的态度,Vue自己也维护一些周边的库,例如说vue-router(路由)、vuex(状态管理)等,能够覆盖前端开发的绝大部分需求,不需要像React一样,一些简单常见的需求都要自己去良莠不齐的第三方库海洋中探索。如果你更认同传统的样式逻辑分离的开发方式,Vue可能更适合你。

官方文档

https://cn.vuejs.org/

组件库:Element UI,饿了么推出的组件库

http://element-cn.eleme.io/#/zh-CN

组件库:Ant Design Vue,是一种Ant Design设计语言在Vue的实现

https://vue.ant.design/docs/vue/introduce-cn/

组件库:Vuetify,Material Design设计语言的Vue实现

https://vuetifyjs.com/

Angular

Angular是Google出的前端框架,具有重量级、全面、难上手的特点。不像Vue和React这种轻量的、只负责用户界面的库,Angular会负责所有前端的各种问题,例如说数据流管理、网络请求、依赖管理等,而且引入了很多后端开发中常见的概念(依赖注入等),让熟悉后端开发(尤其是Spring Boot, ASP.NET Core这种传统的高大全的面向对象设计的库)的人也能够用几乎相同的知识上手前端。Angular不好上手,因为Angular有太多的功能和特性,对于小应用来说,用不上/不需要这些功能,徒增学习成本,用Angular得不偿失;但是当项目规模变大,复杂度变高,这些原本不重要的问题显现出来后,Angular自带这些功能的优势就显现出来了。Angular的高大全还体现在其文档的完备性上,一般来说,在学习的各个阶段,官方文档都能满足你的要求,不需要去自己去做太多的搜索。

个人比较推荐有后端开发经验的同学在构建大项目的过程中尝试使用Angular,在本次hackathon中若不满足大项目的条件,可以避开。

官方文档

https://angular.cn/

官方文档的入门教程,跟着走一遍就可以理解Angular的一些重要的概念

https://angular.cn/tutorial

组件库:Material Design风格的组件的Angular实现。也是由Google自己做的

https://material.angular.io/

原生/jQuery

学习框架总是有成本的。如果你觉得并没有那么多时间学习框架,可以考虑直接使用原生或者jQuery库进行开发。因为大部分项目的复杂度其实并没有使用框架的必要,引入框架所带来的成本可能小于它所能带来的收益。例如说如果你的网页上只有一个输入框和一个按钮,那直接使用原生JS进行DOM操作完全足够了,不需要引入框架。

而对于jQuery,jQuery在2019年应该说算是走完了它的生命周期了,因为它所尝试解决的一些问题(浏览器API不统一等)已经随着Web标准的发展已经解决了,目前引入jQuery只能是徒增学习成本。但是有的时候一些库(特别是一些老牌的库)需要依赖jQuery,这时候只需要依赖一下就可以了。

个人推荐要使用传统开发模式(MVC,模板语言,即不采用前后端分离)的团队采用原生的开发方式。

组件库:Bootstrap,享誉世界的CSS库,各种资料、模板一应俱全,互联网上资料也是特别多。注意,有的组件需要引入jQuery。

https://v4.bootcss.com/

组件库:Semantic UI,另一个简单的库

https://semantic-ui.com/

第三步:写更好的代码

当你跟着第二步,学习了一个框架的基本使用后,你应该已经可以在七天时间拼装出一个能用的网站了。这一部分将会为一些愿意深入了解前端、写出更好更容易维护的代码、做出更好的网站的同学,给出一些进阶的资料以便参考。如果你没有时间,可以跳过这个步骤,但是强烈建议在以后的学习中对这些知识都有所了解。

TypeScript

JavaScript作为一个第一个版本只用了一个人10天就做出来的语言,其设计有一些缺陷,类型系统的缺失即为缺陷之一。弱类型让很多习惯于写强类型语言的开发者感觉极为不便(例如缺乏IDE支持、代码难以维护等),也让很多错误不能及时被找出(当然弱类型也有其灵活性的好处)。随着JS在全世界的爆红,微软提出了TypeScript语言,其在保持动态语言的灵活性的同时,引入类型系统,帮助开发者在开发时就能找到潜在的错误,并获得来自IDE的诸如自动补全等支持,减少开发者在开发时的心智负担。TypeScript目前已经接受到全世界开发者的好评,一般来说前端开发者都应该了解一些TypeScript的知识,并尝试在实际的项目开发中引入TypeScript。

https://www.typescriptlang.org/

Mock

开发前端应用的时候,不可避免地需要和后端服务器进行交互。但是在前后端分离的架构模式下,前后端是独立开发的,两端的进度不会一样。有时候前端在做一个功能的时候,需要和后端交互,但是此时后端还没有做好,该怎么办呢?此时前端应该开启一个虚假的服务器,这个服务器会实现接口文档中的接口定义,但是其数据都是固定的/易于生成的。这样的服务器就称为一个Mock服务器。这样,事先实现一个Mock服务器,前端在开发过程中,就可以只和Mock服务器交互而不和真正的后端服务器交互,提高效率,也避免了两端开发进度不同步带来的问题。当然,在最后的测试阶段,也需要和真正的服务器后端交互进行测试。

你可以尝试在前端抽象出一个网络层,接管所有网络请求,并在此层做Mock和真实地址的切换;一些常见的HTTP请求库(例如axios)都会自带Mock的功能;如果不想自己折腾,GitHub上很多库和工具能够根据接口文档或者手动输入,快速搭建一个Mock服务器,这里就不举例了。

一些资料

https://juejin.im/post/5c0a34f3e51d451dd867951c

https://juejin.im/entry/57bd37c2c4c9710061606b38

单元测试

一般来说,测试的重要性很难被学生理解:一方面是课程中对学生项目的考察中一般不会以功能为主,不会过分深究代码质量和正确性;另一方面是本来学生项目的时间比较紧张,而写测试需要花费大量的精力和时间。并且,对于快速变化、对于需求通常没有严谨的定义的前端项目来说,进行完整的单元测试是几乎不可能的。但是,写完备的单元测试能够确保一个功能的正确性,能够有效杜绝“在我的电脑上明明可以的!”这样的问题。为了在效率和正确性上取得一个平衡,可以尝试在一些关键逻辑上写一些单元测试,以保证这些核心功能不会出错,提高交付时的信心。

一个React的单元测试库

https://github.com/kentcdodds/react-testing-library

持续集成

如果你的项目需要最终部署到网站上,与其每次更新后都手动打包上传,可以尝试下使用持续集成/持续部署工具,让打包->测试->发布这个过程自动化。如果你还写了单元测试,持续集成的过程中还会自动运行测试,并在测试未通过的时候阻止部署,防止将错误的代码交付给用户。

Travis CI

https://travis-ci.org/

持续集成

https://juejin.im/entry/5893590a128fe1006545a980

持续部署

https://cnodejs.org/topic/5885f19c171f3bc843f6017e

总结

在这篇文章中,作者根据最近的前端开发经验,给出了一些短时间内快速入门前端开发的方法、技巧、经验和资料,希望能够帮助在短时间内拼装出一个能看能用的前端网站。再次强调本篇文章并不是让读者能够短时间精通前端。如果对前端有兴趣,建议使用权威的资料对前端知识进行系统性的学习。如果有读者有什么问题或者建议,请在下面的评论中留言。

An Infinite Loop Caused by Updating a Map during Iteration

2019-02-25 15:30:00

The Problem

I accidently encountered a problem during the development of the 2.0 version of simstate: During an iteration of a set of observers stored in a ES6 Map, which contained only one element, an infinite loop occurred.

Loop that never ends

Investigation

This problem confused me quite a lot. It had been confirmed that this Map had only one element and the element remained unchanged between and inside loops, and the all elements in promises array were the same.

Only one element in the map

My first thought was data race. It might be true for other languages with real multi-threading capability, but this is JavaScript: it is single thread only, and so there would be no such concurrent related problems.

Using a function as the key of Map seemed weird for other programming languages, since most Map-like data structure (like HashMap in Java, Dictionary in C# and unordered_map in C++) requires the key to be hashable, and functions are not, at least in the surface. However, MDN clearly states that any value (both objects and primitive values) may be used as a key. The following code works well, which proves that functions, too, can be used as keys in a Map.

const data = [];
const map = new Map();
function a() { console.log("a"); }
map.set(a, "123");
map.forEach((value, key) => key()); // a

The Root of the Problem

After some investigation, the call to observer caught my eyes.

observer() will trigger the re-render of observer component and make the updated state available for end-user. To simplify implementation, every time the component is re-rendered, the component will unsubscribe (delete an entry from a Map) to the stores it subscribes to, and re-subscribe (set an entry in a Map) during the render.

StoreConsumer.tsx omitting some unrelated code

export default class StoreConsumer extends React.Component<Props, State> {
  // ...
  instances = new Set<Store<any>>();
 
  // ...
  private unsubscribeAll() {
    this.instances.forEach((store) => {
      // highlight-next-line
      store.unsubscribe(this.update);
    });
  }
 
  useStore = <ST extends StoreType<any>>(storeType: ST, dep?: Dependency<ST>): InstanceType<ST> => {
    // ...
    // highlight-next-line
    store.subscribe(this.update, dep);
    // ...
  }
 
  render() {
    return (
      <SimstateContext.Consumer>
        {(map) => {
          // ...
          // highlight-next-line
          this.unsubscribeAll();
          this.instances.clear();
          return this.props.children({ useStore: this.useStore });
        }}
      </SimstateContext.Consumer>
    );
  }
}

Store.ts omitting unrelated code

subscribe(observer: Observer, dep?: Dependency) {
  // highlight-next-line
  this.observers.set(observer, { dep, shouldUpdate: getChecker(dep) });
}
 
unsubscribe(observer: Observer) {
  // highlight-next-line
  this.observers.delete(observer);
}

The call to observer (observer()), happened during the iteration of the Map, alters the Map itself!

This is a dangerous action. It has become a common sense not to do so in most programming languages, since it might cause unexpected behaviors, which is exactly what happened here.

In this case, when deleting an entry and re-add an entry during the iteration, even if they are the equal, the item will be considered as a new item, which results in an infinite loop.

You may reproduce the case with the following code snippet. Note that since it runs indefinitely, consider running the script in a CLI environment (instead of browser) where you can kill the process to end the execution with ease.

const map = new Map();
function obs() { map.delete(obs); map.set(obs, 1); console.log("obs called"); }
map.set(obs, 1);
map.forEach((value, key) => key());
// endless "obs called"

Conclusion

This is quite a simple problem, and the cause is also easy to understand for most programmers. However, it still confused me for quite a while. After it, I realized that some bugs might seem strange and difficult to debug, but it doesn't mean the cause is complicated: sometimes it is the indirections on top of all the root that adds to the difficulty. During debugging, it is a good way to track down the code execution flow, and in this process the cause may just reveal itself.

Simstate and Why

2019-02-14 23:50:00

What is it?

npm types Build Status Coverage Status

As the title indicates, this is yet another React state management library favoring React Hooks and TypeScript.

GitHub:https://github.com/ddadaal/simstate

Why use it?

Please read repo's README for why and how to use and integrate simstate into your projects, which covers all the APIs, features as well as future roadmap for this library.

Why write it?

Respect React's programming pattern

MobX and react.di were extensively used for my last projects to have an frontend infrastructure that looks like backend, which helps a lot in state and dependency management. However, some problems occurred during their integration in the context of React, the most important of which are the inconsistencies between the code styles in OOP with Dependency Injection pattern (encouraged by react.di) and functional pattern (by React).

The following code snippet shows the differences between the patterns with an example to implement a commonly seen component in our code, with event handlers and dependency to external data store.

 
interface Props {
  id: string;
}
 
// OOP with DI.
// Class component is required
// to have an instance member for dependency to inject :/
@observer
export class AComponent extends React.Component<Props> {
 
  @Inject store: Store;
 
  handler = () => { };
 
  render() {
    return (
        <button onClick={this.handler}>
          {this.props.id}: {store.text}
        </button>
    );
  }
}
 
// render props with `simstate`
const AComponentWithRenderProps = ({ id }: Props) => (
  <StoreConsumer storeTypes={[Store]}>
    {({ useStore }) => (
        <button onClick={() => { }}>
          {id}: {useStore(Store).text}
        </button>
      );
    }
  </StoreConsumer>
);
 
// HOC with `simstate`
const LocaleMessageWithHOC = withStores(Store)(
  ({ useStore, id }: WithStoreProps & Props) => (
    <button onClick={() => { }}>
        {id}: {useStore(Store).text}
    </button>
  )
);
 
// Hooks (best of all)
const ComponentWitHooks = ({ id }: Props) => {
  const store = useStore(Store);
  return (
    <button onClick={() => { }}>
        {id}: {store.text}
    </button>
  );
}

The HOC, render props and Hooks are the ones that are more compact, easy to write and most importantly, adherent to React's philosophy than class based components are. The code style of DI adds more mental burdens for developers by constantly switching between two programming patterns. You may argue that it is more like a personal taste: it is true, but it is also obvious that recently React focuses more on functional component over class component, and the whole ecosystem is moving to functional components at a rapid speed.

Implement the functions and patterns I like

The immediate choice after realizing the aforementional problems is the unstated. Based on the not-so-new React Context coming with React 16.3, it provides a hybird solution between MobX and Redux: Define state and operation in a class like MobX. But instead of using observables mechanism to detect and react to changes automagically, it requires calling a setState function to initiate a state change, like a normal class component. It turns out to be a excellent balance point for many developers, including me. In fact, simstate almost implements the exact same functionalities with most code looking alike.

The much hyped React Hooks is an another striking thing for all React developers when it is revealed. It gives so much power to functional components in an unexpected way, that nearly every class component can be rewritten into a functional component, and makes the code much clearer than before (See the previous section for comparison, and the YouTube video React Today and Tomorrow and 90% Cleaner React with Hooks). Not after that, the author of unstated posted a twitter revealing the incoming hook powered unstated. It was awesome and ambitious, but it hasn't released yet.

A glimpse of Hooks in unstated

To clarify, I am not blaming unstated: it wants to achieve more. I just want to hookify the use of store, but unstated plans to hookify store itself as well. It is way more challenging than simstate has done now, so it is absolutely reasonable why the new version has not been released. However, I can't wait any longer to put Hooks into my project.

Apart from hating to wait, there are some other reasons that made me build this project. During my 2 year's React usage, I have tried MobX, Redux as well as some other libraries and put all pf them into different real projects, but all of them left me with some disappointments. Rather than endless waiting and learning new libraries, it seems more practical and meaningful to build my own library and integrate it with my own thoughts and experiences.

So, with the eagerness in addition to the simplicity of the implementation of unstated, I built my own library based on it and started deriving some new features that I think would be useful, like the Hooks integration. Implementing the initial version of simstate took me just one day, and it has always taken the place of unstated in my blog project.

Of course, I have more expectations and plans to do with this library than just a copycat to unstated, like more support for server-side rendering and partial observer. You may see the roadmap in README to see what to expect in the future.

Learning the run an open source project

I have always wanted to maintain a open source project by my own, and here comes the chance. It will also be an excellent chance to learn the tactics of manage an open source project, and have a clearer look and a more real experiences at the open source world.

Finally

simstate is a simple library, but the problems it aims to solve are all derived from my real world projects, and these problems are solved exceptionally well. It is also my first npm package and attempt to contribute to the community. If you are interested in it as well, please submit any issues, pull requests or some user experiences, and I will be greatly appreciated about it.

2018年总结

2019-02-04 23:13:00

前言

前不久迁移博客的时候看到了2017年总结,恍若隔世,2018年就这样悄悄地度过了。

咕咕咕到了除夕,距公历新年也过去一个多月了。没想到这一个月中发生了重要的事情,正好借着这个机会写下来,也当做对过去的一年(公历和农历)的总结吧。

比赛和项目

2018年从年初到年末,都有比赛陪伴。做比赛的过程确实比较累,但是收获也不少,除了奖金,最重要的还是在比赛中锻炼了技术,学到了知识,提高了姿势水平。由于比赛太累~~~~课程任务太多太懒,也因为想象力太局限,这一年中大部分空余时间也都花在深耕折腾个人博客上了。

1月:软工2结项,Light x00

虽说在软工2的过程中不停地骂这门课,但是是人还是逃不出真香的道理。软工2成为到目前为止我感受最深、最有收获的一门课之一(当然不是因为课程上教的知识)。

自己设计架构,自己写基础代码,自己造轮子,自己配环境……虽然在过程中经常碰到一鼻子灰,遇到死磕几天都解决不了的、网上也找不到的奇怪问题;虽然不管自己怎么折腾,最后做出来还是远远不如已有的解决方案;虽然自己已经尝试过好几个方案,却还是不能让自己满意……自己思考,自己实践,自己反思的过程真的让人痛苦。可是,最后找到解决方案那一刻,测试成功那一刻,甚至放弃的那一刻,都让我不仅是感觉到满满的成就感,还让我在这时回想起这段时间的尝试和经历时,都让我感觉到一切都是值得的。

举例:2017年11月30前后三天,在九食巴黎贝甜连肝3天,从Linux命令和groovy一无所知,到写出了一个调用了操作系统API的构建脚本,只为了达成让构建脚本能够在测试之前启动服务器,启动成功后运行测试用例,运行结束后关闭服务器这一个目的。即使最后并没有起到应有的作用,但是让人印象深刻。

2018年1月,系统展示、最终答辩,24/7被软工2占据的日子终于结束,在如释重负的同时,我还感到了收获的快乐。

3-6月:软工3,Tag x00

软工3倒是没有像软工2一样,把所有时间都花在这个上面。但是,软工3和软工2完全不同,不清晰的需求,不设限的技术选择,甚至最后1个月推导需求完全重做,每一步都和作业似的软工2完全不一样。为了博得评委的好感,增加项目的“亮点”,不管用不用得上,有没有用到位,我们一股脑上了最新最酷的技术(React, TS, Ant Design, Spring Boot, CI/CD,独立域名,响应式,多语言,视频/音频/3D标注,Tensorflow,各种我没有听说过的网络……距结项1个月推翻重做的时候,还提到了用Unity做VR标记....),做了一切我们能想到的工作,甚至答辩(下午)的上午还在加新的功能(当然坚守了不通宵的底线),答辩时也在尽可能在找花哨的词语。最终虽然也拿到了不错的成绩,却让我产生了思考:真实的项目真的是这样做出来吗?一个项目是否成功,真的是“亮点”越多越好,技术越多越新越好吗?

4-6月:链谷杯,ChainStore

一个偶然的机会,在同学的“引荐”下,认识了商院的同学,抱着试一试的心态接下来链谷杯的锅;花了用了一个晚上4个小时,从0开始xjb设计的拓扑架构,再以“能用就行”的代码质量要求写出了几千行的代码,大部分还是现学的kotlin和Vue;最后却稀里糊涂地进了决赛,有便宜白不占地去苏州玩了一圈,还在老师“五篇博士论文”的怒怼下自己都不敢相信地得了三等奖,还发现这是大学期间我第一次拿有价值的奖项?更让人震惊的是,同一个项目还在6个月后的类似比赛上又拿了奖,而且比赛听上去更有排面?生活中真是处处充满了惊喜。

8-11月:花旗杯,A+Quant

2017年底就开始听说花旗杯并开始组队了,当时听说队里全是工管、商院的大佬,就答应了,没想到真的让人大开眼界,和真·大佬共事真的是非同一般。就算即使到了结束也不认识其他院的同学(……),但是从100多页的结项报告就能看出大家都为了同一个目标在共同努力。虽然做到后期,加上课程上的压力(数据库:angry:编译原理:angry:)心态已经处于爆炸的边缘,但是在最后结果的时候还是欣喜若狂。同时,春招和实习季马上要来了,短时间之内没有特殊情况应该不会参加新的比赛了,这也算是给人生的一个阶段画上了一个完美的句号。最后还是要非常感谢其他26名花旗杯队员,是大家的共同努力才有了这个结果!南大青年上的一篇采访有我更多想说的话。

奖杯

顺便逛了逛大连,真实个美丽的海滨城市,尤其对我这种西部山区出来的,还是非常见世面。

VicBlog

从2016年10月开始,我的个人博客已经度过了2年了。在这2年里,我完全放弃过2个版本,重写过3次。3月,我抛弃了在2017年暑假写的网站,重新用类似的框架重写一遍,却仍然遇到了各种问题。在8月开始,我完全抛弃了原有的传统网站架构,采用了全静态的思路,使用Gatsby框架,在运行效率(静态网站可以有效cache,加快网站加载速度)、开发效率和灵活性(既能使用React以及前端生态圈的各种轮子,又避免了后端、集成和部署上的杂事)上成功两开花。目前你看到的,就是这个最新的架构上诞生的博客程序。目前来说,它已经满足我的绝大部分需求,只有几个小问题(访客统计等)貌似不容易解决,但是不排除以后再次推翻重写的可能。毕竟,生命在于折腾~

其他

活动

俱乐部相关

2018年前半年,作为南大微俱技术部的人,却没怎么做技术相关的事(捂脸),却体验了一把主持人和向导的感觉。而在后半年,换届后我成了一把手,发现各位新部长的才能超强,感到自己非常幸运。我也尝试做到不给俱乐部添乱,希望俱乐部能够更好地发展下去。

俱乐部活动时间表

MR

一开始只是说着玩的、心里感觉全是噱头的MR,买了后却发现真香,偷偷在宿舍拿出来玩,还成为了微俱的“镇部之宝”。

5月20日校庆夜开放体验

参观微软苏州

7月4日,组织四个学校的同学去微软苏州参观,中途意外情况不断,再加上我各种不过脑子的决定和错误,让这次参观出现了很多不该发生的问题。当然了也是宝贵的学习机会,以后一定不会再犯相同的错误了!

其他

解放军军乐团

校新年音乐会

贝店

栖霞山

先锋书店

南京市长江大桥

实习

虽然说对未来是去保研还是就业还是拿不准,但是目前我的所有准备都是按就业准备的(其实有一个原因是我懒……),所以实习对我来说比一切都重要。

微软

从小一直有个加入微软的梦想,终于快到实现的时候了,可是看来是时候不到,两次申请实习,苏州微软时间不合适直接没有进入面试,一次(19年1月)MSRA过了面试就等拿offer了可是学校一纸规定断送梦想(可是在同时商院、计科的同学实习地不亦乐乎)。唉,“歪门邪道”是走不了了,只能去参加春招了。

MSRA面试邮件

SAP

之前从我妈那里听说了这个公司(以及对它的ERP产品繁琐易用性不高的抱怨),没想到这个公司还和学院有个实习项目的合作,听了宣讲会后居然感觉到价值观挺相符……即使听说最终待遇比较低,但是参加了总没有坏处就报了。暑假参加了一次供应链相关内容和SAP相关产品的培训,虽然就5天,每天还有赶单程1个小时的地铁,却感觉学到了很多知识(毕竟是全新的领域,老师讲的也好);然后去玩了一圈,虽然活动比较尬和天气比较热,还是非常好玩。按道理暑假需要去实习,所以这段时间我必须在信仰和偷懒之间做出权衡了。

其他

总结

这篇总结从2月4日20:00开始写,到这里已经23:33了。一个小时己亥猪年的第一声钟声即将敲响,2018年也过去了35天了。时间过得可真快啊!

回忆刚过去的一年,经历了赶DDL的痛苦,也收获了奖状和奖金的喜悦;感受到了收到offer的激动,也体会到了人在屋檐下不得不低头的无奈;不舍地阔别了生活了2年的仙林校区,也迎来了充满了社会气息的全新的环境和生活方式……2018年是变化的一年,成长的一年,收获的一年。

在已经迎来的2019年和即将引来的己亥猪年中,更多重要的学习上,职业生涯上和(hopefully)感情上的改变仍然等着我。2018年给我带来了许多,希望在2019年年末,以及在鼠年的新年钟声敲响的时候,我仍然能对这一年无怨无悔。

MSRA DKI组前端面经

2019-01-11 23:21:00

开端

2018年年底,看到很多同学都去实习了,虽然明知不可能正大光明去,而且MSRA在北京翘课去也很有风险,但算了算三个月也还是有可能,心一横还是先让学长帮忙把简历给了MSRA内部的组,不管怎么样可以体验一次真正的面试,可以为春招做准备。因为本人现在也就web前端比较稍微熟练一点,研究啥的根本没碰过,所以本来是打算投创新工程组创新孵化组的软件开发实习生去做做前端,没想到学长给投了DKI组(数据、知识、智能组)的,而且DKI组根本就没有在官网公开招工程方向的实习生,只开放个研究实习生的职位……经过一周时间的笔试和面试,加上运气好和面试过程比较水的因素,拿到了梦寐以求的offer,但是却因为各种原因去不了,非常遗憾,可能这也是我人生中最近微软的一次了吧。

笔试

元旦前发的简历,元旦后第二个工作日(星期四,2019年1月3日)就收到笔试通知了。笔试是要求根据自己对职位的选择选一个任务来做,并且要求在1天之内提交

任务分2个大类:数据和算法(Data & Algorithms)和工程(R&D Engineering)。

数据和算法部分要求在给定的数据集上做一个二分类问题,由于本人基本不知道这方面的东西所以就不讲了。

选择工程方向的话,首先需要写一份自己的项目经历,详细讲一下我所做过的项目的情况,然后是在一份前端题后端题上选一个做。

后端题是做一个API服务,能够解析在题目中规定格式的查询请求并返回数据。个人看了这个题感觉这个题难点应该在解析上,第一反应是要用上编译原理的知识?后来想了想可能没这么困难,一些简单的字符串分析应该就出来(就像17级软工1大作业那种)。但是反正不是我这种只会在框架上做做CRUD的人能很快做出来的(同时非常想知道为什么很多人都喜欢去投后端岗位,前段时间字节跳动的招聘听说后端40+人,前端和移动端加起来也就10人左右,感觉后端随便问问深一点的知识就凉了啊……),加上本来也是想投前端的职位,所以就选了前端题。

前端题目是在已有代码的基础上做一个支持拖拽项目改变顺序和状态的to-do list,基础代码提供了React和Vue两个版本的基础代码。基础代码就是一个简单的列表,需要自己在这个基础上实现之前提到的需求。这个需求本身不是很难,难点在drag & drop相关事件的处理上,但是这些不知道没关系,网上一查就知道了(MDN),又不是闭卷。基本思想就是在onDragStart的时候记录下被拖拽的项目,然后在每个项目的onDragOver的时候,重新计算当被拖拽的项目拖到这个位置时的列表的顺序,然后更新下dom就行。由于我使用的react,所以直接把列表和被拖拽的项目记录在state里,更新的时候直接setState就可以了。

这里不使用onDrop而使用onDragOver的原因是需求要求在拖拽的过程中就能看到改变顺序时的效果

有的同学看了API可能想到不记录被拖到的项目,而是把项目放在dataTransfer里传递,但是dataTransfer是不能在onDragOver的时候访问到的,所以必须自己记录和处理。而且需求还要求正常被拖动的项目不能在列表中显示,我这里耍了点小trick:被拖动的项目的color将会被设置成white,这样就达到了隐藏的效果~

前端的要求应该还是比较简单的,加上从create-react-app建项目以及最后上传,也就做了2个小时左右吧,倒是写项目经历写了五六个小时……

面试

笔试提交当晚,就打电话来约面试时间了。面试最终是在第二周星期一(2019年1月7日下午),通过Skype进行的。面试分了以下几个阶段:

前20分钟左右:算法题

题目:类似于Leetcode 200

感受:最大的感受就三个字运气好!!!!因为当天上午刚做过,而且这种题目就是那种没做过死活不会,做过就会了的非常典型的题目(你看了就知道),写起来都是套路。和leetcode不同的一点是输入输出要自己处理,还好我还记得一点C++的输入输出的写法……

写完后那边还问了一个追加问题:如果不是一次输入整个矩阵,而是要求每输入一行就输出当前输入的计算结果。最简单的粗暴的做法当然是完全重跑一遍(当然不是最好的),后来在那边的启发下相处了一个稍微好一点的做法:记录上一行的1所处岛的编号,然后遍历本行,当本行遇到一个1时,看上一行,若上一行周围3个位置只有一个1或者都是同一个岛或者左上和右上是同一个岛,那么本行的1是那个岛的一部分,结果不变;若左上和右上都是岛,但是不同的岛,说明这个1会连接这两个不同的岛,那么结果减1;如果上面都是0,说明这个岛是个新的,那么结果+1。最后想出来还是有点不太有底,但是面试官还是非常肯定这个做法。

算法题运气实在是太好了,因为我最没底的就是算法,这次算法运气太好水过,也许下一次就挂了(嗯之前面试的另外一个组就直接挂在一面算法题……)……这也是为什么我有一种这是我最接近MSRA的一次的感觉,因为下一次可能运气就没这么好了……

10分钟:项目经历

毕竟是工程岗,做项目是比较重要的。算法题结束后,有个小姐姐要求讲一讲项目经历。我就拿着花旗杯项目狂吹,吹的我自己都有点不好意思了。小姐姐听了后就问了问一些项目一些可能改进的地方(架构改得更react一点而不是以DI为核心的类似OO的架构,加SSR等),我就稍微讲了讲我的想法。最后小姐姐问了问React的生命周期。这里强调一下,由于这个题比较经典,网络上有很多面经都包括了这个问题,但是很多都是老版本的,如果你说你会用react但是你按老版本的答案,这样会感觉你其实没有真正拿react做过东西而是在背而已。比如只要你最近用过react就知道componentWillMount在16的时候就已经deprecated了,如果你还背这个……自求多福吧。

整个过程还是比较愉快的,可能是因为项目介绍了太多太快小姐姐没听清楚……然后就换了个面试官,进入第三轮面试。

1个小时:项目经历和前端知识

这个感觉是前端组的大佬在问(应该是我进了后的mentor吧,唉)。首先也是问了一些项目经历,我就按照之前的讲法重复了一遍,然后就开始根据自己提到的东西问各种问题了。下面我还记得的一些问题:

  1. 如何将canvas的数据和react的状态进行同步
  2. 如何实现在canvas中使用鼠标拖动标记框将其移动的功能
  3. 解释MobX的observable机制
  4. MobX修改observable后是会立刻通知所有相关observer吗?如果不想每次都通知,有什么办法?(这个我觉得有点拿不准但是我记得是有API可以阻止MobX通知……)
  5. 为什么要用MobX?(我答成和Redux的对比了……)
  6. 响应式实现的方法(media query, onwindowresize...)
  7. 在react中如何使用样式(style, css modules, css-in-js等)
  8. 动画的问题(transition, requestAnimationFrame等,基本没答出来因为当时我就用过transition...)
  9. setInterval和requestAnimationFrame(同上……只用过setInterval)
  10. 长列表懒加载(我只知道这个概念,没有实现过……)
  11. 一些常见的鼠标事件(onmousedown, onmouseup这些)
  12. ……

总的来说,面试官问的还是挺细的,把我会的问到不会,把不会的都问了一遍……但是,所有问题都是从之前讲的项目经历里找出来的,所以建议在介绍项目经历的时候重点介绍自己熟悉的,并且讲的时候一定要结合自己的经验讲,不要死记硬背,如果你真做过对他提的绝大部分问题应该都是遇到过和比较熟悉的,一些确实不知道的也尽量把自己的思路说一下,实在不行再认输。

第三轮结束后问了下时间,整个面试就结束了,整个过程也就1个半小时左右。

最终和总结

星期一下午面完之后,星期三晚上就电话打过来说过了,然后准备确认下时间,正常的话一周后就可以去了……我就是在这个时候才发现这个项目是六个月的……当时心情就不好了,第二天挣扎之后只能拒绝告终。

总的来说,面试通过的原因:

  1. 不管因为什么原因(学长内推?组内缺人?对工程方向要求没那么高?)造成的面试和笔试都比较水(1个半小时的面试就行了??)
  2. 算法题运气爆炸!!!

最终没去的原因:

  1. 第一当然是我自己太怂不敢直接翘课……
  2. 项目要求6个月而我就算翘课也只能翘3个月
  3. 和st说了……

而MSRA由于主要是对研究非常重视,所以可能相比起来工程方向的就以实用为主,而且它不也像互联网公司一样对用户体验要求特别高(后来询问后面找前端主要是给内部研究团队做点可视化等工作,并不需要直接面对用户),所以对一些常见的前端难点也不怎么重视(函数防抖、CSS等,之前看到一份字节跳动的前端题目心态直接爆炸),主要还是看面试者的学习和思考能力吧,并不是考绝对的知识量。

所以建议如下:

  1. 刷题,刷题,刷题
  2. 复习自己的项目,找出其中的亮点,并且对他们熟悉,回想起来当时这样实现的思考的过程,在面试的时候展示出一种如数家珍和自信的感觉
  3. 对一些知识点不要死记硬背!!不要死记硬背!!不要死记硬背!!就算自己面试的时候不知道正确答案,也要自信地把自己的思考过程讲出来
  4. 积累运气

最后祝大家都能去自己想去的地方~

Safety/Security and Extensibility/Scalability in Software System Design and Architecture

2018-12-25 21:55:00

Introduction

Security/safety and extensibility/scalability are two pairs of quality attributes that is of great importance in software architecture. This articles discusses about their definitions and comparisons, followed by their respective general scenarios, which are complemented with typical concrete scenarios to give reader a clearer and concrete picture. Finally, some strategies and tactics to improve each attribute are given, each with description, benefits and penalities.

It is the first assignment for course Software Architecture of NJUSE.

Definitions and Comparisons

In this part, the definitions of, relationships and differences between selected pairs of quality attributes (security/safety and extensibility/scalability) are analyzed and presented with examples.

Safety/Security

A "safe" system is such that the harm from accidental mishaps to the system itself can be minimized or avoided. A "secure" system is such that some important properties of a system (like integrity, access, accountability, availability and confidentiality) can still be maintained under intentional attacks.

In another word, "safety" means the ability to reduce the risk of and harm from unintentional mishaps to the system’s stakeholders and valuable assets, whereas the "security" indicates lowering of the risk of and harm from intentional attacks.

It can be observed that both attributes require a system to be able to preserve important properties of a system and minimize the harm, but from difference types of accidents.

"Safety" focuses on unintentional incidents, i.e. the incidents that not meant to cause damage to the system itself. For example, a "safe" social media system should still be functional if one of its servers is disconnected from Internet because of a maloperation during a construction (like cutting a critical wire by mistake); an artificial satellite should keep intact for its major components operational when facing a strong cosmic ray, but some degree of slowdown is tolerable; in a safety system, important data (like financial transactions) won’t be unrecoverably lost if a disk containing these data malfunctions unexpectedly.

"Security", on the other hand, talks about intentional attacks, i.e. the attacks that targets to the system from the very beginning. For example, in a secure system, no plaintext passwords shall be compromised when hackers are attacking database; a medical system should hold long enough under terroristic cyber attacks to be able to get professional assists from law enforcement department, since a breakdown of such system might cause disasters; if a hacker had gained access to the system illegally, the system should be able to detect their existence, remove their privilege, and then report the bug that was abused as soon as possible.

Extensibility/Scalability

System always grows as time goes, but by two directions: vertically or horizontally. A system might need to implement more functions than originally anticipated or change the implementation that has already been made (vertically); a system might also be required to process more requests without excessive changes to the system itself in the future (horizontally).

Both extensibility and scalability focus on the growth of a system, but from difference perspective. Extensibility is the ability to extend vertically: that is to add "extensions" (like new features, modification to existing modules etc.) without too much changes and impacts to be expected. Scalability, on the other hand, is the ability and potential to scale horizontally: i.e. the ability to effectively handle growing or reducing amount of work using existing system or with minimal changes to the system itself as the number of works increases or decreases.

For example, an extensible frontend project usually indicates that adding a new page (to meet newly-derived business requirements) can be easily implemented without a deep dive into existing code. It also might mean that changing a style to an existing common component can be done within one place which takes effect for all of its occurrences. An extensible system with complex dataflow should be able to integrate a data processing module without an overhaul to the whole dataflow.

As for scalability, existing cloud service providers (Microsoft Azure, AWS etc.) all provide a scalable infrastructure that can gradually accommodate more demands as more companies are migrating their services to cloud based platform for better performance, maintainability and cost. A new concept of computing, serverless or functional computing, are gaining ground in cloud service territory because of its "infinite scalability", which means a service can adapt to handle any amounts of requests the service is actually facing, freeing developers from caring infrastructure and codebase themselves as the number of requests increases.

General and Concrete Scenarios

In this part, scenario-based analysis method is applied on the aforementioned pairs of quality attributes to create their general and two concrete scenarios respectively.

Safety

Portion of ScenarioPossible Values
SourceInternal or external to the system
StimulusEvents that is unintentional to damage but will affect the system
ArtifactSystem or one or more modules of the system
EnvironmentPortion of system might be influenced by this event
ResponseDetect the event
- Detect the event’s occurrence
- Analyze the affected modules
- Notify related entities
Avoid or minimize the damage
- Disable or isolate affected modules
- Deploy backup assets to restore functionality
- Switch into a degraded mode
- Be offline during repair
- Restore to normal after the damage is fixed
Avoid future occurrence
- Find the vulnerabilities
- Report the vulnerabilities
- Fix the vulnerabilities
Response MeasureTime to detect the events
Time to notify the related entities
Time to mask affected modules
Time to restore functionality
Time to repair critical modules
Estimated damage for accidents
Time to find the vulnerabilities
Time to fix the vulnerabilities

Samples

PortionDescription
SourceA construction team unrelated to the system
StimulusCut a network wire that connects system to the Internet by mistake
ArtifactNetwork module
EnvironmentThe affected network module is using the wire when the event occurs
ResponseThe module detects the event and switches to another router bypassing the broken wire
Response MeasureThe detection and reaction take only 30s for the system to go back to normal, during which the throughput dropped 5%.
PortionDescription
SourceNature
StimulusUnexpected earthquake
ArtifactA disaster control system for a nuclear power plant
EnvironmentThe earthquake damages containers and causes leaking of radioactive materials.
ResponseDetect the leaking, initiate early emergency process (like shutting related reactors), notify authorities
Response MeasureThe detection, initiation and notification take only 3 min. Leaked materials are within control and won’t cause severe biohazard.

Security

Portion of ScenarioPossible Values
SourceInternal or external to the system
StimulusIntentional attacks to the system
ArtifactSystem or one or more components
EnvironmentThe system doesn’t foresee the attack
ResponseDetect the attack
- Detect the attack’s occurrence
- Analyze the damage
- Report the attack
Maintain the properties
- Disable compromised modules
- Deploy backup assets
- Lock critical modules and data
- Remove attackers from the system
- Switch to degraded mode
- Restore to normal after the attack is resolved
Avoid future occurrence
- Find the vulnerabilities
- Report the vulnerabilities
- Fix the vulnerabilities
Response MeasureTime to detect the attack
Time to switch to degraded mode
Time to secure critical modules and data
Time to remove or block the attackers
Time to deploy backup assets
Estimated damage of the attack
Time to find the vulnerabilities
Time to fix the vulnerabilities

Samples

PortionDescription
SourceA malicious hacker team
StimulusA well-coordinated and massive DDoS attack
ArtifactSome part of the system that can barely hold the attack
EnvironmentThe hacker initiated a massive DDoS attack to the system
ResponseDetect the attack; Detect and block attack source; Disable compromised module; Deploy backup server resources; Notify security team.
Response MeasureTime to detect and notify the attack is 30s. 80% attack sources have been blocked after 30 minutes. The system goes back to normal in 1 hours.
PortionDescription
SourceA lone-wolf hacker
StimulusAn unauthorized entry to critical database
ArtifactA database that contains critical and confidential data
EnvironmentThe database is operational
ResponseDetect the entry; Block the entry; Report the event to authority; Report the vulnerabilities the intruder uses.
Response MeasureThe detection, blocking and reporting take 15 seconds and no data is leaked. The vulnerabilities are fixed in 1 day.

Extensibility

Portion of ScenarioPossible Values
SourceEnd user, developers, requestor
StimulusA directive to add/delete/modify functionality on existing system
ArtifactCode, data, interfaces, components, resources, configurations…
EnvironmentBusiness analysis time, runtime, compile time, build time, initiation time, design time, test time
Response- Understand extension
- Design extension
- Make extension
- Test extension
- Deploy extension
Response Measure- Time and material cost of communicating, understanding, designing, making, testing and deploying of the system extension
- Time and material cost of reeducating users or other stakeholders after system extensions
- Other affected modules not originally anticipated during actual processing
- Potential time and material cost of newly-introduced defects

Samples

PortionDescription
SourceRequestor
StimulusWish to add a new functionality onto an existing website
ArtifactCode
Environmentdesign time, business analysis time
ResponseUnderstand, design, make, test and deploy the new requirement
Response MeasureAll changes deployed in 3 days but brought 70 more bugs which took 3 days more to resolve. New tutorials are written to teach the end users about the new functionality
PortionDescription
---------------------------------------------------------------------------------------------------------------
SourceDeveloper
StimulusWish to change a provider for a service that depends on third-party services
ArtifactInterfaces
Environmentdesign time, runtime, test time
ResponseDesign, make, test and deploy the new requirement
Response MeasureAll changes made in 1 days. Only 1 module are affected, and no more defects are introduced.

Scalability

Portion of ScenarioPossible Values
SourceEnd user, developers, requestor
StimulusThe need to use existing system to handle different number of requests from originally designed with minimal change
ArtifactSystem or one or more components in the system
EnvironmentSystem’s operation mode
Response- Evaluate possibilities and potential change
- Make the change, if necessary
- Process requests
Response MeasureLatencies on different level of load
Max and min number of requests
The improvement brought by the scale
The cost to expand or shrink scale
The cost to change existing system to adapt for the change
The cost to resolve defects and interference when executing a scaling

Samples

PortionDescription
SourceRequestor
StimulusWish to handle 3 times more requests than originally planned during a unit time
ArtifactThe whole system
EnvironmentSystem hasn’t been online yet.
ResponseThe system is evaluated as capable to accommodate the increase of requests, so no changes should be made.
Response MeasureThe max number of requests is 5 times more than plan and the latency increases only 10% after the 3 times increase. No more cost needed.
PortionDescription
SourceDeveloper, end user
StimulusThe need to improve calculation precision for a complex algorithm within original time
ArtifactThe calculating module
EnvironmentSystem has been operational.
Response800 more CPUs are added into the mainframe as the evaluation indicates, no more changes required.
Response MeasureThe process doesn’t interfere normal operation. No more cost or change except CPUs’ are needed. The precision improvement increases the sale of the system by 30%.

Strategies and Tactics

In this part, strategies and tactics to improve each QAs are presented as well as their benefits and penalties to other attributes and QAs.

Safety

StrategyTacticDescriptionBenefitsPenalties
AvoidRedundancyIntroduce redundant assets into the systemIncrease robustness and security, avoid single-point of failureIncrease cost, complicate system arch design
Avoid risk designAvoid making arch designs that has high possibility to cause problem in the future.Reduce the possibility for problems to occurLimit the decisions that can be beneficial in other perspectives
DetectDesignated monitoring systemA complete and separate system to constantly monitor the critical perspectives of the systemGet accurate, complete data and error report in time without interfering original systemIncrease cost, a new point of failure to be kept watch on
HeartbeatSystem sends a signal every time interval to report its statusEasier to implement and integrate; detect event in timeMay affect system performance
HandleDegradeLimit the system’s functionality to limit the potential damageMaintain basic functionality while handling the problemAffect user experiences during degradation
Disable affected modulesDisable the affected modules completely, fix it and then goes back to normalCompletely avoid further damage and be able to be fixed quicklyMight cause a complete breakdown of a function

Security

StrategyTacticDescriptionBenefitsPenalties
AvoidTestFind and fix as many vulnerabilities as possible before putting the system into useAvoid further and usually more damage at a security breach in runtimeIncrease development time and material cost
Simplify designSimplify the architecture design to avoid vulnerabilities that comes with unnecessary componentsAvoid vulnerabilities and save resourcesMight be negative for other QA like modifiability and extensibility
Add security strategiesAdd more strict security methods (like 2-step auth) to protect the system from being hackedIncrease the cost of hacking to reduce the hacker’s benefit and interestIncrease the complexity. Negative impacts on usability and efficiency
DetectLogLog all entries to protected areaEasy to integrate and implementMight not be effective for well-prepared attacks; entries are too many to check
ReportReport suspicious and abnormal operationReduce amount of work to check all the logsSome operation might be mistakenly ignored
HandleIsolate or shutdown compromised modulesIsolate or shutdown the compromised modules to limit the damageCompletely avoid further damageMight cause a complete breakdown of a function
Delete critical dataDelete critical and confidential data, if backed up, to avoid data leakingAvoid data leakingNot applicable if no backup is available.

Extensibility

StrategyTacticDescriptionBenefitsPenalties
Improve inner architectureSplit by functionSplit a large system by function so that each function can be run individuallyAdding or modifying function won’t affect existing onesNeed careful design; might not be the most efficient and performant
Constant refactoringConstantly refactor the arch as development goes on, not relying on an unrealistic “perfect” archA good balance between cost and quality within a development cycleHigh skill requirement for developers and teams
Improve outer interface designExpose only necessary interfacesOnly exposes necessary APIsIncrease flexibilities on implementation; reduce interface changes; improve securityReduce the flexibility of usage; hard to determine the “necessity” of interfaces
Do one thing, do it wellAn interface should focus on one small piece of work and do it well.Improve usability, implementation flexibility and interoperability; also helps in scalabilityNeed careful design

Scalability

StrategyTacticDescriptionBenefitsPenalties
SplitSplit by responsibilitySplit a system by different responsibilities into difference layers (data accessing, calculating, viewing etc.)Optimize each layer with their own characteristics; easy to scale each layer accordinglyMore complicated architecture design; more time and material cost
Partition databasePartition database so that pressure to database can be “divided and conquered".More throughput and scalability from the databaseComplicated architecture design; not always applicable; inappropriate partition may lower performance
Make use of cacheDeliver static contents from cheap sourcesSplit static contents out of dynamic parts and deliver static contents from cheaper and more scalable sources (like CDN)Reduce server pressure and make the most use of precious calculating resourcesdata synchronization might be a problem
Use in-memory database as cacheUse in-memory database (like redis) to avoid frequent access to actual databaseReduce access to database, improve performance and responsivenessA new layer to worry about; more complicated architecture design

References

5.10 Measuring the System Scalability. (n.d.). Retrieved from Lebanese Republic Office of the Minister of State for Administrative Reform: http://www.omsar.gov.lb/ICTSG/105OS/5.10_Measuring_the_System_Scalability.htm

Bloch, J. (2006, October 22-26). How to Design a Good API and Why it Matters. Proceeding OOPSLA '06, (pp. 506-507). Portland, Oregon, USA. doi:10.1145/1176617.1176622

Firesmith, D. G. (2010). Engineering Safety- and Security-Related Requirements for Software-Intensive Systems. Carnegie Mellon University, Software Engineering Institude, Pittsburgh, PA 15213.

Kellyh, T. (2008). Safety Tactics for Software Architecture Design. The University of York, High Integrity Systems Engineering Group, Department of Computer Science .

Seovic, A. (2010). Achieving Performance, Scalability and Availability Objectives. In M. F. Aleksandar Seovic, Oracle Coherence 3.5.

Serhiy. (2017, April 14). How to Increase The Scalability of a Web Application. Retrieved from Romexsoft: https://www.romexsoft.com/blog/improve-scalability/

Shoup, R. (2008, May 27). Scalability Best Practices: Lessons from eBay. Retrieved from InfoQ: https://www.infoq.com/articles/ebay-scalability-best-practices

微软听听文档体验报告

2018-11-30 15:39:00

简介

微软听听文档是微软新出的一个制作带语音的文档或者幻灯片的工具,它可以将输入的视频或者文档与语音合在一起,让用户能够边听边看一个文档,效率倍增。目前来说,工具用法非常简单,读取和制作都非常简单易上手,也基本能够满足基础的需求,但是仍然在用户体验方面有一些微小的瑕疵;同时,若能增加更多的信息来源,支持除了微信小程序以外更多的平台,以及增加一些更多实用性功能,相信这个工具能更好地发挥它的作用。

想知道更多关于微软听听文档的信息,可通过这个微信推送查看。

用户体验

微软听听文档用户体验对我来说还是比较简单的。总体来说,首先选择需要制作的文档或者照片,然后对docx文档的每一页或者每一张照片说话,然后最后就可以分享了。可以分享到好友、朋友圈或者保存到OneDrive。

选择源文件

select-source

输入音频

add-audio

对多个图片文件输入音频

add-audio-to-pics

预览

preview

保存

done

可以直接从OneDrive上选择文档对我这种OneDrive深度用户来说非常方便。非常简单易用直观的界面可以让所有人无需教程就能直接上手做出听听文档。这个应用的用法甚至比一些电子相框的App更简单。看别人的听听文档的操作也很简单,就像在听有声书一样,手动可以翻页、也可以点击左下角控制声音的播放。

总的来说,应用的使用非常简单直观,对制作者和播放者也都很友好。希望这个工具能保持这样简洁而足够使用的功能,不要增加太多华而不实的功能增加操作的复杂度。

使用场景

我一开始这种需求的适用面可能不是很广,但是后来一想,认为在以下的情况下听听文档的形式可以发挥一些作用。

  1. 给其他人展示一些PPT、图片或者文档

以往,要给其他人详细地展示PPT、图片或者文档,需要我们写一份文稿发给对方,以让对方对我们的工作有个详细的了解。但是这些文档的编写需要花很多的时间,而且写出来的文档本身是一次性的,并没有(也没有必要)被充分利用。如果使用听听文档,我们就可以直接通过说话这种方式快速地对方介绍我们的工作。当然,这种形式并不能替代正式且详细的文档方法,但是非常适合很多对正式程度要求不高,但是对速度、时间和效率要求高的情况,而这种情况在生活中是非常常见的。

  1. 快速分享对一个事物的看法

这个使用场景有点像转发并评论,但是能通过让读者边看信息边听发送者的看法,让读者更能身临其境,更能跟着读者的思路,而且通过声音来表达观点也比文字能表达更多的信息(例如情绪、语气等),并且这种方式对分享者来说也比打字更有效率。

总的来说,这个工具的高制作效率图文声并茂的特点让它非常适合快速分享

改进功能

目前来说这个工具的功能虽然比较完善,但是如果能加入更多的功能,可以让这个小工具的适用面变得更广。

  1. 增加更多的信息来源

现在似乎只支持docs文档和图片,我认为还可以加入更多的导入来源以增加适用面,例如网址(应用访问这个网址获得网址上的信息,并将网站上的内容作为文档内容)、视频(像短视频一样)等。

  1. 支持显示字幕

现在软件只能播放播放者录制的音频,如果能够利用微软的语音转文字技术将播放者说的话转成字幕甚至提供下载的话,可以让听众有更好的体验。

  1. 听语音的时候支持拖动播放进度条

同样,现在工具只能将语音从开始听到结束,不支持修改播放的时间,给用户带来了一些不方便。当然这个可能是因为微信本身就不支持的锅,但是如果能够支持就非常好了。

  1. 制作文档的时候支持动态修改文字大小

文档编写的时候的字体大小一般很小,并不适合直接通过手机屏幕查看内容。若直接用原来的字体大小来显示,那么效果就像下图一样,根本看不清晰。希望能在编辑的时候编辑字体或者缩放大小,以让用户查看的时候体验最优化。

doc-display

  1. 保存到OneDrive的时候把录制的语音也保存

现在在分享的时候可以选择保存到的文档,但是据我自己的测试,似乎不会将语音保存下来(文档就会直接保存一个副本,而图片会被保存为一个PPT)。如果能保存下音频,可能会让分享更容易。

  1. 支持更多平台,尤其是PC端

现在只有微信小程序,只能通过手机在微信上操作,但是微信小程序的限制过多,微信本身的生产力也非常差,很多功能都不支持,加上手机本身的限制,要制作一个稍微复杂的文档,工作效率会非常低。希望以后此工具能支持更多的平台,例如Web端(这样就可以在电脑上通过键鼠高效地制作和查看听听文档,不需要都使用不方便的手机来操作)、Android和iOS的Native客户端等。

  1. 保存到云盘时的一些用户体验细节

现在在分享界面,用户选择保存云盘,系统会首先显示一个“正在保存”的提示,几秒后提示消失,在真正保存成功的时候会发出一个微信通知。这就给用户造成了一些误解:系统显示正在保存,然后直接退出,用户此时会以为保存出错,于是可能会重新点击保存。之后用户才发现有个通知通知保存成功,这样用户可能会重复保存多个副本。如下图。

保存中

save-onedrive

保存成功通知

save-complete

重复保存了多个文件

duplicated-copies

一些可能可用的改进的方法:

  1. 点击保存到云盘时,直接提示用户正在保存,请关注提示
  2. 正在保存的提示直到真正保存成功的时候才关闭,并给予用户操作的真正结果的提示

正则语法分析器和LALR(1)词法分析器

2018-11-19 13:37:00

0. 说明

这是一个编译原理课的大作业,我自己实现了一个正则语法分析器(从输入字符流到Token序列)和LALR(1)语法分析器(Token序列到规约产生式序列)。以下是说明文档。

项目是放在一个repo的子文件夹里的,目录是:https://github.com/ddadaal/Homework/tree/master/Compiler/CompilerLab

编译原理实验报告

1. 目标

实现一个能解析正则表达式和一些扩展语法的通用词法分析器,和使用LALR(1)进行语法分析的语法分析器,并定义一个能够以上提到的分析器所解析的词法定义文件(.myl)和语法定义文件(.myy)的格式,并能够做到通过在系统指定的可用Token集合上,从用户给定的词法定义文件语法定义文件,实现从输入文件产生式规约序列的全过程。

2. 内容描述

本实验提供了以下内容:

2.1 词法定义文件格式myl

一个myl包含数个以下格式的词法定义,每个定义之间可接受任意个数的空行。

第一行:正则表达式的字符串
第二行:对应的TokenType的字符串

其中,正则表达式支持以下元素:

扩展支持:

方括号内部出现的元素之间会通过并集|连接,可与字符类配套使用。

例如:[ac\n]等价于(a|c|\n)

在ASCII表中,-前面的符号和后面的符号之间的所有符号会通过并集|连接。

例如:[a-d]等价于(a|b|c|d)

例如:[\n\ba-d_]等价于(\n|\b|(a|b|c|d)|_)

示例:

\+
PLUS (定义一个正则表达式为\+,对应到PLUS类型的Token的匹配规则,)

\*
STAR(定义一个正则表达式为\*,对应到STAR类型的token的匹配规则)

\(
LEFT_PARENTHESIS

\)
RIGHT_PARENTHESIS

[a-zA-Z]([0-9a-zA-Z_])*
IDENTIFIER (定义一个正则表达式为[a-zA-Z]([0-9a-zA-Z_])*(即以字母开头,后面跟任意个数的数字、字母或下划线),对应到IDENTIFIER类型的token转换规则)

[\ \n\r]
IGNORED (定义空格、空行或\r字符都被匹配到IGNORED类型的token的转换规则)

2.2 语法分析文件格式myy

一个myy文件以一个字符串(代表此产生式列表的开始符,这个开始符可多次出现在产生式的左边)占一行开始,接下来由数个以下格式的语法定义组成,每个语法定义代表一系列具有相同左侧符号的产生式的集合,每个定义之间可接受任意个数的空行。

第一行:一个字符串,代表本产生式集合左边的符号

第二行开始:每一行代表一个产生式的右侧的符号列表。每个符号之间用任意个数的空格隔开。若一行为空,则代表这是一个epsilon产生式。若一个符号包含在TokenType列表里,则这个符号会被认为是一个终结符

本集合最后一个产生式后一行:符号"%"

示例:

E (代表这个产生式列表的开始符为E)


E (定义这个产生式集合的左侧符号为E)
E PLUS T (定义一个E -> E PLUS T的产生式,PLUS是一个类型为PLUS的终结符)
T (定义一个E -> T的产生式)
% (以E为左侧的产生式集合定义完毕)


T (定义这个产生式集合的左侧符号为T)
T STAR F (定义一个F -> T STAR F的产生式,STAR是一个类型为STAR的终结符)
F (定义一个T -> F的产生式)
% (以T为左侧的产生式集合定义完毕)

F (定义这个产生式集合的左侧符号为F)
LEFT_PARENTHESIS E RIGHT_PARENTHESIS (定义一个F -> LEFT_PARENTHESIS E RIGHT_PARENTHESIS的产生式,LEFT_PARENTHESIS和RIGHT_PARENTHESIS是终结符)
IDENTIFIER (定义一个F -> IDENTIFIER的产生式)
    (定义一个F -> epsilon的epsilon产生式)
% (以F为左侧的产生式集合定义完毕)

3. 实现方法

词法定义 --> 词法DFA

语法定义 --> 语法LALR(1) DFA

输入文件 --词法DFA--> Token序列 --语法LALR(1) DFA--> 规约产生式列表

其中,词法分析器每读取一个到一个Token即暂停对输入字符的读取,转发给语法分析器进行语法分析;当语法分析器需要更多Token的时候,词法分析才继续对输入字符的读取,并不是首先生成所有的Token,再一次性交给语法LRDFA进行语法分析。

4. 假设

项目中假设只会用到以下类型的Token。若需要更多类型的Token,可在lex.internal.token.TokenType枚举类型下增加更多的Token类型。

注意,在产生式中出现Token类型列表所包含的字符串,将被认为是对应类型的终结符。字面量仅用于调试和错误报告中。

Token类型字面量备注
VOIDvoid
INTint
RETURNreturn
WHILEwhile
IFif
ELSEelse
ELLIPSIS...
EQUAL==
ASSIGN=
SEMICOLON;
STAR*
OR_OR
LEFT_BRACE{
RIGHT_BRACE}
LEFT_PARENTHESIS)
RIGHT_PARENTHESIS)
COMMA,
PLUS+
MINUS-
DIV/
INC++
LT<
LE<=
IDENTIFIERid
INT_CONSTint_const
STR_CONSTstr_const
IGNOREDIGNORED类型的token将不会传送给语法分析器
DOLLAR_R$R当词法分析器结束了所有的读取时,语法分析器无法获得下一个Token,则语法分析器会认为下一个Token是$R
UNKNOWN#若词法分析器分析到UNKNOWN类型的Token,将会抛出LexicalParseException
EOF$eof词法分析器结束了所有的读取时,将会返回$eof类型的Token

5. 重要数据结构的描述

5.1 词法分析器

词法分析器设计到的数据结构有DFA, DFANode, NFA, NFANode, Regex, RegexNode和Rule。

5.1.1 Regex和RegexNode

Regex顾名思义代表一个正则表达式,一个Regex是由一个RegexNode的列表所组成的。

一个RegexNode代表一个正则表达式的符号,其中正则表达式的符号即为正则表达式标准定义中的集几种组成元素,包括以下几种类型:

每个RegexNode都存储了这个RegexNode的类型它的字面量值,以及其对应的优先级。优先级仅用于连接和并集的优先级选择上,其中连接的连接的优先级高于闭包。这个优先级将会在正则表达式中缀转后缀的过程中起作用。

RegexNode通过lombok生成了equalshashCode方法,以方便比较两个RegexNode的相等性。两个RegexNode相等当且仅当两个Regex具有相同的类型且具有相同的字面量值。

5.1.2 NFA和NFANode

NFA顾名思义代表一个对于一个正则表达式的NFA。本系统里的NFA是根据Thompson算法做出来的,而通过Thompson算法做出的对于一个正则表达式的NFA只有一个结束状态,故本系统中一个NFA是由代表开始状态的NFANode和一个代表结束状态的NFANode组成的。

NFANode代表一个NFA中的节点,或者说一个NFA的状态。一个NFANode是由一个以自己为出发边的边的集合对应的正则表达式所对应的token类型所组成的。

前者(以自己为出发边的边的集合)在Java中的表示形式为Map<Character, List<NFANode>>,其key代表边上的字符,value代表通过key字符能够达到的状态的集合。

后者(对应的正则表达式所对应的token类型)对应在词法定义文件中,满足此正则表达式的字符串应该被语法分析及其后续过程认为的Token的类型

NFA的边集没有单独保存,而是通过每个NFANode的以自己为出发边的边的集合表示。

5.1.3 DFA和DFANode

DFA顾名思义代表一个对于一份词法定义文件的DFA,由一个标志开始状态的DFANode和这个DFA所有的接受状态的列表组成。本系统里DFA是以下算法得到的:

  1. 分析词法定义文件中所有的正则表达式,得到NFA
  2. 新增一个开始状态,将这个开始状态和所有NFA的开始状态通过epsilon边相连接
  3. 使用子集构造算法得到DFA。

DFANode代表一个DFA中的节点,或者说一个DFA的状态。一个DFANode是由它所对应的NFA状态的集合以自己为出发边的边的集合自己所对应的所有可能的token类型的集合所组成的。

第一项(所对应的NFA状态的集合)即在子集构造算法中,构成这个DFA状态的NFANode的集合。

第二项(以自己的为出发边的边的集合)在Java中的表示形式为Map<Character, List<NFANode>>,其key代表边上的字符,value代表通过key字符能够达到的状态的集合。

第三项(自己所对应的所有可能的token类型的集合)即这个DFA状态对应的NFANode所对应的正则表达式所对应的token类型的并集。“这个DFA所对应的NFA状态的集合中包括至少一个结束状态的NFANode”是“DFANode所对应的所有可能的token类型的集合非空”的充要条件。通过保存这个集合能够减少词法分析的时间。

DFA的边集没有单独保存,而是通过每个DFANode的以自己为出发边的边的集合表示。

DFANode重写了equals方法。两个DFANode相等当且仅当两个DFANode具有相同的自己所对应的所有可能的token类型的集合。虽然NFANode并没有重写equals方法,但是在算法过程中保证了没有新的NFANode产生,保证了两个NFANode相等当且仅当它们是同一个对象。

5.1.4 Rule

一个Rule代表词法定义文件中定义的转换规则,由一个正则表达式的字符串对应的Token类型组成。它仅被用于表示用户的词法定义。

5.2 语法分析器

语法分析器用到的数据结构有Symbol, Production, ProductionList, LRItem, LRDFA, LRDFANode。

5.2.1 Symbol

Symbol(符号)是语法分析过程的基本单位,有两个属性组成:代表非终结符名称的ntName代表终结符Token类型的tokenType。一个符号要么是一个非终结符nt != null && tokenType == null),要么是一个终结符nt == null && tokenType != null)。通过调用Symbol.terminal(TokenType)或者Symbol.nonterminal(String)可以分别产生一个终结符或者非终结符实例。

Symbol实现了equals和hashCode方法,两个Symbol相等当且仅当(两个Symbol都是非终结符 && 两个Symbol的非终结符名称相同) || (两个Symbol都是终结符 && 两个Symbol的终结符Token类型相同)。实现hashCode方法允许了将其作为HashMap的Key值,简化了后续的编程。这两个方法都是由lombok实现的。

5.2.2 Production

Production代表一个产生式,由**产生式左边的符号(left)产生式右边的符号列表(right)**组成。当产生式右边的符号列表为空的时候,代表这是一个epsilon产生式。

Production实现了equals和hashCode方法。两个Production相等当且仅当两个产生式具有相同的产生式左边的符号产生式右边的符号列表(包括顺序)。实现hashCode方法允许了将其作为HashMap的Key值,简化了后续的编程。这两个方法都是由lombok实现的。

5.2.3 ProductionList

一个ProductionList代表一个产生式列表,一个ProductionList只允许有一个左边是开始符的产生式。所以,一个产生式列表由产生式的列表初始产生式(startProduction),即左边是开始符的唯一的产生式和**开始符(startSymbol)**组成。

为了简化编程,一个ProductionList还提供了计算函数First(Symbol)以计算一个符号的First函数值,和canDeriveToEpsilon(Symbol)以判断一个符号是否能推出epsilon表达式。为了减少计算时间,这两个函数将会在计算出一个Symbol的结果后,将其结果记录到一个Map中(firstMemo和canDeriveToEpsilonMemo),下次再进行相同的符号的时候,函数将直接从对应的map中直接取值。

5.2.4 LRItem

一个LRItem代表一个LR项,由对应产生式(production)、**点的位置(dotPosition)向前看符号(lookaheadSymbol)**组成。根据向前看符号是否为null,一个LRItem可能是一个LR(0)项(lookaheadSymbol == null),也可能是一个LALR(1)项(lookaheadSymbol != null)。

LRItem实现了equals和hashCode方法。两个LRItem相等当且仅当两个LRItem具有相同的对应产生式点的位置向前看符号。实现hashCode方法允许了将其作为HashMap的Key值,简化了后续的编程。这两个方法都是由lombok实现的。

5.2.5 LRDFA和LRDFANode

LRDFANode代表一个LR自动机中的一个状态,由代表这个状态的内核(kernel)的LR项(LRItem)的集合组成整个状态的LR项的集合以自己为出发边的边的集合组成。其中,以自己为出发边的边的集合在Java中的表示形式为Map<Character, List<LRDFANode>>,其key代表边上的字符,value代表通过key字符能够达到的状态的集合。根据组成其的LR项的类型(LR(0)项或者LALR(1)项),这个LRDFANode可能是LR(0)自动机或者LALR(1)自动机的一个状态。

LRDFANode重写了equals和hashCode方法。两个LRDFANode相等当且仅当它们具有相同的内核。实现hashCode方法允许了将其作为HashMap的Key值,简化了后续的编程。

一个LRDFA代表一个LR确定自动机,由开始状态(startState)、**结束状态列表(endStates)所有状态列表(allNodes)**组成。根据其中包含的状态的类型(LR(0)或者LALR(1)),这个自动机可能是LR(0)自动机或者LALR(1)自动机。

6. 重要算法描述

6.2 构建词法分析DFA

6.2.1 词法定义文件 到 转换规则(Rule)集合

对应的方法:lex.MylexReader.read

首先去掉忽略所有空格行,读到非空格行第一行认为是正则表达式,第二行认为是Token,调用TokenType.valueOf将字符串转换为TokenType。循环这个过程直到输入结束。

6.2.2 转换规则 到 NFA

对应的方法:lex.internal.NFA.constructNFA

此过程分为4个子过程:正则表达式字符串预处理加入点符号中缀正则表达式转后缀后缀正则表达式转NFA

6.2.2.1 正则表达式字符串预处理

对应的方法:lex.internal.Regex.preprocess

预处理过程会将中括号和字符类的符号转换为只包含字符、*、|和()的标准正则表达式,并将字符串转换为RegexNode的列表。具体转换规则如下:

  1. 遇到左中括号,记录下目前已经进入中括号,并将加入左括号类型的RegexNode。
  2. 遇到-字符,获得-前面的RegexNode,再获得之后的一个RegexNode,取得两个字符的ascii码之间的所有字符,通过|连接所有的字符,再在前面和后面各加一个圆括号
  3. 遇到\字符,读取后面一个字符,将查找escapedChar表,将对应的escaped后的字符加入列表。
  4. 遇到右中括号,记录已经出了中括号,并加入右括号类型的RegexNode
  5. 遇到其他字符,将其字面量的CHAR类型的RegexNode加入列表
  6. 最后,如果仍然处于中括号之中,在后面加一个OR(|)的RegexNode
  7. 回到步骤1,直到没有下一个输入字符

6.2.2.2 在RegexNode列表中加入点符号

对应的方法:lex.internal.Regex.addConcatenation

遍历预处理后的RegexNode列表,在满足以下条件的两个符号之间加入点符号,表示这是两个正则表达式相连接的而构成的。

6.2.2.3 中缀正则表达式转后缀

对应的方法:lex.internal.Regex.toPostfix

将加入点符号的RegexNode列表转换为后缀表达式,其中

6.2.2.4 后缀正则表达式转NFA

对应的方法:lex.internal.NFA.constructNFA

使用Thompson算法,将一个后缀正则表达式转换为一个NFA。其中,每个后缀正则表达式的结束状态的对应的正则表达式所对应的token类型被设置为对应转换规则所规定的Token类型,非结束状态的状态的对应的正则表达式所对应的token类型为null。

6.2.3 所有转换规则对应的NFA --> 一个lNFA

对应的方法:lex.LexicalAnalyzer.constructDFA,43-53

得到所有转换规则的正则表达式的NFA后,新增一个开始状态,将这个开始状态用epsilon边连接到所有NFA的开始状态,得到一个lNFA。

6.2.4 lNFA --子集构建算法--> 词法DFA

对应的方法:lex.internal.DFA.constructDFA

使用子集构造算法(龙书图3-29,算法3.20),将lNFA转换为对应的DFA。其中每个DFA状态(DFANode)自己所对应的所有可能的token类型的集合包含组成这个DFANode的NFANode的对应的正则表达式所对应的token类型的并集。构建出来的DFA被称作词法DFA,是后续进行词法分析的核心组件。

子集构造算法中使用的epsilon闭包的计算算法为龙书图3-30。

6.3 构建LRDFA

6.3.1 语法定义 ---> 产生式列表

对应的方法:syntax.MyYaccReader.read

首先忽略带开头的所有空行,读到第一个字符串被认为是整个产生式列表的开始符。继续往后读,重复一下步骤直到没有进一步的输入:

  1. 读取到的第一个非空行的字符串,认为其为本产生式的列表的公共的左边的符号
  2. 往后读取每一行,将每一行认为成一个新的产生式,其左边是在上一步记录下的公共左边的符号。将每一行的内容使用空格作分割,对一行中的每个字符串,首先查找其是否在TokenType中出现过。若出现过,则认为这个字符串是一个非终结符;否则,认为这个字符串是一个终结符。将这个符号加入这个产生式的右边的符号的集合。若这一行的内容为空,那么认为这个产生式是一个epsilon产生式,使其右侧符号列表为空。
  3. 重复第2步,直到读取到一个%,表明本产生式的列表读取结束。
  4. 回到第1步,直到输入结束。

最后,调用ProductionList.fromUnaugmentedList方法,给整个产生式列表加入一个新的开始符S'和新的初始产生式S' -> {原开始符}

6.3.2 产生式列表 ----> LR(0)自动机

对应的方法:syntax.constructLR0DFA

6.3.2.1 计算一个LR项的集合的闭包

对应的方法:syntax.internal.LRDFA.closure

采用书上图4-32所采用的CLOSURE的计算方法。为了提高效率,会使用一个栈来保存还没有进行状态内扩展的项。在函数刚进入的时候,内核的所有项将会入栈;在每次执行算法的时候,会弹出栈顶,在这个过程中新产生的LR项将会入栈;当栈为空的时候,说明没有可以继续进行状态内扩展的LR项,表明闭包构建完成。

注意,在闭包构建过程中会产生新的LRItem对象。由于LRItem对象重写了equals方法,所以这些新产生的LRItem对象和之前可能已经存在LRItem对象无异。

注意,若输入的LR项存在向前看符号(即为LALR(1)项),设为(A -> a.Bc, d)_,则将会计算First(cd),并将这些向前看符号加入到LRItem对象中。

6.3.2.2 LR(0)自动机构建

对应的方法:syntax.constructLR0DFA

  1. 通过产生式列表,获得初始产生式,构建第一个LR(0)项 S' -> .S,将其加入结果集和栈
  2. 重复以下过程,直到栈为空:
    1. 弹出栈顶的LR(0)项
    2. 对每个以这个LR(0)项为出发边的所有边上的符号S:
      1. 将这个LR(0)项移点,将得到的LR(0)项加入集合中
      2. 以这个集合为内核,构建闭包,作为新的状态
      3. 将这个状态和上一个LR(0)项使用符号为S的边连接起来
  3. 到现在,已经获得了LR(0) DFA的所有状态,即已经获得了LR(0) DFA。

6.3.3 LR(0)自动机 ----> LALR(1)自动机

对应算法:syntax.addLookaheadSymbol

6.3.2.1 First(Symbol)和一个符号是否能够推出epsilon的计算

在这个算法中需要用到First函数,以及判断一个符号是否能够推出epsilon。由于它们仅依赖产生式列表,为了方便代码的维护以及设计缓存增加计算效率,所以这两个方法都写在ProductionList作为它们的实例方法。

判断一个符号是否能推出epsilon:syntax.internal.ProductionList.canDeriveToEpsilon

  1. 若输入符号是一个终结符,那么它不能推出epsilon。在我们程序中,不会存在EPSILON终结符。
  2. 若输入符号不是一个终结符,遍历所有以它为左边符号的产生式P:
    1. 若P的右侧符号列表为空(即推出epsilon),或者右边的所有符号都能推出epsilon,则断定输入符号可以返回epsilon,算法结束。
  3. 若以上循环结束后,算法仍然没有结束,说明不存在一个可以让输入符号推出epsilon的产生式,断定输入符号不能返回epsilon,算法结束。

First算法:syntax.internal.ProductionList.first

  1. 若输入符号是一个终结符,那么它的First集合就是以它本身为唯一元素的集合。在我们程序中,不会存在EPSILON终结符。
  2. 若输入符号(L)不是一个终结符,遍历所有以它为左边符号的产生式P:
    1. 对产生式P的右边符号列表中的每个右边符号R:
      1. 如果R.equals(L),为了避免无穷递归,忽略这个符号,继续循环
      2. 将first(R)中的所有元素加入first(L)
      3. 若R不能推到epsilon,则退出循环;否则,继续循环
  3. 返回first(L)

6.3.2.2 对LR(0)项加上向前看符号

使用书上算法4.47(自生成-传播算法),将LR(0)自动机中的所有状态中的所有LR项加上向前看符号(lookahead symbol),构建LALR(1)自动机。

对应算法:syntax.addLookaheadSymbol

首先定义一个新的数据结构StateSpecificLRItem,保存一个LRDFANode项和LRItem项,用于记录一个LRItem项及其它所属的LRDFANode。

  1. 初始化两个Map
    • propagateMap,类型为<StateSpecificLRItem, List<StateSpecificLRItem>>,用于记录向前看符号的传播路径。在这个Map中,Key所拥有的向前看符号都会最终传播给它的Value中的所有LR项。
    • resultLookaheadsSymbolMap,类型为Map<StateSpecificLRItem, List<Symbol>>,用于记录每对一个LR项,它通过自生成或者传播所得到的所有向前看符号的集合。
  2. 在resultLookaheadsSymbolMap中,对S' -> .S项加入一个自生成的向前看符号$R。
  3. 根据书上算法4.46,确定向前看符号的传播路径,以及所有自生成的符号。这个步骤结束后,向前看符号传播路径Map已经被初始化结束(例:龙书图4-46),resultLookaheadsSymbolMap中,自生成的向前看符号的也已经被加入对应LRItem的值的集合中。
  4. 根据算法4.47,将所有自生成符号加到resultLookaheadSymbolMap对应的LR项的值的集合中。到这个步骤结束后,resultLookaheadSymbolMap中的每一项,其Key集为一个输入LR(0)自动机的所有状态的所有LR项的集合,每个key的Value为这个LR项所有的向前看符号。
  5. 根据StateSpecificLRItem中记录的这个LR项所对应的LRDFANode状态,将这些向前看符号加到这个LR项上,加入回原来的LRDFANode项中。为了保持LRDFANode所记录的边的目标状态仍然有效,这里将会直接修改原LRDFANode所持有的内核集合和状态集合(而不是产生一个新的LRDFANode)。
  6. 算法结束。原有的LR(0)已经成为了一个LALR(1)自动机。

6.4 词法分析:输入文件 --> Token

对应方法:lex.LexicalAnalyzer.next

这个过程实现了最长匹配。在这个过程中,一旦返回Token,词法分析暂停,等待语法分析程序需要更多的Token时再继续分析。

在分析之前,已经通过输入的词法定义文件获得了对应的DFA。

  1. 初始化当前状态为DFA的开始状态,初始化读到的字符串为""。
  2. 循环以下步骤,直到输入结束:
    1. 读取一个字符c
    2. 如果存在一条以当前状态为开始状态,c符号为边上的符号的边(说明还存在能够匹配更多字符的目标状态):
      1. 将开始状态设为这条边的目标状态
      2. 将读到的字符串加上字符c
      3. 继续循环
    3. 不存在,匹配结束,尝试返回目前读入的字符串所对应的Token类型
      1. 将输入字符放回输入流
      2. 如果当前状态是一个结束状态:
        1. 获得当前状态对应的所有可能的Token类型
        2. 若唯一可能的类型是UNKNOWN,报词法错;否则,返回以列表中第一个Token类型为类型,读到的字符串为字面量的Token。
      3. 若当前状态不是一个结束状态,报期望更多正确输入字符的词法错
  3. 输入结束,若当前状态是结束状态,则返回当前结束状态对应的Token类型列表的第一项。
  4. 若不是结束状态,则判断当前读到的字符串是否为空串:若是,说明输入流已经结束,返回一个EOF类型的Token;否则,报期望更多输入字符的词法错误。

6.5 语法分析:Token序列 --> 规约产生式序列

对应方法:syntax.SyntaxAnalyzer.getProductionSequence

这个过程通过输入Token序列流,能够返回所有使用到的规约产生式序列。

这个过程没有像书上一样采用LALR(1)分析表实现,而是直接通过使用LALR(1) DFA来进行分析,其核心理念是相同的(移入-规约),核心算法几乎一致(书上算法4.30),而且LALR(1)分析表也是通过LALR(1) DFA获得的。直接使用LALR(1)方便编程。

  1. 初始化产生式序列,状态栈和符号栈。
  2. 加入LALR(1) DFA的初始状态到状态栈中。
  3. 令symbol为第一个输入符号。输入符号一定是非终结符,语法分析器每要求一个输入符号,词法分析器就会进行词法分析,第一个非IGNORED类型的Token的TokenType将会提供给语法分析器。
  4. 无限循环以下步骤:
    1. 获得组成当前状态的所有LALR(1)项中的可规约项,其向前看符号为symbol。若有多个这样的可规约项(等同于分析表对应格子中同时存在多个ri项),报规约-规约冲突错误。语法分析结束。
    2. 获得以当前状态栈顶为开始状态,symbol为边上符号的边的目标状态targetState。
    3. 若同时存在targetState和可规约项(等同于分析表对应格子中同时存在si, rj项),报移项-规约冲突错误,语法分析结束。
    4. 若两项同时不存在(等同于分析表对应格子为空),报意外的token错误,语法分析结束。
    5. 若targetState == null,即目前应该进行规约操作(等同于分析表中遇到ri项),设可规约项为(P, s):
      1. 若P为S' -> S·,说明这是接受状态。语法分析结束。
      2. 将P(产生式)加入规约序列列表。
      3. 从符号栈和状态栈中pop P右侧符号个数次
      4. 将P左侧符号压入符号栈
      5. 以状态栈栈顶为开始状态,目前符号栈栈顶为边上符号,找到对应边的目标状态,将其压入状态栈
      6. 进入下一次循环
    6. 若targetState != null,即目前应该进行移项操作(等同于状态表中遇到si项
      1. 将targetState压入状态栈
      2. 将symbol压入符号栈
    7. 若不存在下一个token,则设置symbol为$R;否则,设置symbol为下一个输入符号。
  5. 循环结束,返回规约序列列表,语法分析结束。

7. 可用测试用例

7.1 初始化编程环境

  1. 使用Jetbrains Intellij IDEA打开项目文件夹CompilerLab
  2. 等待gradle下载依赖(本项目依赖lombokjunit,使用gradle进行依赖管理)
  3. 安装lombok的IDEA插件
  4. 在IDEA Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors,对整个项目在右边勾选Enable Annotation Processing

7.2 运行以龙书例题4.31为依据的集成测试

要运行这个测试,请运行test/java/IntegrationTest::testExample431。这个测例将读取main/java/resources/example4.31下预先提供的输入文件、词法定义文件和语法分析文件,将输入文件进行词法和语法分析,得到规约序列,并与测试中写好的预期序列进行比较,得出测试结果。示例(以及确保正确的)的输出结果位于main/java/resources/example4.31/output中。

7.3 一个C语言子集的运行示例

这个C语言子集提供了flex/bison项目以及基于等价myl, myy定义文件的运行方式。

flex/bison项目中包含了这个C语言子集对应的.l和.y定义文件。要运行flex/bison项目,请参考CSubsetFlexBison目录下的说明文档。

与flex/bison项目中提到的C语言子集等价的词法定义文件和语法定义文件,以及和flex/bison项目中示例文件test.c完全相同的示例输入文件均位于main/java/resources/bigtest下。测试用例test/java/IntegrationTest::bigTest将读取输入文件、词法定义文件和语法分析文件,将输入文件进行词法和语法分析,输出规约序列。注意此“测试”由于比较复杂(规约序列中包含200余条产生式),故只提供了程序的输出信息(main/java/resources/bigtest/output文件),并没有进行正确性验证。

7.4 单元测试

在开发过程中,单元测试起到了测试一个功能点正确性的作用。本项目中test/java/LexTesttest/java/SyntaxTest包含了编写词法和语法分析器过程中所用到的单元测试,若有兴趣,可自行运行。

8. 遇到的问题和解决方案

仅举几个例子。

8.1 计算First的时候无限递归

使用公式递归计算First函数的时候,容易遇到类似First(A) = First(A) U First(B)...的无限递归的情况。根据集合方程的特性,在这种情况下可以直接忽略右侧的First(A),这样即可避免无穷递归的问题,成功算出函数值。

8.2 新对象与老对象的区分和联系

在程序运行过程中,有很多地方可能会产生与老对象有同样的值的新的对象(例如计算一个LR DFA状态的closure的过程中,会产生一些新的状态)。为了让这些新的对象能够在程序中表现得和原来的老对象一致,所以程序中重写了equals方法,这样就可以让有相同内容的新的对象和老的对象在程序中被认为是相等的,简化了判断。

另一方面,程序中有很多地方看起来需要修改现有对象(例如加入向前看符号,移点操作等),但是由于很多对象在一次计算过程中是共享的,若直接修改输入对象的内容,可能会造成不可预知的后果。所以,这些方法都实际被做成了产生新的状态(而不是直接修改现有的对象),由于这些对象都重写了equals方法,它们将会在接下来的算法中,和目前有的对象被认为是同一个对象,不会影响程序的实现。

8.3 不建立分析表而直接使用LALR(1) DFA进行分析

我当时考虑过建立LALR(1)分析表,但是这样会引入很多的类,以及类似Map<State, Map<State, Action>>这样比较拗口的类型用来表示一个二维表。后来为了简化编程,所以直接采用LALR(1) DFA状态机的形式进行分析。

9. 感受和评论

词法分析和语法分析是编译器最早的两个步骤,通过自己实现通用词法分析器和同于语法分析器,我更加深入地理解了正则表达式到NFA到DFA过程和从CFG到LR(0) DFA到LALR(1) DFA的全过程,也更加深入的理解了从输入文件到Token序列到产生式规约序列的全过程,对我理解词法分析和语法分析过程起到了至关重要的作用。

新博客正式上线

2018-11-17 14:51:00

为什么又换了??

相信大家看到这个文章,第一反应如标题:为什么又换了??

上个寒假花了20天,用React+ASP.NET Core完整撸了一套博客网站出来。本以为就这样就可以了,结果却漏洞百出,且维护成本极高,例如:

因此,这套完整的博客系统虽然功能强大和完整,但是日常维护起来可是非常难受。

Gatsby to the rescue!

不久前偷窥其他同学的GitHub发现了Gatsby,从此打开了新世界,马不停蹄地把博客用Gatsby重做一下。

花了两天时间(其实也就10个小时)撸了一个博客出来。这里推荐一个工具wakatime,可以记录每天编程的时间、语言、项目,知道自己到底花了多少时间在编程上。

wakatime

为什么最后用Gatsby?

Gatsby最后生成的是静态网页,不需要折腾部署,并且便于被CDN和缓存

有人看到这个之前可能要问为什么不直接用hexo这种专门写博客的框架,这就是我的原因。hexo等这种静态博客工具过于局限,也不便于用上React等现在已经非常成熟的前端框架来让开发过程更加现代化。同样,Gatsby的灵活也便于扩展网站的功能和自定义。Gatsby有自己的生态系统,这个生态系统里的工具用起来有时还更爽更简单。

当然,Gatsby也是有坑的,例如一些开源项目通病

只不过这些坑踩过之后影响不大,并且随着时间的推移越做越好了。

上线

原型撸完之后,又经过这么久的调试、测试和数据迁移,新博客总算是正式上线了。

新的博客有如下的特性:

现在的版本基本已经满足了我对博客的所有期望。

博客的大厦已经基本建好,接下来只需要小修小补即可

希望大家多多支持,多发评论多发邮件和反馈!谢谢大家!

写代码要动脑子!

2018-07-31 14:12:00

在开发过程中,不要无脑复制粘贴照着示例写,而应边写边想有什么可优化的,并大胆地通过查资料、自己动手做实验等方法验证自己的优化可不可行,如果可行,请大胆地提交代码,并给所有人讲解你的做法。

错误示范

陈振宇说得好,重复3次以上的操作都要应该写程序来做。但是,事实上,很多人写代码根本不动脑子,看到示例怎么写,自己就复制一下,改改变量名,能用就行,不管复制多少次也不嫌烦。

这里举几个例子,全是大作业里的代码。大家都不需要知道每个变量具体是什么意思,单看代码就知道这种代码就是典型的不动脑子的代码:

重复switch(解决方法:多态,策略模式)

@Override
public void updateMission(String missionId, int credits, MissionType missionType) throws SystemException, IOException, MissionIdDoesNotExistException, ClassNotFoundException {
    Mission mission = null;
 
    // highlight-start
    switch (missionType) {
        case IMAGE:
            mission = imageMissionDao.findImageMissionByMissionId(missionId);
            break;
        case TEXT:
            mission = getMissionByMissionId(missionId);
            break;
        case AUDIO:
            mission = audioMissionDao.findAudioMissionByMissionId(missionId);
            break;
        case VIDEO:
            mission = videoMissionDao.findVideoMissionByMissionId(missionId);
            break;
        case THREE_DIMENSION:
            mission = threeDimensionMissionDao.findTHreeDimensionMissionByMissionId(missionId);
            break;
 
    }
    // highlight-end
    mission.setCredits(mission.getCredits() + credits);
    updateMission(mission);
}

不可忍受的重复switch(解决方法:多态,策略模式)

@Override
public String updateInstanceDetailVo(InstanceDetailVo instanceDetailVo) throws SystemException, IOException {
    MissionType missionType = instanceDetailVo.getMissionType();
    InstanceVo instanceVo = instanceDetailVo.getInstance();
    Instance result = null;
 
    // highlight-start
    switch (missionType) {
        case IMAGE:
            ImageInstanceDetailVo imageInstanceDetailVo = (ImageInstanceDetailVo) instanceDetailVo;
            ImageInstance imageInstance = generateImageInstance(instanceVo, imageInstanceDetailVo);
            result = saveImageInstance(imageInstance);
            break;
        case TEXT:
            TextInstanceDetailVo textInstanceDetailVo = (TextInstanceDetailVo) instanceDetailVo;
            TextInstance textInstance = generateTextInstance(instanceVo, textInstanceDetailVo);
            result = saveTextInstance(textInstance);
            break;
        case THREE_DIMENSION:
            ThreeDimensionInstanceDetailVo threeDimensionInstanceDetailVo = (ThreeDimensionInstanceDetailVo) instanceDetailVo;
            ThreeDimensionInstance threeDimensionInstance = generateThreeDimensionInstance(instanceVo, threeDimensionInstanceDetailVo);
            result = saveThreeDimensionInstance(threeDimensionInstance);
            break;
        case VIDEO:
            VideoInstanceDetailVo videoInstanceDetailVo = (VideoInstanceDetailVo) instanceDetailVo;
            VideoInstance videoInstance = generateVideoInstance(instanceVo, videoInstanceDetailVo);
            result = saveVideoInstance(videoInstance);
            break;
        case AUDIO:
            AudioInstanceDetailVo audioInstanceDetailVo = (AudioInstanceDetailVo) instanceDetailVo;
            AudioInstance audioInstance = generateAudioInstance(instanceVo, audioInstanceDetailVo);
            result = saveAudioInstance(audioInstance);
            break;
    }
    // highlight-end
 
    if (result == null)
        throw new SystemException();
    return result.getInstanceId();
}
 

逻辑几乎相同的多个方法(解决方法:泛型,策略模式)

public VideoInstance getVideoInstance(String instanceId)  {
    VideoInstance videoInstance = videoInstanceDao.findVideoInstanceByInstanceId(instanceId);
    try {
        FileInputStream fileIn = new FileInputStream(PathUtil.getSerPath() + "video_instance" + "_" + instanceId);
        ObjectInputStream in = new ObjectInputStream(fileIn);
        List<VideoResult> videoResults = (List<VideoResult>) in.readObject();
        in.close();
        fileIn.close();
        videoInstance.setVideoResults(videoResults);
    } catch (IOException e) {
        System.out.println("Results for " + instanceId + "not found. Returns empty list.");
        videoInstance.setVideoResults(new ArrayList<>());
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
    return videoInstance;
}
 
public AudioInstance getAudioInstance(String instanceId) {
    AudioInstance audioInstance = audioInstanceDao.findAudioInstanceByInstanceId(instanceId);
    try {
        FileInputStream fileIn = new FileInputStream(PathUtil.getSerPath() + "audio_instance" + "_" + instanceId);
        ObjectInputStream in = new ObjectInputStream(fileIn);
        List<AudioResult> audioResults = (List<AudioResult>) in.readObject();
        in.close();
        fileIn.close();
        audioInstance.setAudioResults(audioResults);
    } catch (IOException e) {
        System.out.println("Results for " + instanceId + "not found. Returns empty list.");
        audioInstance.setAudioResults(new ArrayList<>());
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
    return audioInstance;
}
 
public ThreeDimensionInstance getThreeDimensionInstance(String instanceId) {
    ThreeDimensionInstance threeDimensionInstance = threeDimensionInstanceDao.findThreeDimensionInstanceByInstanceId(instanceId);
    try {
        FileInputStream fileIn = new FileInputStream(PathUtil.getSerPath() + "threeDimension_instance" + "_" +    instanceId);
        ObjectInputStream in = new ObjectInputStream(fileIn);
        List<ThreeDimensionResult> threeDimensionResults = (List<ThreeDimensionResult>) in.readObject();
        in.close();
        fileIn.close();
        threeDimensionInstance.setThreeDimensionResults(threeDimensionResults);
    } catch (IOException e) {
        System.out.println("Results for " + instanceId + "not found. Returns empty list.");
        threeDimensionInstance.setThreeDimensionResults(new ArrayList<>());
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
    return threeDimensionInstance;
}

无脑try catch,几乎相同的处理逻辑(解决方法:让Spring boot处理错误)

无脑转发BL层(问题:BL层和Controller完全使用同样的函数签名和Po/Vo/返回值类型,这样的分层毫无意义)(解决方法:可直接将BL的代码写到controller中。)

public ResponseEntity<Response> queryInstance(@PathVariable("instanceId") String instanceId) {
    try {
        return new ResponseEntity<>(requesterMissionBlService.queryInstance(instanceId), HttpStatus.OK);
    } catch (InstanceNotExistException e) {
        e.printStackTrace();
        return new ResponseEntity<>(e.getResponse(), HttpStatus.NOT_FOUND);
    }
}
 
public ResponseEntity<Response> finalize(@PathVariable("instanceId") String instanceId, @RequestBody MissionFinalizeVo missionFinalizeVo) {
    try {
        return new ResponseEntity<>(requesterMissionBlService.finalize(instanceId, missionFinalizeVo), HttpStatus.OK);
    } catch (InstanceNotExistException e) {
        e.printStackTrace();
        return new ResponseEntity<>(e.getResponse(), HttpStatus.NOT_FOUND);
    } catch (SystemException e) {
        e.printStackTrace();
        return new ResponseEntity<>(e.getResponse(), HttpStatus.SERVICE_UNAVAILABLE);
    } catch (MissionIdDoesNotExistException e) {
        e.printStackTrace();
        return new ResponseEntity<>(e.getResponse(), HttpStatus.NOT_FOUND);
    }
}

错误示范带来的问题

以上代码给我们的错误调试带来了巨大的难度,耗费了很多的时间和精力:

  1. 新增一个分支,需要复制粘贴很多现有代码;
  2. 修改一处逻辑,需要在多个switch分支、多个方法甚至多个文件里修改同样的逻辑,漏改几乎是肯定的;
  3. 代码量爆炸,同样的逻辑无意义地重复多次,使得代码不美观;
  4. 逻辑不清晰,需要看懂整个重复代码才能知道这是做什么;
  5. 很多复制粘贴出来的代码其实根本不能运行,发现后浪费更多的时间在调试和重写上。

常见优化方法

  1. 将共用代码提成一个函数/类(谁都知道)
  2. 使用高级语言特性
actual_value = 1
expected_values = [{value: 1}, {value: 2}, {value: 3}]

不好的

for i in range(expected_values.__len__()):
    if actual_value == expected_values[i]["value"]:
        return True
 
return False

好的

return actual_value in [ i["value"] for i in expected_values ]
  1. 使用框架提供的工具,结构

大佬写的、全球这么多人都用的东西,肯定有其可取之处。我们不用自己想、自己实现一个框架那么牛逼的结构,但是总是该会用的。多看看开源代码,遇到问题多搜索,往往能够看到一个精妙的解决方案。

优化实例

下面通过一个例子来解释如何动脑子提高代码质量。

需求:我们正在做一个RESTful接口,其中一些路径在执行前需要验证是否登录,如果没有登录,直接返回401,并保存下当前用户;若已经登录,则继续执行。

最简单的方法如下:

def path1():
    user=UserDao.get_user_by_username(request.args("username")):
    if not user:
        return {"error": "not login"}, 403
    pass
 
def path2():
    user=UserDao.get_user_by_username(request.args("username")):
    if not user:
        return {"error": "not login"}, 403
    pass
 

以上代码中出现了重复的代码(if else)。这个写法带来了以下的问题:

  1. 每新增一个路径,都要复制同样的代码
  2. 一旦逻辑有变(获得用户的方法)或者返回值有变(return语句),每个地方都要重新修改,很容易造成修改不完全
  3. 代码膨胀,不够清晰,需要看懂整个代码才能知道这段代码的作用,并且会影响真正逻辑的阅读

于是,根据方法1:提取公共方法,上述代码可以优化成以下代码。

 
def get_user():
    return UserDao.get_user_by_username(request.args("username")):
 
not_login_error = ({"error": "not login"}, 403)
 
def path1():
    user=get_user()
    if not user:
        return {"error": "not login"}, 403
    pass
 
def path2():
    user=get_user()
    if not user:
        return not_login_error
    pass
 

这个解决方法能够部分解决以上提到的三个问题。

大部分人都能把上述代码优化成如下的代码,并且他们内心中都会觉得这样已经是最简了,即使这个做法仍然要多次重复写if return语句,他们也会觉得这是没有办法的事。

其实,还有更好的方法。

根据以上的方法2:使用高级语言特性,这里是decorator,以上代码还能继续优化成以下代码:

 
def need_login(func):
    def wrapped(*args, **kwargs):
        user=UserDao.get_user_by_username(request.args("username")):
        if not user:
            return {"error": "not login"}, 403
        return func(*args, user=user, **kws)
    return wrapped
 
@need_login
def path1(user: User):
    pass
 
@need_login
def path2(user: User):
    pass

请对比以上代码和之前两段代码,就能很明显的发现这段代码有以下的好处:

  1. 逻辑清晰

看到@need_login就知道,原来这个path需要登录才能进入,并且还能知道这个方法运行的时候还需要使用用户作为参数。当path更多的时候,这样做也能显著降低代码量,并减少阅读时无关代码的数量。

  1. 便于测试

要只想测试path的逻辑,不需要真正去运行获得用户(UserDao.get_user_by_username)的代码,测试代码只需要传入一个Mock User,就可以测试这段代码的逻辑是否正确。当然,前两个代码也可以做到这点,但是不得不引入多余的方法。

  1. 修改灵活

need_login中可以随意修改逻辑,甚至可以完全取消这个验证,完全不影响业务代码。

这种方法有个缺陷,就是只适合于动态类型的语言(JS也有类似的),对于写Spring Boot的Java,应该怎么办呢?

根据方法3:使用框架提供的工具,结构,我们只需要搜索一下,就能知道Spring Boot提供了Filter机制,特别适合用来完成这种工作。(这里黑一波Spring和Java:在网上搜Spring和Java,出来的东西很多都是过时的和重复的,用英文搜索稍微好一些。所以Java和Spring在网上热度高不是没有道理:毕竟你得花很长的时间才能找到你想要的东西,反观CSharp和ASP.NET Core,没有什么是MSDN不能解决的,如果有,就用英文Google一下很快就能找到)

// 定义Filter
 
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Qualifier("jwtUserDetailsServiceImpl")
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Value("${jwt.header}")
    private String tokenHeader;
 
    @Value("${jwt.tokenHead}")
    private String tokenHead;
 
    @Autowired
    private JwtService jwtService;
 
    @Autowired
    public JwtAuthenticationTokenFilter() {
    }
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHeader);
        if (authHeader != null && authHeader.startsWith(tokenHead)) {
            final String authToken = authHeader.substring(tokenHead.length());
            String username = jwtService.getUsernameFromToken(authToken);
 
            if (authToken.length() > 0) {
                try {
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    if (jwtService.validateToken(authToken)) {
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(
                            request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
 
        chain.doFilter(request, response);
    }
}
 
// 使用。这样,整个Controller在执行以前,都会经过Filter验证,如果没有,就直接返回Response。
 
@PreAuthorize(value = "hasRole('" + Role.REQUESTER_NAME + "')")
@RestController
public class RequesterInfoController {
    //...
}

其实,之前decorator的做法在flask文档中都有提到,以上代码就几乎等同于这个官方示例

总结

很多同学写代码只图早点做完,只图能跑,这固然可以理解:学习任务重,DDL紧,检查也只看效果不看代码更别说质量了。可是,只图快不动脑的代码会极大地浪费自己或者组内大佬的调试时间,破坏心情和组内的关系(我看到这种代码的第一反应就是骂人),当代码量上去后,这种代码也只会让开发的时间和难度指数型上升,最终害人害己。

所以,我想呼吁所有人,为了自己和他们的时间,写代码时请带上脑子!

2017年总结

2017-12-31 23:45:00

是时候来简单梳理下我的2017年了。

寒假:

  1. 去年今天的这个时候(2016年12月31日),我都已经到家了。而今年却要一直考到考试周倒数第二天。真是风水轮流转啊。同时,今年也是第一次在外面过元旦。
  2. 南星计划。第一次参加这种社会实践活动,和20多个学校的小伙伴一起进行了第一次联合宣讲,建立的志愿咨询群也一直持续着它的价值。
  3. 又双叒叕折腾出一套博客,又双叒叕以为能一直维护和使用下去,结果还是几乎弃坑了:新功能没加,依赖更新后代码编译不通过不管,前端资源都5M了也没想着优化,文章也没写几篇,最重要的是上了软工二课后看原来的代码就是一坨那啥。又不知道啥时候又能把它捡回来,或者说,我真的要继续去做它吗?其他大佬要么学得更基础(算法等),要么学业内目前的高端的知识(机器学习什么的),做这种项目对以后找工作来说真的有意义吗?做这种项目真的能提高我的“技术”吗?

大一第二学期:

  1. 我到现在都想知道为什么当时的我要花一个Surface Book 2的钱去报托福课。上了一半,另一半拖到现在,不知道什么时候才想继续。而且这也给了我一个教训:做什么事都要三思。
  2. EL比赛,第一次拿到奖金(500块)。
  3. 去北京玩了几天,高中同学招待得非常到位,真的非常感谢他们!
  4. 当时学长说大一下是大学最轻松的一个学期,当时还不信,到了大二才知道确实是这样。
  5. 建议列表里大一的学弟学妹,大一下确实是最轻松的,尤其是还少了一门计组课,有安排请尽快付诸行动,到了大二,一切都晚了。

暑假:

  1. 无聊的小学期。十几年不变的课程,所有人都对它反感,不知道为什么还在继续开,白白占了10天的宝贵的假期时间。
  2. 社会实践。当时的豪情壮志并没有完全做到,虎头蛇尾是最准确的形容词,有点对不起组员。
  3. 微软学生夏令营。有幸来自全国的巨佬在一起,认识了许多人,更加强了信仰。
  4. 比寒假短的暑假。

大二第一学期:

  1. 和大一完全不同,高考结束后经历的第二次大改变。
  2. 第一次在社团有个一官半职,有点想法却很多都因为自己经验不够显得都太naïve,想做出一点改变却总是做不到。
  3. 软工二是大二上的万恶之源。从开始到现在,每天心里都是软工二,每天都想着大作业,除了软工二啥都不想做,一些时间上任何课都是在做软工二。所以整个大二上几乎没有碰个人项目,也没有接触其他的领域(比如创新项目)。感觉有点过了,主次不分?
  4. 上一条成立也幸亏数据结构水的不成样子。一门非常重要的计算机基础课程上成PPT Reading以及离散数学代码版,这个专业是怎么评上A的?以及学成这个样子,真的就够了吗?
  5. 奖学金到手瞬间透支。
  6. 想通过换输入法和键盘布局加快打字速度。dvorak键位学了2个月,打字练习正确率能到90%,然后放弃了,12月转而学双拼来提高中文速度,到现在能日常使用了,但是速度还是不如全拼。我也花了非常多的时间在这个无意义的转换上。
  7. 朋辈导师一直到11月才正式开始,感觉太晚了,从我自己的经验,11月已经过了最困难的适应期,如果能在刚入学的时候甚至暑假就开始,可能能发挥更大的作用。但即使如此,它也不是这两个月没干什么事的借口。新的一年应该多给点学弟学妹们做实事,不能让他们失望。
  8. 要说这学期忙,可是,有的同学同时在多个组织任职并把事情做得很好,有的同学还在学习其他前沿的知识,有的同学在周末安排了各种课外的活动和学习,我和他们上的同样的课,为什么他们就能安排这么多的事情?为什么我就啥都没做,却感觉忙得不可开交?
  9. 脱单。从来没有这学期一样这么想脱单,可是自己太怂,感觉自己很菜等一些原因,也只能在脑子里想想了。

写到这里,时间显示着23点16分。2017年虽然不像2016年一样,是人生的新阶段的起点,可是在这一年里,我经历了许多人生的第一次,尝试做一个不一样的自己。有开心,也有难过;有收获,也有遗憾。

数十公里外的市中心的热闹的人群准备着新年的倒计时,而我也安静地等着电脑右下角的2017/12/31跳到2018/1/1,和大家一起说出那一句

2018年新年快乐

</2017><2018>

Python语言实现的符合本福特定律的十进制固定长度随机数发生器

2017-12-10 00:04:00

前言

本福特定律改变了人们对随机的认识。之前人们认为,在一组自然的随机数中,以每一个数字打头的数占总频数的频率是一样的。但是本福特定律却用严谨的数学语言证明了不同数字打头的数并不是一样的。本福特定律说明,在b进位制中,以数n起头的数出现的概率为log_b(1+1/n)。这篇文章中提出了一个在现有的平均概率随机数生成器的基础上实现一个十进制下符合本福特定律的、固定位数的随机数生成器。这篇文章展示它的效果,介绍它的算法以及它的代码实现。

代码

运行需要Python 3,不需要其他库依赖。

import math, random
from functools import reduce
 
def possibility_for_n(n):
    return 0 if n==0 else math.log(1+1/n,10)
 
def distribution_list(n):
    result = []
    for i in range(0,10):
        r = 0
        for j in range(int(math.pow(10,n-2)),int(math.pow(10,n-1))):
            r = r + possibility_for_n(j*10+i)
        result.append(r)
return result
 
def search_list(distlist):
    return list(map(lambda i: sum(distlist[:i+1]), range(0,10)))
 
def generate_digit(n):
    rand = random.random()
    searchlist = search_list(distribution_list(n))
    if rand <= searchlist[0]:
        return 0
    for index in range(0,10):
        if rand > searchlist[index] and rand <= searchlist[index+1]:
            return index+1
 
def generate_one(length):
    return reduce(lambda x,y: x*10+y, map(lambda i: generate_digit(i), range(1,length+1)))
 
def generate_multiple(length, num):
    return [generate_one(length) for i in range(0,num)]

代码使用

调用generate_one函数来生成一个随机数,参数为数字位数。 调用generate_multiple函数生成指定个数个随机数,第一个参数为数位数,第二个参数为生成个数。

示例:

> generate_one(4)

> 4937

> generate_multiple(4,10)

> [6179, 4971, 7735, 1392, 5046, 4750, 4412, 2249, 1530, 8443]

代码效果

此代码可以生成指定位数的、指定个数的符合本福特定律的随机数集。

对于代码生成的如下50个4位随机数,

[2152, 6766, 2117, 4239, 5047, 7623, 2497, 1382, 8081, 4431, 2983, 8968, 1669, 6670, 7242, 3819, 1565, 1399, 2102, 1706, 3257, 8281, 8735, 9197, 5254, 5872, 6805, 2526, 3951, 1271, 4271, 1540, 3713, 1124, 1452, 6037, 3279, 6424, 1550, 2596, 9411, 1272, 1996, 3735, 2403, 3007, 4303, 1146, 1513, 1098]

统计其首位各个数字出现频率,与理论计算值对照,得到如下表格:

开头数字n实际频率p_a (n)理论频率p_e (n)
1(n_start)0.30.3010
20.160.1760
30.140.1249
40.080.0969
50.060.0792
60.10.0669
70.040.0580
80.080.0512
9(n_end)0.040.0458

使用公式

α=(sum(k=n_start to n_end)((p_a (k)-p_e (k))^2))/(n_start-n_end+1)

度量实际频率与理论频率的偏离程度,数字越小越好。

上例的结果为

α_1=0.00038025004826301343。

以同样方法计算前两位与理论值的误差,结果为

α_2 = 0.00013919331883366668

作为对比,平均概率的随机数生成器生成的4位50个数字

[2665, 2249, 4658, 3974, 2232, 1486, 1616, 7235, 4730, 5351, 1973, 4399, 7146, 2650, 7414, 9585, 2561, 1599, 1839, 6233, 1449, 8225, 7896, 2726, 4011, 7821, 8365, 2231, 4490, 2247, 9524, 7578,4203, 1741, 5035, 9362, 9340, 1798, 5398, 1676, 9247, 8809, 2948, 4933, 1040, 9487, 6529, 5773, 8622, 6047]

α_1 = 0.0037032530835963864

数字越多,其结果越贴近理论值,以下为10000个4位随机数首位各个数字的频率与理论对照值的表格(为了节省篇幅,不附上具体数字):

开头数字n实际频率p_a (n)理论频率p_e (n)
10.29910.3010
20.17830.1760
30.12270.1249
40.09860.0969
50.07800.0792
60.07010.0669
70.05600.0580
80.05000.0512
90.04720.0458

相关指标为:

α_1 = 0.000003909609950132077

α_2 = 0.000002385500926719904

算法分析

若设p(n)为本福特定律中指出的以n开头的数字占所有数字的出现的频率,那么对于从高位起第n位(最高位为第1位),数字i出现的频率为

sum(k=10^(n-2) to 10^(n-1)-1)(p(10k+i))

对于n=1,它的起始为0。对于最高位0,频率为0,对于其他位置出现的0,可以不特殊处理。

例如,对于从高位第2位,它出现2的概率为p(12)+p(22)+p(32)+⋯+p(92),而对于高位第4位,它出现5的概率为p(10005)+p(10015)+⋯+p(99995)

可以看到,

每一位的每一个数字的概率都可以通过这个公式计算出来,所以每一位上每一个数字出现的概率都是一定的,并且是可以计算出来的。

所以针对每一位数,根据它对应的频率生成一次随机数,可以保证最后的结果能够满足本福特定律。

称每一位每一个数字出现的概率按对应数据大小排序的结果为分布表(distribution list,d)。最高位的分布表为

d_0 = [0,0.3010,0.1760,0.1249,0.0969,0.0792,0.0669,0.0580,0.0512,0.0458]

,分别对应0,1,2,3,4,5,6,7,8,9为起始的数字的出现概率。

接下来问题转到了如何根据确定的概率分布生成一位数字。

定义概念搜索表(search list, s)。搜索表定义如下:

s[i]= sum(k=0 to i)(d[k]) ,∀i∈[0,n_end-n_start]

用人话说,搜索表的第n项等于其对应分布表首项到第n项的概率之和。

那么上文d_0对应的搜索表为

s_0=[0,0.3010,0.4771,0.6021,0.6990,0.7782,0.8451,0.9031,0.9542,1.000]

根据定义,搜索表最后一项一定为1。 下面需要假设现有随机数的发生器可以在[0,1)区间按平均的概率生成随机数。幸运的是,几乎所有语言都提供了生成这种随机数的机制。

现在假设生成为随机数为r∈[0,1),那么在搜索表中寻找i∈[0,n_end-n_start],使得r>s[i]且r≤s[i+1],取i+1作为本位随机数结果。如果r≤s[0],取0。因为搜索表的每一项是单增的,可以保证i唯一;由于搜索表最后一项一定为1,可以保证i一定存在。

根据这个算法得出的数字能够保证了每一位数字出现的概率和搜索表符合。

对每一位运用此算法,计算其对应的分布表和搜索表,就可以生成指定位数的随机数。

代码分析

代码分为6个部分,分别为以n开头的数字的频率(possibility_for_n(n)函数)本位的分布表(distribution_list(n)函数)本位的搜索表(search_list(distlist))生成第n位(generate_digit(n)函数)生成一个数(generate_one(length))以及生成多个数(generate_multiple(length, num))

以n开头的数字的频率

def possibility_for_n(n):
    return 0 if n==0 else math.log(1+1/n,10)

这段代码很好理解:计算以n开头的数字的频率。由于数字不能以0开头,所以以0开头的数字的频率为0。

本位的分布表

def distribution_list(n):
    result = []
    for i in range(0,10):
        r = 0
        for j in range(int(math.pow(10,n-2)),int(math.pow(10,n-1))):
            r = r + possibility_for_n(j*10+i)
        result.append(r)
    return result

根据定义生成第n位的分布表。

本位的搜索表

def search_list(distlist):
    return list(map(lambda i: sum(distlist[:i+1]), range(0,10)))

参数为搜索表对应的分布表。为了简洁,这里采用了map高阶函数。它的作用是把第二个参数的每一项作为参数执行第一项的函数,函数的返回值组成新列表作为表达式结果。distlist[:i+1]表示取distlist的从0项到第i项的子表。

生成第n位

def generate_digit(n):
    rand = random.random()
    searchlist = search_list(distribution_list(n))
    if rand <= searchlist[0]:
        return 0
    for index in range(0,10):
        if rand > searchlist[index] and rand <= searchlist[index+1]:
            return index+1

参数n的含义为第n位。这段函数中,第一句代码生成随机数rand ∈[0,1)(random.random()正好如此),之后生成第n位的搜索表,然后根据上文所阐述的搜索index。这里为了简洁,使用了顺序搜索,虽然它时间复杂度为O(n),弱于二分法,但是由于每个表的规模恒定为常数9,这个复杂度可以接受。

生成一个数

def generate_one(length):
    return reduce(lambda x,y: x*10+y, map(lambda i: generate_digit(i), range(1,length+1)))

参数为数的位数(长度)。这里为了简洁,采用了map和reduce两个高阶函数。Map不再赘述。reduce把一个函数作用在一个序列[x1, x2, x3, ...]上,把结果继续和序列的下一个元素做累积计算。

例如,reduce(lambda x,y: x+y, [1,2,3])的结果是6,计算过程为:

[1,2,3]->[1+2,3]->[3,3]->[3+3]->[6]->6。

这里首先采用map生成第1位到第length位的数字,然后采用reduce方法将这些数字合并成一个数字。

生成多个数

def generate_multiple(length, num):
  return [generate_one(length) for i in range(0,num)]

length为数的位数,num为生成数的个数。函数调用num次generate_one(length)方法,返回结果。

总结

这篇文章展示了一个实用的符合本福特定律的十进制固定长度随机数生成器,并对它的原理和代码实现进行了简要的介绍。在此基础上,可以很容易地扩展到任意进制的随机数长度生成器。但是,这个算法仍然有改进空间,例如可以根据相同的原理实现任意长度的生成器。读者如果有兴趣,可以对此进行更深一步的研究。

参考

[1] https://en.wikipedia.org/wiki/Benford%27s_law

[2] http://blog.iharder.net/2010/11/10/benford-how-to-generate-your-own-benfords-law-numbers/

[3] https://softwareengineering.stackexchange.com/questions/255892/unevenly-distributed-random-number-generation

[4] https://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000/0014317852443934a86aa5bb5ea47fbbd5f35282b331335000

院自建GitLab CI配置实录

2017-11-06 23:55:00

动机

因为作业要求想尝试持续集成的效果,又因为很多开源CI平台不支持自建平台(例如Travis CI),院的GitLab也不开放Shared Runner,所以只能手动配置Runner。在整个配置的过程中,遇到了很多让人很无语的坑。在这里记下来,以让大家参考。同时也记下来折腾的过程。

先放一张效果图。

项目地址: http://101.37.19.32:10080/161250010/gitlabcitest

GitLab版本过老,需要使用老版本的runner

院的GitLab版本是

。截止当前(2017/11/6)GitLab CE已经更新到了大版本10,这个8.10版本官方早就已经放弃治疗,甚至在gitlab-runner的兼容性页面上都找不到这个版本的信息。看来你院使用过时技术的传统看来还要继续延续多年。

所以现在在官网的GitLab Runner安装教程已经不适合。

如果使用新版本的gitlab runner运行的话,会提示**405(Method Not Allowed)**错误。这是由于新版本的Runner会使用GitLab从9.0开始换用的新的GitLab API v4,而8.x版本仍然使用老版本的API。这就造成了错误。

同时,Runner从10版本从gitlab-ci-multi-runner更名为gitlab-runner,但是配置方法大致一样。配置可以参考这个链接

GitLab正确的安装和配置方法方法如下(以Ubuntu 16.04 x64通过apt-get举例):

  1. 根据这个设置老版本的apt源;
  2. 运行安装命令:sudo apt-get install gitlab-ci-multi-runner=1.9.5
  3. 配置时使用命令:sudo gitlab-ci-multi-runner register

配置好了后,runner页面应该能看到提示的可用的runner。

Ubuntu官方源Node版本太低

配置好了runner,我首先使用了我的博客前端的程序用来做测试。为了简化,使用了shell executor。这就需要配置runner机器的环境。

先写一份.gitlab-ci.yml文件。我使用的文件如下:

stages:
  - build
 
buildjob:
  stage: build
  script:
    - npm install
    - python cleanup.py
    - set NODE_ENV=production
    - webpack -p --color
  cache:
    paths:
      - ./node_modules/

直接敲sudo apt-get install nodejs,然后就开始build。结果在build的时候才发现node版本很低……

只能自己设置apt源了。Node官方提供的源说明参照这个链接

构建时内存提示不足

上传后,pipeline自动开始build。然而虽然想到了Node非常耗费内存,但是没想到512M内存都不够吃……跑npm install的时候被bash无情杀掉。

后来把内存加到2G才够npm install使用。

为了简化工程,后来使用了Mobx官方提供的boilderplate项目作为测试。这也就是现在的项目。

你正在成功!

成功了!折腾了这些后,终于成功build了一次。

还给README.md加上了badge(题图)(教程),这样看上去就非常像是一个非常好的开源项目。

结语

GitLab CI确实是非常好用的工具,它能和GitLab无缝集成,而且非常的简单易用(这些坑都不是GitLab的)。这几天我会把整个软工2项目的CI环境搭建好,需要学习控制台下gradle相关命令以及Linux下的一些配置,这样真正开始开发的时候才能更高效。

C++测例查看器

2017-10-03 20:00:00

仓库Private了,但是文章不删,如果你们厉害可以按照下面的提示自己试试呗)

请进入这个Github仓库clone代码或者查看用法。

原理

每个题的所有测例文件(和描述文件)都被打包在一个zip压缩包里,然后这个压缩包被AES256加密后,用base64编码存放在C:\Users\{你的用户名}\AppData\Roaming\CppPlugin\download\{作业编号}下,命名为题目编号,无后缀。

所以解密过程嘛,就是直接从反编译的插件代码里抄过来的:AES256的私钥是直接硬编码在插件里的,所以直接使用反编译工具就能看到硬编码和算法。

README.md

CppTestCaseViewer

这个小工具可以用来查看C++作业的测例。

前提

  1. 下载好了题目
  2. 装有.NET Framework 4.5,如果你有VS2013,那么应该没什么问题。

使用

  1. 下载CppTestCaseViewer.exe,并保存到某个空目录,比如说D:\cpptest\下。
  2. 从目录C:\Users\{你的用户名}\AppData\Roaming\CppPlugin\download\{作业编号}\下复制所有文件到目录D:\cpptest\testcases\下;
  3. 运行CppTestCaseViewer.exe,并在最后提示Test cases for all {problem num} problem...后按下任意键关闭程序。
  4. 进入.\export文件夹,里面会有以题号命名的不同文件夹,点开任意一个文件夹,里面会有命名类似于test_{编号}.intest_{编号}.out的文件,它们就是测试用例。

注意

  1. 运行插件后会产生.\export,.\decompressed以及.\zips文件夹,请保证在运行插件以前,把它们全部删掉!目录只留一个exe文件和.\testcases文件夹,否则会报错!
  2. 第二步的作业编号可以在下载题目的时候看到,如果不记得了,直接去目录里找就行。另外,第一次作业的编号是36.

C++插件在VS2017上无法使用的分析

2017-09-27 20:32:00

前言

因为VS2017是我的刚需(和Azure的交互以及个人的喜好),所以当插件可以下载的时候,我直接解包修改了插件的配置文件从而在VS2017上运行。我以为就这样我就可以用VS2017而不需要虚拟机装VS2013了,但事实还是证明我太年轻了……

错误

在VS2017上运行插件,运行测试时,会弹出如下的错误提示。

找错

为了找到这个的原因,使用免费的JetBrains的dotPeek工具对CppPlugin工具解包出的CppPlugin.dll文件进行反编译并导出成解决方案到VS里查看。

导出到VS后,需要在nuget里安装Microsoft.VisualStudio.ShellEnvDTE依赖以正常获得InteliSense提示。否则就要去翻MSDN……

通过查找报错的字符串获取可执行文件路径出错直接定位出错的位置,可以发现相关代码路径是Controller/imp/TestControllerImp.csCallResult RunTest(string)方法。

图中get_Solution()get_Name()等语句不是错误,它们是C#的属性编译后的代码。在自己程序中,如需访问Solution等属性,只能直接.Solution,不能直接调用get_Solution()代码。

更具体来说,是下部if (current.Name.Equals(toProjectNameMap[qid])块中的赋值语句。

path1_1 = current.get_ConfigurationManager().ConfigurationRow("Debug").Item((object) 1).get_Properties().Item((object) "OutputPath").get_Value().ToString();

这句话非常长,有很多函数调用。

为了弄清楚具体哪一个调用出错,我新建了一个全新的VSIX扩展程序项目,并在点击事件响应中加入了这个方法。

在调试中的VS实例中新建一个C++控制台程序,点击菜单按钮,发现如下结果:

Properties是null,Item(1)返回的是一个COM对象,无法获得值。

这就是错误所在了。

社区

微软的相关文档中并没有提到任何相关API在VS2017中的变化。

Configurations.Item(Object)方法

Configuration.Properties属性

同时,在StackOverflow下有类似问题:

Configuration.Properties object returning null in Visual Studio 2017 VSIX extension

按描述,题主也是有一个C++相关的扩展程序,相同代码在VS2015中能够运行,而在VS2017中出现错误。题主最后提出了一个可用解决方案。如有兴趣请点进去查看,这里就不搬运了。

在官方社区中也有相关问题ConfigurationManager.ActiveConfiguration.Properties is empty for c++ projects in Visual Studio Extensions,微软官方并未给出有效回答,却以有效信息不足的理由关闭了这个问题。这就非常滑稽了。

Thank you for your feedback! Unfortunately, we don’t have enough information to narrow down this issue and find a solution. If this is still an issue for you, we recommend that you upgrade to the latest version via the in-product notification or from here: https://www.visualstudio.com. If the problem should again occur, please let us know by creating a new problem report using the latest version.

总结

总的来说,这个插件不兼容VS2017,是微软的锅:API变动却没有在任何地方体现(可能这是个feature吧)。请大家可以不折腾这个插件了,安心用虚拟机或者实机安装VS2013吧!