如何使用Hugo和Github Actions搭建博客

没想到,到了 2023 年的今天,想要找一个能长久免费托管自己个人博客的平台,仍然是一件难事。就在前不久,行业的一位前辈左耳朵耗子逝去,我去他的博客缅怀,发现他的博客托管在 Cloudflare 云计算平台,平台提示,他的博客因为没有续费已经下线。

我不免想到,如果有一天是我,那么我的博客,承载了我在这个世界上公开留下的文字,还能在互联网上存在多久呢?此前我的博客托管在腾讯云服务器上,还比较稳定,较少出现故障等,但是服务器需要人续费维护。后来搬迁到阿里云服务器,比较垃圾,经常会出现故障,还不能自动恢复。而且流量耗光,或者服务器租期到了,我留下的这些内容都会直接下线消失,不复存在。

我想,我需要找一个尽可能长时间免费托管,稳健运行的平台,来备份我的所有博客内容。这样,即使我在相当长一段时间不去维护,不续费,我留下的内容继续存在。所以,我主要的需求,就是一个尽可能免费,稳定运行的平台,托管了博客内容,如果不去维护,也不会出问题。此外,还要满足,撰写方便,发布方便,对开发环境依赖低等等特点。

Github 是我比较熟悉的平台,也已经稳健存在了多年,对个人用户免费。还有一个原因,就是该平台在开发人员中,有比较好的信用。尊重创作和开源,对作者的创作内容没有太多侵犯性的条款。最后一个点,就是我所掌握的技术能力,可以胜任在这个平台进行操作。而 Github Actions 提供的自动化能力,虽然门槛很高,但是在没有人运维的情况下,业已生成的静态化站点,也不会受到生成功能变更的影响。

可能不是最优的选择,但是值得一试,如果事实证明可行,我也不想再去调研更多的方案了。毕竟人都是懒惰的。下面介绍,如何利用 Hugo 和 Github Actions 来搭建一个博客。

1. Hugo

Hugo 是一个 Go 语言实现的静态页面生成器,同类产品很多,还有 Jekyll、Hexo 等。选此款有几个原因,一个是在 Github 上的 Star 数量比较多,社区活跃,年头也很长了,比较成熟稳定。另外,学习 Go 语言也一直以来是我的一个计划。当然,这类生成器模式的博客,原理都大同小异,选哪个其实也都无所谓。其实单就个人喜好来说,我见过的几款 Hexo 的皮肤,更符合我的口味一点,而 Hugo 的皮肤往往都不如 Hexo 好看,原因可能是,Hexo 是前端程序员这个群体的制作的产品。

此类生成器,大多是采用某种结构化或者半结构化语言,比如 Markdown,撰写博客的内容,然后用生成器,将其转化为 HTML 静态页面,根据 theme 主题,组织成一个博客网站,然后发布到托管服务器上,供整个互联网查看。

2. Github Actions

Github Actions 是 Github 提供的一种自动化工具,可以在 Github 上,通过配置文件,实现一些自动化的功能。比如,当你的代码提交到 Github 仓库时,可以自动触发一些操作,比如自动编译,自动测试,自动部署等等。这里,我们就是利用 Github Actions 的这个特性,来实现自动化编译和部署的功能。

这个功能属于 CI/CD 这个技术范畴内。最大的好处是自动化。可以在平台提供的服务中,完成对代码的编译和发布工作。而因为有了 Github Pages 和 Github Actions 这些功能,用户已经很广泛地采用了它们,作为自己的内容托管平台。这也反过来刺激 Github 对此类用法有了一些预设的模版提供支持,后面会提到。

3. Github Pages

Github Pages 是 Github 提供的一个静态页面托管服务。用户可以在 Github 上创建一个仓库,然后将自己的静态页面发布到这个仓库,就可以通过 https://<username>.github.io/<repository> 这个地址访问到自己的静态页面了。这个功能,也是 Github 为了方便用户,提供的一个服务。如果你创建的版本库名字是 <username>.github.io,那么你的静态页面就可以通过 https://<username>.github.io 这个地址访问到了。不需要再提供路径了。

4. 实现的步骤

这个我其实不想展开来写,因为 Hugo 的官方文档写得更好,还能保持持续更新,请访问:这里

大体上的步骤是,先创建一个名为 <username>.github.io 的版本库,然后再本地安装 Hugo,然后利用 hugo 命令创建一个新的站点,名字就用 <username>.github.io,配置好皮肤和各种属性后,将整个项目推送到 Github 同名项目上。

在项目设置里,启用 Github Pages 和 Github Actions,会自动判断出来你在使用 Hugo,甚至可以给出推荐的配置文件 Workflow,hugo.yml,几乎做到了不写一行代码,就能配置成功的地步。体验十分流程。

配置完毕后,你就发现,你每次向版本库推送内容的时候,就会自动生成整个网站并推送给 Github Pages,然后你就可以通过 https://<username>.github.io 这个地址访问到你的博客了。

5. 结语

至此,就完成了纯静态的博客解决方案的搭建,可以开始专注于文章的编写了。可能还免不了要学习一些 Markdown 相关的内容,可以更好胜任文章撰写的任务。

附录

Hugo 官方部署指导文档

Hugo 新手指导文档

V2EX 网友推荐的 Hugo 主题

使用 Flutter 如何实现客户端证书验证 mtls

SSL 已经成为互联网最重要的基础设施,尤其是最近几年,基本上所有网站都已经部署了 HTTPS。曾几何时,SSL 证书对普通个人网站来说,还是很难取得的,主要是要收费。但是,从 Let’s Encrypt 提供服务开始,几乎整个互联网都用上了免费的 SSL 证书。

使用 HTTPS 可以保证你的网站上提供的服务和用户浏览器之间的链接加密,其内容不必篡改和窃听。SSL 证书有很多等级,一般免费的证书都是域名级别的,即证明网站服务提供商,拥有目标域名的管理权限。当然还有更高级别的。

不过,一般的 HTTPS,只是帮助访问网站的用户认证自己访问的域名没有被篡改。但是在安全等级更高的地方,被访问的服务,也需要证明访问者的身份可靠。当然,一般的网站也都有这个要求,比如要求用户进行登录,提供用户名和密码作为一种验证手段。

客户端证书验证概述

但是在安全等级更高的场合下,光有用户名和密码,也还不够安全,比如你的用户名或者密码失窃了,别人就可以冒用你的身份。这时候,多因素验证就会成为一个重要的验证用户身份的手段。常见的是双因素认证,比如需要用户的手机验证码。

而客户端证书,其实也是一种多因素认证的手段。要求用户使用的客户端,必须给服务器出示证书,以证明自己的身份。仔细想一下,对每个人来说,可能也不太陌生。

比如,网络银行使用的 UKey 就是一种常见的客户端证书,证书被置于一个硬件设备中,通过 USB 插入到使用的终端上来提供给服务器。当然,除了硬件证书,还有软件证书。将 CA 颁发的证书,安装到目标的客户端,一般是安装到操作系统。这样,在浏览器访问目标服务器的时候,就可以从操作系统直接提供此类证书。

