在 Docker 容器里使用 Crontab

最近公司有一个需求是在容器里运行 Cron 服务

Dockerfile

CentOS 上安装 Cron 很简单,一条命令就可以搞定

1
yum install cronie

目前的 cronie 版本是 1.4.11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name        : cronie
Arch : x86_64
Version : 1.4.11
Release : 23.el7
Size : 215 k
Repo : installed
From repo : base
Summary : Cron daemon for executing programs at set times
URL : https://github.com/cronie-crond/cronie
License : MIT and BSD and ISC and GPLv2+
Description : Cronie contains the standard UNIX daemon crond that runs specified programs at
: scheduled times and related tools. It is a fork of the original vixie-cron and
: has security and configuration enhancements like the ability to use pam and
: SELinux.

直接基于 CentOS7 编写 Dockerfile

1
2
3
4
5
FROM centos:7
RUN groupadd --gid 1000 foo &&\
useradd --uid 1000 --gid foo foo
RUN yum install cronie -y
CMD ["crond","-n","-x","misc,load","-i"]

启动命令

Cron 的入口当然是 crond 啦,不过 crond 需要前台运行

1
crond -n -i -x misc,load

其中:

-n 进程挂前台

-i 关闭 inotify,因为容器里的 inotify 不生效因此检测不到挂载文件的变更

-x 开启调试信息: misc 可以打印命令的输出,load 可以显示读取了哪些配置,其它配置项见文末参考

配置文件

cron 的配置文件可以在构建镜像的时候打进去,也可以按照需要挂在

挂载为用户服务

1
docker run -it --rm -v ./foo:/var/spool/cron/foo cron crond -n -x misc,load -i

foo 文件

1
2
3
4
5
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

* * * * * id

挂载为系统服务

1
docker run -it --rm -v ./root:/etc/crontab cron crond -n -x misc,load -i

crontab 文件

1
2
3
4
5
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

* * * * * root id

注意 /etc/crontab 和 /var/spool/cron/foo 不一样还有第 6 个参数,即指定运行的用户

参考

参数 备注
ext 通常和其它几个变量组合使用
sch scheduler
proc 进程控制
pars 语法解析
load 读取
misc 杂项
test 测试模式,命令实际不会执行
bit ??

也谈 HashiCorp 禁止中国公司使用

昨天最大的瓜当属 HashCorp 禁止中国公司使用。

作为一名 Devops 工程师,HashCorp 的产品或多或少都接触过。例如之前我们就用他家的 Vagrant 搭建 Ansible 脚本的 CI 环境, 用 Consul 做服务发现,整体给人的感觉还是不错的。

今天这个瓜的震惊之处在于 Hashicorp 赤裸裸的在协议里写了禁止在中国使用,虽然实际上 出口管制法 并不是第一天存在了. 中国的很多外企分公司实际上都在遵守这一法律。比如微软、英特尔这样的公司很多核心技术都不会出口到中国来,甚至中国国籍的员工参与这些项目的研发也会被法律所限制。

HashiCorp 错就错在中美斗争的这个关键节点上,把这个一直存在的问题又强调了一遍,HashiCorp 本可以只写 出口限制国家 的(这个列表里还有我们的朝鲜兄弟、伊朗兄弟等),但是非得写 PEOPLE'S REPUBLIC OF CHINA,瞬间在技术圈引爆了一个大瓜。

另一家公司 Github 其实也不让人省心,因为之前就爆出过屏蔽伊朗开发者的新闻。类似的事件或多或少都会影响中国的开发者参与国际开源软件的热情。还好疫情期间我自己在阿里云上搭了一个 Git 服务器,CI 环境使用的也是自己的,不再担心 Travis 总是很慢的问题。

总之这次事件再次给我们提了个醒,自主知识产权是多么重要,国产软件加油,国产基础设施加油!

Snap 设置代理

Snap 从 2.28 版本开始支持代理设置了,安装开发用的工具就方便很多。

1
2
sudo snap set system proxy.http="http://<proxy_addr>:<proxy_port>"
sudo snap set system proxy.https="http://<proxy_addr>:<proxy_port>"

分享几个我常用的

Redis Desktop Manager

Redis 的一个图形界面客户端

1
sudo snap install redis-desktop-manager

Slack

Slack 客户端

1
sudo snap install slack

[译] 导致 SourceMap 无效常见的 4 个原因

