WangEditor添加首行缩进功能

官方也不增加这个,但对于经常写文章的用户来说,首行缩进是很实用的功能,改变一下样式就可以了text-indent: 2em;

修改一下初始化wangEditor的地方,注册一个菜单

//初始化编辑器
var E = window.wangEditor;
// 菜单 key ,各个菜单不能重复
const menuKey = ‘MyTextIndentMenuKey’

// 注册菜单
E.registerMenu(menuKey, MyTextIndentMenu)

var editor = new E(‘#’+id);

 

新增一个MyTextIndentMenu.js,代码如下:

 

const E = window.wangEditor
const { $, BtnMenu , DropListMenu, PanelMenu, DropList, Panel, Tooltip } = E

const lengthRegex = /^(\d+)(\w+)$/
const percentRegex = /^(\d+)%$/
const reg = /^(SECTION|P|H[0-9]*)$/

// 第一,菜单 class ,Button 菜单继承 BtnMenu class https://www.wangeditor.com/doc/
class MyTextIndentMenu extends BtnMenu{
constructor(editor) {
// data-title属性表示当鼠标悬停在该按钮上时提示该按钮的功能简述text-indent: 2em;
const $elem = E.$(
`<div class=”w-e-menu w-e-icon-indent-increase” data-title=”首行缩进”>
</div>`
)
super($elem, editor)
}
// 菜单点击事件
clickHandler() {
// 做任何你想做的事情text-indent: 2em;
// 可参考【常用 API】文档,来操作编辑器
this.command();
}
// 菜单是否被激活(如果不需要,这个函数可以空着)
// 1. 激活是什么?光标放在一段加粗、下划线的文本时,菜单栏里的 B 和 U 被激活,如下图
// 2. 什么时候执行这个函数?每次编辑器区域的选区变化(如鼠标操作、键盘操作等),都会触发各个菜单的 tryChangeActive 函数,重新计算菜单的激活状态
tryChangeActive() {
// 激活菜单
// 1. 菜单 DOM 节点会增加一个 .w-e-active 的 css class
// 2. this.this.isActive === true
const editor = this.editor
const $selectionElem = editor.selection.getSelectionStartElem()
const $selectionStartElem = $($selectionElem).getNodeTop(editor)

if ($selectionStartElem.length <= 0) return

if ($selectionStartElem.elems[0].style[‘textIndent’] !== ”) {
this.active()
} else {
this.unActive()
}
}
/**
* 执行命令
* @param value value
*/
command() {
const editor = this.editor
const $selectionElem = editor.selection.getSelectionContainerElem()

// 判断 当前选区为 textElem 时
if ($selectionElem && editor.$textElem.equal($selectionElem)) {
// 当 当前选区 等于 textElem 时
// 代表 当前选区 可能是一个选择了一个完整的段落或者多个段落
const $elems = editor.selection.getSelectionRangeTopNodes()
if ($elems.length > 0) {
$elems.forEach((item) => {
this.operateElement($(item), editor)
})
}
} else {
// 当 当前选区 不等于 textElem 时
// 代表 当前选区要么是一个段落,要么是段落中的一部分
if ($selectionElem && $selectionElem.length > 0) {
$selectionElem.forEach((item) => {
this.operateElement($(item), editor)
})
}
}

// 恢复选区
editor.selection.restoreSelection()
this.tryChangeActive()
}

operateElement($node, editor) {
const $elem = $node.getNodeTop(editor)
let type = ‘increase’;
if($node.elems[0].style[‘textIndent’] !== ”)
type = ‘decrease’;

if (reg.test($elem.getNodeName())) {
if (type === ‘increase’) this.increaseIndentStyle($elem, this.parseIndentation(editor))
else if (type === ‘decrease’) this.decreaseIndentStyle($elem, this.parseIndentation(editor))
}
}
increaseIndentStyle($node, options) {
const $elem = $node.elems[0]
if ($elem.style[‘textIndent’] === ”) {
$node.css(‘text-indent’, options.value + options.unit)
} else {
const oldPL = $elem.style[‘textIndent’]
const oldVal = oldPL.slice(0, oldPL.length – options.unit.length)
const newVal = Number(oldVal) + options.value
$node.css(‘text-indent’, `${newVal}${options.unit}`)
}
}
decreaseIndentStyle($node, options) {
const $elem = $node.elems[0]
if ($elem.style[‘textIndent’] !== ”) {
const oldPL = $elem.style[‘textIndent’]
const oldVal = oldPL.slice(0, oldPL.length – options.unit.length)
const newVal = Number(oldVal) – options.value
if (newVal > 0) {
$node.css(‘text-indent’, `${newVal}${options.unit}`)
} else {
$node.css(‘text-indent’, ”)
}
}
}

parseIndentation(editor) {
const { indentation } = editor.config

if (typeof indentation === ‘string’) {
if (lengthRegex.test(indentation)) {
const [value, unit] = indentation.trim().match(lengthRegex).slice(1, 3)
return {
value: Number(value),
unit,
}
} else if (percentRegex.test(indentation)) {
return {
value: Number(indentation.trim().match(percentRegex)?indentation.trim().match(percentRegex):[1]),
unit: ‘%’,
}
}
} else if (indentation.value !== void 0 && indentation.unit) {
return indentation
}

return {
value: 2,
unit: ’em’,
}
}

}

ubuntu 下使用openconnect 连接vpn

使用openconnect在ubuntu 中安装openconnect,可以在软件中心找到.

 

在/etc/vpc/目录下新建vpnc-script 文件

文件内容可以到此处拷贝

http://git.infradead.org/users/dwmw2/vpnc-scripts.git/blob_plain/HEAD:/vpnc-script

 

sudo openconnect -u 用户名 –script=/etc/vpnc/vpnc-script –no-dtls vpn.test.com

 

输入密码后提示

POST https://vpn.test.com/+webvpn+/index.html

Got CONNECT response: HTTP/1.1 200 OK

CSTP connected. DPD 30, Keepalive 20

Connected tun0 as 10.22.22.22, using SSL

 

连接成功!!!

Golang指南:顶级Golang框架、IDE和工具列表

译文链接:http://www.codeceo.com/article/golang-framework-ide-tools.html 英文原文:Golang Guide: A List of Top Golang Frameworks, IDEs, and Tools

自推出以来,Google的Go编程语言(Golang)越来越受主流用户的欢迎。在2016年12月的一份调研中,3,595名受访者中有89%表明他们在工作中或工作以外用Go语言编程。

此外,在编程语言中,Go语言在专业知识和偏好方面排名最高。2017年7月,在Tiobe的年度编程语言排名中,Go语言从去年的第55名一跃跳到了第10名。

显然,Go语言吸引了来自不同学科的许多程序员和软件开发外包专业人士。可以这么说,这全都是因为Go语言的易用性。

作为一种编译型的开源编程语言,Go语言能使开发人员轻松构建简单可靠又高效的软件。它是更保守的语言,如C和C ++的创新和演变的产物。

使用Go语言,可以减少代码输入量,并且编写稳健的API而不牺牲性能变得更加容易。 Go语言旨在实现可扩展性和并发性,从而实现优化。编译器可以在运行时前执行所有代码检查工作。

我们收罗了Golang的顶级框架、IDE和工具列表,以供大家快速参考。建议添加到浏览器书签中,以便随时查看!

Golang框架

Web框架可以帮助开发人员尽可能方便快捷地构建应用程序。Go语言还比较新,所以使用的框架带有充足的文档很重要。

这里有9个框架可帮助你使用Go语言构建项目。

1.Revel

作为Go语言的高效生产力框架,Revel包含的Hot Code Reload工具可以让你在每次更改文件时重建项目。它还包括各种全面和高性能的功能,因此你不需要找外部库集成到框架中。

2.Beego

Beego是一个完整的MVC框架,有自己的日志库、ORM和Web框架。你不需要再去安装第三方库。它有一个称为Bee Tool的内置工具,用于监视代码更改,并在检测到更改时运行任务。

Beego可以为你节省很多时间,特别是在项目一开始,你要弄清楚日志框架或应用程序结构的时候。

3.Martini

受Sinatra启发,Martini是一个极其轻巧但功能强大的框架。它被开发用于用Golang编写模块化Web应用程序和服务。

它的特点是非侵入式设计,快速易用,且包括各种处理程序和中间件。它能够为HTML5模式的AngularJS应用程序执行基本路由,异常处理和默认文档服务。

Martini的最佳功能是可以使用反射,它允许开发人员动态地将数据插入到处理函数中并添加新的服务。Martini也完全兼容http.HandlerFunc界面。不过,缺点在于Martini框架不再维护了。

4.Gin Gonic

Gin Gonic是一个Web框架,有类似Martini的API,但性能更好。如果你以前使用过Martini,那么你也一定熟悉Gin Gonic。没用过Martini也没关系,只需要学习10分钟就能掌握Gin。就是这么容易!

Gin Gonic是一个极简化的框架,仅包含最重要的库和功能。这使得它非常适合开发高性能REST API。此外,它比Martini快四十倍。

你可以添加中间件、嵌套组、JSON验证以及渲染,并依然保持其最佳性能。Gin Gonic使用httprouter,Go语言最快的HTTP路由器。

5.Buffalo

要构建Go语言新的Web应用程序,使用Buffalo是一个快速又简单的方法。当你开始一个新项目时,Buffalo可以为你提供一切——从前端到后端开发。

它具有热重载功能,这意味着dev命令将自动查看.go和.html文件。然后,它将为你重建并重启二进制文件。运行dev命令,你就能看到变化在你的眼前发生!

Buffalo不仅仅是一个框架——它也是一个整体的Web开发生态系统,可以让你直接构建应用程序。

6.Goji

Goji是一个轻量级的快速Web框架,将可组合性和简单性作为其主要优先级。很像net / http.ServeMux,Goji是一个极简的HTTP请求复用器。它包括Einhorn支持,允许在Goji中提供Websocket支持。

其他功能包括URL模式,可重新配置的中间件堆栈,正常关机等。Goji可以用于生产,并在若干组织中提供了数以亿计个请求。

7.Tiger Tonic

受Dropwizard启发,Tiger Tonic是开发JSON Web服务和构建高性能REST API的Go框架。为了忠于Golang的原则,Tiger Tonic努力保持正交特性。

Tiger Tonic的缺点在于构建大型后端应用程序尚有不足之处。

8. Gocraft

这是又一个强大而简约的框架,Gocraft提供快速和可扩展的路由性能。它将路由添加来自标准库的net / http包中。

Gocraft是一个Go mux和中间件软件包,具有强大的投射和反射能力,可以静态输入代码。你还可以使用内置中间件添加可选功能或者自己编写。

由于性能始终是开发人员最关心的问题之一,所以Gocraft是开发人员的绝佳选择。而且使用Gocraft框架编写后端Web应用程序很容易。

9.Mango

虽然Mango没有得到创作者Paul Bellamy的积极维护,但Go语言的许多用户仍然在使用它。Mango的优势在于它的模块化。你可以从各种库中选择,以包含在你的项目中。

Mango让你可以尽可能快速又轻松地构建可重复使用的HTTP功能模块。它将一系列中间件和应用程序编译成单个HTTP服务器对象,以保持代码独立。

Golang的集成开发环境(IDE)

Golang的IDE随着Go语言的普及越来越受大家的欢迎。虽然还是有许多开发人员仍然喜欢使用文本编辑器,但也有很多开发人员更倾向于使用IDE。

如果你正工作于具有广泛代码库的大型项目,那么IDE可以帮助你轻松组织代码和导航。此外,IDE可以帮助你测试代码并相应地编辑。

以下是用Golang工作良好的顶尖IDE。

1.Gogland

软件开发公司JetBrains发布了另一个可靠的IDE,这次是针对Golang发布的。Gogland是一个商业IDE,为Go开发人员提供了一个强大的人机工程学环境。它还具有编码协助、调试器和集成终端的功能。

由于Gogland是由一家已成立的公司创建的,所以它拥有广泛的IntelliJ插件生态系统,让你可以在需要更多工具的时候获得更多。

2. Visual Studio Code

由Microsoft创建的Visual Studio Code是一个功能齐全的开源IDE和代码编辑器,支持各种各样的编程语言。它的特点是智能完成;使用断点调用、调用堆栈和交互式控制台调试;内置Git集成;以及分层文件夹和文件浏览器。

作为另一个流行的IDE,Visual Studio Code有一个Go开发人员定期贡献的支持社区。使用Visual Studio Code,你可以使用可用插件数组来扩展功能。

3. LiteIDE

LiteIDE是五年多前创建的首个以Golang为中心的开源IDE。作为具有独特外观的C ++ Qt应用程序,LiteIDE提供代码管理、可配置构建命令、gdb和Delve调试器,使用WordApi——基于MIME类型的系统——自动完成和创建等等。它还提供JSON和Golang支持。

4.Wide

Wide是Golang程序员使用的基于Web的IDE。它专为协作开发而设计,适用于团队和Web开发机构。Wide功能包括代码高亮、调试、Git集成等。

因为Wide是由一名中国开发者创建和维护的,所以其大部分文档和支持是中文的。

5.带有Go-Plus插件的Atom

如果你已经在使用Atom,那么你可以通过一个名为go-plus的开源软件包来改善Golang语言的代码编辑体验。使用go-plus,你可以立即获得关于语法和构建错误的实时反馈。

Go-plus软件包提供了几乎所有Atom中对Golang的支持。它还可以用于工具,构建流程,linters,vet和coverage工具。

Go-plus还包括各种代码片段和功能,如gocode的自动完成,gofmt、goreturns或goimports等的代码格式化。

6.带有GoClipse的Eclipse

由于Eclipse是广受欢迎的IDE,因此我们为其创建了许多插件。GoClipse是针对Golang的Eclipse插件,提供Go源代码编辑,具有可配置的语法高亮和自动缩进以及大括号完成功能。

GoClipse还可以作为项目向导和构建器来立即报告语法和构建错误。GoClipse的其他功能包括调试功能和代码辅助。

7.带有GoSublime的Sublime Text

Sublime Text也是一个复杂的文本编辑器,具有大量的贡献者和开发者社区。因此,开发者为此IDE创建了各种各样的插件。

GoSublime是Sublime Text 3针对Golang的插件,在你编写代码时,提供来自Gocode的代码完成,lint /语法检查,自动添加和删除程序包导入,等等。

8.带有Vim-Go插件的Vim

Vim是一个免费的开源IDE,可以定制和配置各种插件。如果你是Golang程序员,那么你可以使用Vim中由Fatih Arslan创建的vim-go插件。Vim-go自动安装所有必需的二进制文件,为Golang提供平滑的Vim集成。

Vim-go是一款功能强大的插件套件,用于撰写和开发Go。其功能包括高级源代码分析,添加和删除导入路径,多次第三方支持,goto定义,快速文件执行等等。

Vim-go是高度可定制的,可以根据你的需要启用或禁用各种功能。

9.Komodo

Komodo是一个全功能的Go语言IDE,并且支持如Node.js,Python,Ruby,Perl等其他编程语言。使用这个Go IDE,你可以轻松地编写干净的代码。其功能包括高级代码编辑器,智能代码完成,语法检查,版本控制和单元测试,以及允许代码浏览和代码提示的Go Code Intelligence。

Komodo的优点是,它可以很好地协助团队合作,因为允许多个开发人员同时编辑文档。只要一个许可证,Komodo就可以安装在Mac,Windows或Linux上。

10. 带有Go语言(golang.org)支持插件的IntelliJ IDEA

IntelliJ IDEA(由JetBrains公司开发)是可以通过Go语言支持插件从而使用Golang的IDE。如果你想要在IntelliJ IDEA中使用Golang,那么你需要安装此插件,虽然不同于Gogland,它的功能有限。

Golang工具

Golang工具可用于各种项目和Web应用程序。使用这些有用的工具可以帮助开发人员尽可能快速而轻松地编写代码并构建应用程序。

这里有一系列顶级的Golang工具以供参考。

1.Apicompat

Apicompat是一种新的Go语言工具,可帮助开发人员检测向后不兼容的更改和导出的声明。

你可以通过Apicompat避免误报。但是,Apicompat并不能检测到每个向后不兼容的变化。并且,库作者没有考虑到交换参数和其他更改的需要。

2.Checkstyle

受Java Checkstyle启发,针对Golang的Checkstyle输出编码风格的建议。它还允许开发人员检查文件行/函数和行/参数号,然后由用户进行配置。

3.Depth

又一个有用的Golang工具,Depth可帮助Web开发人员检索和可视化Go源代码依赖关系树。它可以用作独立的命令行应用程序或作为项目中的特定包。你可以通过在解析之前在Tree上设置相应的标志来添加自定义。

4.Go-Swagger

该工具包包括各种功能和功能。Go-Swagger是Swagger 2.0的一个实现,可以序列化和反序列化swagger规范。它是RESTful API简约但强大的代表。

通过Go-Swagger,你可以swagger规范文档,验证JSON模式以及其他额外的规则。其他功能包括代码生成,基于swagger规范的API生成,基于代码的规范文档生成,扩展了的字符串格式,等等。

5.Go Meta Linter

如果你需要运行Go lint工具并同时使其输出正常化,那么Go Meta Linter可以为你办到。Go Meta Linter旨在与文本编辑器或IDE集成,如如Sublime Linter插件,Atom go-plus包,Emacs Flycheck检查器,Vim / Neovim,以及Go for Visual Studio Code一起使用。它还支持各种各样的linter和配置文件,如JSON。

6.Go-callvis

Go-callvis是一个Web开发工具,允许你使用Graphviz的点格式可视化Go程序的调用图。此工具在构建具有复杂代码库的大型项目时特别有用。它在你想要了解另一个开发人员的代码结构或重建别人的项目时,也很有用。

通过go-callvis,开发人员可以在程序中关注特定包;根据软件包的分组函数和根据类型的方法;以及将软件包限制到自定义路径前缀,并忽略那些包含它们的自定义前缀。

7.Gonative

Gonative是一个简单的Golang工具,让你能够使用本机库构建Go工具链,而这可以在使用stdlib软件包的Cgo-enabled版本时进行交叉编译。

Gonative为每个平台下载二进制发行版,并将它们的库复制到正确的位置。同时,Gonative设置正确的mod时间,以避免不必要的重建。

不幸的是,Gonative在Windows上仍然未经测试。此外,也没有提供Linux / arm支持。

8.Grapes

Grapes是一种轻量级的Golang工具,旨在轻松地通过SSH分发命令。它由Yaron Sumel编写和积极维护。

Grapes不久将支持完整的主机密钥验证,这是开发人员应该注意到的。

9.Gosimple

Golang linter的伟大之处在于它专注于简化Go源代码。Gosimple始终将最新的Go版本作为目标,因此它需要Go 1.6或更高版本。

如果有新的Go版本,gosimple会建议最轻松和最简单的方法来避免复杂的构造。

10.Go Vendor

Go Vendor是与标准Vendor文件夹兼容的Golang工具。它允许开发人员通过govendor add / update从$GOPATH中复制现有的依赖关系。你还可以通过govendor fetch直接提取新的依赖关系或更新现有的依赖关系,以及使用govendor迁移来移动旧的系统。

总结

如果你有JS / Node背景,那么你还需要学习一些新的编程概念,如协同程序,通道,严格的类型与编译,接口,结构,指针和其他一些差异。但是,一旦你进入状态,你会发现Golang用起来更容易,也更快。


版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

nmcli网络配置命令

nmcli使用方法非常类似linux ip命令、cisco交换机命令,并且支持tab补全,也可在命令最后通过-h、–help、help查看帮助。在nmcli中有2个命令最为常用:

nmcli语法:
nmcli [ OPTIONS ] OBJECT { COMMAND | help }
OBJECT和COMMAND可以用全称也可以用简称,最少可以只用一个字母,建议用头三个字母。OBJECT里面我们平时用的最多的就是connection和device,还有其他的选项在里暂时不介绍,这里需要简单区分一下connection和device

详细的介绍请看这篇文章:RHEL/CentOS系列发行版nmcli命令概述

这里主要介绍命令的使用

1、查看网络接口信息
————————————————————–
nmcli          ##查看ip(类似于ifconfig、ip addr)

nmcli device status      ##所有接口的简略信息

nmcli device show       ##所有接口的详细信息

nmcli device show interface-name     ##特定接口的详细信息
————————————————————–

2、查看连接信息
————————————————————–
nmcli connection show         ##所有连接的简略信息

nmcli connection show –active      ##显示激活的连接

nmcli connection show inteface-name   ##某个接口的详细连接信息
————————————————————–

3、激活连接与取消激活链接
————————————————————–
#激活连接
nmcli connection up connection-name
nmcli device connect interface-name

#取消激活链接
nmcli connection down connection-name    ##这个操作当取消一个激活后,如果有其它连接会自动激活其它连接
nmcli device disconnect interface-name     ##这个操作会取消接口上的激活,如果有其它连接也不会自动激活其它连接
————————————————————–
建议使用 nmcli device disconnect(connect) interface-name,因为连接断开可将该接口放到“手动”模式,这样做用户让 NetworkManager 启动某个连接前,或发生外部事件(比如载波变化、休眠或睡眠)前,不会启动任何自动连接。

4、创建动态获取ip地址的连接
————————————————————–
nmcli connection add type ethernet con-name connection-name ifname interface-name

add表示添加连接,type后面是指定创建连接时候必须指定类型,类型有很多,可以通过nmcli c add type -h看到,这里指定为ethernet。con-name后面是指定创建连接的名字,ifname后面是指定物理设备,网络接口

例子:nmcli connection add type ethernet con-name dhcp-ens33 ifname ens33
————————————————————–

5、创建静态ip地址连接
————————————————————–
nmcli connection add type ethernet con-name connection-name ifname interface-name ipv4.method manual ipv4.addresses address ipv4.gateway address

ipv4.addresses后面指定网卡ipv4的地址,ipv4.gateway后面指定网卡的ipv4网关

例子:nmcli connection add type ethernet con-name static-enp0s3 ifname enp0s3 ipv4.method manual ipv4.addresses 192.168.1.115/24 ipv4.gateway 192.168.1.1
————————————————————–
注意:创建连接后,NetworkManager 自动将 connection.autoconnect 设定为 yes。还会将设置保存到 /etc/sysconfig/network-scripts/connection-name 文件中,且自动将 ONBOOT 参数设定为 yes。

6、常用参数和网卡配置文件参数的对应关系这个只使用RHEL系列的发行版,不适合Debian系列发行版
————————————————————–

7、修改连接配置

————————————————————–
#添加一个ip地址
nmcli connection modify connection-name ipv4.addresses 192.168.0.58     ##如果已经存在ip会更改现有ip

#给eth0添加一个子网掩码(NETMASK)
nmcli connection modify connection-name ipv4.addresses 192.168.0.58/24

#获取方式设置成手动(BOOTPROTO=static/none)

nmcli connection modify connection-name ipv4.method manual

#获取方式设置成自动(BOOTPROTO=dhcp)

nmcli connection modify connection-name ipv4.method auto

#添加DNS

nmcli connection modify connection-name ipv4.dns 114.114.114.114

#删除DNS

nmcli connection modify connection-name -ipv4.dns 114.114.114.114 (注意这里的减号)

#添加一个网关(GATEWAY)

nmcli connection modify connection-name ipv4.gateway 192.168.0.2

#可一块写入:

nmcli connection modify connection-name ipv4.dns 114.114.114.114 ipv4.gateway 192.168.0.2

#修改连接是否随开机激活
nmcli connection modify connection-name connection.autoconnect no/on

#配置静态路由,重启系统依然生效

nmcli connection modify connection-name +ipv4.routes “192.168.12.0/24 10.10.10.1”

这样会将 192.168.122.0/24 子网的流量指向位于 10.10.10.1 的网关,同时在 /etc/sysconfig/network-scripts/目录下生产一个route-connection-name的文件,这里记录了这个连接的路由信息

————————————————————–

8、重载connection
————————————————————–
#重载所有ifcfg到connection(不会立即生效,在通过配置文件更改后需要做这个操作让NM知道你做了更改,重新激活连接或重启NM服务后生效)
nmcli connection reload
————————————————————–
#重载指定ifcfg到connection(不会立即生效,重新激活连接或重启NM服务后生效)
nmcli connection load /etc/sysconfig/network-scripts/ifcfg-connection-name
nmcli connection load /etc/sysconfig/network-scripts/route-connection-name
————————————————————–

9、删除connection
————————————————————–
nmcli connection delete connection-name
————————————————————–

10、设置主机名
————————————————————–
#查询当前主机名
nmcli general hostname

#修改主机名
nmcli general hostname new-hostname

#重启hostname(主机名)服务
systemctl restart systemd-hostnamed
————————————————————–
注意:CentOS7 / Redhat7 下的主机名管理是基于系统服务systemd-hostnamed,服务自身提供了hostnamectl命令用于修改主机名,推荐这种方式进行修改;
使用nmcli命令更改主机名时,systemd-hostnamed服务并不知晓 /etc/hostname 文件被修改,因此需要重启服务去读取配置;

automatically connect OpenConnect VPN use a service file

i use a service file

/etc/systemd/system/myVpn.service

[Unit]
Description=My Vpn Connection
After=network.target

[Service]
Type=simple
Environment=password=correcthorsebatterystaple
 ExecStart=/bin/sh -c 'echo YourPasswordHere | sudo openconnect --protocol=nc YourServerHere --user=YourUserHere --passwd-on-stdin'

Restart=always

systemctl enable myVpn

systemctl start myVpn

快速分析 Apache 的 access log,抓出前十大網站流量兇手

說到 Log 分析大家都會先想到用 AWStats 來分析,沒錯這絕對是一個最好的解決方式,但如果你只是要簡單的分析一些資訊,就可以利用一些簡單的 shell 組合來撈出你要的資料

 

這篇主要是針對 Apache 的 access log 來進行分析,並提供以下範例給大家參考

 

取得前十名access 最多的IP 位址

cat access_log | awk'{print $ 1}'| sort | uniq -c | sort -nr | head -10

 

取得前十名 access 最多的網頁

cat access_log | awk'{print $ 11}'| sort | uniq -c | sort -nr | head -10

 

取得前十名下載流量最大的 zip 檔案

cat access.log | awk'($ 7〜/ \。zip /){print $ 10“” $ 1“” $ 4“” $ 7}'| sort -nr | head -10

 

取得前十名 Loading 最大的頁面 (大於60秒的 php 頁面)

cat access_log | awk'($ NF> 60 && $ 7〜/ \。php /){print $ 7}'| sort -n | uniq -c | sort -nr | head -10

 

取得前十名 User access 最久的頁面

cat access_log | awk'($ 7〜/ \。php /){print $ NF“” $ 1“” $ 4“” $ 7}'| sort -nr | head -10

 

取得access log 平均流量(GB)

cat access_log | awk'{sum + = $ 10} END {print sum / 1024/1024/1024}'

 

取得所有404 Link

awk'($ 9〜/ 404 /)'access_log | awk'{print $ 9,$ 7}'| 分類

 

取得所有 access code 的 stats 數量

cat access_log | awk -F'''$ 9 ==“ 400” || $ 9 ==“ 404” || $ 9 ==“ 408” || $ 9 ==“ 499” || $ 9 ==“ 500” || $ 9 ==“ 502” || $ 9 ==“ 504” {print $ 9}'| | 排序| uniq -c | 更多

 

以上只是簡單分析出常用的需求,也可以自行斟酌調整,然後再從中找到自己想要的分析模式

相信在日常的維護使用中可以幫上很大的忙。

 

希望是最淺顯易懂的 RxJS 教學

前言

關注 RxJS 已經好一段時間了,最早知道這個東西是因為 redux-observable,是一個 redux 的 middleware,Netflix 利用它來解決複雜的非同步相關問題,那時候我連redux-saga都還沒搞懂,沒想到就又有新的東西出來了。

半年前花了一些時間,找了很多網路上的資料,試圖想要搞懂這整個東西。可是對我來說,很多教學的步調都太快了,不然就是講得太仔細,反而讓初學者無所適從。

這次有機會在公司的新專案裡面嘗試導入redux-observable,身為提倡要導入的人,勢必要對這東西有一定的瞭解。秉持著這個想法,上週認真花了點時間再次把相關資源都研究了一下,漸漸整理出一套「我覺得應該可以把 RxJS 講得更好懂」的方法,在這邊跟大家分享一下。

在開始之前,要先大力稱讚去年 iT 邦幫忙鐵人賽的 Web 組冠軍:30 天精通 RxJS,這系列文章寫得很完整,感受得出來作者下了很多功夫在這上面。看完這篇之後如果對更多應用有興趣的,可以去把這系列的文章讀完。

好,那就讓我們開始吧!

請你先忘掉 RxJS

沒錯,你沒看錯。

要學會 RxJS 的第一件事情就是:忘記它。

忘記有這個東西,完全忘記,先讓我講幾個其他東西,等我們需要講到 RxJS 的時候我會再提醒你的。

在我們談到主角之前,先來做一些有趣的事情吧!

程式基礎能力測試

先讓我們做一個簡單的練習題暖身,題目是這樣的:

有一個陣列,裡面有三種類型的資料:數字、a~z組成的字串、數字組成的字串,請你把每個數字以及數字組成的字串乘以二之後加總
範例輸入:[1, 5, 9, 3, ‘hi’, ‘tb’, 456, ’11’, ‘yoyoyo’]

你看完之後應該會說:「這有什麼難的?」,並且在一分鐘以內就寫出下面的程式碼:

const source = [1, 5, 9, 3, 'hi', 'tb', 456, '11', 'yoyoyo'];
let total = 0;

for (let i = 0; i < source.length; i++) {
  let num = parseInt(source[i], 10);
  if (!isNaN(num)) {
    total += num * 2;
  }
}

相信大家一定都是很直覺的就寫出上面的程式碼,但如果你是個 functional programming 的愛好者,你可能會改用另外一種思路來解決問題:

const source = [1, 5, 9, 3, 'hi', 'tb', 456, '11', 'yoyoyo'];

let total = source
  .map(x => parseInt(x, 10))
  .filter(x => !isNaN(x))
  .map(x => x * 2)
  .reduce((total, value) => total + value )

一開始的例子叫做Imperative(命令式),用陣列搭配一堆函式的例子叫做Declarative(聲明式)。如果你去查了一下定義,應該會看到這兩個的解釋:

Imperative 是命令機器去做事情(how),這樣不管你想要的是什麼(what),都會按照你的命令實現;Declarative 是告訴機器你想要的是什麼(what),讓機器想出如何去做(how)

好,你有看懂上面這些在說什麼嗎?

我是沒有啦。

所以讓我們再看一個例子,其實 Declarative 你已經常常在用了,只是你不知道而已,那就是 SQL:

SELECT * from dogs INNER JOIN owners WHERE dogs.owner_id = owners.id

這句話就是:我要所有狗的資料加上主人的資料。

我只有說「我要」而已,那要怎麼拿到這些資料?我不知道,我也不用知道,都讓 SQL 底層決定怎麼去操作就好。

如果我要自己做出這些資料,在 JavaScript 裡面我必須這樣寫(程式碼取自声明式编程和命令式编程的比较):

//dogs = [{name: 'Fido', owner_id: 1}, {...}, ... ]
//owners = [{id: 1, name: 'Bob'}, {...}, ...]

var dogsWithOwners = []
var dog, owner

for(var di=0; di < dogs.length; di++) {
  dog = dogs[di]
  for(var oi=0; oi < owners.length; oi++) {
    owner = owners[oi]
    if (owner && dog.owner_id == owner.id) {
      dogsWithOwners.push({
        dog: dog,
        owner: owner
      })
    }
  }
}

應該可以大致體驗出兩者的差別吧?後者你必須自己一步步去決定該怎麼做,而前者只是僅僅跟你說:「我想要怎樣的資料」而已。

接著我們再把目光放回到把數字乘以二相加的那個練習。對我來說,最大的不同點是後面那個用陣列搭配函式的例子,他的核心概念是:

把原始資料經過一連串的轉換,變成你想要的資訊

這點超級重要,因為在一開始的例子中,我們是自己一步步去 parse,去檢查去相加,得出數字的總和。而後面的那個例子,他是把原始的資料(陣列),經過一系列的轉換(map, filter, reduce),最後變成了我們想要的答案。

畫成圖的話,應該會長這樣(請原諒我偷懶把乘二的部分拿掉了,但意思不影響):

把原始資料經過一連串的轉換,最後變成你想要的答案,這點就是後者最大的不同。只要你有了這個基礎知識之後,再來看 RxJS 就不會覺得太奇怪了。

Reactive Programming

談到 RxJS 的時候,都會談到 Reactive 這個詞,那什麼是 Reactive 呢?可以從英文上的字義來看,這個單字的意思是:「反應、反應性的」,意思就是你要對一些事情做出反應。

所以 Reactive 其實就是在講說:「某些事情發生時,我能夠做出反應」。

讓我們來舉一個大家非常熟知的例子:

window.addEventListener('click', function(){
  console.log('click!');
})

我們加了一個 event listener 在 window 上面,所以我們可以監聽到這個事件,每當使用者點擊的時候就列印出 log。換句話說,這樣就是:「當 window 被點擊時,我可以做出反應」。

正式進入 RxJS

如果你去看 ReactiveX 的網頁,你會發現他有明確的定義 ReactiveX:

ReactiveX is a combination of the best ideas from
the Observer pattern, the Iterator pattern, and functional programming

第一個 Observer pattern 就像是 event listener 那樣,在某些事情發生時,我們可以對其作出反應;第二個 Iterator pattern 我們跳過不講,我認為暫時不影響理解;第三個就像是一開始的例子,我們可以把一個陣列經過多次轉換,轉換成我們想要的資料。

在 Reactive Programming 裡面,最重要的兩個東西叫做 Observable 跟 Observer,其實一開始讓我最困惑的點是因為我英文不好,不知道這兩個到底誰是觀察的誰是被觀察的。

先把它們翻成中文,Observable 就是「可被觀察的」,Observer 就是所謂的「觀察者」。

這是什麼意思呢?就如同上面的例子一樣,當(可被觀察的東西)有事情發生,(Observer,觀察者)就可以做出反應。

直接舉一個例子你就知道了:

Rx.Observable.fromEvent(window, 'click')
  .subscribe(e => {
    console.log('click~');
  })

上面這段程式碼跟我幫 window 加上 event listener 在做的事情完全一樣,只是這邊我們使用了 RxJS 提供的方法叫做fromEvent,來把一個 event 轉成 Observable(可被觀察的),並且在最後加上 subscribe。

這樣寫就代表說我訂閱了這個 Observable,只要有任何事情發生,就會執行我傳進去的 function。

所以到底什麼是 Observable?

Observable 就是一個可被觀察的對象,這個對象可以是任何東西(例如說上述例子就是 window 的 click 事件),當有新資料的時候(例如說新的點擊事件),你就可以接收到這個新資料的資訊並且做出反應。

比起 Observable 這個冷冰冰的說法,我更喜歡的一個說法是 stream,資料流。其實每一個 Observable 就是一個資料流,但什麼是資料流?你就想像成是會一直增加元素的陣列就好了,有新的事件發生就 push 進去。如果你喜歡更專業一點的說法,可以叫它:「時間序列上的一連串資料事件」(取自 Reactive Programming 簡介與教學(以 RxJS 為例)

或是我再舉一個例子,stream 的另外一個解釋就是所謂的「串流影片」,意思就是隨著你不斷播放,就會不斷下載新的片段進來。此時你腦中應該要有個畫面,就是像水流那樣,不斷有新的東西流進來,這個東西就叫做 stream。


(圖片取自 giphy

我理解資料流了,然後呢?

上面有說過,我們可以把任何一個東西轉成 Observable,讓它變成資料流,可是這不就跟 addEventListener 一樣嗎?有什麼特別的?

有,還真的比較特別。

希望你沒有忘記我們剛開始做的那個小練習,就是把一個陣列透過一系列轉換,變成我們要的資料的那個練習。我剛剛有說,你可以把 Observable 想成是「會一直增加元素的陣列」,這代表什麼呢?

代表我們也可以把 Observable 做一系列的轉換!我們也可以用那些用在陣列上的 function!

Rx.Observable.fromEvent(window, 'click')
  .map(e => e.target)
  .subscribe(value => {
    console.log('click: ', value)
  })

我們把 click 事件經過 map 轉換為點擊到的 element,所以當我們最後在 subscribe 的時候,收到的 value 就會是我們點擊的東西。

接著來看一個稍微進階一點的例子:

Rx.Observable.fromEvent(window, 'click')
  .map(e => 1)
  .scan((total, now) => total + now)
  .subscribe(value => {
    document.querySelector('#counter').innerText = value;
  })

首先我們先把每一個 click 事件都透過map轉換成 1(或者你也可以寫成.mapTo(1)),所以每按一次就送出一個數字 1。scan的話其實就是我們一開始對陣列用的reduce,你可以想成是換個名字而已。透過scan加總以後傳給 subscriber,顯示在頁面上面。

就這樣簡單幾行,就完成了一個計算點擊次數的 counter。

可以用一個簡單的 gif 圖來表示上面的範例:

可是 Observable 不只這樣而已,接下來我們要進入到它最厲害的地方了。

威力無窮的組合技

如果把兩個陣列合併,會變成什麼?例如說[1, 2, 3][4, 5, 6]

這要看你指的「合併」是什麼,如果是指串接,那就是[1, 2, 3, 4, 5, 6],如果是指相加,那就是[5, 7, 9]

那如果把兩個 Observable 合併會變成什麼?

Observable 跟陣列的差別就在於多了一個維度:時間。

Observable 是「時間序列上的一連串資料事件」,就像我前面講的一樣,可以看成是一個一直會有新資料進來的陣列。

我們先來看看一張很棒的圖,很清楚地解釋了兩個 Observable 合併會變成什麼:


(取自:http://rxmarbles.com/#merge)

上面是一個 Observable,每一個圓點代表一個資料,下面也是一樣,把這兩個合併之後就變成最下面那一條,看圖解應該還滿好懂的,就像是把兩個時間軸合併一樣。

讓我們來看一個可以展現合併強大之處的範例,我們有 +1 跟 -1 兩個按鈕以及文字顯示現在的數字是多少:

該怎麼達成這個功能呢?基本的想法就是我們先把每個 +1 的 click 事件都通過mapTo變成數字 1,取叫 Observable_plus1 好了。再做出一個 Observable_minus1 是把每個 -1 的 click 事件都通過mapTo變成數字 -1。

把這兩個 Observable 合併之後,再利用剛剛提到的scan加總,就是目前應該要顯示的數字了!

Rx.Observable.fromEvent(document.querySelector('input[name=plus]'), 'click')
  .mapTo(1)
  .merge(
    Rx.Observable.fromEvent(document.querySelector('input[name=minus]'), 'click')
      .mapTo(-1)
  )
  .scan((total, now) => total + now)
  .subscribe(value => {
    document.querySelector('#counter').innerText = value;
  })

如果你還是不懂的話,可以參考下面的精美範例,示範這兩個 Observable 是怎麼合在一起的(O代表點擊事件,+1-1則是mapTo之後的結果):

讓我們來比較一下如果不用 Observable 的話,程式碼會長怎樣:

var total = 0;
document.querySelector('input[name=plus]').addEventListener('click', () => {
  total++;
  document.querySelector('#counter').innerText = total;
})

document.querySelector('input[name=minus]').addEventListener('click', () => {
  total--;
  document.querySelector('#counter').innerText = total;
})

有沒有發覺兩者真的差別很大?就如同我之前所說的,是兩種完全不同的思考模式,所以 Reactive Programming 困難的地方不是在於理解,也不是在於語法(這兩者相信你目前都有些概念了),而是在於換一種全新的思考模式。

以上面的寫法來說,就是告訴電腦:「按下加的時候就把一個變數 +1,然後更改文字;按下減的時候就 -1 並且也更改文字」,就可以達成計數器的功能。

以 Reactive 的寫法,就是把按下加當成一個資料流,把按下減也當成一個資料流,再透過各種 function 把這兩個流轉換並且合併起來,讓最後的那個流就是我們想要的結果(計數器)。

你現在應該能體會到我一開始說的了:「把原始資料經過一連串的轉換,最後變成你想要的答案」,這點就是 Reactive Programming 最大的特色。

組合技中的組合技

我們來看一個更複雜一點的範例,是在 canvas 上面實現非常簡單的繪圖功能,就是滑鼠按下去之後可以畫畫,放開來就停止。

要實現這個功能很間單,canvas 提供lineTo(x, y)這個方法,只要在滑鼠移動時不斷呼叫這個方法,就可以不斷畫出圖形來。但有一點要注意的是當你在按下滑鼠時,應該先呼叫moveTo(x, y)把繪圖的點移到指定位置,為什麼呢?

假設我們第一次畫圖是在左上角,第二次按下滑鼠的位置是在右下角,如果沒有先用moveTo移動而是直接用lineTo的話,就會多一條線從左上角延伸到右下角。moveTolineTo的差別就是前者只是移動,後者會跟上次的點連接在一起畫成一條線。

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
ctx.beginPath(); // 開始畫畫

function draw(e){
  ctx.lineTo(e.clientX,e.clientY); // 移到滑鼠在的位置
  ctx.stroke(); // 畫畫
}

// 按下去滑鼠才開始偵測 mousemove 事件
canvas.addEventListener('mousedown', function(e){
  ctx.moveTo(e.clientX, e.clientY); // 每次按下的時候必須要先把繪圖的點移到那邊,否則會受上次畫的位置影響
  canvas.addEventListener('mousemove', draw);
})

// 放開滑鼠就停止偵測 
canvas.addEventListener('mouseup', function(e){
  canvas.removeEventListener('mousemove', draw);
})

那如果在 RxJS 裡面,該怎麼實作這個功能呢?

首先憑直覺,應該就是先加上mousedown的事件對吧!至少有個開頭。

Rx.Observable.fromEvent(canvas, 'mousedown')
  .subscribe(e => {
    console.log('mousedown');
  })

可是滑鼠按下去之後應該要變成什麼?這個時候應該要開始監聽mousemove對吧,所以我們這樣寫,用mapTo把每一個mousedown的事件都轉換成mousemove的 Observable:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .mapTo(
    Rx.Observable.fromEvent(canvas, 'mousemove')
  )
  .subscribe(value => {
    console.log('value: ', value);
  })

接著你看一下 console,你會發現每當我點擊的時候,console 就會印出FromEventObservable {_isScalar: false, sourceObj: canvas#canvas, eventName: "mousemove", selector: undefined, options: undefined}

仔細想一下你會發現也滿合理的,因為我用mapTo把每一個滑鼠按下去的事件轉成一個 mousemove 的 Observable,所以用 subscribe 訂閱之後拿到的東西就會是這個 Observable。如果畫成圖,大概長得像這樣:

好了,那怎麼辦呢?我想要的其實不是 Observable 本身,而是屬於這個 Observable 裡面的那些東西啊!現在這個情形就是 Observable 裡面又有 Observable,有兩層,可是我想要讓它變成一層就好,該怎麼辦呢?

在此提供一個讓 Observable 變簡單的訣竅:

只要有問題,先想想 Array 就對了!

我前面有提過,可以把 Observable 看成是加上時間維度的進階版陣列,因此只要是陣列有的方法,Observable 通常也都會有。

舉例來說,一個陣列可能長這樣:[1, [2, 2.5], 3, [4, 5]]一共有兩層,第二層也是一個陣列。

如果想讓它變一層的話怎麼辦呢?壓平!

有用過 lodash 或是其他類似的 library 的話,你應該有聽過_.flatten這個方法,可以把這種陣列壓平,變成:[1, 2, 2.5, 3, 4, 5]

用 flat 這個關鍵字去搜尋 Rx 文件的話,你會找到一個方法叫做 FlatMap,簡單來說就是先map之後再自動幫你壓平。

所以,我們可以把程式碼改成這樣:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove'))            
  .subscribe(e => {
    console.log(e);
  })

當你點擊之後,會發現隨著滑鼠移動,console 會印出一大堆 log,就代表我們成功了。

畫成示意圖的話會變成這樣(為了方便說明,我把flatMap在圖片上變成mapflatten兩個步驟):

接下來呢?接下來我們要讓它可以在滑鼠鬆開的時候停止,該怎麼做呢?RxJS 有一個方法叫做takeUntil,意思就是拿到…發生為止,傳進去的參數必須是一個 Observable。

舉例來說,如果寫.takeUntil(window, 'click'),就表示如果任何window的點擊事件發生,這個 Observable 就會立刻終止,不會再送出任何資料。

應用在繪畫的例子上,我們只要把takeUntil後面傳的參數換成滑鼠鬆開就好!順便把subscribe跟畫畫的 function 也一起完成吧!

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove'))
  .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))         
  .subscribe(e => {
    draw(e);
  })

改完之後馬上來實驗一下!滑鼠按下去之後順利開始畫圖,鬆開以後畫圖停止,完美!

咦,可是怎麼按下第二次就沒反應了?我們做出了一個「只能夠成功畫一次圖」的 Observable。

為什麼呢?我們可以先來看一下takeUntil的示意圖(取自:http://rxmarbles.com/#takeUntil)

以我們的情形來說,就是只要mouseup事件發生,「整個 Observable」就會停止,所以只有第一次能夠畫圖成功。但我們想要的其實不是這樣,我們想要的是只有mousemove停止而已,而不是整個都停止。

所以,我們應該把takeUntil放在mousemove的後面,也就是:

Rx.Observable.fromEvent(canvas, 'mousedown')
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove')
      .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))  
  )
  .subscribe(e => {
    draw(e);
  })

這樣子裡面的那個mousemove的 Observable 就會在滑鼠鬆開時停止發送事件,而我們最外層的這個 Observable 監聽的是滑鼠按下,會一直監聽下去。

到這邊其實就差不多了,但還有一個小 bug 要修,就是我們沒有在mousedown的時候利用moveTo移動,造成我們一開始說的那個會把上次畫的跟這次畫的連在一起的問題。

那怎麼辦呢?我已經把mousedown事件轉成其他資料流了,我要怎麼在mousedown的時候做事?

有一個方法叫做do,就是為了這種情形而設立的,使用時機是:「你想做一點事,卻又不想影響資料流」,有點像是能夠針對不同階段 subscribe 的感覺,mousedown的時候 subscribe 一次,最後要畫圖的時候又 subscribe 一次。

Rx.Observable.fromEvent(canvas, 'mousedown')
  .do(e => {
    ctx.moveTo(e.clientX, e.clientY)
  })
  .flatMap(e => Rx.Observable.fromEvent(canvas, 'mousemove')
      .takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup'))  
  )
  .subscribe(e => {
    draw(e);
  })

到這邊,我們就順利完成了畫圖的功能。

如果你想試試看你有沒有搞懂,可以實作看看拖拉移動物體的功能,原理跟這個很類似,都是偵測滑鼠的事件並且做出反應。

喝口水休息一下,下半場要開始了

上半場的目標在於讓你理解什麼是 Rx,並且掌握幾個基本概念:

  1. 一個資料流可以經過一系列轉換,變成另一個資料流
  2. 這些轉換基本上都跟陣列有的差不多,像是mapfilterflatten等等
  3. 你可以合併多個 Observable,也可以把二維的 Observable 壓平

下半場專注的點則是在於實戰應用,並且圍繞著 RxJS 最適合的場景之一:API。

前面我們有提到說可以把 DOM 物件的 event 變成資料流,但除了這個以外,Promise 其實也可以變成資料流。概念其實也很簡單啦,就是 Promise 被 resovle 的時候就發送一個資料,被 reject 的時候就終止。

讓我們來看一個簡單的小範例,每按一次按鈕就會發送一個 request

function sendRequest () {
  return fetch('https://jsonplaceholder.typicode.com/posts/1').then(res => res.json())
}

Rx.Observable.fromEvent(document.querySelector('input[name=send]'), 'click')
  .flatMap(e => Rx.Observable.fromPromise(sendRequest()))
  .subscribe(value => {
    console.log(value)
  })

這邊用flatMap的原因跟剛才的畫圖範例一樣,我們要在按下按鈕時,把原本的資料流轉換成新的資料流,如果只用map的話,會變成一個二維的 Observable,所以必須要用flatten把它壓平。

你可以試試看把flatMap改成map,你最後 subscribe 得到的值就會是一堆 Observable 而不是你想要的資料。

知道怎麼用 Rx 來處理 API 之後,就可以來做一個經典範例了:AutoComplete。

我在做這個範例的時候有極大部分參考30 天精通 RxJS(19): 實務範例 – 簡易 Auto Complete 實作Reactive Programming 簡介與教學(以 RxJS 為例)以及构建流式应用—RxJS详解,再次感謝這三篇文章。

為了要讓大家能夠體會 Reactive Programming 跟一般的有什麼不一樣,我們先用老方法做出這個 Auto Complete 的功能吧!

先來寫一下最底層的兩個函式,負責抓資料的以及 render 建議清單的,我們使用維基百科的 API 來當作範例:

function searchWikipedia (term) {
    return $.ajax({
        url: 'http://en.wikipedia.org/w/api.php',
        dataType: 'jsonp',
        data: {
            action: 'opensearch',
            format: 'json',
            search: term
        }
    }).promise();
}

function renderList (list) {
  $('.auto-complete__list').empty();
  $('.auto-complete__list').append(list.map(item => '<li>' + item + '</li>'))
}

這邊要注意的一個點是維基百科回傳的資料會是一個陣列,格式如下:

[你輸入的關鍵字, 關鍵字清單, 每個關鍵字的介紹, 每個關鍵字的連結]

// 範例:
[
  "dd",
  ["Dd", "DDR3 SDRAM", "DD tank"],
  ["", "Double data rate type three SDRAM (DDR3 SDRAM)", "DD or Duplex Drive tanks"],
  [https://en.wikipedia.org/wiki/Dd", "https://en.wikipedia.org/wiki/DDR3_SDRAM", "...略"]
]

在我們的簡單示範中,只需要取 index 為 1 的那個關鍵字清單就好了。而renderList這個 function 則是傳進一個陣列,就會把陣列內容轉成li顯示出來。

有了這兩個最基礎的 function 之後,就可以很輕易地完成 Auto Complete 的功能:

document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  searchWikipedia(e.target.value).then((data) => {
    renderList(data[1])
  })
})

程式碼應該很好懂,就是每次按下輸入東西的時候去 call api,把回傳的資料餵給renderList去渲染。

最基本的功能完成了,我們要來做一點優化,因為這樣子的實作其實是有一些問題的。

第一個問題,現在只要每打一個字就會送出一個 request,可是這樣做其實有點浪費,因為使用者可能快速的輸入了:java想要找相關的資料,他根本不在乎jjajav這三個 request。

要怎麼做呢?我們就改寫成如果 250ms 裡面沒有再輸入新的東西才發送 request 就好,就可以避免這種多餘的浪費。

這種技巧稱作debounce,實作上也很簡單,就是利用setTimeoutclearTimeout

var timer = null;
document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    searchWikipedia(e.target.value).then((data) => {
      renderList(data[1])
    })
  }, 250)
})

在 input 事件被觸發之後,我們不直接做事情,而是設置了一個 250ms 過後會觸發的 timer,如果 250ms 內 input 再次被觸發的話,我們就把上次的 timer 清掉,再重新設置一個。

如此一來,就可以保證使用者如果在短時間內不斷輸入文字的話,不會送出相對應的 request,而是會等到最後一個字打完之後的 250 ms 才發出 request。

解決了第一個問題之後,還有一個潛在的問題需要解決。

假設我現在輸入a,接著刪除然後再輸入b,所以第一個 request 會是a的結果,第二個 request 會是b的結果。我們假設 server 出了一點問題,所以第二個的 response 反而比第一個還先到達(可能b的搜尋結果有 cache 但是a沒有),這時候就會先顯示b的內容,等到第一個 response 回來時,再顯示a的內容。

可是這樣 UI 就有問題了,我明明輸入的是b,怎麼 auto complete 的推薦關鍵字是a開頭?

所以我們必須要做個檢查,檢查返回的資料跟我現在輸入的資料是不是一致,如果一致的話才 render:

var timer = null;
document.querySelector('.auto-complete input').addEventListener('input', (e) => {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    searchWikipedia(e.target.value).then((data) => {
      if (data[0] === document.querySelector('.auto-complete input').value) {
        renderList(data[1])
      }
    })
  }, 250)
})

到這裡應該就差不多了,該有的功能都有了。

接著,讓我們來挑戰用 RxJS 實作吧!

首先,先從簡單版的開始做,就是不包含 debounce 跟上面 API 順序問題的實作,監聽 input 事件轉換成 request,然後用flatMap壓平,其實就跟上面的流程差不多:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .map(e => e.target.value)
  .flatMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

這邊用了兩個map,一個是把e轉成e.target.value,一個是把傳回來的結果轉成res[1],因為我們只需要關鍵字列表,其他的東西其實都不用。

那要如何實作debounce的功能呢?

RxJS 已經幫你實作好了,所以你只要加上.debounceTime(250)就好了,就是這麼簡單。

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .flatMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

還有最後一個問題要解決,那就是剛才提到的 request 的順序問題。

Observable 有一個不同的解法,我來解釋給大家聽聽。

其實除了flatMap以外,還有另外一種方式叫做switchMap,他們的差別在於要怎麼把 Observable 給壓平。前者我們之前介紹過了,就是會把每一個二維的 Observable 都壓平,並且「每一個都執行」。

switchMap的差別在於,他永遠只會處理最後一個 Observable。拿我們的例子來說,假設第一個 request 還沒回來的時候,第二個 request 就發出去了,那我們的 Observable 就只會處理第二個 request,而不管第一個。

第一個還是會發送,還是會接收到資料,只是接收到資料以後不會再把這個資料 emit 到 Observable 上面,意思就是根本沒人理這個資料了。

可以看一下簡陋的圖解,flatMap每一個 promise resolve 之後的資料都會被發送到我們的 Observable 上面:

switchMap只會處理最後一個:

所以我們只要把flatMap改成switchMap,就可以永遠只關注最後一個發送的 request,不用去管 request 傳回來的順序,因為前面的 request 都跟這個 Observable 無關了。

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .switchMap(value => {
    return Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

做到這邊,就跟剛剛實作的功能一模一樣了。

但其實還有地方可以改進,我們來做個小小的加強好了。現在的話當我輸入abc,會出現abc的相關關鍵字,接著我把abc全部刪掉,讓 input 變成空白,會發現 API 這時候回傳一個錯誤:The "search" parameter must be set.

因此,我們可以在 input 是空的時候,不發送 request,只回傳一個空陣列,而回傳空陣列這件事情可以用Rx.Observable.of([])來完成,這樣會創造一個會發送空陣列的 Observable:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete input'), 'input')
  .debounceTime(250)
  .map(e => e.target.value)
  .switchMap(value => {
    return value.length < 1 ? Rx.Observable.of([]) : Rx.Observable.from(searchWikipedia(value)).map(res => res[1])
  })
  .subscribe(value => {
    renderList(value);
  })

還有一個點擊關鍵字清單之後把文字設定成關鍵字的功能,在這邊就不示範給大家看了,但其實就是再創造一個 Observable 去監聽點擊事件,點到的時候就設定文字並且把關鍵字清單給清掉。

我直接附上參考程式碼:

Rx.Observable
  .fromEvent(document.querySelector('.auto-complete__list'), 'click')
  .filter(e => e.target.matches('li'))
  .map(e => e.target.innerHTML)
  .subscribe(value => {
    document.querySelector('.auto-complete input').value = value;
    renderList([])
  })

雖然我只介紹了最基本的操作,但 RxJS 的強大之處就在於除了這些,你甚至還有retry可以用,只要輕鬆加上這個,就能夠有自動重試的功能。

相關的應用場景還有很多,只要是跟 API 有關連的幾乎都可以用 RxJS 很優雅的解決。

React + Redux 的非同步解決方案:redux-observable

這是我們今天的最後一個主題了,也是我開場所提到的。

React + Redux 這一套非常常見的組合,一直都有一個問題存在,那就是沒有規範非同步行為(例如說 API)到底應該怎麼處理。而開源社群也有許多不同的解決方案,例如說 redux-thunk、redux-promise、redux-saga 等等。

我們前面講了這麼多東西,舉了這麼多範例,就是要證明給大家看 Reactive programming 很適合拿來解決複雜的非同步問題。因此,Netflix 就開源了這套redux-observable,用 RxJS 來處理非同步行為。

在瞭解 RxJS 之後,可以很輕鬆的理解redux-observable的原理。

在 redux 的應用裡面,所有的 action 都會通過 middleware,你可以在這邊對 action 做任何處理。或者我們也可以把 action 看做是一個 Observable,例如說:

// 範例而已
Rx.Observable.from(actionStreams)
  .subscribe(action => {
    console.log(action.type, action.payload)
  })

有了這個以後,我們就可以做一些很有趣的事情,例如說偵測到某個 action 的時候,我們就發送 request,並且把 response 放進另外一個 action 裡面送出去。

Rx.Observable.from(actionStreams)
  .filter(action => action.type === 'GET_USER_INFO')
  .switchMap(
    action => Rx.Observable.from(API.getUserInfo(action.payload.userId))
  )
  .subscribe(userInfo => {
    dispatch({
      type: 'SET_USER_INFO',
      payload: userInfo
    })
  })

上面就是一個簡單的例子,但其實redux-observable已經幫我們處理掉很多東西了,所以我們只要記得一個概念:

action in, action out

redux-observable 是一個 middleware,你可以在裡面加上很多epic,每一個epic就是一個 Observable,你可以監聽某一個指定的 action,做一些處理,再轉成另外一個 action。

直接看程式碼會比較好懂:

import Actions from './actions/user';
import ActionTypes from './actionTypes/user'

const getUserEpic = action$ =>
  action$.ofType(actionTypes.GET_USER)
    .switchMap(
      action => Rx.Observable.from(API.getUserInfo(action.payload.userId))
    ).map(userInfo => Actions.setUsers(userInfo))

大概就是像這樣,我們監聽一個 action type(GET_USER),一接收到的時候就發送 request,並且把結果轉為setUsers這個 action,這就是所謂的 action in, action out。

這樣的好處是什麼?好處是明確制定了一個規範,當你的 component 需要資料的時候,就送出一個 get 的 action,這個 action 經過 middleware 的時候會觸發 epic,epic 發 request 給 server 拿資料,轉成另外一個 set 的 action,經過 reducer 設定資料以後更新到 component 的 props。

可以看這張流程圖:

總之呢,epic就是一個 Observable,你只要確保你最後回傳的東西是一個 action 就好,那個 action 就會被送到 reducer 去。

礙於篇幅的關係,今天對於redux-observable只是概念性的帶過去而已,沒有時間好好示範,之後再來找個時間好好寫一下redux-observable的實戰應用。

結論

從一開始的陣列講到 Observable,講到畫圖的範例再講到經典的 Auto Complete,最後還講了redux-observable,這一路的過程中,希望大家有體會到 Observable 在處理非同步行為的強大之處以及簡潔。

這篇的目的是希望能讓大家理解 Observable 大概在做什麼,以及介紹一些簡單的應用場景,希望能提供一篇簡單易懂的中文入門文章,讓更多人能體會到 Observable 的威力。

喜歡這篇的話可以幫忙分享出去,發現哪邊有寫錯也歡迎留言指正,感謝。

參考資料:

30 天精通 RxJS (01):認識 RxJS
Reactive Programming 簡介與教學(以 RxJS 為例)
The introduction to Reactive Programming you’ve been missing
构建流式应用—RxJS详解
Epic Middleware in Redux
Combining multiple Http streams with RxJS Observables in Angular2

影片:
Netflix JavaScript Talks – RxJS + Redux + React = Amazing!
RxJS Quick Start with Practical Examples
RxJS Observables Crash Course
Netflix JavaScript Talks – RxJS Version 5
RxJS 5 Thinking Reactively | Ben Lesh

一次搞懂OAuth與SSO在幹什麼?

一次搞懂OAuth與SSO在幹什麼?

最近的Line Notify、Line Login,以及前一陣子的Microsoft Graph API,全都使用到了OAuth作為用戶身分驗證以及資源存取的基礎。但很多讀者會卡在OAuth的運作流程上,根本的原因是不理解OAuth到底是幹嘛的?其存在的目的為何?以及如何應用?

因此,我想花一個篇幅,盡可能短的介紹一下OAuth與SSO,但,與坊間文章不同的是,我希望從應用情境的角度(而非技術)切入談這件事情,冀望能夠讓開發人員對OAuth有個最基本的認識。

OAuth的背景

我們回頭看Line Login與Line Notify中的例子,OAuth在這邊最簡單的應用情境,就是身分驗證。典型的情境中有幾個角色,分別是:

  1. 網站或App的開發單位 : 也就是各位開發人員
  2. OAuth服務的提供者(Provider) : 也就是Line(或Google、Microsoft…etc.)
  3. 終端用戶(End-User) : 網站使用者、Line使用者、消費者、客戶…etc.

上面這三者的關係是什麼?

當我們建立一個網站(例如Pc Home購物)、或App(例如一個手機遊戲),都非常有可能需要建立一組會員機制,這些機制包含:

  1. 登入(包含身分驗證,帳號、密碼保存…等)
  2. 個資管理(用戶名稱、地址、電話、暱稱、手機…等)

以往,幾乎都是每一個網站自己做一套,但這樣有很多麻煩事,首先用戶要記得很多組帳號密碼,而每一個網站都自己搞一套會員機制,網站開發人員自己也很辛苦,加上最近這幾年大家都很重視個資,網站儲存(保管)了很多帳號密碼與個人資料,總是會有被駭的風險。因此,這十年來,很多大廠開始提供登入(與身分驗證)機制服務。

也就是說,小網站你不用自己做登入和會員管理了,你連過來我這邊,我是大網站,我已經有幾百萬上億的用戶,(例如全台灣都用Line),而且早就做了超級安全的會員管理機制,你這小網站何必自己做會員管理呢?你跟我連結不就得了,我大網站來幫你管理個資,提供你登入的服務,你把會員資料通通存我這裡,用戶也不需要記得很多組帳密,只需要記得我大網站的帳密,一樣可以登入你小網站(或稱為第三方應用)來使用你提供的服務,這樣皆大歡喜。

因此,大家就這麼做了。

但提供這樣服務的大廠越來越多,Google、Microsoft、Yahoo…都提供了這樣的服務,導致小網站為了對使用者更貼心,可能要同時連結上很多這種提供身分服務的大網站,如果每一家連結方式都不同,就很煩。因此,業界就開了幾個會,共同決定了一套工業標準,就是OAuth了。

有哪些功能?

所以你會發現,基本上網站開發人員有兩種身分,一種是OAuth服務的提供者(像是Google、微軟、Line),另一種是OAuth服務的使用者,像是一般的小網站(trello)。而終端用戶只需要在大網站申請過帳號,就可以登入小網站來使用服務。

但,大網站當然不能給你(小網站)用戶的帳號和密碼,否則多麼不安全呢?因此OAuth工業標準讓服務提供者(大網站)透過一種標準的作法,在用戶驗證過身分之後,提供一組會過期的令牌給小網站,這就是token。

小網站拿著這個令牌,就可以跟大網站取得用戶的個資,或是其他需要的資料。小網站也可以拿著這個令牌,跟大網站確認該令牌是否已經到期。

所以,整個流程大概是底下這樣:

由於上述過程中的(2),登入畫面是大網站提供的,因此你小網站不會得知用戶的帳號密碼,大網站只會在登入成功後,把一個具有有效期限的Token傳給你小網站,一旦你需要存取用戶的資料,就拿這個token去跟大網站溝通。

當然,實際上的OAuth操作步驟又更複雜,如果你參考我們前面介紹的Line Login那篇,就會知道,用戶被引導到大網站完成登入之後,你小網站是無法直接取得token的,而是取得一個code,再去用這個code跟大網站換得一個token。為何要多這一道手續?因為,網際網路是個不安全的所在,在網路上傳遞的任何東西,都可能被路上經手的路由器或其他設備給擷取、偽造、變更,因此要確保安全,得更加小心一點。

因此一般的OAuth流程,其實應該長得像是底下這樣(這是微軟Graph API的OAuth Auth Authorization Code Flow流程) :

還有更複雜的、更進階的。

如果大網站除了提供用戶的個資之外,還要可以讓小網站有權限做一些額外的事情,像是變更用戶大頭照、取得用戶上傳的檔案、幫用戶book一個行事曆…這都是Office 365/Google Apps裡面典型的情境,如此一來,終端使用者(end-user)可能就要授權小網站,到底能夠使用該用戶在大網站中多少資料,也就是大網站的用戶要賦予小網站多大的權限,來存取該終端用戶的個資? 這部分,一般稱之為 Permission Scope。

所以,OAuth除了提供登入身分驗證之外,也逐漸開始負擔了網站合作之間的授權管理功能。

好,現在回過頭來看,請參考Line Login與Line Notify這兩篇中的例子,你會發現一開始我們都只是組出一個URL,來取得Authorization Code,這一段取得的code是明碼,走的是http get,透過瀏覽器網址列來傳遞,所以在網際網路上是可以被任何人擷取看到的(因此你當然應該加上SSL),但你會發現接下來小網站取得Authorization Code之後,要透過http post,從後端走另一個路徑去跟大網站換得token,這一段並不是走瀏覽器http get,而是在小網站的伺服器端走另一個https路徑,去跟大網站溝通。由於這一段往往是在背後做的(伺服器端對伺服器端,不會經過用戶端),因此安全性相對高(OAuth也有實作成在前端取token的implicit flow,但走後端相對安全點)。如果從後端換取Token,不管是瀏覽器或用戶本身都無法得知token,就算你的用戶被人在瀏覽器或電腦中安裝了木馬也無法得知,再加上Token還有期限,因此相對安全。

這也是我們前面說的,實務上小網站被導引到大網站完成登入之後,並非直接取得token而是取得一個Authorization Code的原因。

所以你也不難理解,既然Token會到期,就衍生出需要更新(refresh)token、判斷token的有效性、設定Token的生命長度…等相關議題,但在這邊就先不介紹了。

更進一步實現SSO

好的,假設網路世界的身分驗證,都是某一個大網站(例如Google)提供的,而其他服務的小網站(網站A、網站B、網站C…),都使用Google提供的身分驗證服務,那這世界就很單純了,一旦用戶登入了網站B,用著用著,連結到了網站A,還需要重新登入一次嗎? 不需要,因為在網站B已經登入過了,這就是SSO(Single Sign On)在internet上的實現。

一旦OAuth提供者和使用者(也就是大小網站),都有實作這樣的功能,那用戶翱翔於網際網路上時,就只需要記得一組帳號密碼了,這世界多麼美好…

當然,現實世界不是這樣的,你想想,當個大網站將會擁有所有人的個資耶,這意味著什麼呢? 不用大腦想也知道。 所以,只有你想做大網站? 不,每個人都想做。因此只要稍具規模網際網路服務提供者,都希望自己是最大的那個身分驗證提供者。

現在、連Line這個IM界的新玩家(相對What’s app、skype來說,真的算是新的),挾著在亞洲(其實也只有台灣、日本、和韓國…)的超人氣,都開始提供OAuth Provider服務了,你說,Line這家公司它還不夠任性嗎?
#搞懂了OAuth和SSO,不妨接著玩玩Line NotifyLine Login,很好玩唷… Smile

從零開始建造一個vue項目

準備工作

環境依賴:Node.js; vue官方腳手架:  vue-cli

具體怎麼安裝nodejs和vue-cli的部分就不再具體說明了,請查看官方文檔按步驟執行即可(安裝nodejs會安裝npm(包管理工具),vue-cli依賴npm來安裝,注意這個先後關係)。

背景知識

vue.js核心  框架

webpack  打包工具,使用vue-cli初始化項目的時候,我們選擇webpack作為我們的模塊打包工具

開始動手

初始化項目,選擇webpack作為打包工具,項目名稱是my-project,然後按照提示進行一些配置,過程中可以選擇使用vue-router(推薦使用);這些配置最終會寫到項目的package.json中和安裝相應的模塊

vue init webpack my-project
复制代码

接下來使用自己熟悉的編輯器打開項目,目錄結構大致是這樣的

構建和配置目錄:webpack打包的相關配置文件

src目錄:我們最終編寫業務代碼的地方

static目錄:我也不知道幹嘛用的

package.json

package.json是項目最基礎的配置文件。可以發現裡面的很多內容,例如名稱,作者,描述等就是剛才初始化項目時我們填充的值; dependencies和devDependencies是項目依賴的包,運行項目之前需要先執行npm install來安裝項目所依賴的包

npm install复制代码

然後我們來重點關註一下scripts

npm允許在package.json文件裡面,使用scripts分區定義腳本命令。其中dev和start都是啟動本地開發環境的,lint是做語法校正的,build是打包最終在線代碼的

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A Vue.js project",
  "author": "ideagay <[email protected]>",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "lint": "eslint --ext .js,.vue src",
    "build": "node build/build.js"
  },
  "dependencies": {
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  },
  "devDependencies": {...},
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}

复制代码

為了統一團隊內的代碼風格,我們一般會選擇一些語法校驗插件來實現代碼風格的統一。這裡我們選擇eslint作為我們的代碼檢查插件。首先我們來改一下eslint(語法校驗)的相關配置, :根目錄下的.eslintrc.js,在規則下面加一個結尾分號的配置,強制末尾要加分號,養成好習慣;然後把src下面所有文件裡的代碼所在分號的補全,不然編譯會不通過;其他風格根據習慣自己配置吧。

"semi": [
  2,
  "always"
]复制代码

運行項目看下效果

:命令行工具,在當前目錄下執行以下命令,一切順利的話,會自動打開在瀏覽器上打開localhost:8080

# 默认8080端口
npm run dev

# 也可以指定端口
PORT=8090 npm run dev复制代码

添加業務代碼

src目錄是我們主要編寫業務代碼的地方,可參考以下目錄結構配置

資產js,css,圖片等資源目錄

組件公共組件目錄

路由器vue-router配置目錄

views頁面組件目錄

main.js程序主入口,一般在這裡添加插件,如吐司,裝載等,可自己編寫或使用第三方,如ui

App.vue根組件

main.js

import Vue from 'vue';
import App from './App';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import router from './router';

Vue.config.productionTip = false;

Vue.use(ElementUI);

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
});复制代码

路由

往router / index.js裡添加首頁的配置

import Vue from 'vue';
import Router from 'vue-router';
import Home from '@/views/index';

Vue.use(Router);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    }
  ]
});复制代码

網絡請求

網絡請求可以使用axios,然後根據業務再進行一些封裝

資產/js/api/ajax.js

import axios from 'axios';
var qs = require('qs');

var instance = axios.create({
   baseURL: 'http://xxx.com/',
   timeout: 20000,
   headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
   }
});

const ajax = (url, params) => {
   return new Promise((resolve, reject) => {
      instance({
         url: url,
         method: 'post',
         data: qs.stringify(params)
      }).then(res => {
         console.log(res);
         if (res.data.success === true) {
            resolve(res.data.data);
         } else {
            throw res;
         }
      }).catch(err => {
         console.error(err);
         reject(err);
      })
   })
};

export default ajax;复制代码
import Ajax from '@/assets/js/api/ajax.js';
Ajax(`/tui/search`, {
   'key': this.keyword
}).then(res => {
   console.log(res);
});
复制代码

樣式

使用normalize.css重置基礎樣式,消除不同瀏覽器間的差異,在根組件App.vue中約會就好了

<script>
import 'normalize.css';

export default {
   name: 'App'
}
</script>复制代码

現在一般業務所需的框架已經基本建造完成。

作者:ideagay
链接:https://juejin.im/post/5a7c18d36fb9a0633e51c458
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。