大家在使用网银的时候,其实已经远远不是双因素认证了,至少需要用户名,密码,短信验证码和 UKey,经常至少是三因素的。关乎到个人财产安全的网络设施,怎么小心都不为过。

Web 服务部署客户端证书验证

作为非常重视信息安全的金融公司技术人员,我们一开始就非常注重保护公司的信息安全。公司所有内部网站,都采用了 HTTPS 作为基本的部署要求,同时,客户端证书认证,也是我们一直以来所要求的。

在 Web 服务器上部署客户端证书认证,是非常简单的。只要在 Nginx 的虚拟服务器配置上,打开选项:

# 客户端公钥证书
ssl_client_certificate /path/to/root.crt;
# 开启客户端证书验证
ssl_verify_client on;

即可以实现对客户端证书的验证。使用 openssl 套件,自己构建 CA,然后将根证书配置在 Nginx 侧,即可以实现对签发的客户端证书进行验证。如何使用 openssl 套件,颁发客户端证书,这就是一个很长的操作指引,本文不在赘述,网上相关的教程很多,大家也可以看看 easyrsa 套件的文档或者相关介绍(应该是 openssl 的一个友好的命令行界面)。

移动设备环境下的客户端证书

Web 服务实现了客户端证书验证后,所有需要访问受保护网站的客户端,都要安装对应的证书。使用 openssl 生成的 x.509 证书,倒是可以在各种手机设备上安装,不过都是默认安装到操作系统的层面,然后手机操作系统官方提供的浏览器都可以正常使用。

但是,前不久,我们遇到一个场景,需要一个三方 App,也能够访问我们公司的内部网站,通过 App 的内嵌浏览器或者 WebView 实现访问需要客户端证书验证的网站。没有想到,这对我来说是一个不小的挑战。

场景是这样的,我开发了一款 App,是公司的办公网站 OA 的配套 App,通过 App 可以访问公司的办公环境。一般来说,我们的办公系统,是不需要客户端证书的,因为没有涉及到核心业务。不过我们给大家提供了一个很好的特性,就是使用 OA 系统统一登录去访问其他公司的内部网站,也就是常说的 SSO。而 OA 客户端 App 作为配套 App,自然带有将客户端身份转换为网页登录态的机制。所以,用 OA App 就可以免登录访问公司的业务网站。

在电脑上,这种 OAuth 授权是非常流畅的,因为 OA 的登录功能,只是代替了用户名密码这个输入环节,遇到需要使用客户端验证的业务网站,浏览器自然会提供相应的证书。

但是没想到,到了手机上,这变成了一个十足的困难点。在手机上,因为各种沙盒和隔离机制,想要从操作系统调用身份证书,变成了一个不可能的事情。这也是我经过很多的调查才搞清楚的事情。

我主要针对的是 iOS 开发环境,我就以 iOS 来举例说明这一点。我想,如果安卓的系统设计考虑因素差不多,那么阻碍也差不多。我需要用自己开发的 OA App 内嵌访问公司的一个核心业务网站,起因是通过 OA App 访问业务网站,可以免去输入用户名密码的步骤。但是,业务网站需要提供客户端证书才能通过验证。

我想到的第一个方案是,通过 OA App 来拉取系统里保存的客户端证书。这个证书保存在哪里呢?在 iOS 里,是保存在 KeyChain 里,这是一个 SecurityStorage 的概念。也是苹果操作系统提供的基础设施。怎么才能从 App 里调用到 KeyChain 里保存的证书呢?答案是,不能。

这么做的公司和开发者非常少,可见使用客户端证书验证这种手段的场景和开发者都非常罕见,所以能搜到的资料非常少。总算还是有一点点的。我发现,在苹果环境里,有一个叫安全组的概念,只有处于同一个安全组,才能访问这个安全组在 KeyChain 中的区域。那么,我们使用 Safari 浏览器安装的客户端证书,其实,必须要求你的 App 和 Safari 浏览器在一个组里,才能访问到。事实上,可能只有官方的 Mail App 才能访问这张证书。搞清楚这一点,就耗费了我大量的搜索时间。

第二条路,是自己在 KeyChain 里开辟一个区域,保存客户端证书,然后,在需要的时候从自己独享的存储里取得证书。似乎这才是唯一的正路。幸好我没去研究这一点。当然,自己在 KeyChain 里存储一张证书,似乎并不难。我使用 Flutter 开发 App,所以我找到了一些 SecurityStorage 相关的插件,似乎往里读写一些文件或者内容,并不是很困难,API 看起来就像是普通的 Key-Value 存储一样。知道这一点后,其实要搞清楚的还有两件事情,第一,怎么把一张证书存储到 KeyChain,第二,怎么从里面再读出来。

我们公司办法的证书是 p12 格式的,而且有密码保护。而 KeyChain 的存储,API 默认是存储一个数值或者对象。所以,这里就涉及到怎么把一个 p12 证书解密,然后转换成一个对象,存储到 KeyChain 里面。这个很难搜,真的。至少我是找了很久没有太搞清楚这个点。

于是我从另一点入手,就是假设我已经存储好了证书,那么在服务器要求我提供客户端证书的时候,我怎么提供。没想到,这也是一个巨大的难点。当然,我目前已经成功实现了,就不觉得很难,但是我反复搜索方案的时候,真的很绝望,几乎没有文章正经提及怎么实现这个需求。似乎每个人都知道怎么做,默认指点两个方向即可。而我追踪下去的时候,发现都没有进一步的文档和步骤指引了。

WebView 很关键

其实,在 Flutter 里开发 App,很多关键性的对象,都是系统原生提供的,Flutter 必须通过 plugin 桥接才能调用。比如 WebView 这种对象,就必须有插件包装原生的 WebView 才能正常使用。

这时候,你选了哪种 WebView 的实现,决定了你怎么实现这个需求。我用的是一个 flutter_inappwebview 的插件,这个插件的选型可以说是有一定偶然性的。当时我开发 Flutter 还不熟,当时的搭档随手引入了。我也一直没有换。后来我经过了审慎的调研,发现,这几乎是 Flutter 开发中,最好用的 WebView 插件了,比官方提供的,Google Dev 团队的那个版本,要好用。

这个插件会提供一个叫 onReceivedClientCertRequest 的回调,当出现了客户端证书验证的请求时,插件会回调,但是我几乎没有搜到文档要怎么实现这个回调的内容。文档是非常缺失的,包括插件的官方网站,最后我只到了只言片语的指点。

你需要在这个回调里,返回一个 ClientCertResponse 的对象,这样插件会 handle 剩下的所有事情。看起来如此简单对不对,但是这么一句话的指导,也很难在各种网站找到。而且我至今也不确定这是不是唯一正确的方案,或者是最优方案。至少我知道这是一个可行的方案。