原文

Souce map 非常好用。换句话说,它们被用来在调试阶段显示源代码,这比线上压缩后的代码好懂多了。从某种意义上讲,source map 可以说是秘密代码(压缩后的代码)的解码器。

但是要让 source map 正常工作可能很棘手。如果你遇到了麻烦,接下来的一些提示或许能帮助你更好的工作。

如果你第一次接触 source map,请在继续阅读前看看这篇早期的博客 Debugging Minified JavaScript with Source Maps.

丢失或错误的 source map 注释

我们假设你已经通过 UglifyJS 或者 Webpack 生成了一个 source map。但如果只是生成,而浏览器实际上找不到它,那就很划不来了。要做到这一点,浏览器会假设打包好的 JavaScript 文件里有一行含 sourceMappingURL 的注释或者返回一个叫 SourceMap 的 HTTP 响应头,这个响应头指向 source map 文件的位置。

为了验证 source map 注释能够正常工作,你需要:

找到文件最后,自成一行的 sourceMappingURL 注释

1
//# sourceMappingURK=script.min.js.map

这个值必须是一个有效的 URI。如果是相对路径,那么它是相对于打包出来的 JavaScript 文件(例如 script.min.js)的路径。大多数 source map 生成工具会自动生成这个值,而且提供了选项用于覆盖它。

如果用的是 UglifyJS,可以通过指定 source map 参数 url=script.min.js.map 来生成这个注释:

1
2
# Using UglifyJS 3.3
$ uglifyjs --source-map url=script.min.js.map,includeSources --output script.min.js script.js

如果用的是 Webpack ,通过指定 devtool: "source-map" 能够开启 source map,Webpack 会在最终生成的文件最后输出 sourceMappingURL。你可以通过 sourceMapFilename 自定义该文件的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
module.exports = {
// ...
entry: {
app: "src/app.js"
},
output: {
path: path.join(__dirname, 'dist'),
filename: "[name].js",
sourceMapFilename: "[name].js.map"
},
devtool: "source-map"
// ...
}

需要注意的是即使你正确生成了 sourceMappingURL,也有可能它没有在最终上线的版本里出现。例如,前端构建工具链里其它的工具可能会移除所有的注释,结果就是把 //# sourceMappingURL 也一并删掉。

还有一种情况是你的 CDN 可能会相当智能地把不认识的注释统统删掉;Cloudflare 的自动压缩功能以前就会这么干。所以记住上线后一定要再次确认!

另外一种做法是:确保服务器返回有效的 SourceMap HTTP 响应头

除了这个神奇的 sourceMappingURL 注释,你还可以通过返回一个 SourceMap HTTP 响应头来指定 source map 的地址。

1
SourceMap: /path/to/script.min.js.map

sourceMappingURL 一样,如果这个值是相对路径则相对于打包出来的 JavaScript 文件。浏览器解析 SourceMap HTTP 响应头和 sourceMappingURL 的规则是一样的。

注意你需要配置你的 web 服务器或者 CDN 来返回这个响应头。但是很多 JavaScript 开发者并不能够随意的修改线上资源的头,所以对大多数人来说,生成 sourceMappingURL 要更简单一些。

缺少源代码文件

我们假设你已经正确配置好 source map,你的 sourceMappingURL(或者 SourMap 响应头)存在且生效。到源代码的转换前面部分已经能够正常工作;例如,错误堆栈现在指向源文件的文件名,并且行号和列号也有意义了。尽管这已经算有所提升,但还是缺少一部分,你还是不能通过浏览器的调试工具查看到源代码。

这很有可能是由于你的 source map 文件没有包含或是指向你的源文件导致的。如果没有源文件,你在调试压缩后的代码时还是会卡住。哦天哪。

有几种解决方案可以让源代码文件能够正常工作:

通过 sourcesContent 把源代码嵌到 source map 文件里

实际上把源代码放到 source map 里是有可能的。在 source map 里,这个字段是 sourcesContent。虽然这会导致 source map 的体积增迅速增长(数以兆计),但是能够非常简单地让浏览器定位并关联你的源文件。如果你为了让浏览器显示源文件而焦头烂额,我们推荐你这么做。

如果你用 UglifyJS,你可以用过 includeSources 命令行参数把源代码包含到 source map 的 sourcesContent 属性里:

