本文是什么,不是什么
整个2023年,我做了很多的 Web 全栈应用,有的是创业产品,有的是为了自己或身边的朋友亲戚获取便利。这个过程里我犯了很多错,也一一修正了它们。现在我把这些经验写成文字,希望可以帮助到读者。
本文不是什么?本文不是《Web 全栈工具2024概览》,不是罗列 2024 年的技术栈,然后过几年发现它们纷纷过期。如果这篇文章是一个投资品,我希望它在未来是保值的。鉴于 Web 技术更新换代极快,这堪称雄心壮志。
本文是什么?本文希望总结归纳 Web 全栈学习中最通用的问题,在十年之后,学习者仍然会面它们。比如选择造工具还是使用现成工具、如何学习新框架、如何寻找项目的 idea 等等。本文的理想读者是具有编程经验,但是不懂全栈技术(或者懂前端不懂后端,反之同理)的工程师。
我之前的工作多为后端 API 编写,有创业公司和大公司。根据我的小规模问卷,不管你的工作名称是什么,在中国的很多大公司工作都会收敛为一个内容:使用内部工具扩展历史悠久、充满技术债的软件。以从零到一制作 Web App 的角度看,我仍是一个具有空杯心态的初学者。
在学习全栈技术前,我与2022年系统上完了 MIT、Stanford 等学校计算机系的公开课,包括操作系统、分布式系统、计算机网络、编译器、计算机架构等等,这些知识也为我提供了比较独特的视角。
本文的来源
本文源于我 2023 年里尝试做的三个 Web 全栈项目:医学研究软件、个人知识管理工具、商家管理应用。第一个烂尾,后两个完成并且已经上线。第一个和第三个项目是为别人做的,第二个项目是为我自己做的。
2022年底,我找了两个信赖的朋友,计划为中国的医学研究做一个开源软件,功能包括问卷调查、调查对象管理、采集的生物样本管理等等,后期维护作为收费功能。这类软件本身很少,开源的又都是国外的,没有针对中国人的习惯做一些本土化。我花了好几个月设计产品,学习全栈,但当进度条过半时,我意识到在中国做 2B 的产品是很困难的。因为软件赚钱的本质是规模化:同一个软件给大量的用户使用,随着用户量猛增,软件的制作成本是微增的,这样才能赚钱。而中国的 2B 用户是方差极大的,国企和大型民企占据头部,剩下大量付费能力弱的小公司。这样最终会沦为巨头的外包,无法规模化。而且高科技来自于规模化的技术,总是给用户个性化的需求做定制,也无法提升我们几人的技术水平。所以在跟两个朋友赔罪之后,我叫停了这个项目。之后的两个项目由我独立完成。
中国有成语叫功不唐捐、殊途同归。虽然第一个项目戛然而止,但是我在做后两个项目时,却感觉从中汲取的知识和经验在持续输出。甚至我做第一个软件是设计的产品特性都可以直接套用。后来想到这并非偶然。第一个项目是管理问卷、调查对象、生物样本等等,第二个项目是管理我的日记和文件等等,第三个项目是管理订单、会员还有产品。看似是不同领域,其实他们的功能重叠度很高。比如前者需要用条形码定位调查对象,后者需要用条形码定位产品,这部分程序小改一下就可以后者就可以使用了。
个人知识管理工具包括一个定制的 Todo list、时间日志、电脑和手机文件互传,还有一个基于 LLM 的 PDF Reader。
商家管理工具是为经营化妆护肤品生意的亲戚做的。包括对于产品、订单、会员的管理,其中可以扫码产品一键添加,背后利用中国物品编码网来填写产品信息,利用 ChatGPT 来对产品做自动分类。
虽然之前的工作中学到的经验多为”不要xxx"的形式,但是这期间的工作确实让我对数据安全和质量控制有了很深的理解。这也是本文的来源之一。比如做医学研究软件早期我就和朋友商讨过如何做数据备份,这些方案也被我用在了商家管理应用上面。
为什么学习全栈
2021年,我从上份工作辞职时在想过接下来做什么。网上有很多人说,互联网应用太低端,造编译器、操作系统等等才是真正的高科技。而我在上份工作中也目睹了很多怪现象,比如工作中用户下单时要对接大量的第三方 API,这些 API 会经常挂掉,最后公司管理层的智囊团想出一个非常人工智能的方法:雇人24小时盯着管理面板,一旦出问题就给我们开发者打电话。我们半夜被电话叫醒后填一个单子,一般内容为“第三方故障,我们什么都做不了"。然后等到第二天通知其他同事把这些产品下架。这种奇怪的琐事让我们心生厌倦。在我辞职之后偶然也会想,是不是 Web 应用就是技术鄙视链的低端?它是一个”低科技"?而且像我这样想的朋友不在少数,他们中间有想过转方向去做数据库或者 AI。
观念的转折点有两个。第一是我在 2022 年的时候 GAP year,读了很多好课程。其中一门是 MIT 的分布式系统,其中讲了 Google 内部使用的 Map Reduce 系统。我读到这篇文章时醍醐灌顶,因为这篇论文通篇下来其实就是一个概念:自动化容错(Fault Tolerance):当系统里面有大量的计算节点时必然经常出错,所以要设计一套机制来自动把这些节点的任务转移到其他节点,持续监控这些节点的状态,等他们恢复之后再重新分配任务。我才明白,上述提到的第三方 API 大量出错的场景里,我当时觉得这些第三方技术水平太差,希望他们能提升技术水平,而论文作者却另辟蹊径,让我意识到当数量够多时,这些 API 一定会大量出错。所以要转换思路,自动监控他们的状态,等到出错时自动关闭、恢复时再自动开启。
这让我产生了很复杂的情绪,一方面是羞愧,因为自己眼窝子太浅,明明自己不懂却把责任推到了这个领域身上;另一方面是滑稽,Map Reduce 系统发明二十年了,居然有智囊团提出派人来24小时盯着打电话这样聪明的方案来。
第二个转折点是学习 Django 和 React。在 2022 年我读完了操作系统、TCP 协议、分布式系统等课程,这些领域确实让我大开眼界,打下扎实基础。但我学习 Django 和 React 时,才真正意识到了实践中的顶尖工具是多么巧妙。Django 试图解决的问题恰好是我上份工作中经常手动解决的问题,所以给我的震撼尤为强烈。相当于同样的问题我先看到别人用笨办法去解决,再看到高手用非常聪明的办法去解决,这种对比教育让人印象太深刻了。我发现这些软件里其实充斥了操作系统、编译器等等基础软件的影子,同时这些软件本身也非常强大精巧。
所以为什么学习 Web?我觉得有两个原因,第一是过往几十年里大量的资金和人才都流入到 Web 领域,导致这个领域已经沉淀了含金量很高的技术。学习这些技术本身就有助于提升自己的基础能力、品味和视野。第二是 Web 已经成为这个时代的显学,很多软件内部都要用到 Web 技术,最后也是靠 Web 技术来交付到用户侧的,比如 ChatGPT 内部基础设施是高度复用 Web 的,深度使用了 Kubernates。最后也是用 Web 技术来和用户交互。我之前在工作中曾经想要做一个调试器,把后端开发中产生的各种数据汇总到一个可视化的页面上,因为我发现开发中查找各个环节的数据要花费大量的时间,比如对一个 API 调试之后,要去多个数据库的表、消息队列、配置中心、缓存系统、日志系统里面查找数据,前后要切换很多的软件。但因为我对于前端掌握不够,最后放弃了这个项目,颇为可惜。
如何建立学科地图
在生活中做决策时,经常有这样一种场景,我们在两个选项之间费尽心思选了一个,后来才发现其实有第三个选项。这就涉及到一对问题形成完整全面认知的问题。同样的,当我们学习一个新学科时,如何建立起学科的地图?也就是说,怎么知道这个学科里面有哪些部分?这些部分的关系是什么?
在 Web 领域,入门时我们需要知道:Web 领域有哪些知识?这些知识是什么关系?以及哪些知识是入门期间的,哪些是进阶期间的,还有他们的学习顺序。
很多人会推荐结实相关领域的专业人士,但我觉得很多专业人士都是茶壶倒饺子的状态,他们长期浸淫在这个领域,已经丧失了新手的视角。所以我更加推荐自己去调研,遇到问题可以问相关的专业人士,或者让他们推荐一些资料。
对于 Web 来说,MDN 和 Stanford Web 课程 都是很不错的入门资料。顺着这些起码可以摸清楚有哪些关键名词。需要强调的一点是光看视频和文字是很难理解编程概念的,它毕竟是一门工程学科,所以还是要今早动手去做。
建立学科地图可能是入门期间最重要的事情,因为它会决定我们在学习期间提出怎样的问题。如果没有建立基本的地图,我们甚至无法提出正确的问题,必然会浪费大量的时间。
如何寻找 idea
编程和写作很像,没人能靠阅读成为一个作家,一定要进行大量的创作。那么编程的时候如何寻找 idea?我的看法受 Paul Graham 影响,做你自己想要的产品,也可以为做身边关系好的朋友亲戚做产品。这样更容易有驱动力,为自己做可以给自己带来方便,为朋友和亲戚做可以利用社交动力,他们的称赞也能为我们提供燃料。
回顾 CS 科技史,Ken Tompson 和 Dennis Ritchie 等人为写游戏自己玩,而设计了 Unix;LInus 为了写一个远程登录学校服务器的终端而设计了 Linux;Woz 为自己设计了第一代苹果电脑。像这样的例子数不胜数,我在设计个人知识管理时也动力十足,持续尝试把各种技术应用到项目中去改善我的日常生活和学习体验,后续上线确实为我带来了很大的便利,而运营生意的亲戚发自内心喜欢我的软件时,也让我非常有成就感。而且这些软件中间历经多次重构,也让我学习了大量的软件工程的技巧。
学工具 VS 造工具
基础扎实的程序员在写软件时,经常会面临一个问题,在某个场景下需要一个软件,是选择自己造还是使用现有的?网上有很多人推崇自己造,觉得自己造才是高水平的表现。我认为这是典型的谬误。我把软件分为两种,造工具和编排大量工具做应用。前者如果是写数据库,后者则是利用数据库做后端 API。在现实中往往只能二选一,因为编排大量工具做应用已经够难了,再去造工具是给自己增加额外的负担。所以我认为在做 Web 应用的时候应该全神贯注在利用工具写应用上面,如果有好的 Idea 后续可以额外花时间制作工具。除非这个工具极其重要,而且目前的开源网站或者闭源市场上没有。
还有一个常见的问题是,新手入门期可能意识不到某些问题有现成的工具可以解决,会选择用手动、重复性的方式去做。比如我有个朋友刚开始用 Python 写后端 API,抱怨 Python 无法像 Golang 这样的静态类型语言对数据反序列化时做校验,我提示了他使用 Pydantic 库,他才意识到不用这么低效。针对这个问题,我有两个解决方案,第一就是针对某个领域经常翻 Github 和 Hacker News 上面相关的工具,用关键词来搜索,也可以看一看开源的应用用了哪些技术栈。我就是看 Flask 的贡献指南时知道了 Pre Commit 这个工具,现在已经融入了我日常开发的工具流;第二就是尝试迁移自己的经验,当我们有了大量使用工具的经验之后,面对一个新领域往往会形成一种直觉,“这个地方应该有工具",这时可以尝试问 ChatGPT。它很擅长把我们的大白话描述转成专业术语,然后搜索专业术语,比如刚开始写前端的时候,我对 UI 库没有概念,但是又觉得按钮、表单等等应该有现有的库,让用户用 High Level 的 API 去操作,而不是完全手写。后来搜索就发现了 MUI 这样的库。
如果搜索完发现没有这些工具,怎么办?恭喜你!你找到了一个非常宝贵的开源项目的 Idea。
如何学习框架
学习 React、Django、React Router 这些库的时候,刚开始我总感觉磕磕绊绊,后来某天我突然有了灵感:这些工具其实不是库,而是框架。我其实在用学库的方式去学框架。
库和框架的区别是什么?表面看,库提供函数 API,用户调用时通过指定输入得到输出,用户也要负责写调度程序(决定什么情况下调用这些函数,以及输入是什么),而框架提供的是回调的 API,用户写好这些回调函数或者类方法之后,框架来进行调度。深层次看,框架是一种解决问题的思维,框架在教给我们解决一个问题时如何思考:从哪里入手,到哪里收尾,中间经历哪些环节,把问题分解为哪些部分。
比如我们使用 OCR 做图像中的文字识别,就是使用一个函数,输入这个图片然后返回里面的文字,至于我们在什么场景去调用这个函数是完全透明,由我们自己掌控的;而当我们使用 React 的时候,我们要按照框架的要求写好组件和 Event、useEffect ,这样框架会在适当的时候触发这些回调程序,在我们使用 Django Rest Framework 的时候,我们写好 View、Serializer,然后框架会决定何时触发,以及参数是什么。
这就涉及到两个问题,第一是理解框架是思维,我们必须主动频繁训练自己,让它成为我们的思考本能。它更像是学习投篮或者出拳,需要大量练习才能改变我们的神经系统。初期可能要每天尝试运用它去分解程序,这样才能用得顺手。我们可以通过简单翻文档就掌握一个库,但是很难通过这种方式掌握一个框架,掌握框架一定是需要大量的思考、记忆、实践的。第二就是当我们将写好的回调交给框架来做调度之后,会经常发现这些回调没有按照我们期望的方式去运行,甚至可能没有运行,所以这种情况下难免要去理解框架的原理,甚至适当阅读一部分调度的源码,这样才能顺利调试。
除此之外,面对框架必须要养成一种直觉,就是判断自己的回调有没有接入这个框架。比如我写 Django 的时候如果发现某个 API 报 404,很有可能自己忘了注册路由;写 React Router 的时候需要用框架提供的 Link 组件替换掉原生的 Html 的 Link。
我有个朋友初用 Python 的 Async 特性时,使用了 sleep 函数,最后阻塞了所有的协程。如果对 Python 的协程和线程、进程的区别有所了解,自然会建立起直觉来,知道要想让一个协程暂停而不影响其他协程,要使用单独的 API。这种直觉可以帮助节省大量 debug 的时间。
如何在不确定性下做技术决策
在做应用的过程中,我经常要决定技术选型,作为新手又犯了很多错。
其中最大的错误就是依赖阅读和思考去做决策。这个做法的前提是,决策者掌握了所有的信息可以坐而论道,包括:深入理解不同工具的特点,在思维模拟中可以和当前的项目结合。但这个前提并不现实,否则就没必要有自动化测试存在了。我看 React 的纪录片时,提到 React 刚出来时,Netflix 的 CTO 想要使用 React,但是游移不定,最后他抓住了一个内部新项目的机会,把团队分为 AB 两个小组,分别使用之前的工具和 React 来搭建这个项目的原型,最后对比发现 React 的效果更好就决定全部使用 React。这个例子给了我很大的启发,我知道在科学研究中经常需要做大量的实验,比如爱迪生团队研究电灯泡时选用了五千种材料做实验,但是我从来没有想到编程也应该这样。回想起来可能是学习 CS 基础课没有那么多的不确定性,而工作时团队也没有这个意识,所以给我造成了潜移默化的影响。
后来在进行技术决策时我大量使用这样的技巧,比如 Remix VS NextJS,React VS Vue,还有做一个 UI 时考虑选用哪个 MUI 组件来实现。这样还有一个好处就是可以锻炼快速掌握一个知识最核心的 20%,我习惯于花大量的时间去深度掌握一门知识,但是现实中很多时候因为时间紧迫和优先级分配,只能快速学习新知识写一个能用的功能出来,所以按照我的性格这种情况下就会又很强的摩擦力。
原型与快速迭代
在编写医学研究的应用时,我犯的最大的错误之一,就是没有搭建原型并快速迭代,而是反过来一开始就事无巨细规划好了所有的细节,然后把每个细节逐一实现。
其实之前在读 Paul Graham 的文章时,我多次读到这个理论,后来在各种创业文章里面也都读到过,但是为什么我仍然犯了这个错误?我猜测可能是因为创业项目太过于反直觉了,我们生活中很少承担这么强的不确定性,所以总是想要进行精细规划。我记得写项目中间,重新读 Paul Graham 记录创业细节的文章,他提到 HTML 技术刚出来不久,他们团队想把在线商店编辑器用 Web 技术实现,就花了两周做了一个原型出来。他说这个原型没有任何样式,完全不可能给用户用,但是已经证明了他们的想法是可行的。而我做第一个项目时完全是缺乏搭建原型这个过程的。
比如我和两个朋友刚开始花了很多的精力去设计权限系统,后来我真的旁观这些医学调查的现场时,我发现他们是共用一个账号的,虽然在我们看起来很奇葩,但是面对调查对象众多而且时间紧迫的场景,确实有其道理。
再比如我刚开始花了很多的时间做自动化测试,包括单元测试、集成测试、端到端测试,光选择前端的端到端测试的框架就花费了大量的精力,但是到了后面才意识到,其实对于一个 Web 全栈项目从零到一的过程中,做各种复杂的测试是很不划算的。有以下几个原因:
- 纵深的逻辑没那么复杂。
- 端到端测试写起来又很费时间。
- 这时功能需要快速迭代,也就意味着测试要跟着改。
- 这是我的个人项目,意味着我有能力评估改动影响,而且改动完可以快速手动测试一次。
回想起来,我 2022 年的项目是操作系统、TCP 协议、分布式系统等等基础软件,面对这些项目如果没有自动化测试简直天方夜谭。2021年我还写了一个完整的编译器,如果没有自动化测试,这个项目压根寸步难行。所以之前的项目还有工作经历影响了我,让我过于注重甚至滥用了自动化测试,John Carmack 也曾在自己的社交平台上写过滥用自动化测试的问题。
我把先做原型,再快速进行迭代总结为”先能用,再好用"。后来在做商家管理应用时,我就先把核心功能点写完直接上线,后续用户几乎每周给我提两三次需求,我就跟随需求去迭代,发现这些需求其实很多自己都压根没有想到。比如对于产品的品类,用户希望可以自动分好类。这在之前有点异想天开,但是现在 LLM 技术初露头角,我就使用 ChatGPT 来做分类。第一个应用我们花了半年最后仍然烂尾了,但是第三个项目时因为运用了这种理念,我花了两周左右就把项目上线了。虽然这只是原因之一,但确实占了很大的比重。而且使用这种推出原型快速迭代的方法论之后,在整个过程中都不会对着自己幻想出来的需求盲目写程序,全程都是清晰有反馈的,不会有迷失的感觉。
学习方法
问题是有不同的层次的,就像洋葱一样。对于编程来说,大概分为如何编程、如何学习编程、如何学习任何知识三个层次,由浅到深。很长时间里,这三个层次的问题共同交织编织成一张大网,困住了我的编程效率让我无法提升。
这里我想着重讲一讲第三个层次,如何学习任何知识,因为我在做这些项目是犯的最大的错误就来自于这里。我观察到的一个现象是,如果一个人之前学习各种知识都非常平庸,那么当他面对编程时往往也会比较一般,我已知的几个这样的同学都转行了。学习编程其实只是学习能力在编程这个具体问题上面的映射。我在写医学研究软件时,评估工作量大概是 MIT 操作系统课程的三倍,于是我定下三个月写完,但是最后花了半年仍然没有上线。其实就是因为我在学习全栈技术时,带着严重的轻敌态度(也和之前受网络上”互联网应用属于低端技术"观点影响分不开),没有把之前学习基础课程和工作的认真劲头拿出来。之前用到的好方法全部抛到脑后了。
那么具体有哪些学习方法?我觉得这期间,最重要的两个注意事项就是项目管理和费曼法。
项目管理主要包括仔细评估项目的工作量,把工作量分摊到每天、每周、每月;同时把项目拆分成不同的环节,每做完一个环节就要进行验收,确定这个环节没有问题再进行下一个,这样才能防止问题累计,到后面直接爆发。
比如我之前工作中,面对每个产品功能都会很仔细计算工作量,通过评估其中的数据库表数量、API 数量、状态机复杂度等等估算时间,产品沟通、初步调研、实现、测试、上线,每个环节只有按照我的质量完成验收才会进入到下一个环节。但是我在做这些全栈项目时太过放松了,比如学习 React 的时候其实并没有完全理解它的思想,还有 state、useEffect 等等概念,等到过了一个月写前端部分的时间发现完全忘光了,根本难以下手,这样返工浪费了巨量的时间。
费曼法主要是一种高强度的学习方法,迫使自己的大脑去深入理解和记忆知识。我在学习分布式系统的 Raft 算法的时候就大量使用这样的方法,我把整个算法在笔记本描述了一遍,然后又对着录音机花十几分钟仔细描述了一遍,做完这些才开始写 Lab。回想起来可能是因为我的一位朋友上过这门课之后,和我说非常难,所以我整个过程都提心吊胆战战兢兢,这样反而让我顺利完成。轻敌是项目攻坚中的大忌讳。
这两种方法在实际使用中是深度结合的。比如费曼法可以用来对框架学习做验收,只有能清晰讲述一遍框架的特性和原理,才能进入到开发。
维护个人文档
我记得在上份工作中尝试提升自己的工作效率,当我写日记仔细复盘之后,得出一个惊讶的结论:我每天有一个小时用来查找各种工作资料。比如三月份的时候,要去查找二月份的一些文档上面的细节,或者一个配置文件、第三方 API 文档、用来做对称加密的字符串等等。于是我就按照自己定好的格式,设计了个人文档。每做一个产品功能,我就添加一个文件,文件名就是任务 ID 加上文字描述,然后把我这个过程中用到的所有的资料全部放在里面。这个文档维护只是举手之劳,每周不超过十分钟,但是给我的工作带来了非常大的影响,我跟朋友常提起的是,我从入职到离职写过的任何一个程序,都可以在十秒钟内找到它的所有资料。相比之下,我见过有的资深同事为了找一个魔法数字的含义,去公司的聊天软件里面查了十几分钟。
刚开始写全栈项目时我不以为意,没有刻意维护个人文档。等到第一个项目烂尾之后进行了大量的复盘和反思,发现在搭建环境还有部署的时候,其实经常重复查找一些资料,比如 Git 和 PostgreSQL 的一些操作可能几周用一次,但是每次要查询加做实验半小时,如果当时花半分钟记录下来,后来再用到时就能节省半小时。于是从第二个项目开始,我重新维护了全栈项目的文档。结果立竿见影,等到了第三个项目时已经为我节省了大量的时间。
我觉得记文档有两种极端,一种是什么都记,明明官方文档都有而且很详细,实际中用得也很少,但还是记下来,另一种就是什么都不记,觉得到时再搜索和实验。这两种都不可取。重要的是做权衡,比如有些操作看起来简单,但是每次查询可能要综合三四种资料,还有敲不少命令去实验,十分钟就过去了。看似不起眼,但是打断编程的心流,这种情况就不妨记录下来。记录时我推荐使用本地的文档软件,我的文档都是记录在 Visual Studio Code 上面的,本地文档能够给我们实时的搜索体验。我会使用 iCloud 和 Git 来做同步。
我甚至觉得对于各行各业,这都是非常有效的策略。
全栈工具链之前端
接下来,我将列出我这个过程中用到的具体的工具链,还有我的使用体验。它更多是为初学 Web 的读者提供知识地图的作用。
Web 全栈技术,按照从零到一搭建应用的角度,分为前端、后端、部署。
前端我用到的最底层工具主要有CSS、HTML、Javascript。这些内容我觉得掌握大概就好,因为它们有点像汇编,表达力太弱了,所以 Web 技术发展到后期都对它们做了很多抽象。
CSS 我比较推荐 这门课。 CSS 其实对于习惯了后端编程的人来说是较难入手的,刚开始一定不要掉以轻心。学习的过程中我发现 CSS 只是用来表达 UI 的,但是很多时候我不知道要设计什么 UI,对于对齐、留白等等完全没有概念,导致刚开始做出来的 UI 非常丑陋,于是我又去读了写给大家看的设计书,后来本打算系统上一门 UI 设计课,但是实在没有时间了,我就在设计时重度借鉴了 Github 的设计风格。
学习 HTML 和 CSS 的过程,也是我持续吐槽的过程,虽然他们两号称是语言,但是复用性、隔离性、工具链都特别差,尤其 CSS 简直就是 UI 领域的汇编。但是等到了学后续的工具链,包括 React 和 MUI 的时候,我才意识到前端社区其实已经为他们设计了”编译器“,大大提升了这些语言的表达力。也让我意识到事在人为,有的人在吐槽,有的人在改变。
再往上就是 Typescript,它是 Javascript 的超集,有点类似 Mypy。使用 Typescript 的过程中,我最大的感受就是它的用户体验非常好,比如返回值也可以自动推导,官网甚至还有一个在线的 Playground 可以用来学习和做实验。其他语言像 Golang 也有,但是 Typescript 难得的是它的 Playground 都有静态检查和自动补全。
传统的 Web 需要大量操作 DOM,当 Facebook 的一位很聪明的程序员发现这一点之后,想到可以把状态值直接映射到 UI,每次修改状态就自动更新 UI。先是一意孤行用工作时间开发,后来甚至说服了公司派来把他“推到正轨"的上级之后, React 随之诞生。值得一提的是,React 第一版官方发布的演讲里,提到这个项目的想法和 John Carmack 为 Doom 游戏设计的游戏引擎非常类似,可以看到好的设计是横跨软件的各个领域的。而且 React 本身也大量吸收了函数式编程的特征,虽然我上过华盛顿大学的 Programming Languages 课程,老师在里面也花很大篇幅讲了函数式编程,但却是这样一个前端库让我比较深刻地理解了函数式编程的意义。
React 本身只是一个比较底层的做状态与 UI 自动映射的库,有了这样的库虽然避免了大量的手动 DOM 操作,但是还有很多程序要写,这种程序分为两类:第一就是用户要自己设计大量的组件,比如表单、按钮、表格等等,这些组件之间有很强的共同模式;第二就是涉及和后端交互的部分,比如从后端拉取数据,或者用户做了输入之后发送数据到后端,然后重新从后端拉取数据更新 UI。
针对第一类,React 社区设计了 MUI 这样的 UI 组件库,它里面提供了大量常见的组件;针对第二类,目前主流的有 NextJS 和 Remix,而且这些框架走得更远,还提出了服务端渲染和全栈框架的概念。
在仔细对比之后,我选择了 Remix 作为前端的框架,因为 NestJS 有些过于晦涩了,而 Remix 只是在客户端路由库的寄出后添加了服务端的渲染和操作,我用它写了我的个人知识管理,但是在写的过程中遇到了很多的问题。
第一是我发现对于一个后端采用 Python 的项目来说,使用 Remix 必须把程序链条分为三个环节:前端,代理端,后端。代理端就是 Remix 基于 Nodejs 运行时部署的一个后端,所有的请求都要从这里转发。在之前的工作中,项目组把后端拆分成了很多个微服务,后来了解了微服务的设计思想,我才了解到那时典型的错误拆分,因为没有解耦,完成一个功能经常需要一个人改动四五个微服务,微服务之间通过 RPC 调用而非本地的函数调用,所以导致有大量的 IO,背后又有大量的错误处理,还有字段传递时也容易出错。我们甚至经历过两个高度耦合的服务之间调用某接口过于频繁,IO 资源占用率太高,最后一个请求延时达到了一秒钟以上,不得不把这个程序复制粘贴放到同一个服务里面。使用 Remix 的时候我仿佛穿越回去维护那个拆分稀烂的微服务。这个代理层对我毫无意义,但我又要为它付出额外的编程、调试、部署成本。
第二是我发现服务端渲染 SSR 其实现在还不够成熟,比如当我使用一个 PDF 库的时候只能手动设置为浏览器渲染,甚至就连 Date 对象的 toLocaleDateString 方法返回的日期格式在服务端渲染和浏览器渲染都是不同的。
综上,我最后选择了放弃使用 Remix。Remix 项目其实是起源于 React Router,它是基于 React 的路由库,但是其实已经提供了数据拉取和后端数据提交的功能,所以第三个项目里我选择了使用 React Router,然后使用 Remix 官方使用的 Vite 作为 Bundler。一路写下来非常顺畅,没有了额外的毫无收益的复杂度,让我轻松不少。
所谓 Bundler 其实相当于 Go、Rust 等语言的编译器。用户使用 Web APP 时,服务器会把前端的程序发送到浏览器,浏览器只能运行 HTML、CSS、Javascript,而我们在后端会使用 React 设计的 DSL 等语言,需要进行编译和整合,把我们开发的程序汇总转变成浏览器需要的程序;而且我们写前端程序时会用到很多库,在发送程序时只需要发送这些库里我们用到的函数,把没用到的去掉,这个过程称之为 Tree Shaking。也是由 Bundler 来做,前端目前流行的 Bundler 有 webpack、esbuild、VIte 等等。由于 Remix 本身使用了 Vite,我也就选择了它。
React Router 有两点让我非常赞赏,第一就是它对于 URL 作用的强调和提升,之前我对于 URL 是朦朦胧胧的理解。学完 React Router 我才意识到看似不起眼的 URL 里面有这么多的门道。一个页面里面的 UI 还有加载的数据都应该在 URL 里面有所体现。之前我见过一些反面应用,随着用户在应用中操作进入各种路径比较深的页面,URL 是不变的,这样有三个坏处:第一是无法保存页面书签,第二是无法分享页面,第三是无法方便使用浏览器开多个 Web 应用。第二个让我赞赏的地方是,它大量复用 Web API,比如它的 Request 和 URL 都是和浏览器共用一套 API,而不是自己另外设计一套,这样极大降低了用户的学习成本,甚至用户学完之后对整个浏览器都更加了解了。
值得一提的是 React Router 很多真正的思想还有更老道的用法是要在 Remix 的教程中才能体会的,比如如何利用 Type Manipulation 获得 loader 的返回值类型。这也是我走弯路过程中的意外发现。
在写医学研究应用的时候,其中一个朋友负责用户部分的前端,我接手了他的程序之后,发现里面有十几处错误的 useEffect 的用法,都是官方文档里面提到的典型错误,比如用 useEffect 来监听一个状态的改变然后改变另一个状态。当我和他沟通的时候,才意识到他并没有读过 React 的官方文档,因为他当时时间过于紧迫了,只能用下班的一点时间来做这个项目。所以最后选择了捷径,找视频教程看几个例子上手写。后来又发现他也没有使用 React Router 的完整功能,只是用了里面基本的路由功能。联系到上文提到的项目管理中的质控和验收,我也在思考,其实在工作中带新同事融入时,应该有一个清晰的标准,只有对方对于项目的程序和工具链掌握到某个程度才应该开始写程序,这样也可以提供一个快速学习的指南。我在之前的工作中,发现有的公司管理层希望新员工快速上手程序,但是项目中需要哪些工具,需要掌握哪些具体的特性又没有列出,导致只能进行踩坑式学习,融入的过程很不愉快,如果企业在制度上增加了这方面的指南,相比对于公司和员工都会高效很多。
全栈工具链之后端
后端语言我采用了 Python + Mypy。因为希望把精力专注在应用编写上,所以我放弃了 Go 这样才 Web 应用很不成熟的语言。而 PHP Ruby 这样的语言又处于式微的状态。Java 设计过于厚重和死板,所以最后我选择了比较熟悉的 Python。Python 本身是动态类型,缺乏系统系统导致自动补全、静态检查、文档都不太好用,所以我选择了 Mypy。选择 Mypy 时有一些怀疑,因为怀疑 Python 社区是否对 Mypy 做了足够成熟的适配,而且 Mypy 本身的用户量似乎并没有 TYpescript 那么大。于是我又采取了做实验的措施,尝试搭建了原型之后,发现 Django、Django REST Framework 这些库其实对于 Mypy 的支持已经相当不错。用的过程中只出现了一个反序列化时候的问题,而这个问题随着 2024 年初 Django REST Framework 的新版本也得到了修复。
数据库我采用了 PostgreSQL,相较于 MySQL 它更加开放,是由开源社区驱动的,文档也很精良,我更青睐这样的软件。
后端框架我选择了 Django ,使用 Django REST Framework 来作为 API 框架,使用 OpenAPI 作为文档和客户端的自动生成。之所以选择 Django 是因为我觉得它在 Python 生态里面最符合 DHH 提到的 One personal Framework 的概念。所谓的 One person Framework 其实就是指存在某个强大的框架,单个程序员也可以用它快速做出非常强大的产品,产生巨大的价值。换言之它完美体现科技为个体所加的杠杆。
现在回过头看,Django 体系给我带来的影响是巨大而深远的,因为 Django 所做的事情,也是我在之前的工作中频繁做的事情,比如写 API、大量操作数据库、对数据做反序列化、写后端的业务逻辑、提供接口文档、为反序列化写数据格式校验。但是当时只是随波逐流,顺着项目的技术债使用手动、重复的方式,我工作经验还尚浅,到了下班的时候也只想着写操作系统、编译器这些基础软件,所以一直没有调研是否具有更好的方式。而这次深入学习 Django 不由得被其智能与精良所震撼。可能很多熟悉 Web 编程框架的人已经习以为然,但是从一个空杯心态的角度看,Django 有以下几点是让我深受启发的(这里的 Django 包括 Django 本身还有 Django REST Framework):
第一就是针对项目中的数据结构进行尽可能的自动推导。写一个 Web API 的过程中,通常从数据源开始要写表结构、ORM、序列化反序列化、文档、前端或者其他服务的序列化反序列化程序,在反序列化的过程中还有大量的校验,而 Django 识别出来了这些数据结构背后的共同模式:它们都是来源于数据库表结构,然后在这个过程中做一些微调甚至是原封不动,于是 Django 从最底层的数据结构开始一层层往上推导,自动生成。第二就是协议的意义,之前我对于 REST 理解一直不够深刻,只是觉得它是一个众人约定俗成的规范,但是不明白这个规范有什么好处,直到接触了 Django REST Framework,我才意识到,规范的目的是自动化。如果有一套规范,我们可以利用程序直接生成一套接口。
当我学完 Django 之后,我萌生了一个想法:Django 从各方面看都太像一个操作系统了。它有自己的内核,包括 ORM、View、序列化反序列化、Middleware,然后基于内核它变得可扩展,可以在这个内核的基础上写应用,而且设计团队本身就写好了很多应用方便我们使用,比如 Authentication、Authorization、CSRF 防御等等。相比之下,Linux 有自己的内核,内核里面提供虚拟内存。进程、线程、文件系统这样的 API,然后基于这些 API 用户可以实现应用,Linux 本身也携带了 Man、Vim、grep 等等的作用。
再后来我更往前走了一步,我意识到任何一个公司,一旦程序功能变多了,都需要在内部实现一个”操作系统",这个操作系统可以是 Django 也可以是 React,或者 Map Reduce。如果没有这个“操作系统",里面的人就是在用刀耕火种的方式去写程序。
之前我听到 10X programmer 的说法很不解,怎么可能有程序员的生产力相当于十个人?难道我之前的工作中,项目组十个人的工作可以由一个人来做完?后来我明白了,如果在裸 CPU 上面写应用,确实没有所谓的 10X Programmer。但是如果某个强悍程序员写一个操作系统或者编译器呢?一切就大不同了。追求写这个领域的”操作系统"“编译器"才是一个热爱编程的极客该做的事情。
全栈工具链之部署
部署这一点是我做得非常差劲的环节,而且之前的公司也做得很一般。它其实是一个很大的难点,而且非常影响开发体验。
2018 年刚开始学习后端编程的时候,我制作简单的个人应用,是通过手敲命令的方式,一旦需要写第二个应用,这个过程就是极其琐碎枯燥的。到了 2020 年之后,我开始写个人文档,部署应用变得快了很多。到了24年初,我从一个做自动化部署的朋友那里听说了 Ansible,心向往之。如果能用一个脚本来做自动化部署,那之后复用起来该有多方便。
于是做个人知识管理时,我学习了 Ansible。学习 Ansible 的过程是非常令人愉悦的,我知道很多试图解决规模化问题的软件,在面对从零到一的场景时反而会带来额外的成本和不便,典型的例子就是 Kubernates(如果你只是部署单个或者两三个机器)。但是为了集群部署设计的 Ansible 却让我在部署个人应用时感觉到无比方便。它本身的核心设计理念就是声明式,你只需要写一个服务器需要的状态就行,比如 Nginx 安装上了,或者某个文件夹存在,这样的好处就是可以把部署脚本做成幂等,可以零成本重试,一旦 Ansible 发现服务器达到了这个状态,那么就会直接执行下一步。写个人知识管理应用时,我用 ChatGPT 大概断断续续查询了一周,才把部署脚本写完。等到部署商家管理应用了,我花半小时改了改,然后就几分钟直接部署上线了。这才以前完全是难以想象的。
前端的部署目前比较简单,我只是把 VIte 生成的静态文件用 Nginx 来做托管。之前使用 Remix 的时候,也需要类似 Python 的方案来进行部署,因为 Remix 被我弃用了,所以先不表。
后端的部署相对复杂,因为 Django 本身只是逻辑层的程序,它的 HTTP 服务器是开发环境用来测试的,所以我用了 Gunicorn 来做 HTTP 服务器,监听请求把请求传递给 Django,同时提供并发能力,然后使用 Supervisor 来做进程监控和容错。最外层使用 Nginx 来做网关。其中前端的请求通过 https://xxxx.com ,后端的请求是 https://xxxx.com/api ,用 Nginx 来根据路径分别转发到前端和后端的服务。
使用 Go、Rust 等语言的读者,可能对于 Gunicorn 有些不解,不明白在 Web 框架之外为什么还要有一个 Web 服务器。其实是因为 PHP、Ruby、Python 这样动态类型基于解释器实现的语言,实现一个高性能的 Web 服务器来并发处理请求是比较大的工作量,所以社区都会写一个通用的 Web 服务器,然后 Web 框架的作者只需要专心做应用就行了。Go 这样的语言实现并发相对容易,所以就简化了这一步。Gunicorn 是基于多个 Worker 实现的,比如服务器接到请求之后,会分配给空闲的 Worker,每个 Worker 来调用 Django 处理这个请求。默认的 Worker 是基于 Unix 进程的,开发者也可以替换成其他实现,比如 Python 的协程。
Web 服务是对外开发的,所以难免涉及到大量的安全问题。这里比较推荐 Stanford Web Security Course。安全问题比较难学,因为容易被有心之人利用,这是网上少有的系统讲解的课程。我在开发和部署过程中,主要注意的点是:服务器使用密钥登录,关闭密码;网站采用 HTTPS,我的 HTTPS 方案使用了 Let’s encrypt it,可以参考酷壳;除此之外,主要是对 Authentication 接口做限流还有防御 CSRF。在学完上述课程里面的 CSRF 之后,我又仔细阅读了 Django CSRF 防御的源码,包括背后的 Session、Authentication ,让我受益匪浅。因为之前是主要做后端的 API 的,类似于企业内部的一个通用服务,有很多部门来调用,其实并不涉及用户的信息,所以对这一块不太熟悉。值得一提的是 Django REST Framework 是不做 Login CSRF 防御的,但是了解了 CSRF 防御的原理之后可以自己实现,那就是对于登录接口强制做 CSRF 校验,专门写一个派发 CSRF token 的 API,然后在登录页面的前端程序先调用这个接口,再让用户进行登录。
数据安全是需要额外强调的,因为我们依赖的各种服务可能出错,用户本身也可能误操作。PostgreSQL 有三种备份数据的方式:导出所有表的 SQL、导出所有表的底层文件系统、使用 WAL 日志来做备份。第二种严格依赖操作系统的文件系统还有数据库版本,而且备份的数据占用内存更大,所以不推荐。而第三种适合数据量极大的应用做实时备份,运维成本比较高。权衡之下我选择了第一种方案,把文件用定时任务备份上传到云服务商提供的对象存储中。
刚开始写这篇文章的时候,我的计划是讲学习 Web 编程,方式是总结自己过去一年中犯过的错误。但是当我写完时,我才意识到本文写的其实是如何学习编程,总结的时间段也超出了过去一年。
我在写作中一直想文章的目标读者是谁,后来发现其实最理想的读者是一年前的自己。这也是我的私心之一,如果有科幻情节发生,过去的自己可以看到这篇文章,我希望可以帮助他节省时间,少走弯路,去早日挑战更难的项目。希望读者也可以因本文而受益。