而构造这个 ClientCertResponse 对象,是很麻烦的,你会发现,构造函数要求你提供一个证书的物理存储路径,和一个密码。没有文档说明这个路径接受哪些格式的证书。大体上能猜到,使用一个 p12 证书是可以的,而且,正好 p12 证书也需要一个密码进行解密。但是,说好的 SecurityStorage 呢?那个东西的 API 里,可没有一个参数是 path。所以,等于是 KeyChain 的配套 API 和 ClientCertResponse 的 API 是几乎不匹配的,让人完全没有脾气。

一个临时的可用方案

这里我用到的方案,我坚信不是最优的方案,但是是我能力范围内,目前能找到的能流畅使用的方案。我使用了一个 path_provider 的插件,访问了 App 本地存储空间,将 p12 证书的文件下载到本地存储,然后将用户的证书密码存储在 K-V 中,这样,就可以满足 ClientCertResponse 的构造函数 API,提供一个路径和一个密码。

结果就是这么荒诞,研究了半天的 SecurityStorage,竟然根本没有用上。我也是很无奈,但是大家都懂,一个程序员第一步就是要先顶住,再优化。后续的东西,我只能慢慢研究了。

总结

客户端验证服务器的证书,而服务器也验证客户端的证书,这个行为模式,就叫做 mTLS,也即双向 TLS。在需要安全访问要求较高的场景下,是一种成本不是那么高的实现方案。

不过,通过此项目的实施,我发现,我们对 TLS 知之甚少,应用也不够深入,所以整个互联网能搜到的有效的经验和文档还不够多。给程序员抄代码带来很大的不便。所以进行此分享,以帮助更多的人。

目前,我文中提到的方案,还有很多的问题,只是一个勉强能用的方案,绝非最佳实践。比如,证书的存储是否足够安全?证书密码存储在客户端里,是否足够安全?第二,如果用户卸载了客户端,重新安装,则此前安装的证书就会丢失,这是否是一个期望中的行为?第三,文章中,我只提及了使用 WebView 进行客户端证书验证的方法,这只是展现网页用的。那么如何调用 API,例如,客户端调用 RESTful API,怎么进行客户端证书验证呢?我并没有提及,可以看看这里。有人做了说明文档和 POC 的。

附录

在应用中使用证书和秘钥(2014/09/17 QA1745)

问:我通过使用 Safari 下载证书和私钥到我的 iOS 设备上进行了安装。但是,当我运行我的应用时,它找不到我刚刚安装的身份。为什么呢?

答:用户可以通过在 Safari 中下载、作为电子邮件附件打开或使用配置文件安装数字身份(证书及其相关的私钥)到其 iOS 设备上。或者,身份可以从移动设备管理(MDM)服务器推送。但是,以任何这些方式安装的身份都将添加到 Apple 密钥链访问组(Access Group)中。

应用程序只能访问其自己 KeyChain 访问组中的项。这意味着 Apple 访问组中的项仅适用于由 Apple 提供的应用程序,例如 Safari 或 Mail。

要在自己的应用程序中使用数字身份,您需要编写代码来导入它们。这通常意味着读取一个 PKCS#12 格式的 blob,然后使用证书、密钥与信任服务参考文档中记录的 SecPKCS12Import 函数将 blob 的内容导入应用程序的密钥链中。

这样,您的新密钥链项将被创建为您的应用程序的密钥链访问组。

参考文档包含到“AdvancedURLConnections”示例的链接,该示例展示了如何在 iOS 应用程序中建立安全的网络连接。

通过电子邮件的方式提供身份的一种方法是,当您为设备提供设置时,向关联的用户发送一个电子邮件,附带客户端身份,以 PKCS#12 文件的形式附加。除了不给它标准的 PKCS#12 MIME 类型或扩展名,而是给它应用程序声明的 MIME 类型和扩展名。(扩展名“.p12”是由 iOS 声明的,不能被另一个应用程序声称。)用户随后可以打开附件,启动您的应用程序,在这一点上,您可以提供导入身份的选项。如果您将身份托管为 Web 下载,则可以使用相同的技术。

您可以在“iOS 文档互动编程主题”中了解更多有关声明文件扩展名的信息。

怎样在应用中得到客户端证书?(2019/07/26)

(略)此问答也给出了一些信息,不过内容类似,我就省略了。

处理认证挑战(Handling an Authentication Challenge)

介绍了当服务器要求对URL请求进行身份验证时,适当做出响应。苹果官方文档,介绍了,如何响应服务器的证书要求。

问题编号152584:Chrome不支持在系统根存储中安装的客户端信任根证书(2012-09-27)

……

能够访问系统根存储是不公开的API – 它隐藏在SecTrustEvaluate的实现细节后面。iOS密钥链API仅提供访问每个应用程序密钥链的权限,而无法列出或迭代系统密钥链。

由于“添加证书”也可用于配置客户端证书,因此由MDM / iCU添加的客户端证书存储在一个由苹果限制的密钥链访问组中,可供所有苹果系统应用程序使用,但无法供任何第三方应用程序使用。

……

【以上,在 Chrome 浏览器里(特指 iOS 版),无法应对服务器的客户端证书挑战,现在(2022)十年过去了,似乎仍是同样的问题,我们使用Google Worksapce,如果通过 MDM 下发证书等,可以在 Chrome 浏览器实现安全并一致的体验,但是因为苹果这个障碍,变得不可实现了。下发私有信任的根证书和客户端证书,都不可能,甚至手动在 Chrome 里安装客户端证书都没有找到什么方式,估计 Google 也放弃了吧,毕竟企业应用只是个小众的领域,用 Safari 能做好的,何必费力呢?】

iOS 上的客户端证书处理

这篇文章也提及了,在 iOS 上,App 如何处理客户端证书的问题,老哥甚至开发了一个 demo,录了视频来讲解怎么实现这个事情,不过是 iOS 环境开发的,有一定的参考意义。

他引用的插图不错,很好地阐释了整个 mtls 的过程。我就也贴过来好了。

how mutual authentication works

如何使用 Flutter 实现一个 OTP 验证器

我刚入职的时候,公司使用 RSA 公司的 token,所谓的 token 就是一个像优盘一样的硬件,每隔 30 秒会产生一个 6 位数字,这个数字作为一次性密码,也即标题里提到的 OTP,one-time-password 的缩写。

后来手机普及后,出现了很多软件实现的 OTP,比如 QQ 安全中心,微软的 Authenticator,以及 Google Authenticator。这些验证器和一次性密码,成为两步验证一种比较流行的方式。我个人也很喜欢,至少我觉得比短信验证码体验要好一些。

什么是 OTP?

OTP (One-Time Password),翻译成中文为一次性密码,是一种加强网络安全的方法。在使用传统的用户名和密码进行登录时,由于密码可能被泄露或者猜测,因此使用 OTP 能够在一定程度上防止网络攻击和不法分子的破坏。