1
uglifyjs --source-map url=script.min.js.map,includeSources --output script.min.js script.js

如果你用 Webpack,不需要做什么 - Webpack 会默认把源代码包含进 source map (前提是已经打开了 devtool:"source-map" 配置)。

把源代码放到开放服务器上

除了在 source map 里包含源代码,你也可以把它们放到服务器上供浏览器下载。如果你对安全性有担忧,毕竟是你的原始代码,你可以放到 localhost 服务器或者确保它们通过 VPN 才能访问(即这些文件只能通过公司内部网络访问)

Sentry 用户可以上传源文件

如果你是一个 Sentry 用户并且你的首要目的是确保 source map 文件能够被用来还原堆栈信息以及前后的源代码,你可以试一下第三种方法:使用 sentry-cli 或者直接调用 API 上传源文件

当然,如果你用的是前两种方法 - 不管是在 source map 了包含源代码还是放到对外开放的服务器上 - Sentry 都能够找到。这完全取决于你。

多次转换导致 source map 失效

如果你用到了两个或以上的 JavaScript 编译器(例如 Babel 和 UglifyJS)独立调用,有可能生成的 source map 文件指向的是一个处于中间转换状态的代码,而不是源代码。这意味着你在浏览器里调试的时候,步进的是未压缩的代码(这已经有所改善)而不是和你的源代码一一对应。

举个例子,你用 Babel 把 ES2018 的代码转换成 ES2015,然后用 UglifyJS 进行压缩

1
2
3
4
# Using Babel7.1 and UglifyJS 3.3
$ babel-cli script.js --presets=@babel/env | uglifyjs -o script script.min.js --source -map "filename=app.min.js.map"
$ ls script*
script.js script.min.js script.min.js.map

如果你直接用这个命令生成的 source map 文件,你就会发现它并不准确。这是因为这个 source map 只能把压缩后的代码转换成 Bebel 生成的代码。它并不会指向你的源代码。

注意这个问题在用 Gulp 或者 Grunt 这类任务管理器的时候也很常见。

要解决这个问题,有两种方案:

用类似 Webpack 的打包工具管理所有的转换

不再把 Babel 和 UglifyJS 分开调用,而是用它们的 Webpack 插件形式(例如 babel-loaderuglifyjs-webpack-plugin)。Webpack 能够生成单一的 source map 文件来把最终结果转换回源代码,虽然实际上背后依然有多个转换步骤。

用一个库把不同转换步骤的 source map 串联起来

如果你决意要分开使用编译器,你可以用 source-map-merger,或者 Webpack 的 source-map-loader 插件,来把上一步的 source map 吐给下一步的转换。

如果你有的选,还是推荐你用第一步,直接用 Webpack 省得后来哀怨。

文件版本不对或缺少版本管理

我们假设你遵循了上面所有的步骤。你的 sourceMappingURL(或 SourceMap HTTP 响应头)存在并且被正确的声明。你的 source map 包括了你的源代码(或放到公网上)。并且你用了 Webpack 做转换端到端的管理。你的 source map 还是会时不时地映射错误。

还剩下这样的可能:source map 和生成的代码不匹配。

这个问题会在这种情况下会发生:首先、浏览器或者工具下载了一个生成的代码(例如 script.min.js),然后试着去下载对应的 source map 文件(script.min.js.map),但是下载到的是 “更新” 后的 source map 文件,和之前的生成代码已经不匹配了。

这种情况并不会很常见,但是当你在调试的同时进行部署的时候会发生,或者你调试的是即将过期的、被浏览器缓存的资源时会发生。

要解决这个问题,你需要管理好文件和 source map 的版本,有下面几种方式:

  • 给每个文件添加版本号,例如:script.abc123.min.js
  • 在 URL 里添加版本号字符,例如 script.min.js?abc123
  • 为父级目录添加版本号,例如 abc123/script.min.js

选择哪种策略并不要紧,关键是对所有的 JavaScript 资源要使用一致的策略。最好每一个生成的文件和 source map 都有相同的版本号和命名规则,就像下面这样:

1
2
3
// script.abc123.min.js
for(var a=[i=0];++i<20;a[i]=i);
//# sourceMappingURL=script.abc123.min.js.map

用这种方法管理版本能够保证浏览器下载到生成代码和 source map 文件对应上,避免不必要的版本不一致问题。

