用过的一些Markdown编辑器

开篇

买了MacBook Pro之后的一段时间里,为了打造适合自己的知识管理体系,折腾起了笔记类软件(题外话,我还挺喜欢尝试新软件的,尤其在接触macOS后发现许多软件都长得很漂亮)。其实在入手Mac之前,我已经试用过不少笔记类软件和服务了,包括Evernote(还有印象笔记)、有道云笔记、为知笔记,等等。再后来,改用Emacs的org-mode来写笔记——主要是将一些常常搜索的内容或经验记录在了多个.org文件中,算是一份自己的FAQ。后来想看看在macOS的世界中有没有更好的工具,同时渐渐觉得Markdown是一个更好的笔记内容载体,便尝试了一些知名的笔记类软件暨Markdown编辑器。

大致上尝试了下列这些:

  • Emacs
  • Boostnote
  • Quiver
  • Typora
  • Visual Studio Code
  • Yu Writer

本文并不是一篇完整的、专业的软件评测报告,只是我兴趣使然的对各个软件的吐槽和赞美,各位权当打发时间吧。下面我按顺序说一下上面提及的各款软件。

Emacs

Emacs并不仅仅是一款Markdown编辑器,我用得最多的是用它来做计划(之前还用来写Node.js代码,不过现在交给VSCode了)。用Emacs来写Markdown,坏处是没有live preview的功能。在Emacs中打开了一个.md文件,只会原原本本地显示着井号、星号,三个反引号等Markdown语法的关键字——并且还是白底黑字的模样,而不带有丝毫不同的样式。为了让它们好看点,你还需要安装一个叫做markdown-mode的Emacs扩展。但几遍安装了markdown-mode,也无法实时预览。markdown-mode的菜单栏中有一个叫做“Preview”的功能,它依赖一个名为markdown的命令行工具(用brew install markdown可以安装)。当一切安装完毕点击“Preview”菜单项时,才发现是在网页浏览器中查看的方式——虽然有preview了,但并不live。

Emacs在写Markdown方面也并非一无是处。对程序员而言,在一篇Markdown写就的文章中插入代码是再正常不过的事情了。在Emacs中将光标定位到Markdown语法的代码块内,按下controlc的组合键,再敲一下单引号键,Emacs便会另起一个相应模式的buffer,并将代码块中的内容复制到新buffer中供继续编辑。如下图所示

Emacs_编辑Markdown代码块.gif

在上面的GIF中,代码块以GitHub Flavored Markdown的语法在开头的三个反引号后附上了模式的名字,即lisp,Emacs便会打开lisp-mode的buffer。在这个buffer中可以继续使用Emacs的完整功能编辑对代码,包括语法高亮、自动补全,等等——如果启动了SLIME,甚至可以运行里面的Common Lisp代码。

Emacs和VSCode用于在编写代码的同时写写项目的README.md文件应当是绰绰有余的了。

Boostnote

Boostnote自诩为“程序员的笔记本”,它并不是我在Emacs之外寻找的第一款笔记软件,在它之前,我还尝试了NotionQuiver来着。上手后发现,Boostnote简直就是Quiver的开源免费版本,相当的喜爱。

Boostnote当然让我格外喜欢的有几点:首先,Boostnote可以实时预览键入的Markdown源文档。会有一列跟编辑区域差不多宽的区域被用来展示Markdown渲染后的效果。(刚刚发现,原来这个区域的宽度是可以拖动调节的)

其次,它不仅支持Markdown、带语法高亮的代码块,甚至还支持表格和流程图的绘制!当然我以为,用竖线和连字符绘制表格的功能仅在Emacs的org-mode中存在(孤陋寡闻了汗颜),刚开始用Boostnote制作表格的时候可是相当兴奋。而text-based的绘制流程图的方式也是让我大开眼界(后来才知道原来有flowchart.js这样的工具)——尽管后来我渐渐发现,绘制流程图其实挺少用。

然后Boostnote具备在多份笔记中搜索的功能,这对于一款笔记软件而言倒是真的非常重要,因为有时候只能想到一些只言片语,而并不能确定所要查阅的内容究竟在哪一份笔记中。

但Boostnote也有一些缺点。首先,Boostnote是用自有的文件格式(而不是纯文本的.md文件)来存储输入的内容的——打开~/Boostnote/notes/可以看到这些后缀为.cson的文件。这样一来,假设我日后发现了一款更优秀的Markdown编辑器,那就不能无痛迁移了,还得先从Boostnote中将这些笔记逐一导出成.md文件才行。

其次,Boostnote只支持三层的组织结构——最外层是storage,然后是folder,最后就是笔记本身。当初有道云笔记特别让我喜欢的,就是它支持非常多层级的目录结构。尽管目录不是越多越好,但有这种灵活性总是更好的。否则,笔记的使用者就只能在命名和标签上下功夫了

最后一点,就是Boostnote在我的系统上非常容易崩溃。有时候一翻起盖子,看到的就是Boostnote崩溃的提示。

不过Boostnote支持往其中粘贴图片,当我需要快速记录一些图文内容时,我还是很喜欢用它的。

Yu Writer

某一天偶然遇到了Yu Writer,它官网上的截图看着很吸引人,于是我便试用了一下。第一印象是,Yu Writer is awesome!首先它很人性化。它的预览区域是一个minimap——就是Sublime Text最右侧的那一列。在做到实时预览的时候,也不会占用太多的横向空间。其次,它支持大纲视图

Yu Writer的大纲视图

即上图左侧的目录。恰逢当时我在用Boostnote写一篇比较长的设计文档,深刻地体验到了一个大纲视图的重要意义——对在长文档内的多个标题间跳转非常有帮助。再次,Yu Writer还准备了工具栏,方便不懂得Markdown语法的用户;支持标签页,便于在多个文档间切换;甚至可以把一个Markdown文档作为幻灯片来播放。

但Yu Writer也有它自己的劣势。第一,在Yu Writer内,原本在macOS系统中全局可用的Emacs风格快捷键——即control+b往左移动光标、control+f往右移动光标——居然不生效!这些快捷键对我个人还是非常重要的。

第二,在Yu Writer中,不能直接插入磁盘上的图片文件的绝对地址,既没有在预览区域显示出来,也没有在文档列表显示成功。

据说Yu Writer的作者的主业是厨师,感觉好强

Typora