OTP 常见的实现方式有 HOTP 和 TOTP。而HOTP (HMAC-based One-Time Password),利用哈希算法来生成密码。HMAC(Hash-based Message Authentication Code,中文名:基于哈希的消息认证码),这个算法主要是用于验证消息的合法性,与常见的哈希算法的唯一区别是,在计算哈希摘要时,还需要额外提供一串密钥,俗称加盐(salt 或 nonce)。这串密钥只有客户端和服务端双方知道,被计算摘要的消息要求双方都能知道并保持相同,一般是一个自增计数器,比如:0, 1, 2, 3, 4。被计算出的一次性口令每使用一次,这个计数器就加一,由于密钥只有双方才知道,故双方都可以计算出一样的一次性口令,而第三方不知道这串密钥的,无法计算出一样的口令。RFC 4226 描述了其技术规范。

验证与被验证两方,怎么能保证用于验证的消息始终保持同步呢?在现实世界中,确实是一个麻烦的事情,所以,就有 TOTP。TOTP (Time-based One-Time Password),时间同步动态口令,是一种基于时间的 OTP,也就是说 TOTP 通过计算当前时间和一个密钥来生成密码。这样,验证与被验证两方,共同使用的用于验证的消息,就是时间。其实就是将消息建立在全球时间服务的基础上,实现共识。不但各方得到的时间是一致的,而且时间在单向向前改变数字,成为了 OTP 的很好基础。

如果两边时间不一致,有误差怎么办呢?这确实是一个问题,所有的服务器或者电子设备都有保持正确时间的基础设施。能保证在一个足够小的延迟内时间完全一致。我们在生成验证码的时候,只要忽略那些最高精度的时间,就可以保证多方一致了。不过还是要求两方要接入足够可靠的时间服务器。RFC 6238 描述了 TOTP 的技术规范。

如何运用 OTP?

综上,OTP 就是一次性密码而已,可以提高一个体系内部的密码验证过程的安全性。因为降低了密码的时效性,使得猜测和破解变得更加困难了。

那么我们如何将 OTP 运用在日常生活中呢?如果大家接触安全要求高的领域就很容易发现,其实使用已经非常广泛了。为大众所熟知的,其实就是手机短信验证码。只不过,那个验证码虽然是一次性的,但是未必是 TOTP,因为手机传送短信的信道,时效性比较差,所以,经常一个验证码的有效期长达几分钟或者数小时。不过 OTP 的思想是一样的。

在我们公司的服务器运维中,要求工程师登录服务器的时候提供一次性密码,在堡垒机进行验证。在公司内部的 Web 系统里,涉及到敏感操作,也一般需要提供 OTP,这样降低操作被冒用的概率。(不能完全避免)

我们只要在服务器端,接入相应的 OTP 验证的类库,既可以完成此项认证,并不难。一般来说,更常用的是 TOTP,因为使用方便,开发容易。

为什么要实现一个 OTP 生成器?

其实,TOTP 的原理是有规范的,实现也不难。市面上已经很多很多 App 了,也都可以通用在各个要求的场景。那么为什么每家都要自己实现呢?而不是去用通用的产品。这个问题的答案,就不得而知了。不过我可以基于自己的经验来猜测一下。

大厂是必然要实现自己的验证码生成器的,不然容易被对手所乘。比如,你能想象,如果微软的用户都用 Google 验证器,如果有一天,Google 在发布验证器 App 的时候,突然故意不支持微软的密码生成,会对微软的用户产生巨大的影响。虽然事实上,不可以这样做,但是防人之心不可无。

登录对于每家公司来说,都是公司提供的服务与用户连接的重要入口。打开频率十分高,如果用户在登录自己的服务时候,一直弹出一个竞品的广告,天长日久的,也可能被刺激更换了自己的服务。那么商业上的损失是巨大的。

或者,用户用了一个黑客实现的验证器。在给用户提供验证码的时候,顺便拷贝一份发送到黑客自己的服务器。那么 OTP 就被黑客取得了。一旦从另外渠道取得了用户的固定密码,那么安全就当然无存了。

所以,无聊是基于哪个原因,各个大厂,甚至所有需要使用到 OTP 的厂,都有动机去实现一个自己的 OTP 验证码生成客户端。

如何使用 Flutter 实现一个 OTP 生成器?

我负责开发公司的 OA 系统 App,公司内部也有一些场景需要用到 OTP,现在我们使用聊天软件来发送 OTP,其实有点类似短信验证码。而聊天软件的可靠性一般,短信和邮箱延迟又比较大,而短信还需要保持手机号的准确,相比之下,在用户手机上使用 TOTP 的话,会更经济,使用过程中也不需要网络。作为验证的一种补充,所以,我也要在 OA App 里实现一个 OTP 生成器。

使用 Flutter 来实现这个功能是非常简单的。下面规范文档介绍了一些实现的细节。

Extensions to Salted Challenge Response (SCRAM) for 2 factor authentication

首先,我们来设计一下整个功能的基本使用流程:

  1. 首先,用户需要去绑定一个 OTP 验证码,现在一般通过扫码的方式进行绑定;
  2. 然后,用户把绑定后产生的一组密码输入到绑定的服务上,进行第一次验证,验证成功即完成绑定。
  3. 每次打开验证器,会展示一个列表,是用户此前用 1 和 2 绑定的 OTP 列表。每个条目产生一组 6 位数字。
  4. 如果用户不想使用了,可以删除一项绑定关系。
  5. 此外,每次出现数字后,还要展示一个倒计时,告诉用户这组 OTP 的有效期还剩多少时间。

我们可以使用 otp 3.1.4 这个包来实现验证码生成,下面是生成符合规范的 TOTP 的核心代码:

  /// TOTP 的 code
  String get code {
    var now = DateTime.now();
    // 经过反复尝试,终于试出来正确的参数组合了
    // 以下参数组合,在 Twitter 的两步验证中,可以正常工作
    return OTP.generateTOTPCodeString(
      secret!,
      now.millisecondsSinceEpoch,
      algorithm: Algorithm.SHA1,
      isGoogle: true,
    );
  }

从上述代码片段可以看出,调用是非常简单的,首先获取到时间,然后调用类库方法即可。类库是开源的,点进去就可以看到具体实现,我就不赘述了。

其实真正耗费我一些时间去摸索的是,到底怎么填写这个参数。用户通过扫码二维码,会得到服务器分配给用户的一个秘钥,其格式是这样的:

otpauth://totp/{label}?secret={secret}&issuer={issuer}

这是一个 URI 的规范。说明是一个 totp,label 里包含了用户的信息和服务的信息,例如:Google:alice@gmail.com 类似这样的。secret 这个字段就比较耐人寻味,这个字段的得到的内容是参差的,有的小写字母,有的是大写字母,长短也不一样,一时我也不知道有什么区别。

另外规范里要求 secret 是一个 base32 编码的字符串。一时也搞不清,是否需要自己 decode,至少,类库的文档是比较模糊的。方法调用填上什么参数,都是可以产生出 6 位数字的,就是未必是服务器认可的,不一定正确。要想正常使用,必须使用和服务器一样的生成方法,才能验证通过。