[译] 书写灵活、可维护,可扩展的 Ansible Playbook

原文

自从 2013 年开始使用 Ansible,我已经用 Ansible 自动化完成很多事情:SaaS 服务,树莓派集群,家庭自动化系统,甚至我自己的电脑。

从那以后,我学会了很多能够降低维护负担的技巧。对我来说项目的可维护性异常重要,因为我的很多项目,例如一个 Apache Solr 项目已经存在超过 10 年了!如果项目难维护或者架构上难以做出大的改变,我会把项目输给其它更敏捷(nimble)的对手,进而丢掉金钱,更重要的是我可能会疯掉。

今年我会在奥斯汀举办的 AnsibleFest 上做一个同名分享,本文即总结这次分享的主题。

保持井井有条

我喜欢摄影和自动化,所以我花了很多时间在涉及树莓派和相机的电子项目上。如果没有图中的组织系统,想要把部件放到正确的位置会是一件让人沮丧的事情。

组织系统

同样的,在 Ansible 中,我喜欢把我常用的 task 组织起来,这样才能更轻松编写和测试它们,并且不需要太多的精力就可以管理好它们。

开始的时候我会把所有的 task 写到一个 playbook 文件里。当文件到达 100 行左右,我会把相关的任务拆分到不同的文件里,并在 playbook 中使用 include_tasks 引入它们。

随着 playbook 越来越复杂,我经常注意到有一些相关性很高的的 task 可以被独立开,例如安装一个软件、拷贝配置文件、启动(重启)一个守护进程。这种情况下我会用 ansible-galaxy init ROLE_NAME 命令新建一个 role,并且把那些 tasks 放进这个 role 里。

如果这个 role 够通用,我会把 role 放到 Github 并且提交到 Ansible Galaxy 里,又或者放到一个单独的私有 Git 仓库里。现在我可以通过 Molecule 或者其它测试工具为 role 添加一系列的通用测试,哪怕这些 role 被隶属不同的团队的不同项目所使用。

之后我会通过 requirements.txt 文件把外部的 role 引入到项目里。对于某些稳定性至关重要的项目,我会通过 git ref 或者 tag 指定 role 的版本。对于其它项目我则会牺牲一点稳定性以换取更好的可维护性(例如测试 playbook 或者一次性的服务器配置),我直接使用 role 的名字(如果不在 Ansible Galaxy 上就指定仓库的详情)。

对于大部分项目我都不会把外部 role 提交到代码仓库里,因为在 CI 系统里每次从头运行的时候都会去安装。但是在有一些情况下,最好把所有的 role 都提交到仓库里。比如有一些开发者日常会用到我写的 Drupal 虚拟机的 playbook,这些开发者通常住在离 Ansible Galaxy 服务器很远的地方,所以他们在安装大量必要依赖的时候会遇到麻烦。因此我把所有 role 都提交到仓库里了,这样他们在构建一个新的 Drupal 虚拟机实例的时候就不用等着所有 role 安装完成了。

如果你真的把 role 都提交到仓库里了,你需要在每次更新 role 的时候有一个彻底(thorough)的流程,确保你的 requirements.yml 文件和已经安装的 role 同步!我通常通过 ansible-galaxy install -r requirements.yml --force 命令来强制替换仓库里的 role,并且保持诚实(踏实?)!

简化和优化

YAML 不是一门编程语言
- Jeff Geerling

大家喜欢用 Ansible 的一个原因是它基于 YAML 并且拥有一套声明式的语法。如果你要安装一个模块就在 task 里这样写:package: name=httpd state=present。如果你要确保一个服务运行就这样写 service: name=httpd state=started

然而在很多情况下,你会需要让一切更智能化。举个例子,如果你用相同的 role 构建虚拟机和容器,但是你并不想在容器里启动服务,你需要增加一个只在某些条件下执行(when condition)的限制:

1
2
3
4
5
- name: Ensure Apache is started
service:
name: httpd
state: started
when: 'server_type != "container" '

此类逻辑是简单的,并且在别人阅读 task 以搞清楚它的目的时候很有用。但是有的人会在 when condition 里写上一大堆花里胡哨的判断,甚至是 Ansible 暴露出的 Jinja2 和 Python 的接口,这种情况下容易失控(get off rails)。