Typora is best。不同于前面提到的几款Markdown编辑器,Typora是“所见即所得”的编辑器。你敲入两个井号,加一个空格,再敲入你的标题内容,最后回车,那么标题内容就会被渲染为二级标题的形式,如下图所示

Typora_输入二级标题

这么一来,屏幕上的空间基本都可以被用来写作,不需要担心被预览用的列给占据了。

然后,Typora没有自定义它的存储结构,它直接打开磁盘上的.md文件进行编辑,这些Markdown源文件可以随心所欲地放在任何喜欢的目录下,只要能打开就行。再加上它文件树视图,就实现了不受限制的笔记组织方式了,如下图

Typora_文件树视图

不过一个可以想到的缺点,就是Typora不支持在所有的Markdown文件上搜索关键字——毕竟它也不知道要去哪个目录下寻找这些待搜索的源文件。

尽管Typora外观很简洁,但Boostnote有的功能它一个也没有落下,就像它的官网所说的那样

Typora的官网宣传

现在我的博客的文章基本都是用Typora来写的,冥冥中感受到了一股乐趣。但Typora毕竟没有搜索功能,所以我又开始摸索额外的搜索笔记的方式了(比如把记录在.org文件中的FAQ导入到ElasticSearch中再借助全文搜索的力量来找到自己要的内容)。

后记

没有最好的,只有最适合的,祝各位都能找到最适合自己的Markdown编辑器。

把GitHub作为图床

背景

最近又迷恋上了写博客,尤其是前一段时间很想要写点东西分享一些软件的使用感想。但当写完文章想要发表时就会碰到一个问题:由于我是现在本机的编辑器中用Markdown写好了全文的内容,再发表到各个平台(曾经是GitHub Pages搭建的博客,后来又多了简书,现在再加上SegmentFault)上的,因此文章里的图片都是引用在本地磁盘上的文件路径的。这么一来,如果直接将文章源码粘贴到博客平台上——比如粘贴到SegmentFault中,那么这些本地的图片链接就无法在发布后的文章中正常显示了。

如果一开始就在SegmentFault中写作也会遇到问题。SegmentFault上的文章插入图片后,并不是像普通的Markdown源码那般插入一条![]()形式的标记的,而是像下图这样

在SegmentFault中插入图片后的效果

显然,这样的文章源码复制到其它平台(GitHub Pages、简书)去发布的话,必然是需要针对其中的图片标记修改一番的——比刚开始的方法或许要更麻烦。

看来要解决这个图片链接在不同平台间共用的问题,必须有一处纯粹的用于存放图片文件的地方——也就是大家常说的图床了。刚开始我也放狗搜了一下,看看别人的推荐,印象中得到的答复不外乎是又○云、七○云、新○微博,以及sm.ms等。但它们要么需要注册并且实名认证,要么不纯粹,要么让人觉得随时会丢失。

某个晚上忽然想到,GitHub不就是一个很好的图床么?!在GitHub上建一个仓库专门存放博客中的图片,不仅免费、完全受自己管理,而且自带CDN加速,并且我的读者群(如果真的有这么一个群体的话)也应当可以畅通地访问GitHub。

放图片的仓库虽然有了,但用起来还不是很便利——因为作为写作素材的图片在我的电脑上是存放在一个单独的、非GitHub仓库的目录下的,所以如果要丢到图床上,就需要先将文件复制过去,然后执行git的add、commit、push三部曲,最后还要到GitHub上复制这张新图片的“raw”地址。

这个过程很机械化,完全可以用一个AlfredWorkflow来代劳。

编写Workflow

编写Workflow就像编写Common Lisp中的宏一样,总是从它们的用法入手的。在我的设想中,这个Workflow的使用方式应当是:

  1. 首先,按下快捷键调出Alfred的输入框,输入关键字(在我这里就叫做upload)来唤起这个Workflow;
  2. 然后,输入要上传的图片文件的绝对路径并按下回车,开始在后台处理
  3. 最后,上传完毕后,弹出通知来告诉我

整个Workflow的概貌其实很简单

upload Workflow的全貌

第二个节点所调用的External Script是长这样子的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
# 将磁盘文件上传到GitHub

path=${1}

pictures_dir="${HOME}/Documents/Projects/riverbed/pictures"
cp "${path}" "${pictures_dir}"
echo '文件复制完毕'

file=$(basename "${path}")
cd "${pictures_dir}"
git add "${file}"
git commit -m '上传一张图片'
git push -u origin master
echo '文件已提交到GitHub'

/usr/local/bin/node -e "console.log(encodeURI('https://raw.githubusercontent.com/Liutos/riverbed/master/pictures/${file}'));" | tr -d '\n' | pbcopy

获取文件的绝对路径其实很简单,在Finder中选中文件后,按下Command+Option+C即可

这里使用basename命令获取文件名。并且,为了避免git打开文本编辑器要求输入commit message,向git-commit命令传递了-m选项。

因为文件名含有非ASCII的字符(毕竟会有中文),需要做一次URL编码,因此用了node来做转换。在Node.js代码中用console.log输出编码后的图片URL,结尾会有一个换行符,所以用tr将其去掉。最后,输出的内容重定向给pbcopy,就将上传后的图片URL复制到剪贴板中了。如果此时正在编辑文章,便可以粘贴这个图片的链接到源码中。

Alfred也提供Copy to Clipboard,用于将Workflow中上一个节点的输出复制到剪贴板中。之所以不使用,其实是因为刚开始的时候就是用的Alfred的Copy to Clipboard,结果发现git运行过程中的输出也被Alfred接收了,跟图片URL一起混进了剪贴板中。所以最后改为直接调用pbcopy

全文完。

编写嵌套反引号的宏

一个没事找事的例子

当在Common Lisp中定义宏的时候,常常会使用到反引号(`)。比方说,我有这么一个函数

1
2
3
4
(defun foobar ()
(+ 1 1)
(+ 2 3)
(+ 5 8))

它被调用后会返回最后一个表达式的结果——13。如果我希望在第二个表达式计算后就把结果返回给外部的调用者的话,可以用return-from

1
2
3
4
(defun foobar ()
(+ 1 1)
(return-from foobar (+ 2 3))
(+ 5 8))

当然了,这属于没事找事,因为完全可以把最后两个表达式放到一个prog1(这也是没事找事),或者直接点,把最后一个表达式删掉来做到同样的效果——但如果是这样的话这篇东西就写不下去了,所以我偏要用return-from

还有一个更加没事找事的办法,就是用macrolet定义一个局部的宏来代替return-from——我很想把这个新的宏叫做return,但这样SBCL会揍我一顿,所以我只好把这个宏叫做bye(叫做exit也会被揍)

1
2
3
4
5
6
(defun foobar ()
(macrolet ((bye (&optional value)
`(return-from foobar ,value)))
(+ 1 1)
(bye (+ 2 3))
(+ 5 8)))