好在消耗了一些时间,终于还是让我凑出了正确的参数填写方法。

这里还有一个交互上的小细节,就是根据原理,TOTP 的验证码默认的有效时间是 30 秒,那么现在的怎么告知用户这个呢,首先是要根据原理,计算出现在剩余的时间,然后用一个小动画,告诉用户这个剩余的时间。

  /// 剩余有效时间
  int get remainingSeconds {
    return OTP.remainingSeconds();
  }

好在,类库也提供了计算剩余时间的算法。这样直接调用就可以得到数字了。然后,在界面上,我放置了一个,CircularProgressIndicator 圆环进度指示器,用来指示剩余时间,很方便,也比较美观。

总结

TOTP 是一种简易方便的一次性密码生成方法和验证方法,其实现有标准规范,以及很多开源的类库实现。简单动手就可以提升自己系统体系的安全性。自研客户端也很容易实现。

[1] 在这里可以尝试验证实现的OTP token 是否正确。https://moyuscript.github.io/2fa-test/
[2] 此文介绍了用 node 实现的方法 https://zhuanlan.zhihu.com/p/484991482
[3] 本文介绍了 HOTP 和 TOTP 的原理,有图示 https://bbs.huaweicloud.com/blogs/307638

Shell 编程的备忘录

作为程序员工作十几年了,Shell 编程这项技能,就好像“你永远得不到的爸爸”一样,每次想用的时候,都觉得自己从来没学会过。本文的编写作为一篇学习笔记,或者一个备忘录,或者一个作弊小抄,我会在每次遇到的时候,不断前来添砖加瓦,说不定有朝一日,能有望凑成一本秘籍。虽然网上类似的内容很多了,但是那些终究是别人的东西,自己用着总是不得劲,所以,做笔记这项技能总是伴随着人类的发展。

内建变量

在大牛写好的脚本里,你是不是看到过$##@,等等符号,然后并不认识?关键是,这个系列的变量,长得都差不多,就好像白雪公主的七个小矮人,你总是分不清谁是谁。

这些变量叫做“内建变量”,Built-in Shell Variables,通过内建变量,我们可以方便地引用或者访问一些值。

变量 Variables用途 Use
$#通过命令行传递给 shell 程序的参数的数量
$?上一个执行的 shell 命令的 exit value,也即返回值
$0当前执行命令的第一个字(word),通常是 shell 程序的名字
$*当前执行命令的所有参数
$@当前执行命令的所有参数,每个参数用引号括起来
$-显示shell使用的当前选项,与set命令功能相同
$!当前 shell 的最后一个后台进程的 PID
$$当前正在执行的进程的 PID
以上说明引用自这里,以及这里

参考

Shell Programming

git submodule 的应用

大家有没有发现,有些技术点,我们每次想用的时候,都不会用,然后学会了,用一次,又很久不用,然后到了要用的时候,又开始新一轮的循环。比如 git 的 submodule,对我来说就是这样一个技术点。

为什么使用 submodule?

完全一个人不可能写出来大型的软件项目,不得不站到巨人的肩膀上。另一个方面说,虽然,一个人可以开发出所有软件,但是万事万物都从零做起,也是比较笨拙的一种做法,君子善假于物,如果有人发明了很好的工具,那就应该尽量使用,尤其是在一些专业的领域里。

我常用的语言是 PHP,Python,Dart,这三种语言,在编写项目的时候,都有强大的包和依赖管理工具。比如,PHP 里面,可以使用 Composer,在 Python 里面,可以使用 pip,而在 Dart 里,可以用 pub 命令。

语言,或者框架层面提供的依赖管理,可以很好地解决 90% 以上的问题,非要将源代码引入到自己的项目里,而且不是以分发版本的形态,真的不多见,这也解释了我为什么每次用到 submodule 都不会用的原因,因为真的不常用。

我自己现在遇到的一个场景是这样的,我有个项目,叫 env,这个项目是我用来在一台服务器上初始化我自己常用的环境的一个项目。如果,是一台 Mac,我会安装 oh-my-zsh,然后,如果是 Linux 服务器,我会安装 bash-it(类 oh-my-zsh 的 bash 工具),为了配置 vim,还需要安装一个 vim-plug,有了这个工具,才能有效管理 vim 的依赖插件。

这三个工具,恰巧都是通过 github 来安装和管理的。env 是一个混合代码的项目,有 shell 脚本,python 脚本,vim 配置等等,而且刚提到的三个工具也不是某一种语言的包,所以,就自然而然用 submodule 来管理了。不知道你有没有那种非要用 submodule 管理的项目场景呢?希望能留言告诉我,让我也学习一下。

submodule 是什么?

submodule 本质上是一个标准的 git repo,也即一个标准版本库,只是 submodule 允许将一个版本库内嵌到另一个版本库里面。

submodule 可以直接以版本库的管理形式来管理依赖的代码,而不让被管理的代码和主体项目混淆。“主体”项目,是相对于“sub-”来说的。

submodule 的常见操作

在项目中添加一个 submodule:

git submodule add https://github.com/robbyrussell/oh-my-zsh.git

查看项目存在的 submodule 列表:

git submodule status
git submodule # status 可以省略

首次 clone 一个项目后,里面依赖的 submodule 需要初始化:

git submodule init
git submodule update # 同步回所有的代码

不过,使用 git submodule update 命令,不能更新依赖的版本库。如果想要更新依赖的版本库的版本,可以直接 cd 到那个模块中,刚才说,submodule 实质上是一个标准的 git repo,在那里可以随意使用 git 命令。

当我们对依赖的版本库进行了 pull 操作后,我们在项目根目录执行 submodule status 命令,就会看到对应的模块的 hash 值前面多了一个 +,而且整个项目的状态也变成了 dirty,意思是,当前项目依赖的代码库的版本发生了变化,如果这个变化是接受的,需要进行 commit 操作。

一般来说,我们设置了一个 submodule 后,这个依赖的 submodule 会进入到 detach 状态,以防止意外的切换版本,导致出现预期外的问题。

每个 submodule 升级的时候,可以选择在 tag 之间进行切换的方式,cd 到 submodule 目录后,执行:

git checkout <tag>

这样,就会将依赖的 submodule 切换到新的版本。然后,host 项目里,执行 commit,就可以固化这个切换,如果想放弃这个切换,可以在 host 项目里执行 git submodule update 命令即可恢复。

Docker 常用操作的备忘录

从公有仓库拉取镜像:

docker pull redis:7.0.5-alpine3.16 --platform linux/amd64

查看本地的镜像:

docker images

删除一个本地镜像:

docker image rm <REPOSITORY>:<TAG>

查看本地的所有容器:

docker container ls --all

删除本地所有停止中的容器:

docker rm $(docker ps -a -q -f status=exited)

给本地镜像打上一个 tag:

docker tag redis:7.0.5-alpine3.16 registry.selfhost.com/redis:7.0.5-alpine3.16

将镜像推送到私有仓库:

docker push registry.selfhost.com/redis:7.0.5-alpine3.16