根据经验(as a rule of thumb),如果你在 playbook 的 when condition 里为了正确的转义引号上花费了 10 分钟以上,你这时候就应该考虑写一个单独的模块来完成 task 用到的逻辑。Python 脚本通常应该位于独立的模块里,而不是和其它的 YAML 写到行内。当然也有例外(比如比较复杂的字典和字符串时),但我会努力避免在 Ansible playbook 里写任何复杂的代码。

除了避免复杂逻辑,还有一个很有效的方法是让 playbook 运行更快。我经常 profile 一个 playbook (通过设置 callback_whitelist = profile_roles, profile_tasks, timer 默认参数),发现一两个 task 或者 role 和 playbook 其它的相比花了很长的时间。

举个例子,有一个 playbook 里用了 copy 模块来复制一个有几十个文件的大目录。由于 Ansible 拷贝文件模块的内部实现,复制每个文件都意味着一直在 SSH 链接上等待着传输完成。

把这个 task 改成基于 synchronize 的可以在每次运行的时候节省好长时间。针对单次运行这看起来没什么,但是当 playbook 需要定期运行的时候(例如确保一台服务器的配置),或者作为 CI 流程的一部分的时候保持它的高效就很重要了。否则它会让 CPU 周期耗费在一些无用代码上,开发者通常会很讨厌等待 CI 测试通过,他们只想知道代码会不会导致问题。

请关注我在 AnsibleFest Austin 2018 的演讲 Make your Ansible Playbooks flexible, maintainable, and scalable。如果这还不够,我在我的书 《Ansible for DevOsp》 里有很多关于编写和维护 Ansible Playbook 的文章(你可以从 Red Hat 官网下载到摘录)。

基于 Webpack 的跨平台开发

小程序、快应用的开发最近相当热门,公司也在开发对应的 SDK 。既然小程序、快应用都是选用的 JavaScript 做为开发语言,那么有没有
可能,让小程序和快应用都能公用基于 H5 的 SDK 核心。

答案是肯定可以!如果用一个词来概括软件工程最想解决的问题,那么 问题拆分 也许是最合适的,我们把问题拆解:

  • JavaScript 核心
  • 对不同平台接口的抽象

JavaScript 核心 不用说,和具体的业务逻辑有关,设计之初就要清晰的知道系统的边界在哪里,核心和接口之间如何通信。

主要的问题在如何降低接口抽象的复杂度。

环境变量 - 代码如何知道当前运行的环境

现代软件开发越来越重视过程,例如很多软件都很明显的区分为 构建运行 两个部分。通过参数的不同取值指定运行环境
构建 过程一个比较常用的步骤,这样做的好处是能够为不同平台构建出不同的工件,有利于减小工件的体积。

1
2
# 为 H5 打包
PLATFORM=H5 build
1
2
# 为快应用打包
PLATFORM=quickapp build

这样就通过环境变量把运行时的环境传给了负责构件的命令 build (这里假设构件的命令是 build)。

下面的代码就能够打出不同的包,并在不同平台上会打印出不同的内容:

1
2
// app.js
console.log(`我在 ${process.env.PLATFORM} 平台下`);

不同平台如何访问环境变量

process 其实是 Node.js 全局定义的一个属性,那么为什么在非 Node.js 平台下也能访问到呢?这就要借助 前端 JavaScript 打包
工具 webpack 了, 关于 process 的处理有两种情况:

  • 在全局会有一个 mock 的 process 对象,确保相关代码能够访问到 process 对象而不会报错
  • 通过 DefinePlugin 替换代码里的 process.env.PLATFORM

模块隔离 - 根据平台执行不同的逻辑

知道了当前的运行环境,就能够根据平台执行不同的逻辑了

一般做法

其实就是 if else ,代码里一定多多少少有一些判断当前平台的代码。

1
2
3
4
// app.js
if(process.env.PLATFORM === 'H5'){
// 如果是 H5 平台就执行这里的代码
}

通过 if else 虽然能够解决问题,但是当代码比较复杂的时候还是会导致难以维护,比如下面这种情况:

1
2
3
4
5
6
7
8
9
// app.js
if(process.env.PLATFORM === 'H5'){
// H5 相关逻辑
}else if(process.env.PLATFORM === 'quickapp'){
// 快应用下要加载一个模块
const dep = require('some-dependency');
}else{
// 其它
}

因此这种做法只适合代码比较简单的情况,例如传递一些 flag,否则可以用下面这种方法。