如果我有另一个叫做foobaz的函数

1
2
3
4
(defun foobaz ()
(+ 1 2)
(+ 3 4)
(+ 5 6))

也想要拥有bye这种想来就来想走就走的能力的话,可以依葫芦画瓢地包含一个macrolet

1
2
3
4
5
6
(defun foobaz ()
(macrolet ((bye (&optional value)
`(return-from foobaz ,value)))
(+ 1 2)
(bye (+ 3 4))
(+ 5 6)))

好了,现在我觉得每次都需要在函数体内粘贴一份bye的实现代码太麻烦了,想要减少这种重复劳作。于是乎,我打算写一个宏来帮我复制粘贴代码。既然要定义宏,那么首先应当定义这个宏的名字以及用法,姑且是这么用的吧

1
2
3
4
(with-bye foobar
(+ 1 1)
(bye (+ 2 3))
(+ 5 8))

with-bye这个宏需要能够展开成上面的手动编写的foobar中的函数体的代码形式,那么with-bye的定义中,就一定会含有macrolet的代码,同时也就含有了反引号——好了,现在要来处理嵌套的反引号了。

这篇文章有个不错的讲解,各位不妨先看看。现在,让我来机械化地操作一遍,给出with-bye的定义。首先,要确定生成的目标代码中,那一些部分是可变的。对于with-bye而言,return-from的第一个参数已经macrolet的函数体是可变的,那么不妨把这两部分先抽象为参数

1
2
3
4
5
(let ((name 'foobar)
(body '((+ 1 1) (bye (+ 2 3)) (+ 5 8))))
`(macrolet ((bye (&optional value)
`(return-from ,name ,value)))
,@body))

但这样是不够的,因为name是一个在最外层绑定的,但它被放在了两层的反引号当中,如果它只有一个前缀的逗号,那么它就无法在外层的反引号求值的时候被替换为目标的FOOBAR符号。因此,需要在,name之前再添加一个反引号

1
2
3
4
5
(let ((name 'foobar)
(body '((+ 1 1) (bye (+ 2 3)) (+ 5 8))))
`(macrolet ((bye (&optional value)
`(return-from ,,name ,value)))
,@body))

如果你在Emacs中对上述的表达式进行求值,那么它吐出来的结果实际上是

1
2
3
4
5
(MACROLET ((BYE (&OPTIONAL VALUE)
`(RETURN-FROM ,FOOBAR ,VALUE)))
(+ 1 1)
(BYE (+ 2 3))
(+ 5 8))

显然,这还是不对。如果生成了上面这样的代码,那么对于bye而言FOOBAR就是一个未绑定的符号了。之所以会这样,是因为

  1. name在绑定的时候输入的是一个符号,并且
  2. name被用在了嵌套的反引号内,它会被求值两次——第一次求值得到符号foobar,第二次则是foobar会被求值

因此,为了对抗第二次的求值,需要给,name加上一个前缀的引号(‘),最终效果如下

1
2
3
4
5
(let ((name 'foobar)
(body '((+ 1 1) (bye (+ 2 3)) (+ 5 8))))
`(macrolet ((bye (&optional value)
`(return-from ,',name ,value)))
,@body))

所以with-bye的定义是这样的

1
2
3
4
(defmacro with-bye (name &body body)
`(macrolet ((bye (&optional value)
`(return-from ,',name ,value)))
,@body))

机械化的操作方法

我大言不惭地总结一下,刚才的操作步骤是这样的。首先,找出一段有规律的、需要被用宏来实现的目标代码;然后,识别其中的可变的代码,给这些可变的代码的位置起一个名字(例如上文中的namebody),将它们作为let表达式的绑定,把目标代码装进同一个let表达式中。此时,目标代码被加上了一层反引号,而根据每个名字出现的位置的不同,为它们适当地补充一个前缀的逗号;最后,如果在嵌套的反引号中出现的名字无法被求值多次——比如符号或者列表,那么还需要给它们在第一个逗号后面插入一个引号,避免被求值两次招致未绑定的错误。

一个例子

就用上面所引用的文章里的例子好了。有一天我觉得Common Lisp中一些常用的宏的名字实在是太长了想要精简一下——毕竟敲键盘也是会累的——假装没有自动补全的功能。我可能会定义下面这两个宏

1
2
3
4
(defmacro d-bind (&body body)
`(destructuring-bind ,@body))
(defmacro mv-bind (&body body)
`(multiple-value-bind ,@body))

显然,这里的代码的写法出现了重复模式,不妨试用按照机械化的操作手法来提炼出一个宏。第一步,先识别出其中可变的内容。对于上面这个例子而言,变化的地方其实只有两个名字——新宏的名字(d-bindmv-bind),以及旧宏的名字(destructuring-bindmultiple-value-bind)。第二步,给它们命名并剥离成let表达式的绑定,得到如下的代码

1
2
3
4
(let ((new-name 'd-bind)
(old-name 'destructuring-bind))
`(defmacro ,new-name (&body body)
`(,old-name ,@body)))

因为old-name处于嵌套的反引号中,但是它是由最外层的let定义的,所以应当添上一个前缀的逗号,得到

1
2
3
4
(let ((new-name 'd-bind)
(old-name 'destructuring-bind))
`(defmacro ,new-name (&body body)
`(,,old-name ,@body)))

最后,因为old-name绑定的是一个符号,不能被两次求值(第二次是在defmacro定义的新宏中展开,此时old-name已经被替换为了destructuring-bind,而它对于新宏而言是一个自由变量,并没有被绑定),所以需要有一个单引号来阻止第二次的求值——因为需要的就是符号destructuring-bind本身。所以,最终的代码为

1
2
3
(defmacro define-abbreviation (new-name old-name)
`(defmacro ,new-name (&body body)
`(,',old-name ,@body)))

试一下就可以确认这个define-abbreviation是能用的(笑

后记

能够指导编写宏的、万能的、机械化的操作方法,我想应该是不存在的

重复输入相似的命令的几种方法

在命令行经常需要重复输入一些shell代码,例如用cd切换到某个目录、运行npm run local,或者git commit等。每次都完整地一个个字符地敲入这些命令还是很麻烦的,这种时候就要寻找可以解决重复输入,提高效率的办法了。

最原始的,当然是找一个文本文件,把平时经常敲入的命令存放在其中,每当需要运行这些命令的时候就打开文件选中内容复制一下,再到终端粘贴并运行,但这未免过于原始了。

使用ctrl-r翻出历史命令

使用ctrl-r是一种不那么原始的方法。在终端中按下ctrl-r后,shell会等待进一步地输入,并根据输入从以前输入过的命令中找出匹配的一条。找到了自己所需要的命令行,直接敲击回车即可,效果如下
ctrl-r的效果
PS:上面是使用了fzf之后的效果,所以在敲入回车后并不会立即执行所选中的命令。原生的ctrl-r命令不支持在不同的位置上匹配输入字符,所以还是推荐一试fzf的。

使用alias

alias相比于ctrl-r而言进化了一点,因为它毕竟不再需要往命令行中塞入那么多字符了——它让终端用户可以用较短的内容来代替较长的内容。例如,我就给登录我本地的MySQL的命令写了一个alias

1
alias myroot='mysql -u root -p*******'

而且alias更像是宏展开,所以可以在后面添加其它内容,如下图
alias的效果
在myroot之后输入的test和user_info都跟着myroot展开后的结果一起喂给了shell去执行。使用alias之后,每次只需要输入较短的myroot即可。

使用函数

如果说alias是C语言里面的宏的话,那么shell所支持的函数就是C语言里面的函数了(这不是废话么)。alias始终不太适合所要输入的内容比较多的场景——定义也特别难写,并且alias没有输入参数可言,也不适合处理需要有为妙差异的重复内容的情况。shell函数很适合这种情况,例如,我在本地编辑完一个.sd文件后需要用sdedit将其转换为.png文件,方能上传到Confluence上贴到设计文档里,我希望.png文件跟.sd文件有相同的basename,那么用下面这个shell函数可以减轻一些重复输入的劳动力

1
2
3
4
5
# 根据.sd文件生成同名的.png文件
function sdpng() {
basename=${1}
/usr/local/bin/sdedit -t png -o ${basename}.png ${basename}.sd
}

只需要我输入一次文件名即可,效果如下
shell函数的效果

使用Alfred的Snippets功能

Alfred带有一个叫做Snippets的特性
Alfred的Snippets特性
它跟上面所说的alias很相似,但它不是由shell自己处理悄悄展开的,它是显式地输入一长串的字符。比如说我定义了三个短语:gpd、gct,以及gpt,它们分别会展开为

1
2
3
git push -u origin develop
git checkout test
git push -u origin test

效果如下
Alfred的Snippets自动展开的效果
Alfred的Snippets也跟alias一样是不能接受参数的,不过支持一些占位符,可以展开为一些特定模式的动态内容。一个比较有用的是{cursor}这个占位符,可以让光标定位至此。例如我可以定义这样的一串展开结果

1
SELECT * FROM `user_info` WHERE `userId` = {cursor}\G

这样我敲入对应的短语后就可以正确定位到WHERE语句,然后直接输入要查询的参数即可,效果如下
cursor占位符的效果

除了Alfred之外,还有其它的通过snippet提高输入效率的软件,比如aTextDash,不过我没有实际地用过,就不多说了。

后记

没有代码才是最快地输入代码的方式

值得使用的软件之Alfred

Alfred是一款所谓的“生产力工具”,可以理解为就是帮助Mac用户提高日常事务的处理效率的工具,在我还没有入手MBP的时候就已经(在知乎上)听闻了这款软件的大名了。实际使用了之后发现确实可以提升一些事情的处理效率,是一款值得身为程序员的读者朋友使用的应用。接下来我会举一些例子来说明一下,希望可以传达到我的感受。献上我的Alfred使用统计
Alfred的使用统计

Alfred的Clipboard

剪贴板真是一个再常用不过的功能了,我想所有的读者朋友应该都使用过复制&粘贴的功能——不管是在Windows上面的Ctrl-c Ctrl-v也好,还是在Mac上面的Command-c Command-v也罢。Alfred的Clipboard功能可以认为是一个强化版的剪贴板,它可以通过快捷键(在我的系统上设置为了Command-p)快速唤出
Alfred唤出Clipboard

并且支持搜索(虽然很遗憾图片没办法搜索)
在Clipboard中搜索

当需要在两个应用间复制粘贴多段内容的时候,Clipboard就派上用场了。只需要先把需要的每一段内容在一个应用中分别复制一次,打开另一个应用后唤出Clipboard,便可以把刚才复制的内容逐个粘贴进来。每当我在一些地方看到有趣的图片想要分享给微信或者QQ的朋友时,也是打开微信或者QQ后进入Alfred的Clipboard浏览——打开Clipboard后,敲入“Image”,便可以只查看记录在剪贴板中的图片了,并且还可以在发送前预览
在Clipboard中搜索图片

Alfred的Snippets

Snippets算是我近期才挖掘到并开始重度使用的功能,用一句话概括,就是“长话短说”。在Snippets中可以新建一个较短的关键字来代替一串较长的输入,例如我就分别用了gcd、gct,以及gmd来代替切换到develop分支、切换到test分支,以及合并develop分支这三条常用的Git操作命令
在Snippets中定义短语

之后既可以通过快捷键唤出Snippets面板的方式来输入短语,也可以直接在短语定义时勾选【Auto expandsion allowed】来做到输入短语后自动展开为完整的内容。下图演示的是输入gcd后自动展开为完整的命令
自动展开Snippets中的短语

我现在已经积累了很多的短语了,不仅提高了输入的速度,也降低了重复输入这些内容的出错率,实在是居家旅行coding必备。

Alfred的Workflow

购买Alfred的Powerpack后就可以开启Workflow的功能了,实际上,在我真正开始用Alfred之前(还在用着Windows的时候),对Alfred的了解基本上局限于“它拥有一个很强大的叫做Workflow的功能”这样,可以说,让Alfred如此闻名遐迩的就是它的Workflow特性吧——不过后来我才知道原来Mac自带一个叫做Automator的类似的功能。

刚开始接触Workflow的时候,我也沉迷于在网上搜罗别人写好的来用,慢慢地才发现这些其他人经常(在知乎的答案里)列举到的Workflow,其实并不适合我。有一两个觉得眼前一亮的,在使用了一两次之后也就不怎么用了。现在,我自己写了一些Workflow,倒是显著地提升了我的开发过程。

比较合适作为例子的是我写的三个用于处理时间的Workflow。一个是用于将日期时间字符串转换为UNIX时间戳(毫秒单位)的Workflow,名为gt——取的是get time之意。使用起来的效果大致如下
gt的使用效果

这个Workflow最终会把结果复制到剪贴板中,便于在其它应用中使用。由于工作内容的缘故,我常常会需要获取某一个时候的UNIX时间戳(毫秒单位)。在有这个Workflow之前,我都是打开iTerm运行node,然后敲入

1
new Date('2018-11-15 00:00:00').getTime();

这般的代码来得到结果的,不仅要在不同的应用间切换来切换去的,而且还需要重复地敲入new、Date,以及getTime等字眼,实在是一件很低效的事情。使用了gt之后,感觉幸福感也提高了很多。

另一个Workflow名为wt——取的是what time之意,它的作用跟gt相反,是将毫秒数转换为可读的日期时间字符串,效果如下
wt的使用效果

最后一个Workflow名为int——即I need time,它可以提供特定的一些时刻的时间戳,例如【今天零点】这样的特定的时刻。这三个Workflow的入口节点都是一个Script Filter,int的使用效果如下
int的使用效果

Alfred的Workflow还可以做很多的事情。它是一个入口,很适合用于不需要肉眼查看含有大段文字的结果的交互场景,例如对字符串做编码转换、计算字符串的摘要、通过AppleScript调起微信联系人,以及控制音量等等,只要好好利用,就可以提升平时的使用效率。程序员朋友们,不妨一起来发挥自己的创造力吧。

flexi-streams用法简介

每过一段时间总会燃起一种用Common Lisp(下文简称CL)来写Web应用的冲动,继而就会开始感慨在CL的生态圈中居然没有一款好用的Web框架。尽管放狗搜索“common lisp web framework”可以找到一些——例如Caveman2,以及在Cliki中记录的一些其它框架。然后使用过其中一部分的人就会知道,大部分用起来的体验都不咋地。

在业界摸爬打滚了一小段时光(从业几年姑且可以这么说吧)后,感觉制作一款专门用于编写JSON-in-JSON-out的Web应用的Web框架应该是一个不错的点子——反正大家都是发出application/json的请求期望application/json的响应,于是乎就撸起袖子自己干了。不过完全从零开始编写起是不现实的,于是乎选择了一个“平台”来作为基础。这个平台就是Clack

Clack会负责屏蔽下层的Web Server的差异,它只需要我提供一个函数给它作为来访的HTTP请求的“handler”即可,然后在这个handler中我就可以为所欲为啦。Clack在收到HTTP请求后,会把HTTP请求中的一些信息组织为一个列表类型的值传递给这个handler。在这个handler中,我只需要综合运用CAR、CDR之类的奇怪名字的函数就可以拿到自己需要的东西了——当然了,鉴于这个列表是个plist,用CL提供的DESTRUCTURING-BIND就可以很方便地提取啦。

在这个plist中,就有一个叫做:RAW-BODY的p,它的值是一个“流”——是的,就是那种文件流的流!但它又不是一个路边随处可见的妖艳贱货的流,而是一个来自FLEXI-STREAMS这个包(指CL中的package)的流。FLEXI-STREAMS是一个提供流操作的库,鉴于我没有看过Gray streams相关的内容,就不在这里瞎逼逼误导读者了。总而言之,我必须找到一个办法可以从一个FLEXI-STREAMS提供的输入流类型的值中读出一些东西来。

其实这个办法很简单,就是用CL原生提供的读取流的函数即可——比如READ-SEQUENCE这样的函数。不过我得验证一下不是,为此,我需要有办法可以构造出一个FLEXI-STREAMS流类型的值出来。FLEXI-STREAMS提供了一个叫做MAKE-FLEXI-STREAM的函数,显然这个就是我所需要调用的最后一个函数了。从它的描述来看,它需要一个CL中的原生流来作为第一个参数才行。为此,我试了一下下面的代码

1
2
3
4
5
(let ((text "Hello, world!"))
(with-input-from-string (s text)
(let ((buffer (make-array (length text)))
(fs (flexi-streams:make-flexi-stream s)))
(read-sequence buffer fs))))

遗憾的是,运行上面的代码会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
The value
#\H
is not of type
(UNSIGNED-BYTE 8)
when setting an element of (ARRAY (UNSIGNED-BYTE 8))
[Condition of type TYPE-ERROR]

Restarts:
0: [RETRY] Retry SLIME interactive evaluation request.
1: [*ABORT] Return to SLIME's top level.
2: [ABORT] abort thread (#<THREAD "worker" RUNNING {1007766D53}>)

Backtrace:
0: ((SB-VM::OPTIMIZED-DATA-VECTOR-SET (UNSIGNED-BYTE 8)) #<unavailable argument> #<unavailable argument> #<unavailable argument>)
1: (SB-IMPL:ANSI-STREAM-READ-SEQUENCE #(0 0 0 0 0 0 ...) #<SB-IMPL::STRING-INPUT-STREAM {1007975973}> 0 13)
2: (READ-SEQUENCE #(0 0 0 0 0 0 ...) #<SB-IMPL::STRING-INPUT-STREAM {1007975973}> :START 0 :END 13)
3: ((FLET FLEXI-STREAMS::FILL-BUFFER :IN FLEXI-STREAMS::READ-SEQUENCE*) 13)
4: ((:METHOD FLEXI-STREAMS::READ-SEQUENCE* (FLEXI-STREAMS::FLEXI-LATIN-1-FORMAT T T T T)) #<FLEXI-STREAMS::FLEXI-LATIN-1-FORMAT (:ISO-8859-1 :EOL-STYLE :LF) {1007975B43}> #<FLEXI-STREAMS:FLEXI-INPUT-STRE..
5: ((:METHOD STREAM-READ-SEQUENCE (TRIVIAL-GRAY-STREAMS:FUNDAMENTAL-INPUT-STREAM T)) #<FLEXI-STREAMS:FLEXI-INPUT-STREAM {1007975C73}> #(0 0 0 0 0 0 ...) 0 NIL) [fast-method]
6: (READ-SEQUENCE #(0 0 0 0 0 0 ...) #<FLEXI-STREAMS:FLEXI-INPUT-STREAM {1007975C73}> :START 0 :END NIL)
7: ((LAMBDA ()))
8: (SB-INT:SIMPLE-EVAL-IN-LEXENV (LET ((TEXT "Hello, world!")) (WITH-INPUT-FROM-STRING (S TEXT) (LET # #))) #<NULL-LEXENV>)
9: (EVAL (LET ((TEXT "Hello, world!")) (WITH-INPUT-FROM-STRING (S TEXT) (LET # #))))
10: ((LAMBDA NIL :IN SWANK:INTERACTIVE-EVAL))
--more--

既然在调用READ-SEQUENCE的时候出状况了,不妨试试下面的代码

1
2
3
4
(let ((text "Hello, world!"))
(with-input-from-string (s text)
(let ((fs (flexi-streams:make-flexi-stream s)))
(read-char fs))))

再次令人遗憾的,它会抛出另一个状况(CL中的condition啦)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#<SB-IMPL::STRING-INPUT-STREAM {10020A7243}> is not a binary input stream.
[Condition of type SIMPLE-TYPE-ERROR]

Restarts:
0: [RETRY] Retry SLIME interactive evaluation request.
1: [*ABORT] Return to SLIME's top level.
2: [ABORT] abort thread (#<THREAD "worker" RUNNING {1001F6E813}>)

Backtrace:
0: (SB-KERNEL:ILL-BIN #<SB-IMPL::STRING-INPUT-STREAM {10020A7243}>)
1: (READ-BYTE #<SB-IMPL::STRING-INPUT-STREAM {10020A7243}> NIL NIL)
2: ((:METHOD FLEXI-STREAMS::READ-BYTE* (FLEXI-STREAMS:FLEXI-INPUT-STREAM)) #<FLEXI-STREAMS:FLEXI-INPUT-STREAM {10020A74C3}>) [fast-method]
3: ((:METHOD FLEXI-STREAMS::OCTETS-TO-CHAR-CODE (FLEXI-STREAMS::FLEXI-LATIN-1-FORMAT T)) #<FLEXI-STREAMS::FLEXI-LATIN-1-FORMAT (:ISO-8859-1 :EOL-STYLE :LF) {10020A7393}> #<unavailable argument>) [fast-me..
4: ((:METHOD STREAM-READ-CHAR (FLEXI-STREAMS:FLEXI-INPUT-STREAM)) #<FLEXI-STREAMS:FLEXI-INPUT-STREAM {10020A74C3}>) [fast-method]
5: (READ-CHAR #<FLEXI-STREAMS:FLEXI-INPUT-STREAM {10020A74C3}> T NIL #<unused argument>)
6: ((LAMBDA ()))
7: (SB-INT:SIMPLE-EVAL-IN-LEXENV (LET ((TEXT "Hello, world!")) (WITH-INPUT-FROM-STRING (S TEXT) (LET # #))) #<NULL-LEXENV>)
8: (EVAL (LET ((TEXT "Hello, world!")) (WITH-INPUT-FROM-STRING (S TEXT) (LET # #))))
9: ((LAMBDA NIL :IN SWANK:INTERACTIVE-EVAL))
--more--

看来从一开始提供给MAKE-FLEXI-STREAM函数的参数就应当是一个“二进制”的流才对。为此,我需要借助FLEXI-STREAMS自身的力量——调用它的STRING-TO-OCTETS函数。使用这个函数,可以将一个字符串转换为某种编码下的字节数组,例如下面的代码

1
(flexi-streams:string-to-octets "Hello") ;#(72 101 108 108 111)

得到一串“octet”后,还需要将其转换为“流”才行。再次借助FLEXI-STREAMS的力量,调用它的MAKE-IN-MEMORY-INPUT-STREAM函数,然后将这个函数调用的返回值作为MAKE-FLEXI-STREAM的第一个参数即可,最终的代码如下

1
2
3
4
5
6
7
(let* ((text "Hello, world!")           ; 原始文本
(octets (flexi-streams:string-to-octets text)) ; 使用flexi-streams转换为字节数组,因为下一个函数只接受这种类型的参数
(memory-input (flexi-streams:make-in-memory-input-stream octets)) ; 同样先转换为内存中的流,因为下一个函数只接受这种类型的参数
(flexi-stream (flexi-streams:make-flexi-stream memory-input)) ; 终于可以得到一个真正的flexi-stream了
(buffer (make-array (flexi-streams:octet-length text)))) ; 这里其实用字节的长度还是用字符的长度(flexi-streams:char-length)都没差
(read-sequence buffer flexi-stream) ; 可以像处理CL中的流那样处理flexi-stream
(print (coerce buffer 'string))) ; 把字节数组拼成字符串再输出比较好看

全文完

sdedit使用方法介绍(混沌向)

最近在寻找绘制时序图的过程中遇到了sdedit,感觉非常适合自己使用,故写这么篇文章向自己也向有同样需求的其它开发人员介绍一些这款软件

sdedit在macOS上安装还是非常容易的,只需要使用homebrew就可以轻松安装,命令如下

brew install sdedit

之后sdedit就会被安装到/usr/local/bin 这个目录下,在命令中输入sdedit就可以启动了。

输入sdedit后会启动一个Java Swing写的GUI程序,具体的外观和布局就不介绍了,这里主要讲解一下sdedit所支持的语法

sdedit中绘图的常用语法

PS:我对UML中的一些术语并不了解,下面的介绍是可能有错误的,具体请以sdedit的官方文档(http://sdedit.sourceforge.net/enter_text/)为准。同时,既然有官方文档了,我就不在此翻译一遍了,只记录一些常用的用法

如果需要输入整个时序图的标题(姑且叫做标题),那么可以使用下面的语法

#![一键获取手机号并登录的交互流程]

即以UNIX中的shebang开头,后面通过一对方括号包住标题内容即可

如果需要绘制一个参与时序图的节点,那么使用下面的语法(摘抄自官方文档)

<name>:<Type>[<flags>] "<label>"

其中,就是节点的唯一名字,之后当描述节点间的交互时需要这个字段,请给每一个节点都取一个独一无二的名字(就像取变量名那样);部分顾名思义就是类型,虽然我在使用的时候这个字段的值也是随心所欲地写的,但这个字段对sdedit而言似乎有特殊含义——例如,如果这个字段填入的是Actor,那么绘制出来的就会是一个人形的节点,在用来表示用户的时候特别有用;(flags我还没有用过就不介绍了);label可以理解为节点的文案,如果不填

定义好节点之后,就需要把各个节点在不同的时间用不同的消息联系起来了。描述节点间联系用的语法是(摘自官网)

<caller>[<s>]:<answer>=<callee>[m].<message>

其中的caller和callee都是写的节点的(所以name需要时独一无二的),这样就会绘制出两条线——一条实线从caller指向callee,以及一条虚线从callee指向caller,以及在callee的生命周期下绘制出一个纵向的矩形,表示callee的处理过程;是从caller发往callee的消息,例如参数的描述;如果需要同时描述从callee返回的结果,那么就需要填写在上述语法的的的位置——个人觉得这个语法是有点奇怪的

生成图片

上面的这些文本描述都需要输入到sdedit的文本框中(唯一UI上的右下角),之后点击保存就可以得到一个XXX.sdx的文件了。由于在我的电脑上,sdedit的GUI上的导出功能用起来非常有问题,所以我摸索出来的是在命令行导出图片文件的做法(并且可以导出的格式似乎更丰富)。总体的用法是

sdedit -t <类型> -o <输出文件名> <原始的.sdx文件>

输出文件名随性地取即可,其中类型对常用的都有支持(svg、png、jpg,和bmp),我一般常用的是png,然后就可以得到一张PNG图片了(便可以美美地用到设计文档里了)

配图什么的等我哪天特别闲了再补充上来吧

MacBook Pro使用体验

生平第一次有一台自己的MacBook,使用了一段时间之后也有了自己的一番感想,特此写下来留个纪念。感想主要分为硬件以及软件两个方面,本文不会有太多的条理性

硬件

上周四晚上第一次从收件的超市老板手里接过MacBook Pro的时候,第一感觉是意想不到的沉重。回到家拆开包装,拿起机体和电源适配器的时候,也是觉得非常的重。虽然经过几天的使用后发现并没有当初那般沉重的感觉了,但总体还是超过了我的预期。

接着是发现这台电脑居然没有开机键。老实说,其实这个是我在昨天才意识到的;同时,这台电脑只有四个Type-C的接口,不得不下单买了个外接的适配器。

触控板的面积很大,但正如传闻中所说的,触控板非常地好用,不像我之前用的电脑触控板是塑料材质的,当手出汗的时候非常地难用。

比较可惜的是,control键只在键盘的左侧有,对于使用Emacs来编码的人(指我自己)来说,是比较不方便的。虽然现在已经分别将左右的option设置成了(Windows上的)control,以及将command设置成了(Windows上的)Alt,但无名指在按键的时候还是难免误触。同时,没有独立的home/end/up/down,刚开始还真有点不习惯

touchbar毕竟是一块没有力反馈的触摸屏,所以每当需要按ESC的时候都忍不住要肉眼确认一下或者按两下。但用touchbar来控制音量则非常方便

散热有点糟糕。第一天晚上折腾的时候,安装完Visual Studio Code然后VSC自己卡死了,随后机器开始发热,尤其是touchbar上方靠近屏幕铰链的位置很烫——可千万不要轻易烧坏了呀

电池很给力,不需要每天下班都带着重重的电源适配器回家

软件

开机后的第一感觉就是界面新颖,虽然并不是第一次亲眼看见macOS了,但作为自己的机器来使用,认真一看确实觉得比Windows要美观,比起之前在虚拟机中使用的Mint也要更优雅统一。触控板非常地好用,尤其是三指上划用于在窗口间切换的功能非常地棒,两指点击表示右键单击的效果也非常地好,既轻盈又方便。字体很好看,就连在Emacs中展示的中文也变得可爱起来

摆脱了虚拟机,也就不用再把内存用在一起重复的功能上了(比如把内存分给虚拟机的操作系统),搭配上SSD现在开启软件都非常地快。不过大概是因为虚拟机用惯了,有时候总忍不住切换到调度中心,然后找一个看起来比较像虚拟机的应用来点一下,2333

以前在Mint里面用的搜狗输入法总觉得有什么欠缺,现在可以用上全功能的版本了感觉很爽快,连在Emacs中输入中文也变得畅快起来。更令人惊喜的是,不知道如何折腾,现在我居然可以在多个软件里用上Emacs的键绑定了,目前发现的包括但不限于Firefox的输入框、微信、QQ,以及有道云笔记。

Mac上的一些软件很有趣,比如Alfred、Timing等等,同时也是我第一次使用zsh+oh-my-zsh,的确是很强大的工具。这两天我也开始编写自己的Workflow了,打算接着学习一下Apple Script更好地帮助自己使用这个系统。

好了,就酱

cl-mongo用法入门

最近用Common Lisp开发一个个人项目,需要记录发出的HTTP请求的参数,包括了目标地址、HTTP body,以及HTTP头部等多种信息。为了可以结构化地存储这些数据(比如HTTP头部是由多个键值对组成的),我选择将它们保存到MongoDB中。Google一番后,我找到了cl-mongo这个库,可以在Common Lisp中读写MongoDB,尝试了一下也确实可以满足自己的需求。为了方便自己查阅,也为了方便有相同需求的人了解如何使用cl-mongo,于是写了这篇文章。

首先使用CL-MONGO:MONGO函数连接MongoDB的服务器程序(mongod)。因为在我的系统上mongod进程监听的是27017这个端口,并且我希望使用的数据库名为test,因此键入如下代码来连接数据库

1
2
3
(cl-mongo:mongo :db "test"
:host "127.0.0.1"
:port 27017)

连接上了数据库后,首先尝试往其中写入一个文档。假设现在要记录的是发出的HTTP请求的信息,那么一个可以写入的基本信息就是请求的目标地址。假设在命令行的mongo shell中输入的内容如下

1
db.http_request.insert({uri: 'http://example.com'});

那么使用cl-mongo提供的DB.INSERT函数达到上述效果的代码如下

1
2
(cl-mongo:db.insert "http_request"
(cl-mongo:kv "uri" "http://example.com"))

求值上述代码后返回值为NIL。为了将上述写入的文档重新查询出来,需要使用cl-mongo提供的DB.FIND函数。因为只有一个文档,所以直接查询就可以查看到结果了。在mongo shell中我们可以使用如下代码查询

1
db.http_request.find();

使用DB.FIND函数的话编写的代码可能会长得像下面这样

1
(cl-mongo:db.find "http_request" :all)

在我的系统上求值了上述代码后在REPL中输出的内容如下

1
2
3
4
5
6
((86 449 0 1 8 0 0 1 "http_request")
(<CL-MONGO:DOCUMENT> : {
_id : CL-MONGO::BSON-OID [#(89 52 26 160 109 156 254 71 184 115 102 30)]
elements : 1}

))

值得注意的是,DB.FIND的返回值不完全是文档组成的数组,而是在这个结果集的数组之外又多了一层列表,并且列表的第一个元素还是一个一看之下不知其所以然的子列表。由于cl-mongo的GitHub上没有提及这个玩意儿的来历,我也没有深入去了解DB.FIND函数的实现代码,因此这个元素就先忽略它吧。如果需要使用DB.FIND查询到的结果,那么开发者需要对DB.FIND的返回值应用一下函数SECOND才行,如下

1
(second (cl-mongo:db.find "http_request" :all))

返回值的列表中的每一个元素都是CL-MONGO:DOCUMENT这个类的实例对象,如果要直接使用还是稍微有点不方便的,因此我写了一个函数用来将DB.FIND函数查询到的CL-MONGO:DOCUMENT的实例对象都转换为较为熟悉,容易操作的数据类型——association list,函数的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(defun document-to-alist (doc)
"Convert a DOC of type CL-MONGO:DOCUMENT to a equivalent, serializable alist."
(check-type doc cl-mongo:document)
(labels ((aux (doc)
(cond ((typep doc 'cl-mongo:document)
(let ((keys (cl-mongo:get-keys doc)))
(mapcar #'(lambda (key)
(cons key
(aux (cl-mongo:get-element key doc))))
keys)))
(t doc))))
(let ((id (cl-mongo:doc-id doc)))
(append (aux doc) (list (cons "_id" id))))))

使用如下代码即可查看方才所写入的文档究竟长什么样子了

1
(document-to-alist (first (second (cl-mongo:db.find "http_request" :all))))

如果想要修改数据库中的文档,例如增加一个字段,那么可以使用cl-mongo提供的DB.UPDATE函数,用法如下

1
2
3
4
(cl-mongo:db.update "http_request"
(cl-mongo:kv "uri" "http://example.com")
(cl-mongo:kv "$set"
(cl-mongo:kv "method" "GET")))

最后如果要删除刚才所写入的这个文档,可以使用cl-mongo的DB.DELETE函数(我很好奇这个函数居然不是叫做DB.REMOVE),用法如下

1
2
(cl-mongo:db.delete "http_request"
(cl-mongo:kv "uri" "http://example.com"))

远程请求Squid

不久前在办公室抓取某网站S被对方发现,导致对方自动屏蔽了来自办公室网络的所有HTTP请求,连正儿八经地用浏览器打开也不行。为了可以摸索出“改头换面”(改HTTP头部)访问的方法,必须先成功访问至少一次,看看发出的HTTP头部是怎样的才行。恰好想起自己有一台腾讯云服务器,登上去用curl访问网站S,发现是成功的(也就是尚未被屏蔽)。既然如此,干脆在服务器上部署一套Squid作为正向代理,帮助办公网络的请求成功抵达网站S并拿到响应页面。

apt-get安装了squid软件包后启动并监听端口8321,在办公网络下将公网地址和8321端口作为代理配置传递给curl-x选项,访问网站S。不料Squid拒绝了我的请求,返回了如下内容(节选自curl -v命令的输出)

1
2
3
4
5
6
7
8
9
10
11
12
13
< HTTP/1.1 403 Forbidden
< Server: squid/3.5.12
< Mime-Version: 1.0
< Date: Wed, 17 May 2017 15:18:08 GMT
< Content-Type: text/html;charset=utf-8
< Content-Length: 3531
< X-Squid-Error: ERR_ACCESS_DENIED 0
< Vary: Accept-Language
< Content-Language: en
< X-Cache: MISS from VM-44-136-ubuntu
< X-Cache-Lookup: NONE from VM-44-136-ubuntu:8321
< Via: 1.1 VM-44-136-ubuntu (squid/3.5.12)
< Connection: keep-alive

经过一番Google,才知道原来是Squid的配置导致的。在Squid配置文件(/etc/squid/squid.conf)中,默认的acl和http_access指令的设置如下

1
2
3
4
5
6
7
8
9
10
11
12
acl SSL_ports port 443
acl Safe_ports port 80 # http
acl Safe_ports port 21 # ftp
acl Safe_ports port 443 # https
acl Safe_ports port 70 # gopher
acl Safe_ports port 210 # wais
acl Safe_ports port 1025-65535 # unregistered ports
acl Safe_ports port 280 # http-mgmt
acl Safe_ports port 488 # gss-http
acl Safe_ports port 591 # filemaker
acl Safe_ports port 777 # multiling http
acl CONNECT method CONNECT
1
2
3
4
5
6
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow localhost manager
http_access deny manager
http_access allow localhost
http_access deny all

由于Squid是按照第一条匹配的http_access指令来决定允许还是拒绝的,因为来自我办公网络的请求实际上命中的是

1
http_access deny all

因此被拒绝是必然的。为了可以接受来自办公网络发起的请求,首先需要新增一行acl指令。通过Squid的日志(/var/log/squid/access.log)可以查看到被拒绝的请求的IP地址是多少,此处假设IP地址为8.7.198.45,那么相应的acl指令如下

1
acl myclients src 8.7.198.45

此处的myclients为自定义的名称,顾名思义,它表示“我的客户端”;src是一种acl类型,表示客户端的IP地址;8.7.198.45是src类型下的参数,也就是我所使用的客户端发出的请求的来源IP地址。配置了acl后,还需要配置http_access指令。这个就简单多了,只要允许上面创建的这个acl的访问即可,内容如下

1
http_access allow myclients

之后再重启Squid服务即可

1
sudo service squid restart

这时候再从办公网络中以腾讯云服务器上的Squid为正向代理发出请求,就不会再被Squid拒绝了。

全文完