连接一个正在运行的 container:

docker exec -it <CONTAINER-ID> /bin/sh

查看现在已经创建出来的卷:

docker volume ls
删除所有的卷:

docker volume rm $(docker volume ls -q)

⚠️ 注意:使用 Apple Silicon 的 MacBook Pro (M1/2) 时候,docker 命令,默认拉取的镜像,构建的镜像,都是 linux/arm64/v8 的,但是,服务器开发的运行环境往往是 linux/amd64 的,注意交叉编译的问题。

ADD 指令和 COPY 指令有什么异同?

ADD 指令和 COPY 指令有一些重叠,都是将一个文件从源路径复制到目的路径。不过 ADD 指令会有更多的内涵。

如果源路径是一个网址的话,ADD 指令会下载文件,如果源路径是一个压缩包的话,ADD 指令会解压缩。

最佳实践里,建议尽量使用 COPY 指令而不是 ADD 指令,因为 COPY 指令有更明确的功能。

一般建议,在解压缩的场景使用 ADD 指令,其他场景都是用 COPY 指令。

ENTRYPOINT 和 CMD 有什么异同?

今天我在看 Docker Hub 的一个镜像的代码,发现 Dockerfile 里同时指定了 ENTRYPOINT 和 CMD 两个指令,我没有细细研读过 Docker 的手册,不过看代码,以及看字面意思,就觉得这两个仿佛重复了。

放狗一搜,才知道,这两个指令的用途真的是一样的。如果在 Dockerfile 两个都不指定,则 docker run 默认不能自动运行镜像,必须指定一个命令。两者只指定一个,docker run 就可以不指定命令自动运行。不过仍然有细微的差别。

首先,ENTRYPOINT 的执行在 CMD 执行的前面。第二,CMD 命令,更容易被“覆盖”,docker run 如果后面带上命令的话,会覆盖 CMD;不过 ENTRYPOINT 指令的内容也可以覆盖,却是用参数 –entrypoint 来进行覆盖。第三,如果两个命令都存在的话,容器启动后,会用将两个命令的内容拼成一个,再执行。

这种设计会给容器的启动带来一些灵活性。

在 Alpine 中,如何定位问题

Alpine 是一个极简的操作系统,各种 Linux 发行版常见的命令,里面都是缺乏的,我试过了,如果不特意去安装的话,里面 vi 倒是有,其他什么都没有,比如 curl,ss,ip 等等命令,几乎都没有。

apk add iputils 可以安装 ip 和 ss 命令,用于查看 ip 地址,侦听端口等,便于开发时候调试用。

参考

Dockerfile: ENTRYPOINT 和 CMD 的区别 英文版

Dockerfile 最佳实践 英文版

指令式编程和声明式编程

指令式编程,Imperative Programming,是一种编程泛型,通过编程指令,告诉计算机,每个步骤执行的命令,计算机按照代码的指令,逐行执行,就能得到最终结果。

指令式编程的优点是非常符合直觉,代码发出命令,计算机执行。所以,这种编程泛型也非常容易学习,我们入门学习的 C 语言,Java 语言等等,都是支持指令式编程的,也是这类语言的主要编程泛型。

面向对象编程,也是一种编程泛型,同时,面向对象编程泛型,也是一种指令式编程泛型的特例。所以,指令式编程,是一个更抽象的概念。

声明式编程,Declarative Programming,顾名思义,这种编程泛型,更倾向于告诉计算机,“是什么”,由计算机自行决定如何操作。

声明式编程是很不好理解的,因为有不少无法不言自明的东西。程序,只需要告诉计算机,两个不同的状态,怎么从一个状态,切换到另一个状态,计算机自动就可以完成补充。

比如我正在学习的 Flutter,这个框架在构建一个应用的界面的时候,比如要做一个功能是这样的,界面是一个按钮,点击后,可以隐藏界面的上的数字,切换成星号。

如果用指令式编程,我们可能会这样写(伪代码):

button.onClick(function() { label.text.hide(); })

如果是声明式编程,我们可能是这样写:

Panel(
  Label(
    text: hide ? "***" : "12345"
  )
  Button(
    onClick: function() {
      hide = !hide
    }
  )
)

在指令式编程泛型里,我们给 label 下达了一个命令,让其对内容进行隐藏。而在声明式编程里,我们告诉界面,根据 hide 这个标志决定,是显示星号,还是显示数字。而点击事件里,我们只是重新指定了 hide 的值。那么,界面上本来是 12345,是怎么变成 *** 的呢?代码里没有提及,你也不能期待,代码执行到某一行,界面上的数字就会变成星号。这些都是框架自行决定和调度的。

其实,我们不难想象,框架或者系统底层,肯定会有一个指令,在某个时间,告诉图形引擎,要把数字擦掉,重新绘制上星号,不过这些都跟写上层业务的程序员无关。所以,指令式编程泛型是根本,毕竟 CPU 终究机器代码,或者汇编指令来驱动的,不可能不回归到指令式编程泛型里。

声明式编程泛型,是比指令式编程泛型更高层级的方法论抽象。就好像,一个公司只描述公司的愿景是什么,至于怎么实现,就要从总裁到基层员工每个人努力工作,朝着目标努力,经过漫长时间才能做到。

使用声明式编程泛型,程序员的生产力可以更多地释放,编程效率也更高,但是显然,底层框架或者引擎,都会承担更多的工作,才能做到智能地在程序员声明的界面或者状态中间完成变化。

-完-

什么是数据湖?

邮箱里看到了技术文章推送的邮件,提到了数据湖、湖仓一体等等概念,我有点好奇到底是什么东西。

初步看了两篇文章,我感觉,这个“数据湖”又是一个热门潮流概念吧。没想到 Martin Fowler 大叔也撰文专门描写了数据湖。

什么是数据湖呢?

简单说,就是把不同来源和不同格式的数据,一股脑存到一起,就是数据湖了。有些人,会暗讽,叫做“数据沼泽”,也没什么错。😂

和数据仓库有什么关系?

其实,如果你不需要去亲手实现一个“数据仓库”,你恐怕根本不知道数据仓库的概念,就跟我一样。

我家住的老公房,一楼有一个储藏间,我会把各种短时间不用的,但是又不能直接扔掉的东西,都放入到储藏间里去。当然,这种东西只能叫做储藏间,不能叫仓库。我们去逛宜家的时候,最后结账前,会看到分门别类的巨大货架,各种家具都在箱子里整理的整整齐齐堆放在编号的货架上,你看好了哪种,就请工作人员帮你取下来,然后直接推去结账就行了。这个意象,可能更像是仓库的概念。

一般人关于数据仓库的概念,大概就逃不出这两种印象吧。

数据仓库,如果不是一个储藏间的话,应该是分门别类的,堆放整齐的,能够找到的,能够快速取出的。空间是巨大的。

现在我们来假想一种情况,就是突然来了一些形状不那么规整的东西,没法码放在货架上,一般做法会在外面套上保护的包装箱,形状规整后,我们就可以堆放在货架上了。不过没办法,只能找一块平地堆放了。