更优方案

假设 H5、快应用、小程序都需要用到一个模块叫做 foo,引入的源代码如下:

1
2
// app.js
require('./foo')

目录结构如下,foo 模块对应不同平台有不同实现,但是暴露的方法签名以及返回类型是一致的:

1
2
3
4
.
foo.js
foo.quickapp.js
foo.miniprogram.js

通过 webpackconfig 参数可以指定不同平台的配置文件。

平台 webpack 配置文件
h5 webpack.config.js
miniprogram webpack.config.miniprogram.js
quickapp webpack.config.quickapp.js
1
2
3
4
5
6
7
// webpack.config.js
module.exports = {
//...
resolve: {
extensions: ['.js', '.json']
}
};

如果是快应用的话就修改成这样

1
2
3
4
5
6
7
// webpack.config.quickapp.js
module.exports = {
//...
resolve: {
extensions: ['.quickapp.js', '.js', '.json']
}
};

可以看到 resolve.extensions 其实是一个数组,当 webpack 遇到 require 一个文件依赖的时候会按照这个顺序进行匹配。

命名空间 - 解决第三方模块依赖于浏览器的问题

这其实是开发过程过程中一个非常具体的问题,这个模块是 jsencrypt

H5 平台下引入了这个模块,打包后运行没有问题,但是当尝试在快应用上运行的时候一直报这个错:

1
"window is undefined"

显然这个插件是为了 H5 开发的,没有考虑其它平台的兼容性。

一般做法

直接把源码下下来丢到 vendors 文件夹下,然后把 windownavigator 相关的兼容性一个一个问题修复。这种做法其实放弃了使用 npm 管理依赖的优势,
并且在项目中留下了一个技术债。以后如果 jsencrypt 发布了一个新的不得不使用的版本(例如修复某个安全漏洞),需要手动更新依赖并且打补丁。

更优方案

后来突然想起来网上有人遇到过类似的情况:社区有很多的 jQuery 插件,这些插件在编写的时候会假设全局有一个 $ 对象,但是使用了 webpack 以后,
由于用到了闭包,全局环境下其实是没有 $ 这个对象的的,而解决这个问题一个比较通用的做法是使用 webpackProvidePlugin

1
2
3
4
5
6
7
8
9
10
// webpack.config.js
module.exports = {
//...
plugins: [
//...,
new webpack.ProvidePlugin({
$: 'jquery'
})
]
};

顺着这个思路,修改了一下 webpack 配置文件:

1
2
3
4
5
6
7
8
9
10
11
// webpack.config.quickapp.js
module.exports = {
//...
plugins: [
//...,
new webpack.ProvidePlugin({
window: path.join(__dirname, 'noop.js'),
navigator: path.join(__dirname, 'noop.js')
})
]
};

noop 的代码也非常简单,就是返回一个空对象

1
2
// noop.js
module.exports = {};

这样至少可以保证所有第三方模块都不会报 window 或者 navigator 找不到的错误。

总结

通过上面几个由浅入深的问题,我们都够了解到 webpack 不仅仅是前端的打包工具,也可以用于跨平台的开发。

解决问题 Webpack 配置 试用场景
环境变量 DefinePlugin 判断平台,传递 Flag 等较为简单的逻辑
模块隔离 resolve.extensions 引入同一个模块在不同平台下的实现
命名空间 ProvidePlugin 修复兼容性问题,提供跨平台的全局空间

珍惜身体

最近每天中午都坚持去公园走上 1 万步,虽然没有防晒,皮肤黑了许多,但是感觉精神状态明显比之前强。

前几天在朋友圈看到一位搞 IT 的朋友得了重病,在水滴筹上募捐。因为这条信息有很多熟人帮忙证实,所以我也参与了一下。

今天,这位朋友发了一个公众号文章,文章里更新了他的最新状态,并表示将会退还所有捐款,文章末尾发出了他在极客帮上的技术专栏,想都没想就买了。

后来,到晚上大概算了一下,只用了短短几个小时,课程销售的收入已经超过了之前的捐款总额,真心佩服这位兄弟!

最后,祝他早日康复。

整理博客

偶尔会有一些想法,有的时候会直接在 Github 上新建一个项目,但是越来越发现用这种方法不好整理。

所以这两天把散落到 Github 上的文章好好整理一下。