这样情况越来越多,东一堆,西一堆,就成了一个“数据湖”。

数据湖就是,不考虑数据的原始形态,不对数据进行初步清理和规整,就直接堆放在一起。

数据湖的优劣

其实,我个人理解,数据湖和数据仓库,只是帮助外行更多理解这里面的一些事情。算是想让这个东西“出圈”的一些宣传努力。

如果我们不对原始数据进行整理和处理,就是单纯的存储的话,那么势必要在使用数据的时候进行处理。降低了使用的效率。

优点当然很明显,这么做的话,把任何数据收集起来,就会很快,效率高,你不用去分析原始数据,也不用整理。就是单纯地拿来。

另一个优点,就是数据保持了最大的信息量,原汁原味,不会在清理的过程中,出现遗失或者失真的问题。

缺点呢?当然,使用数据的一方,就会比较痛苦。首先,你不知道“湖”里有哪些数据,因为数据没有整理归一过,你很难检索。只能低效了解有哪些数据。其次,数据探索的效率也会降低,因为数据没整理过,你第一个问题就要搞清楚数据的结构,设计一种读取或者索引数据的方式。

大概这个数据湖的概念给大数据专业的工程师创建了更多的就业机会吧?这是嘲讽。

结论

数据湖,这个概念的宣传价值,大概大于其技术价值吧。事情的本质并没有什么改变。数据无非就是收集,整理,分析。数据仓库是收集 + 整理,然后分析就方便了。而数据湖就是只管收集,不进行整理。分析师,需要找到特定数据,先进行整理,才能分析。

至于湖仓一体之类的概念,大概就可以去猜测了。

最后,对于技术人来说,重要的仍然还是具体的需求场景和具体的问题。每个场景和问题,都有具体的实现。

“回溯法”和“深度优先搜索”有什么区别?

“回溯法”(Backtracking)和“深度优先搜索”(Depth First Search-DFS),看起来名字截然不同,字面意思也没什么相似的,不过,如果你经常刷题,渐渐就不免会注意到这个问题。为什么这两者会凑到一起去了?

我最早注意到这个问题,是观看题解的示范代码,示范代码是一道使用“回溯法”解决的题目,可是很奇怪,函数却并命名为 dfs,明明是回溯法,怎么用 dfs 来命名函数呢?为啥不用 backtracking 命名函数呢?

Backtracking is a general algorithm for finding all (or some) solutions to some computational problems, notably constraint satisfaction problems, that incrementally builds candidates to the solutions, and abandons a candidate (“backtracks”) as soon as it determines that the candidate cannot possibly be completed to a valid solution.

回溯法是一种通用的算法,用于找到一些计算问题所有的(或者部分的)解,尤其是约束满足类问题,该算法逐渐构建潜在的解,一旦确认潜在的解无法满足,则立刻抛弃。

回溯法一个最经典的例题是“八皇后”问题,在一个国际象棋棋盘上,尝试放 8 个皇后,这 8 个皇后不能互相攻击,尝试找到一种或所有的解。8 个皇后不能在在同一行,同一列,或者同一对角线上。

回溯法解题的步骤大概是,首先在第一行挑选一个位置防止一个皇后,然后在第二行,放置皇后的时候,就不能产生攻击,一旦发现第 8 个皇后已经放置完毕,则搜索到了一个解。如果还没有放足 8 个,就发现已经无处可以放置了,则返回上一行,将上一行的皇后换一个位置放置(回溯)。

Depth-first search (DFS) is an algorithm for traversing or searching tree or graph data structures. The algorithm starts at the root node (selecting some arbitrary node as the root node in the case of a graph) and explores as far as possible along each branch before backtracking.

深度优先搜索,是一种用于遍历或者搜索树或者图数据结构的算法。算法从树的根节点开始(或者图的任意节点),然后沿着连接节点的边,尽可能深的遍历图,直到回溯。

在深度优先遍历的定义里,出现了回溯这个词。不难想象,假设有一颗树,我们从根节点开始深度优先遍历,那么会从根节点,一直向下深入,直到叶子节点,这时候如果想继续遍历,则必须返回父节点,这样才能寻找兄弟节点进行遍历。这个“返回” 的过程,就根回溯极其类似。

结合维基百科的定义和两个算法的基本描述,我们不难发现这两个算法的相同之处,通俗点说,都是选定一种策略,一条路走到黑,如果走入死路,就退回来,重新选择。

这“两种”算法的另一个相同之处是,都是遍历解空间的一种策略,说直白点,都是“穷举”的算法,只不过,穷举解空间也好,穷举图也好,都需要选定一个策略,才能没有遗漏和没有重复的完成穷举。

所以,我自己更倾向于认为,这两者,其实是同一种算法,只是从不同角度出发去称呼他们。事实上,我打开了好几本中文版算法书,《算法4》,《算法导论》之类的,《算法竞赛入门经典》《算法竞赛进阶指南》等等,几乎就没找到过关于“回溯法” 的介绍和定义。难怪那些搞竞赛的,遇到回溯法,都会用 dfs 去命名函数。

通过在 StackOverflow 搜索,还真有人问这个问题

For me, the difference between backtracking and DFS is that backtracking handles an implicit tree and DFS deals with an explicit one. This seems trivial, but it means a lot. When the search space of a problem is visited by backtracking, the implicit tree gets traversed and pruned in the middle of it. Yet for DFS, the tree/graph it deals with is explicitly constructed and unacceptable cases have already been thrown, i.e. pruned, away before any search is done.

So, backtracking is DFS for implicit tree, while DFS is backtracking without pruning.

这个回答认为,回溯法,处理了隐式树,而DFS则处理显示的树。这看起来平凡,但是也意味深长。当使用回溯法遍历一个问题的搜索空间时,隐式树在遍历的过程中,进行剪枝。但是,DFS 算法则不同,显示的树或者图已经被构造出来了,那些不可达的节点,或者不可能的情况,此时已经被排除在外了,只要按部就班全部遍历即可。所以,他认为,回溯法就是隐式树/图的深度优先搜索,而DFS就是没有剪枝的回溯法。

当然,也有很多回答认为是两种不同的算法,只是我比较倾向于这个答案。也有从字面去解释的,我觉得也可以听听,比如有人说,回溯法是解决问题的方法论,而DFS是算法。隐式图,显示图什么的,也不那么难以理解。事实上,我认为现实世界里,只有隐式图,真正的显示图是很罕见的。正因为我们对现实世界进行了抽象,才出现了显示图这种东西。

比如,使用回溯法常见的一类问题就是排列组合问题。例如经典的,使用 n 对括号,能排列出多少种合法的括号串(括号必须配对)。这是经典的需要回溯法解决的问题,你能把这个问题想象成图么?不管如何,如果你能把这个问题写成一个递归函数来解决,至少在递归深入到下一层和回来的过程,都绘制成节点和边的话,这个可视化出来的东西,就是一棵树(树是图的特例)。

所以,不用在纠结“深度优先搜索”和“回溯法”有什么区别了,只要知道,这两者就是一体两面,或者你认为根本是一回事也不为过。显示图和隐式图也没任何区别。只是隐式图更贴近显示世界,而显示图更贴近抽象世界。如此而已。

刷完 500 题!偷偷纪念一下吧

又有许久不曾写过题解了,题目一直还在做着,就是懒得写题解,主要最近家里事有点让我烦心,心绪不宁无法安心写作。今天我猛然一看,统计数字 498 了,赶快找两道简单水题,凑个整,算是突破了 500 大关。当然,这对我个人来说,或者毫无意义,总是一个付出的明证吧。

LeetCode 刷题统计

从做题结构看,还是简单题偏多,你就可以想见,这个数字还是挺水的。含金量不像数字那么敦实。花了多久做了这么多题目呢?大概可以认为是 8 个月多吧。

LeetCode 打卡热力图

其实第一次尝试刷题是 2020 年 8 月开始,只在工作日,每天做 1 道,这样坚持了一个半月,放弃了,当时刷了 100 多道吧。今年的 2 月,又开始第 2 波了,上次使用 Go 刷的,这次是用 Python 刷的。两次都重开了做题计划,所以,第二次其实是把第一次的题都又做了一遍的。即这 500 题有 100 多道我是用 Go 做过一遍的。

如何看待刷题这个事情?

刷题大体来说还是快乐的,因为简单。这话不是凡尔赛,真实的个人体会。题目都是那么纯粹,有些题甚至是为了做题而出题,就更显得纯粹。你不懂的时候,可能会让你暴躁得抓头发,那只能是你的见识太少了。因为这些题并不难,只是没见过而已。

比起生活中遇到的那些无解的困难,比起工作上的这些困境。刷题相比之下只是一个纯粹而快乐的过程。刷题不能解决我的问题,但是可以让我从痛苦的生活中逃离一小会儿,用能获得的进步给自己一点点安慰。我想这和那些沉溺在游戏里打怪的人没有太多的区别,只是这个上瘾程度有限。

刷题有什么好处?

我加过各种群,自己也有个小群,都是大家一起刷题的。大部分人比我年轻 10 岁以上。对他们来说,刷题是很实在的,因为现在找工作,想去个好点的公司,都要考算法题的。刷题最大的好处是,让大家有所准备,能够胜任 Code Challenge,获得心仪的 offer,确实还是有效的。

于我而言,只是增加了我的些许自信。毕竟刷题不是面试的全部。对我来说,更大的好处是,增加了我日常工作中解决难题的信心。我们都知道,工作中可能没有算法题,不过工作中可能会有很多很麻烦的场景。那不是说问题多困难,就是十分麻烦,我感觉刷题训练让我更加耐烦。

会让你写麻烦烧脑,容易写错,难于调试的场景时候,更有信心一点,即使写不出来,也不会气恼,删掉重写就是,这种打击在刷题的过程中早已习惯了。

刷题有什么诀窍?

没有太好的。很多同学分享经验我都看了。有几个,我觉得挺认同。刷题呢,不要纠结于非要自己做出来,刚开始很多同学就会沉溺在这个误区里。看题解不丢人。其实这是非常重要的一点,很多人比如我,很晚才领悟这一点。

刷题呢,专项密集训练效果是不错的,就是把相近题,相同考点的题,堆在一起,一顿猛刷,这样效果好。一个主题完了,再去下一个主题。不是随机拉个单子刷,那样效果不好。举个不恰当的例子吧,比如你去健身房健身。你可能会看到眼花缭乱的器械。不过你请过教练,你就会发现,那么多器械,只是少量几个跟你有关。你每次去用的器械不会超过三种。这次去,深蹲,腿弯举,腿屈伸,倒登机。全部虐腿。下次去了,高位下拉,悍马机,引体,全是虐背。刷题也差不多,这个礼拜全是位运算,下个礼拜全是动态规划。

刷题呢,也会倦怠,需要休息。我第一轮刷题,什么都不懂的,就天天随机做,当时不懂回溯,就死磕了几天回溯,连续做了十几道,然后有点感觉了。第二轮刷题,隔了三四个月多吧,我发现但凡看到题目可能可以用回溯解的,我似乎都有点感觉,写个回溯也自然了许多。可见知识技巧,需要沉淀和发酵,有时候你需要的时间地积淀。一顿猛学后,休息几个月,捡起来,反倒觉得似乎脑袋更清楚了。

刷题呢,反复做相同的题目也是有效果的。比如,你看我做了 500 题,其实这里,很多题是做了不止一遍的,其中有一道题,我看到自己有 7 次正确的提交,都在不同的日期里。有不少同学分享经验,按照一些榜单去刷,比如 Hot 100 列表啊,Top 200 列表啊什么的。还有学习专区,有很多专项,动态规划,二分法,堆等等,专项里和列表里,重复其实很高的。但是你重复做也不用担心的,你再次遇到的时候,还真的未必会做的。我们掌握的知识可能没有自己想象的那么扎实。一些很基础的题目条件反射秒写对后,你才能在一些复杂的场景下,水到渠成写出来。再举个不恰当的例子就是,你盖房子的时候,砖块水泥沙子钢筋,最后房子塌了,你怎么确定砖块的问题,还是钢筋的问题,还是你房子结构设计的问题?谁的错?最后可能是沙子用得不够。所以反复做一些基础题,做到倒背入流随手就来,你就知道你的房子砖块水泥都没问题,就是结构不对。能帮你快速排除一些错误的猜测。

我现在什么水平?

很遗憾,我还是水平很低的,就好像上面说的,我刷题的结构里,太多简单题了。就是灌水成分大了点。我现在的水平就是简单题,大多数可以想出来,中等题,我大概 70% 把握,困难题,我大概 40% 把握,也即一多半做不出。

前天晚上周六,正好没有双周赛,我就开了个模拟周赛试试。最后 1 个半小时的时候,我做对了两道。又加了 5 分钟,终于第三道做出来了。第四道我都没读题目,时间耗完了。我历史上参加过 9 次周赛,6 次我做出来 2 道,2次只做出来 1 道,还有 1 次做出来 3 道,高兴坏了,那次也是我最后一次参加周赛。

未来

刷题是需要长期坚持的,跟健身又很像,你一顿操作猛如虎,一看效果二百五。还不如细水长流,常年坚持。可能不需要很卖力,就是时不时练练,可以保持健康。在市场不断内卷的当下,保持头脑健康是很重要的。

现在我的水平已经进入瓶颈,每天这么做着也没啥长进。朋友说,你需要画个脑图一个点一个点细细去补那些漏洞了(数据结构和基础算法的漏洞,故意忽略的那些知识点),另外以前不曾踏足的领域也要逐步尝试,拓宽面,这样才可能在竞赛里做出好成绩。总之,是进入了一个边际效益很低的区段了。后续怎么做,还是要看自己的目的,想通过刷题得到什么。

— END —