实际上,上面构建的流程化模型是一个有向无环图(Directed Acyclic Graph, DAG)。顾名思义,DAG有三大特点:“有向”、“无环”、“图”。DAG首先是一种“图”,这种“图”是有方向性的,并且没有环路。
DAG是一个专业术语,虽然对于一般人比较陌生,但它实际上隐藏在生活中的方方面面。例如,一个工程项目有多个子项目组成,子项目之间有先后关系,一个子项目的开展必须保证前面的项目完成;工厂里面生产的产品,产品由若干部件组成,每个部件再由多个零件组成,组装时必须保证一定的先后顺序。在软件领域,我们也会不知不觉地碰到DAG,例如程序模块之间的依赖关系、makefile脚本、区块链、git分支等。
按照依赖关系对DAG的顶点进行排序,使得对每一条有向边(u, v)
,均有u
(在排序记录中)比v
先出现,这种排序就是拓扑排序。
例如,下图中1 → 2 → 4 → 3 → 5
是一个正确的拓扑排序,每个节点都在它说依赖的节点后面。而1 → 2 → 3 → 4 → 5
则不满足拓扑排序的要求,3
依赖于4
却出现在前面。
对于有向图进行拓扑排序要解决两个问题:一是要判断待排序的有向图是不是无环;二是按照依赖关系生成正确的序列。
我们可以从入度着手,选择一个入度为0的节点,说明该节点不依赖于其他节点,可以放在结果序列最前面。然后,删除该节点和节点的边,找出下一个入度为0的节点,依次放到结果序列中。重复以上过程,即可完成拓扑排序。这种方法可称之为入度方法。
同样,我们也可以从出度着手,选择一个出度为0的节点,说明没有任何节点依赖该节点,可以放到结果序列最末尾。然后,删除该节点和节点的边,找出下一个出度为0的节点,依次放到结果序列尾部。重复以上过程,即可完成拓扑排序。这种方法可称之为出度方法。
不管是入度方法还是出度方法,如果最后还存在入(出)度不为0的节点,或者结果序列中的节点个数不等于有向图中的节点个数,说明有向图中存在环。
按照维基百科的说法,拓扑排序主要有Kahn算法和DFS(深度优先搜索)算法两种。Kahn算法采用入度方法,以循环迭代方法实现;DFS算法采用出度方法,以递归方法实现。Kahn算法和DFS算法的复杂度是一样的,算法过程也是等价的,不分优劣,因为本质上循环迭代 + 栈 = 递归
。
Kahn算法采用入度方法,其算法过程如下:
下图是对Kahn算法的演绎:
DFS算法采用出度算法,其算法过程如下:
下图是对DFS算法的演绎:
仔细观察发现,Kahn算法并不一定局限于入度方法,同样适用于出度方法,过程为先选择一个出度为0的节点输出,然后删除该节点和节点的边,重复“选择-删除”过程直到没有出度为0的节点输出,最终序列的逆排序即是拓扑排序结果。这个过程实际上是将原来的有向图的边反向,原来的入度变出度、出度边入度,Kahn算法将出度当成入度处理,最后我们再将结果逆序就还原了拓扑排序结果。
同样,DFS算法也不一定局限于出度算法,我们同样可以将有向图反向,入度变出度、出度边入度。由于我们对有向图反向,所以需要对结果做两次逆序操作,两次逆序操作相当于不需要逆序操作,所以结果序列刚好是拓扑排序结果。
最后,我将在下篇文章中使用Javascript来分别实现Kahn算法和DFS算法。
1 | FROM maven:3.6-jdk-8-alpine |
粗看之下似乎没有改进的空间,但是细看之后发现镜像里安装了完整的jdk环境,包含java的sdk和runtime。实际上,运行jar包只需要java runtime,sdk是多余的。
通过使用多阶段构建,将应用的编译环境和运行环境分离,可以极大地减少最终镜像的体积。例如,对将上面的Dockerfile修改为多阶段构建:
1 | FROM maven:3.6-jdk-8-alpine AS builder |
以上Dockerfile生成的最终镜像只包含runtime,不再有sdk。
但是凡事皆有两面性,多阶段构建虽然能够减小镜像体积,但是构建的速度慢了许多。原因在于:一是相比于原先的单阶段构建,多了一些构建步骤;二是缓存失效,多阶段编译之后只保留最终镜像的缓存,中间镜像的缓存丢失。其中缓存失效的问题在CI环境中尤为显著。
加快多阶段构建的措施有两项:并行构建和保留缓存。
如果把多阶段构建中各阶段之间的依赖关系画出来,实际上是一个有向无环图(DAG, Directed Acyclic Graph)。在图中,有些节点之间是没有前后关系的,意味着某些阶段可以并行构建。
从Docker 18.09开始引入了并行构建,启用方法有两种:
DOCKER_BUILDKIT=1
;/etc/docker/daemon.json
中设置{ "features": { "buidkit": true }}
。保留缓存意思是不仅保留最终镜像的缓存,还保留中间镜像的缓存。
docker build
有两个与缓存相关的参数:--cache-from
和BUILDKIT_INLINE_CACHE=1
。--cache-from
表示可以指定镜像作为缓存源,可以指定多个镜像,指定后会从镜像仓库自动拉取本地不存在的镜像。BUILDKIT_INLINE_CACHE=1
表示在构建时将缓存的元数据打包到镜像中,使之可以作为缓存源。默认构建的镜像不包含缓存的元数据,不能被--cache-from
使用。
还是拿文章开头的java应用为例:
首先将编译阶段的镜像进行构建和上传:
1 | docker build --build-arg BUILDKIT_INLINE_CACHE=1 \ |
然后将最终阶段利用前一阶段和当前阶段的缓存进行构建:
1 | docker build --build-arg BUILDKIT_INLINE_CACHE=1 \ |
通过以上过程,就可以在多阶段构建过程中充分利用缓存来加快构建速度。
本文完。
]]>知识库里面的文章的主要目的其一是作为团队工程经验的积累,其二是让团队新人能够快速掌握时空大数据可视化相关的知识。基于以上目的,知识库里面的文章偏向于科普向和入门向,注重于知识的广度而不是深度,放到博客上似乎有些“低技术含量”了。同时,平时在做文章选题时,经常会在知识库和博客之间犹豫徘徊,犹豫来犹豫去,最后写作的热情也就没有了😂。
不过最近我的想法又有了些变化,“低技术含量”的文章不见得让本博客掉价。每个人的技术专精的方向不一样,我认为简单的技术,读者可能不太了解,需要一些“低技术含量”的文章帮助他快速了解和入门。所以,今后本博客的文章不会太刻意去追求技术深度,只要我觉得有价值的点,都会形成文章分享出来。
今天的这篇文章就是一篇“低技术含量”的文章,主要讲使用GDAL来完成遥感影像的切片。
对了,最近我更新了简历,如果您有什么好的工作机会介绍给我,那就太感谢了🙏。
前段时间做一个可视化大屏,需要对遥感影像进行切片。由于近几年一直在研究矢量瓦片技术,对栅格切片这块的工具比较生疏。在网上搜了一下,发现合适的工具并不多。要么是诸如GeoServer这样的巨无霸,要么就是一些只支持限定数据源和限定切片规则的小工具。仔细研究了下GDAL,发现组合使用栅格格式转换工具gdal_translate
和金字塔生成工具gdaladdo
就能够实现支持任意数据源和任意切片规则的影像切片工作流。
开始之前,需要选定一种存储栅格瓦片的容器格式。看了一下发现选择并不多,暂时时只发现MBTiles、GeoPackage和COG(Cloud Optimized GeoTIFF)三种。MBtiles只支持EPSG:3857一种切片规则,不符合需要支持任意切片规则的要求。COG则是基于GeoTiff的一种Hack实现,技术过程过于复杂,也不予以考虑。所以能够选择的实际上只有一种:GeoPackage。
接下来使用如下命令就可以将影像转换为栅格瓦片,并输出到GeoPackage中:
1 | gdal_translate -of GPKG chengdu.tif chengdu.gpkg -co TILING_SCHEME=InspireCRS84Quad |
以上命令中,-of
用来指定输出格式。TILING_SCHEME
用来指定切片规则,比较常用的是GoogleMapsCompatible
(EPSG:3857)和InspireCRS84Quad
(EPSG:4326),也可以自定义切片规则,具体做法请查看文档。
需要注意的是,以上命令只是根据切片规则选择一个合适的级别,生成一个单一级别的瓦片,并不是我们需要的多级别的瓦片。生成多级别的瓦片,需要使用接下来的命令:
1 | gdaladdo chengdu.gpkg |
如果GeoPackage里面有多个瓦片集,可以使用以下命令来指定为某个瓦片集生成金字塔:
1 | gdaladdo GPKG:chengdu.gpkg:layername |
从GDAL 3.2开始还支持并行,使用以下命令使用所有的CPU来加速生成:
1 | gdaladdo chengdu.gpkg --config GDAL_NUM_THREADS ALL_CPUS |
当正常人听到某些话,一般会在心里掂量一下是不是真话,并不会毫不保留地接受;否则,那就是容易被骗的傻子。每当电视上一些机构发布的统计数字,有分辨力的听众,也得先搞清楚统计方法再评判统计数字是不是真实。拿到一本书,有批判精神的读者,总是要质疑论据能不能支撑观点。所以,当前大众对于话语、统计数字、书本这些信息来源有良好的警惕性,知道假话、错误的数字、误导人的观点都有可能存在于这些信息来源中。但是,当面对的信息来源是地图时,大众的警惕性似乎就没有那么普遍了。
《会说谎的地图》这本书告诉我们一个事实:地图也会说谎,而且说谎的程度并不亚于常规的信息来源。地图是现实世界的概括和抽象,并不是与现实世界一一对应的比例模型(从这个角度来说,遥感影像并不能称为地图,除非在上面加绘地理要素)。制图员在概括和抽象的过程中,有可能会根据预设观点,对要素进行适应性地取舍或者夸大。例如,我们经常在各种房产传单中看到一些简略的地图,那幅地图会告诉客户房产的地段好、交通方便。如果你认为地图是可靠的信息来源的话,等你到实地考察一番,很可能会让你大跌眼镜:地图上葱葱郁郁的绿化现在是光秃秃的小树苗,地图上近在迟尺的商场,需要搭乘半个小时以上的公交。所以说,地图只是制图员(或者背后的金主爸爸)一种观点表达,并不是完全权威可靠的信息来源,大众对于地图同样需要保持警惕性。
《会说谎的地图》列举了一些常见的地图“骗术”,下面讲一讲印象比较深刻的几种。
第一种是利用地图投影形变,突出某些地区。例如,我们常见的网络地图通常采用的Web Mercator投影,会对高纬度地区进行夸大,所以我们会发现俄罗斯、格陵兰岛大得惊人。如果地图上一个区域的面积比重大,那么人们会潜意识地认为这个地区的重要性、话语权比较大。所以,别有用心的地区或国家,会利用地图投影的形变特点,夸大其国土面积,以增强在世界地区的影响力。好在稍有地图常识的人,很容易识别出这种骗术。
第二种是对地图要素进行有目的性地选择,以美化或者劣化环境要素。前面说过,地图是对现实世界的抽象概括,制图过程涉及到大量的要素取舍。对于由政府编制的地形图,尚且对要素取舍有严格的规定;而对于由商业公司制作的各类专用图,则没有规范惯例可循,制图的目的基本上是服务于特定商业诉求。例如,一个房地产的宣传挂图上,绝不对出现如化工厂、垃圾中转站这些“不和谐”要素,以证明社区环境优美;反之,一个环保组织的地图上,则不会放过这些“不和谐”要素,以证明社区环境恶化程度,提醒人们关注环保问题。这类骗术的识别,首先是要识别出地图提供方的利益关系,其次是要与官方提供的标准图进行对比。
第三种是变换统计口径,以呈现出特定的分布。同一份统计数据,按照不同的统计口径,有可能会出现完全不同的空间分布特征。例如,每到美国大选,共和党和民主党都会公布支持率分布的“红蓝”选战地图,我们会发现两张地图会呈现完全相反的趋势。那到底那张图是准确的呢?如果没有统一的统计标准,很难说哪张图是正确的。共和党辩解说是按照参议员占比来统计的,民主党则说是按照选民占比来统计的,各自都有理。这种骗术的识别,需要搞清楚统计口径。
第四种是选择不同的统计单元,抹平区域差异。将人口密度按照省、市、县、乡、村等不同粒度的统计单元进行统计,会得到不同的区域分布特征。统计单元越大,统计值的区域分异规律就会难以显现。某些别有用心的制图者,还会“巧用”统计单元合并策略,将一些极值区域合并到相邻区域,以掩饰某些问题。这类骗术的识别,需要认识到统计单元粒度越大越能掩盖问题,尽量参考统计粒度更小的地图。
第五种是使用不同的分段策略,呈现出特定的趋势。我们经常对统计值进行分段,然后在地图上使用不同颜色来表示统计值的高低。对统计分段的方法有很多种,例如自然断点法是按照“内间距最小、外间距最大”的原则进行分段,等间距法则是按照相等的间距进行分段。没有办法说哪一种分段方式科学合理,每种分段方法各自有适用的场景。制图者在做这类专题图时,可以有意或者无意地混淆各类分段的适用场景,选择一种能够表达观点的分段方法,使得地图呈现出特定的趋势。这类骗术的识别,需要读者仔细审查地图的图例,看选择的分段策略是不是合理的。另外,使用连续分布的渐变色,可能比经过加工后的分段颜色更可靠。
以上就是对我印象比较深的几种骗术,当然在《会说谎的地图》中还有更多的骗术,例如使用鲜艳醒目的颜色突出重点、使用虚假的地名误导抄袭者,感兴趣的读者可以去阅读本书了解。总而言之,《会说谎的地图》告诉我们,要对地图有批判精神,不要满盲目地相信地图。
]]>本文利用ssh的反向穿透技术,来实现内网服务器的自动部署。
ssh一般用来客户端远程登录到服务器上,而ssh反向穿透“反其道而行之”,由服务端主动发起请求连接客户端,然后在客户端打开一个端口,之后发往客户端的数据包将会转发到服务端。例如在服务端执行:
1 | ssh -NR 22022:localhost:22 clientUser@clientMachine |
连接成功后,发往客户端22022
的数据包将被转发到服务端的22
端口上。这时,如果客户端具有公网IP,那么我们就可以利用客户端机器作为跳板,远程登录到内网服务器上,命令如下:
1 | ssh -p 22022 serverUser@clientMachine |
通过ssh的反向穿透技术,让内网服务器借用公网客户机的IP,实现内网服务器公网可见。在此基础上,实现内网服务器自动部署的原理如下图:
上述原理很简单,但具体操作起来,还是有许多细节要考虑,下面对此一一说明。
通过ssh在第①步建立了生产服务器prodServer
与跳板机jumpServer
的连接隧道,但是这条隧道如果长时间没有数据包传输,那么ssh会主动关闭连接,之后测试服务器testServer
就不能再通过跳板机连接到生产服务器。
为了保持生产服务器与跳板机的连接隧道不断开,需要ssh自动重连。ssh不支持自动重连功能,幸好有autossh帮我们完成这项工作。
autossh不属于系统自带工具,我们需要在生产服务器安装它,Ubuntu上的安装命令如下:
1 | sudo apt-get install autossh |
安装好之后,执行以下命令:
1 | autossh -M 0 -f -o "ServerAliveInterval=30" -o "ServerAliveCountMax=3" -o "ExitOnForwardFailure=yes" -NR 22022:localhost:22 jump@jumpServer |
上述命令中,除了-M 0
和-f
是autossh的参数外,其他参数都原样传递给ssh,其中-M 0
表示不另开端口监测ssh,-f
表示后台运行。auotssh以前是靠-M
另开一个端口发送心跳数据包,由于新版ssh(protocol 2)内建了心跳功能,所以不再推荐另开端口。上面的命令使用ServerAliveInterval
和ServerAliveCountMax
两个参数,表示客户端向服务端每30秒发送一次心跳数据包,如果发3次还没响应,那么断开连接。我们也可以在服务端的/etc/ssh/sshd_config
配置文件中添加ClientAliveInterval 30
和ClientAliveCountMax 3
参数后,重启sshd,表示由服务端向客户端发送心跳数据包。ExitOnForwardFailure
表示ssh转发失败后,关闭连接并退出,这样autossh才能监测到错误并重启ssh连接。
执行上述命令后,我们只能在jumpServer
上登录到prodServer
。如果我们想要从任意机器上远程登录到prodServer
,需要在jumpServer
的/etc/ssh/sshd_config
中添加GatewayPorts yes
参数并重启sshd,之后执行:
1 | ssh -p 22022 prod@jumpServer |
如果prodServer
重启后,我们希望autossh
也能随系统启动,此时需要将autossh
添加到启动项中,以下是以systemd为例,新建一个/lib/systemd/system/autossh.service
配置文件来添加autossh启动项:
1 | [Unit] |
注意上面启动autossh的命令中,要把-f
选项去掉,并且autossh使用绝对路径。
最后执行以下命令执行来启动autossh:
1 | sudo systemctl enable autossh |
不管是prodServer
在系统启动时反向连接到jumpServer
,还是testServer
完成自动测试后推送部署包到prodServer
,这些过程都是后台自动执行,我们没有机会去输入登录密码。因此,我们需要在三者之间配置ssh key,实现免密登录。
首先是prodServer
连接到jumpServer
,这一步比较简单,只需在prodServer
使用ssh-copy-id
命令将公钥复制到jumpServer
即可。
然后是testServer
连接到prodServer
,这一步稍微有点复杂,主要是因为testServer
一般是动态创建的虚拟机或容器,测试完就删除了,所以没办法提前将testServer
的公钥复制到prodServer
。解决的思路是使用任意机器登录一次prodServer
并将该机器的公钥复制到prodServer
,然后将该该机器的私钥复制到testServer
,让testServer
伪装成为该机器登录prodServer
。Gitlab、Travis-ci、Circle CI各自有不同的方法安全地传输ssh key,具体参考相应的文档。Gitlab可以在project和group中的“设置->CI/CD->Variables”中设置环境变量,如下图:
设置好之后,配置.gitlab-ci.yml
:
1 | deploy: |
本文借助ssh反向穿透技术,实现了内网服务器与测试服务器的互通,并且利用autossh解决了ssh持久连接的问题,以及利用复制私钥来实现测试服务器到内网服务器的免密登录。最终,结合这些技术,实现了内网服务的自动化部署。
在实际测试过程中,ssh的反向穿透连接速度慢而且不是很稳定,下一步研究webhook技术来实现自动部署。
]]>第一种写shell脚本的方法用得不多,毕竟太原始。相比之下,使用logrotate则要省心得多,配置logrotate很简单。关于如何配置logrotate不是本文要讲的内容,感兴趣的话可以自行搜索。
虽然大多数Linux发行版都自带了logrotate,但在有些情况下不见得安装了logrotate,比如nginx的docker镜像、较老版本的Linux发行版。虽然我们可以使用包管理器安装logrotate,但前提是服务器能够访问互联网,企业内部的服务器可不一定能够联网。
其实我们有更简单的方法,从nginx 0.7.6版本开始access_log
的路径配置可以包含变量,我们可以利用这个特性来实现日志分割。例如,我们想按天来分割日志,那么我们可以这样配置:
1 | access_log logs/access-$logdate.log main; |
那么接下来的问题是我们怎么提取出$logdate
这个变量?网上有建议使用下面的方法:
1 | if ($time_iso8601 ~ "^(\d{4})-(\d{2})-(\d{2})") { |
上面的方法有两个问题:一是如果if
条件不成立,那么$year
、$month
和$month
这三个变量将不会被设置,那么日志将会记录到access-$year-$month-$day.log
这个文件中;二是if
只能出现在server
和location
块中,而access_log
通常会配置到顶层的http
块中,这时候if
就不适用。
如果要在http
块中设置access_log
,更好的方法是使用map
指令:
1 | map $time_iso8601 $logdate { |
map
指令通过设置默认值,保证$logdate
始终有值,并且可以出现在http
块中,完美地解决了if
指令的问题。
最后,为了提高日志的效率,建议配置open_log_file_cache
,完整的日志分割配置如下:
1 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' |
本文完。
]]>接下来的部分,我将以离线安装pm2为例来进行说明。pm2是一个进程守护程序,用于启动node集群和服务进程出错时自动重启,在生产环境下部署nodejs应用一般都会使用到。
npm link
使用npm link
的方式是最常用的方法,具体做法是在联网机器上下载pm2的源码并安装好依赖,拷贝到离线服务器上,最后借助npm link
将pm2链接到全局区域。
首先,将pm2的源代码克隆下来:
1 | $ git clone https://github.com/Unitech/pm2.git |
然后进入到pm2项目中,安装好所有的依赖:
1 | $ cd pm2 |
将安装好依赖的pm2文件夹拷贝到目标服务器上,进入pm2目录链接到全局区域:
1 | $ cd pm2 |
这种方式最关键的是借助npm link
完成链接,但npm link
这条命令本意是设计给开发人员调试用的。但开发人员开发某个全局命令工具的时候,通过将命令从本地工程目录链接到全局,这样调试的时候,可以实时查看本地代码在全局环境下的执行情况。所以,npm link
的项目需要安装所有的依赖,包括dependencies
以及devDependencies
,而我们如果只是使用而不是开发某个包的话,正常情况下不应该安装devDependencies
。
总而言之,这种方式优点是比较简单,缺点是安装了不需要的devDependencies
,对于有“洁癖”的人是难以忍受的。
npm bundle
那有什么方法相比于上一种方法更干净呢?答案是使用npm-bundle工具将pm2的所有依赖打包,然后到目标服务器上使用npm install <tarball file>
安装。
首先在联网机器上安装npm-bundle工具:
1 | $ npm install -g npm-bundle |
然后打包pm2:
1 | $ npm-bundle pm2 |
上面的命令会生成一个tgz的包文件,复制到目标服务器上安装:
1 | $ npm install -g ./pm2-3.2.2.tgz |
npm-bundle的本质是借助npm pack
来实现打包的。npm pack
会打包包本身以及bundledDependencies
中的依赖,npm-bundle则是将pm2的所有dependencies
记录到bundledDependencies
,来实现所有依赖的打包。
这种方式不需要安装多余的devDependencies
,并且不需要克隆pm2的源码,比第一种方法更方便。
更新:npm-bundle
对于scoped packages的处理有bug,不能正确地打包,这时考虑采用第一种方式。
在离线部署方面,Windows明显比Linux做得好,Windows软件包通常会将软件所需的依赖打包,部署时只需拷贝一个软件安装包即可。那Linux有没有类似Windows软件安装包的东西呢?幸运的是,Ubuntu提供了snap软件包机制,可以用来简化离线部署。
snap软件包类似于windows的软件安装包,将所需的依赖都统一打包到软件包中,部署时只需拷贝snap文件。另外,snap也加强了安全隔离机制,通过注册软件包的签名和权限控制信息,使得snap软件运行在“沙盒”环境中。
从Ubuntu 16.04起,snap环境是自带的,可以直接使用。如果是早于16.04的版本且服务器不能联网,安装snap环境很困难,你只能自求多福了。下面以安装docker为例,来说明离线安装snap包的方法。
首先,我们需要在能联网的机器上将相关的snap包下载下来。不仅要下载docker软件包,还需要下载core软件包。core软件包是snap的核心运行时,几乎所有的snap包都依赖core运行时,Ubuntu 16.04自带了snap环境却没安装core运行时,实在是让人有些搞不懂。
下载有snap包两种方式。一种方法是在能联网的Ubuntu上使用snap download
命令下载:
1 | $ snap download core |
以上命令将会得到.assert
和.snap
两类文件,其中.assert
是软件包的元数据信息,包括签名和权限控制信息,.snap
是实际的安装文件。
另外一种方法是到uApp Explorer网站上下载,好处是不需要有Ubuntu环境,缺点是只能下载.snap
文件,无法下载.assert
文件。
安装snap包的方法很简单:将软件包拷贝到服务器上,安装时首先注册.assert
,然后再安装.assert
。对于首次安装,需要安装core和docker两个软件包:
1 | $ sudo snap ack core_5897.assert |
这样就完成了docker的安装。
要是我们是从uApp Explorer网站上下载的软件包,缺少.assert
文件怎么办?其实我们可以使用snap install --dangerous
方式安装:
1 | $ sudo snap install core.snap --dangerous |
如--dangerous
所提示的是,这种模式有些“危险”。这是因为缺少.assert
文件所描述的签名信息和权限控制信息,意味着软件不是在“沙盒”环境下执行的,运行过程不受控。其实,大可不必紧张,只要snap包来源可信,一般没什么问题,毕竟以前咱们没有snap包的时候不都是这么干的么。
最近部署一套系统,使用nginx作反向代理,其中nginx是使用docker方式运行:
1 | $ docker run -d --name nginx $PWD:/etc/nginx -p 80:80 -p 443:443 nginx:1.15 |
需要代理的API服务运行在宿主机的1234
端口,nginx.conf
相关配置如下:
1 | server { |
结果访问的时候发现老是报502 Bad Gateway
错误,错误日志显示无法连接到upstream。
仔细想一想,nginx.conf
中的localhost
似乎有问题。由于nginx是运行在docker容器中的,这个localhost
是容器的localhost,而不是宿主机的localhost。
到这里,就出现了本文要解决的问题:如何从容器中访问到宿主机的网络?通过搜索网络,有如下几种方法:
在Linux下安装Docker的时候,会在宿主机安装一个虚拟网卡docker0
,我们可以使用宿主机在docker0
上的IP地址来代替localhost
。
首先,使用如下命令查询宿主机IP地址:
1 | $ ip addr show docker0 |
可以发现宿主机的IP是172.17.0.1
,那么将proxy_pass http://localhost:1234
改为proxy_pass http://172.17.0.1:1234
就可以解决502 Bad Gateway
错误。
但是,在Windows和macOS平台下并没有docker0
虚拟网卡,这时候可以使用host.docker.internal
这个特殊的DNS名称来解析宿主机IP。
由此发现,不同系统下宿主机的IP是不同的,所以使用宿主机IP,不能跨环境通用。
Docker容器运行的时候有host
、bridge
、none
三种网络可供配置。默认是bridge
,即桥接网络,以桥接模式连接到宿主机;host
是宿主网络,即与宿主机共用网络;none
则表示无网络,容器将无法联网。
当容器使用host
网络时,容器与宿主共用网络,这样就能在容器中访问宿主机网络,那么容器的localhost
就是宿主机的localhost
。
在docker中使用--network host
来为容器配置host
网络:
1 | $ docker run -d --name nginx --network host nginx |
上面的命令中,没有必要像前面一样使用-p 80:80 -p 443:443
来映射端口,是因为本身与宿主机共用了网络,容器中暴露端口等同于宿主机暴露端口。
使用host网络不需要修改nginx.conf
,仍然可以使用localhost
,因而通用性比上一种方法好。但是,由于host
网络没有bridge
网络的隔离性好,使用host
网络安全性不如bridge
高。
本文提出了使用宿主机IP和使用host网络两种方法,来实现从容器中访问宿主机的网络。两种方法各有优劣,使用宿主机IP隔离性更好,但通用性不好;使用host网络,通用性好,但带来了暴露宿主网络的风险。
]]>网上关于申请Let’s Encrypt证书的文章多如牛毛,本篇文章的不同在于以下几点:
申请证书要解决的一个关键问题是:如何证明域名是你所拥有的?Let’s Encrypt提供了三种模式:http、dns、tls-sni。
http模式就是Let’s Encrypt给你一个随机字符串,你需要在Web服务器的/.well-known/acme-challenge/
服务路径下放置一个以该字符串命名的文件,当Let’s Encrypt能够访问到这个文件时,证明你是这个域名的所有者。
dns模式同样是Let’s Encrypt给你一个随机字符串,你需要以该字符串建立一个DNS TXT记录,当Let’s Encrypt查询域名的TXT记录时,发现得到的字符串一致,则证明你是这个域名的所有者。
tls-sni模式没有仔细研究过,似乎通过SSL加密传输。http走的是80端口,tls-sni走的是443端口。由于当前的tls-sni似乎有漏洞,该模式一度遭禁用,所以不推荐采用此模式,本文也不加以讨论。
http模式和dns模式各有优劣,适用于不同的场景。http模式不需要操作DNS记录,只需要新建一个文件就可以完成验证,带来的限制就是验证过程必须操作服务器;dns模式不需要操作服务器,只需添加DNS TXT记录就行,缺点是必须登录到域名提供商的页面上修改DNS记录。不管是http模式还是dns模式,申请证书的操作都是可以在任意电脑上完成,申请完之后再将证书复制到服务器上。http模式验证过程需要操作服务器,dns模式验证过程需要操作DNS。
由于我们要申请的是通配符证书,必须使用dns模式验证,为什么呢?以通配符域名*.example.com
为例,它包含的域名千千万万,不太可能使用http模式去验证每个域名。dns模式的验证能力更强,如果用户具有操作example.com
域名的DNS记录权限,那当然是拥有通配符域名*.example.com
。
搞清楚申请域名关键问题,接下来说明申请域名的步骤。
申请let’s encrypt的客户端软件很多,这里采用的是官方推荐的客户端certbot。运行certbot需要python环境,还需要在系统全局环境安装一些python包。考虑到证书的有效期是90天,申请证书并不是一个频繁的操作,我不想因为一个低频使用的软件污染我的全局系统,因此采用Docker作为运行环境。
申请let’s encrypt只需要一条命令,之后certbot通过交互的方式询问申请信息,很人性化:
1 | $ docker run -it --rm --name certbot \ |
上述命令中,-v $PWD:/etc/letsencrypt
表示把当前文件夹映射到docker中的/etc/letsencrypt
文件夹,这样certbot生成的证书将出现在当前文件夹中;certonly
表示证书申请子命令,还有renew
、revoke
、delete
等其他子命令;--manual
是手动申请模式;--preferred-challenges=dns-01
表示采用dns模式验证,默认采用http模式验证;--server=https://acme-v02.api.letsencrypt.org/directory
表示指向ACME V2版本服务器,默认指向ACME V1版本服务器,但只有ACME V2才支持通配符证书。
接着需要提供一个email地址用于接收证书更新和安全问题提醒:
1 | Saving debug log to /var/log/letsencrypt/letsencrypt.log |
需要同意用户协议:
1 | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
询问你需不需要接收电子前哨基金会(EFF)的新闻、活动,可以选择不接收:
1 | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
接下来就需要填写要申请证书的域名,这里需要注意一点的是*.example.com
并不包括裸域名example.com
,如果申请的证书需要囊括example.com
,就必须要同时包含example.com
和*.example.com
。
1 | Please enter in your domain name(s) (comma and/or space separated) (Enter 'c' |
申请证书的电脑的IP会被记录,可能是防治滥用吧,需要同意记录IP:
1 | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
然后开始使用dns模式验证域名,这时候需要登录到你的域名服务商的DNS编辑页面上,在_acme-challenge.example.com
增加两个TXT记录,一个用来验证example.com
,另一个用来验证*.example.com
。TXT记录的内容就是命令行中提供的随机字符串:
1 | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
TXT记录修改好之后,使用dig -t TXT _acme-challenge.example.com
来验证TXT记录是否生效了。如果确认生效了,则按回车完成证书的申请:
1 | Press Enter to Continue |
上面的信息告诉我们,证书保存在/etc/letsencrypt/live/example.com/fullchain.pem
,证书的秘钥保存在/etc/letsencrypt/live/example.com/privkey.pem
,我们可以把这两个文件拷贝到服务器上用于设置https。
证书的有效期是90天,在证书失效前30天之内,Let’s Encrypt会发邮件通知我们。
虽然有专门的renew
命令用来自动更新证书,但是我们申请的是通配符证书,仍然需要手动验证域名。因此,我们需要重新运行原来申请证书的命令来更新证书:
1 | $ docker run -it --rm --name certbot \ |
本文通过手动模式一步一步操作,完成了通配符域名的申请。只要理解了let’s encrypt验证域名的http模式和dns模式的原理,申请域名就能做到心中有数。下一篇将讲一讲在nginx环境下,如何使用申请到的证书配置https。
]]>闲话不多说,本篇继续承接前文讲一讲Git内部原理,本篇的主题是Git引用的原理。
首先来搞清楚什么是Git引用,前文讲了Git提交对象的哈希、存储原理,理论上我们只要知道该对象的hash值,就能往前推出整个提交历史,例如:
1 | $ git log --pretty=oneline 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
现在问题来了,提交对象的这40位hash值不好记忆,Git引用相当于给40位hash值取一个别名,便于识别和读取。Git引用对象都存储在.git/refs
目录下,该目录下有3个子文件夹heads
、tags
和remotes
,分别对应于HEAD引用、标签引用和远程引用,下面分别讲一讲每种引用的原理。
HEAD引用是用来指向每个分支的最后一次提交对象,这样切换到一个分支之后,才能知道分支的“尾巴”在哪里。HEAD引用存储在.git/refs/heads
目录下,有多少个分支,就有相应的同名HEAD引用对象。例如代码库里面有master
和test
两个分支,那么.git/refs/heads
目录下就存在master
和test
两个文件,分别记录了分支的最后一次提交。
HEAD引用的内容就是提交对象的hash值,理论上我们可以手动地构造一个HEAD引用:
1 | $ echo "3ac728ac62f0a7b5ac201fd3ed1f69165df8be31" > .git/refs/heads/master |
Git提供了一个专有命令update-ref
,用来查看和修改Git引用对象,当然也包括HEAD引用:
1 | $ git update-ref refs/heads/master 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
上面的命令我们将master
分支的HEAD指向了3ac728ac62f0a7b5ac201fd3ed1f69165df8be31
,现在用git log
查看下master
的提交历史,可以发现最后一次提交就是所更新的hash值:
1 | $ git log --pretty=oneline master |
同理,可以使用同样的方法更新test
分支的HEAD:
1 | $ git update-ref refs/heads/test d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
.git/refs/heads
目录下存储了每个分支的HEAD,那怎么知道代码库当前处于哪个分支呢?这就需要一个代码库级别的HEAD引用。.git/HEAD
这个文件就是整个代码库级别的HEAD引用。我们先查看一下.git/HEAD
文件的内容:
1 | $ cat .git/HEAD |
我们发现.git/HEAD
文件的内容不是40位hash值,而像是指向.git/refs/heads/master
。尝试切换到test
:
1 | $ git checkout test |
切换分支后,.git/HEAD
文件的内容也跟着指向.git/refs/heads/test
。.git/HEAD
也是HEAD引用对象,与一般引用不同的是,它是“符号引用”。符号引用类似于文件的快捷方式,链接到要引用的对象上。
Git提供专门的命令git symbolic-ref
,用来查看和更新符号引用:
1 | $ git symbolic-ref HEAD refs/heads/master |
至此,我们分析了两种HEAD引用,一种是分支级别的HEAD引用,用来记录各分支的最后一次提交,存储在.git/refs/heads
目录下,使用git update-ref
来维护;一种是代码库级别的HEAD引用,用来记录代码库所处的分支,存储在.git/HEAD
文件,使用git symbolic-ref
来维护。
标签引用,顾名思义就是给Git对象打标签,便于记忆。例如,我们可以将某个提交对象打v1.0标签,表示是1.0版本。标签引用都存储在.git/refs/tags
里面。
标签引用和HEAD引用本质是Git引用对象,同样使用git update-ref
来查看和修改:
1 | $ git update-ref refs/tags/v1.0 d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
还有一种标签引用称为“附注引用”,可以为标签添加说明信息。上面的标签引用打了一个v1.0
的标签表示发布1.0版本,有时候发布软件的时候除了版本号信息,还要写更新说明。附注引用就是用来实现打标签的同时,也可以附带说明信息。
附注引用是怎么实现的呢?与常规标签引用不同的是,它不直接指向提交对象,而是新建一个Git对象存储到.git/objects
中,用来记录附注信息,然后附注标签指向这个Git对象。
使用git tag
建立一个附注标签:
1 | $ git tag -a v1.1 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 -m "test tag" |
使用git cat-file
来查看附注标签所指向的Git对象:
1 | $ git cat-file -p 8be4d8e4e8e80711dd7bae304ccfa63b35a6eb8c |
可以看到,上面的Git对象存储了我们填写的附注信息。
总之,普通的标签引用和附注引用同样都是存储的是40位hash值,指向一个Git对象,所不同的是普通的标签引用是直接指向提交对象,而附注标签是指向一个附注对象,附注对象再指向具体的提交对象。
另外,本质上标签引用并不是只可以指向提交对象,实际上可以指向任何Git对象,即可以给任何Git对象打标签。
远程引用,类似于.git/refs/heads
中存储的本地仓库各分支的最后一次提交,在.git/refs/remotes
是用来记录多个远程仓库各分支的最后一次提交。
我们可以使用git remote
来管理远程分支:
1 | $ git remote add origin git@github.com:jingsam/git-test.git |
上面添加了一个origin
远程分支,接下来我们把本地仓库的master
推送到远程仓库上:
1 | $ git push origin master |
这时候在.git/refs/remotes
中的远程引用就会更新:
1 | $ cat .git/refs/remotes/origin/master |
和本地仓库的master
比较一下,发现是一模一样的,表示远程分支和本地分支是同步的:
1 | $ cat .git/refs/heads/master |
由于远程引用也是Git引用对象,所以理论上也可以使用git update-ref
来手动维护。但是,我们需要先把代码与远程仓库进行同步,在远程仓库中找到对应分支的HEAD,然后使用git update-ref
进行更新,过程比较麻烦。而我们在执行git pull
或git push
这样的高层命令的时候,远程引用会自动更新。
到这里,三种Git引用都已分析完毕。总的来说,三种Git引用都统一存储到.git/refs
目录下,Git引用中的内容都是40位的hash值,指向某个Git对象,这个对象可以是任意的Git对象,可以是数据对象、树对象、提交对象。三种Git引用都可以使用git update-ref
来手动维护。
三种Git引用对象所不同的是,分别存储于.git/refs/heads
、.git/refs/tags
、.git/refs/remotes
,存储的文件夹不同,赋予了引用对象不同的功能。HEAD引用用来记录本地分支的最后一次提交,标签引用用来给任意Git对象打标签,远程引用正式用来记录远程分支的最后一次提交。
数据对象、树对象和提交对象都是存储在.git/objects
目录下,目录的结构如下:
1 | .git |
从上面的目录结构可以看出,Git对象的40位hash分为两部分:头两位作为文件夹,后38位作为对象文件名。所以一个Git对象的存储路径规则为:
1 | .git/objects/hash[0, 2]/hash[2, 40] |
这里就产生了一个疑问:为什么Git要这么设计目录结构,而不直接用Git对象的40位hash作为文件名?原因是有两点:
1.有些文件系统对目录下的文件数量有限制。例如,FAT32限制单目录下的最大文件数量是65535个,如果使用U盘拷贝Git文件就可能出现问题。
2.有些文件系统访问文件是一个线性查找的过程,目录下的文件越多,访问越慢。
在在Git内部原理之Git对象哈希中,我们知道Git对象会在原内容前加个一个头部:
1 | store = header + content |
Git对象在存储前,会使用zlib的deflate算法进行压缩,即简要描述为:
1 | zlib_store = zlib.deflate(store) |
压缩后的zlib_store
按照Git对象的路径规则存储到.git/objects
目录下。
总结下Git对象存储的算法步骤:
1.计算content
长度,构造header
;
2.将header
添加到content
前面,构造Git对象;
3.使用sha1算法计算Git对象的40位hash码;
4.使用zlib的deflate算法压缩Git对象;
5.将压缩后的Git对象存储到.git/objects/hash[0, 2]/hash[2, 40]
路径下;
接下来,我们使用Nodejs来实现git hash-object -w
的功能,即计算Git对象的hash值并存储到Git文件系统中:
1 | const fs = require('fs') |
最后,测试下能否正确存储Git对象:
1 | $ node index.js 'hello, world' blob |
由此可见,我们生成了一个合法的Git数据对象,证明算法是正确的。
]]>Git中的数据对象、树对象和提交对象的hash方法原理是一样的,可以描述为:
1 | header = "<type> " + content.length + "\0" |
上面公式表示,Git在计算对象hash时,首先会在对象头部添加一个header
。这个header
由3部分组成:第一部分表示对象的类型,可以取值blob
、tree
、commit
以分别表示数据对象、树对象、提交对象;第二部分是数据的字节长度;第三部分是一个空字节,用来将header
和content
分隔开。将header
添加到content
头部之后,使用sha1
算法计算出一个40位的hash值。
在手动计算Git对象的hash时,有两点需要注意:
1.header
中第二部分关于数据长度的计算,一定是字节的长度而不是字符串的长度;
2.header + content
的操作并不是字符串级别的拼接,而是二进制级别的拼接。
各种Git对象的hash方法相同,不同的在于:
1.头部类型不同,数据对象是blob
,树对象是tree
,提交对象是commit
;
2.数据内容不同,数据对象的内容可以是任意内容,而树对象和提交对象的内容有固定的格式。
接下来分别讲数据对象、树对象和提交对象的具体的hash方法。
数据对象的格式如下:
1 | blob <content length><NULL><content> |
从上一篇文章中我们知道,使用git hash-object
可以计算出一个40位的hash值,例如:
1 | $ echo -n "what is up, doc?" | git hash-object --stdin |
注意,上面在echo
后面使用了-n
选项,用来阻止自动在字符串末尾添加换行符,否则会导致实际传给git hash-object
是what is up, doc?\n
,而不是我们直观认为的what is up, doc?
。
为验证前面提到的Git对象hash方法,我们使用openssl sha1
来手动计算what is up, doc?
的hash值:
1 | $ echo -n "blob 16\0what is up, doc?" | openssl sha1 |
可以发现,手动计算出的hash值与git hash-object
计算出来的一模一样。
在Git对象hash方法的注意事项中,提到**header
中第二部分关于数据长度的计算,一定是字节的长度而不是字符串的长度**。由于what is up, doc?
只有英文字符,在UTF8中恰好字符的长度和字节的长度都等于16,很容易将这个长度误解为字符的长度。假设我们以中文
来试验:
1 | $ echo -n "中文" | git hash-object --stdin |
我们可以看到,git hash-object
和openssl sha1
计算出来的hash值根本不一样。这是因为中文
两个字符作为UTF格式存储后的字符长度不是2,具体是多少呢?可以使用wc
来计算:
1 | $ echo -n "中文" | wc -c |
中文
字符串的字节长度是6,重新手动计算发现得出的hash值就能对应上了:
1 | $ echo -n "blob 6\0中文" | openssl sha1 |
树对象的内容格式如下:
1 | tree <content length><NUL><file mode> <filename><NUL><item sha>... |
需要注意的是,<item sha>
部分是二进制形式的sha1码,而不是十六进制形式的sha1码。
我们从上一篇文章摘出一个树对象做实验,其内容如下:
1 | $ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
我们首先使用xxd
把83baae61804e65cc73a7201a7252750c76066a30
转换成为二进制形式,并将结果保存为sha1.txt
以方便后面做追加操作:
1 | $ echo -n "83baae61804e65cc73a7201a7252750c76066a30" | xxd -r -p > sha1.txt |
接下来构造content部分,并保存至文件content.txt
:
1 | $ echo -n "100644 test.txt\0" | cat - sha1.txt > content.txt |
计算content的长度:
1 | $ cat content.txt | wc -c |
那么最终该树对象的内容为:
1 | $ echo -n "tree 36\0" | cat - content.txt |
最后使用openssl sha1
计算hash值,可以发现和实验的hash值是一样的:
1 | $ echo -n "tree 36\0" | cat - content.txt | openssl sha1 |
提交对象的格式如下:
1 | commit <content length><NUL>tree <tree sha> |
我们从上一篇文章摘出一个提交对象做实验,其内容如下:
1 | $ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
这里需要注意的是,由于echo 'first commit'
没有添加-n
选项,因此实际的提交信息是first commit\n
。使用wc
计算出提交内容的字节数:
1 | $ echo -n "tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
那么,这个提交对象的header
就是commit 163\0
,手动把头部添加到提交内容中:
1 | commit 163\0tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
使用openssl sha1
计算这个上面内容的hash值:
1 | $ echo -n "commit 163\0tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
可以看见,与实验的hash值是一样的。
这篇文章详细地分析了Git中的数据对象、树对象和提交对象的hash方法,可以发现原理是非常简单的。数据对象和提交对象打印出来的内容与存储内容组织是一模一样的,可以很直观的理解。对于树对象,其打印出来的内容和实际存储是有区别的,增加了一些实现上的难度。例如,使用二进制形式的hash值而不是直观的十六进制形式,我现在还没有从已有资料中搜到这么设计的理由,这个问题留待以后解决。
]]>这篇文章的主题的Git对象。
从根本上来讲,Git是一个内容寻址的文件系统,其次才是一个版本控制系统。记住这点,对于理解Git的内部原理及其重要。所谓“内容寻址的文件系统”,意思是根据文件内容的hash码来定位文件。这就意味着同样内容的文件,在这个文件系统中会指向同一个位置,不会重复存储。
Git对象包含三种:数据对象、树对象、提交对象。Git文件系统的设计思路与linux文件系统相似,即将文件的内容与文件的属性分开存储,文件内容以“装满字节的袋子”存储在文件系统中,文件名、所有者、权限等文件属性信息则另外开辟区域进行存储。在Git中,数据对象相当于文件内容,树对象相当于文件目录树,提交对象则是对文件系统的快照。
下面的章节,会分别对每种对象进行说明。开始说明之前,先初始化一个Git文件系统:
1 | $ mkdir git-test |
接下来的操作都会在git-test
这个目录中进行。
数据对象是文件的内容,不包括文件名、权限等信息。Git会根据文件内容计算出一个hash值,以hash值作为文件索引存储在Git文件系统中。由于相同的文件内容的hash值是一样的,因此Git将同样内容的文件只会存储一次。git hash-object
可以用来计算文件内容的hash值,并将生成的数据对象存储到Git文件系统中:
1 | $ echo 'version 1' | git hash-object -w --stdin |
上面示例中,-w
表示将数据对象写入到Git文件系统中,如果不加这个选项,那么只计算文件的hash值而不写入;--stdin
表示从标准输入中获取文件内容,当然也可以指定一个文件路径代替此选项。
上面讲数据对象写入到Git文件系统中,那如何读取数据对象呢?git cat-file
可以用来实现所有Git对象的读取,包括数据对象、树对象、提交对象的查看:
1 | $ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 |
上面示例中,-p
表示查看Git对象的内容,-t
表示查看Git对象的类型。
通过这一节,我们能够对Git文件系统中的数据对象进行读写。但是,我们需要记住每一个数据对象的hash值,才能访问到Git文件系统中的任意数据对象,这显然是不现实的。数据对象只是解决了文件内容存储的问题,而文件名的存储则需要通过下一节的树对象来解决。
树对象是文件目录树,记录了文件获取目录的名称、类型、模式信息。使用git update-index
可以为数据对象指定名称和模式,然后使用git write-tree
将树对象写入到Git文件系统中:
1 | $ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt |
--add
表示新增文件名,如果第一次添加某一文件名,必须使用此选项;--cacheinfo <mode> <object> <path>
是要添加的数据对象的模式、hash值和路径,<path>
意味着为数据对象不仅可以指定单纯的文件名,也可以使用路径。另外要注意的是,使用git update-index
添加完文件后,一定要使用git write-tree
写入到Git文件系统中,否则只会存在于index区域。
树对象仍然可以使用git cat-file
查看:
1 | $ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
上面表示这个树对象只有test.txt
这个文件,接下来我们将version 2
的数据对象指定为test.txt
,并添加一个新文件new.txt
:
1 | $ git update-index --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt |
查看树对象0155eb
,可以发现这个树对象有两个文件了:
1 | $ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341 |
我们甚至可以使用git read-tree
,将已添加的树对象读取出来,作为当前树的子树:
1 | $ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
--prefix
表示把子树对象放到哪个目录下。查看树对象,可以发现当前树对象有一个文件夹和两个文件:
1 | $ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614 |
最终,整个树对象的结构如下图:
树对象解决了文件名的问题,而且,由于我们是分阶段提交树对象的,树对象可以看做是开发阶段源代码目录树的一次次快照,因此我们可以是用树对象作为源代码版本管理。但是,这里仍然有问题需要解决,即我们需要记住每个树对象的hash值,才能找到个阶段的源代码文件目录树。在源代码版本控制中,我们还需要知道谁提交了代码、什么时候提交的、提交的说明信息等,接下来的提交对象就是为了解决这个问题的。
提交对象是用来保存提交的作者、时间、说明这些信息的,可以使用git commit-tree
来将提交对象写入到Git文件系统中:
1 | $ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 |
上面commit-tree
除了要指定提交的树对象,也要提供提交说明,至于提交的作者和时间,则是根据环境变量自动生成,并不需要指定。这里需要提醒一点的是,读者在测试时,得到的提交对象hash值一般和这里不一样,这是因为提交的作者和时间是因人而异的。
提交对象的查看,也是使用git cat-file
:
1 | $ git cat-file -p db1d6f137952f2b24e3c85724ebd7528587a067a |
上面是属于首次提交,那么接下来的提交还需要指定使用-p
指定父提交对象,这样代码版本才能成为一条时间线:
1 | $ echo 'second commit' | git commit-tree 0155eb4229851634a0f03eb265b69f5a2d56f341 -p db1d6f137952f2b24e3c85724ebd7528587a067a |
使用git cat-file
查看一下新的提交对象,可以看到相比于第一次提交,多了parent
部分:
1 | $ git cat-file -p d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
最后,我们再将树对象3c4e9c
提交:
1 | $ echo 'third commit' | git commit-tree 3c4e9cd789d88d8d89c1073707c3585e41b0e614 -p d4d2c6cffb408d978cb6f1eb6cfc70e977378a5c |
使用git log
可以查看整个提交历史:
1 | $ git log --stat 3ac728ac62f0a7b5ac201fd3ed1f69165df8be31 |
最终的提交对象的结构如下图:
Git中的数据对象解决了数据存储的问题,树对象解决了文件名存储问题,提交对象解决了提交信息的存储问题。从Git设计中可以看出,Linus对一个源代码版本控制系统做了很好的抽象和解耦,每种对象解决的问题都很明确,相比于使用一种数据结构,无疑更灵活和更易维护。每种Git对象都有一个hash值,这个值是怎么计算出来的?Git的各种对象是如何存储的?这些问题将在下一篇文章中讲解。
]]>使用homebrew安装软件很方便,一条命令brew install your-formula-name
就可以搞定。更新formula到最新版本,使用brew upgrade your-formula-name
即可。
但是,各种formula是人工维护的,当软件包更新后,formula不见得能及时更新到最新版本。本文就以gdal为例,说明如何手动编辑formula文件,以此来将软件更新到最新版本。
Homebrew中的每个软件包都是通过一个formula.rb
文件来配置软件的源代码URL、依赖、编译规则和选项,例如以下是gdal的formula文件:
1 | class Gdal < Formula |
以上是一个ruby文件,好在并不需要懂ruby的语法就能看懂formula文件。desc
和homepage
都是描述性信息,不对软件安装产生什么影响。url
是软件源代码的位置,编译安装时从此位置将源代码下载下来。sha256
是源代码包的校验码,这是保证下载下来的包不被篡改。revision
是修订版本号,主要用来保持版本号不变的情况下,对软件包打补丁,每打一次补丁,修订版本号就自增一次。当使用brew install
安装软件包时,除非使用--build-from-source
强制指定使用源代码安装,大部分情况下,brew会下载编译好的二进制包,这样安装起来更快。bottle
选项就记录了各版本macOS下预编译的二进制包的校验码,这部分内容是homebrew的自动集成工具自动维护的,我们并不需要编辑修改。
更改上面的url
和sha256
,即可将formula的配置更新到任意版本。编辑好后,使用brew install
或者brew upgrade
进行安装或者更新升级。
Homebrew实际上是通过git来管理formula配置文件的,因此我们还可以发送Pull request,将我们的更新推送到GitHub上,让别人也能够方便地更新软件包。
这部分以更新gdal 2.2.4到2.3.0为例,来说明手动更新formula的步骤。由于写作本文时,gdal已经更新到2.3.0,所以某些步骤的输出可能与本文不一致,但不妨碍理解更新步骤。
使用brew edit gdal
即可打开gdal.rb
开始编辑,我们将url
更新为2.3.0版本的源代码链接:
1 | class Gdal < Formula |
理论上我们还需要更新sha256
,使它和url
相匹配。但是sha256
需要我们使用工具计算或者从发布网站上找,不是很方便。我们可以通过下一步的安装调试,来自动计算sha256
。
使用brew install gdal --verbose --debug --build-from-source
来安装调试gdal的formula,如果已经安装旧版本的gdal,那么使用brew upgrade gdal --verbose --debug --build-from-source
。--verbose
表示显示详细输出,便于调试;--debug
打开调试;--build-from-source
强制从源代码编译。
安装过程中,会报sha256校验码不匹配的警告,并打印出url
所指向的源代码包的sha256校验值,这是因为在上一步我们并没有修改sha256
,配置文件中sha256
还是gdal 2.2.4版本的。这时候重新使用brew edit gdal
打开并编辑formula文件,将sha256
更改为正确的校验值。
最后,再安装调试,经过漫长的编译,成功地安装上了gdal的最新版本。
测试包括对软件包的测试和对formula文件的测试。使用brew test gdal
可以测试gdal的功能是否正常,使用brew audit --strict gdal
测试formula文件是否正确。运行brew audit
时,会报告revision 2
需被删除,这是因为当前homebrew线上库中还没有gdal 2.3.0,意味着本地端的gdal应该是第一版本,不存在修订版本之说。同样地,使用brew edit gdal
打开并删除revision
部分,然后再重新测试。
通过上述步骤,我们完成了gdal的手动更新,如果将更新推送到homebrew的线上库中,那么其他人就可以方便地更新到最新版本。并且推送到线上库后,homebrew的自动集成工具会自动地编译生成二进制包,这样就不需要从源代码编译那么耗时了,可谓是利人利己。
由于homebrew是在GitHub上协作的,所以更新一个formula就和发一个Pull request是一样的,基本步骤如下:
1.使用cd $(brew --repository homebrew/homebrew-core)
切换到本地的homebrew-core目录;
2.使用git commit
提交自己的修改;
3.把https://github.com/Homebrew/homebrew-core fork一份;
4.使用git remote add
命令添加自己的fork的homebrew-core库;
5.使用git push
推送将本地提交推送到自己的fork的homebrew-core库中;
6.在GitHub网页上发起Pull request。
上面一步步完成了编辑配置、安装调试、测试、推送更新,操作起来有些繁琐。但其实homebrew还提供了一个工具,能够一键完成上面4个步骤,命令如下:
1 | brew bump-formula-pr gdal --URL=https://download.osgeo.org/gdal/2.3.0/gdal-2.3.0.tar.xz --audit --strict |
brew bump-formula-pr
自动的修改formula配置文件、检查文件错误、提交并推送更新,其中提交并推送更新的过程需要使用hub
来在终端上操作GitHub,可以使用brew install hub
来安装这个工具。
1.改变子进程的所属的父进程,只要父进程不关闭,子进程也不会关闭;
2.让子进程忽略挂断信号,即使收到SIGHUP信号,也任性地继续运行;
3.不向子进程广播SIGHUP信号,子进程收不到SIGHUP信号,因而不会关闭。
使用setsid
可以新开一个session运行进程,此session不从属于当前终端,因此终端关闭时进程也不会退出。
1 | >setsid ping www.baidu.com |
从上面可以看出,ping进程的父进程是1,即init进程,因此只要电脑不关机,ping进程就不会停止。
linux下自带setsid
这个命令,但是macOS上并没有这个命令。此时,可以结合使用()
和&
达到同样的效果。()
可以新开一个subshell,&
让命令后台运行。
1 | >(ping www.baidu.com &) |
可以看到,效果与setsid
是一样的。
使用nohup
可以使进程忽略SIGHUP信号,这种方式也是最常用的。例如:
1 | >nohup ping www.baidu.com & |
可以看到,ping进程的父进程并不为1,nohup是让进程忽略SIGHUP信号实现进程不退出的。
需要注意的是,当在zsh
中使用nohup
时,退出终端时会提示:
1 | zsh: you have running jobs. |
再次强行退出,那么进程仍然会被干掉。这时候,采用以下命令:
1 | nohup ping www.baidu.com &! |
使用nohup的前提是,进程以nohup来启动。但是,如果启动时忘记了以nohup启动,有什么方法在不停止进程的情况,让它继续后台运行呢?接下来就要将另外一个命令:disown
。disown
的原理是,将子进程从终端任务队列中移除,所以即使终端挂断,子进程也收不到SIGHUP信号。
假设现在使用ping命令:
1 | >ping www.baidu.com |
这时采用Ctrl + z
使它进入后台,使用jobs
查看后台进程:
1 | >jobs |
可以看到虽然ping进程进入后台,但是进程被挂起了,没有继续运行。使用bg
命令可以使他在后台继续运行:
1 | >bg %1 |
通过组合Ctrl + z
和bg
,成功地将前台进程变为了后台进程。为了让进程不随终端关闭而终止,还差最后一步:
1 | >disown %1 |
上面使用jobs
命令查看任务队列,发现ping进程不在任务队列中,意味着进程不会收到SIGHUP信号。
以上我们通过三种方式,避免进程随着终端关闭而被杀掉:
setsid
改变父进程,只要父进程不关闭,进程就可以持续运行;nohup
使得进程忽略SIGHUP信号,父进程即使发送挂断信号,进程也不会终止;disown
将进程从任务队列中移除,保证进程收不到SIGHUP信号。但是,以上种种方法只是避免了进程受到SIGHUP信号的影响,进程的持续运行还需要一些其他环境,例如stdin、stdout以及stderr。通常从终端启动的进程,会继承终端的stdin、stdout和stderr。当终端断掉之后,stdin、stdout和stderr也会随着消失,若此时后台进程需要读写stdin、stdout、stderr,该进程将会暂停或者挂住。所以,为保证进程正常后台运行,最好启动时对输入输出重定向:
1 | >ping www.baidu.com > a.log 2>&1 & |
此时,将stdout和stderr重定向到文件a.log
中,文件a.log
不受终端关闭的影响。如果进程依赖于stdin,意思是进程需要由于键盘输入,那就说明这是个交互式程序,交互式程序后台运行就没多大意义了。
Linux 技巧:让进程在后台可靠运行的几种方法
Difference between nohup, disown and &
网上确实有很多Windows Server破解激活工具,但是有各种各样的工具,不知道哪个能起作用。而且,这些工具大部分杀毒软件都报有病毒,不太敢用。最重要的是,破解激活涉及到版权问题,道义上过不去。
由于我使用的是Windows Server评估版本,评估版本可以重置5次试用期,所以加上安装的那次,那么理论了最多可以试用1080天,差不多3年。
重置方法很简单,以管理员身份打开命令提示符,输入以下命令即可重置试用期,获得180天试用:
1 | slmgr.vbs /rearm |
重置成功后需要重启一次才能生效。
通过以下命令可以查看剩余的试用时间和可重置次数:
1 | slmgr.vbs /dlv |
那么重置次数试用完之后怎么办呢?据说可以通过改注册表的方式获得额外的重置次数,但是这种方法可能违反了系统试用协议。具体的做法是使用regedit
打开注册表编辑器,找到:
1 | HKEY_LOCAL_MACHINE -> SOFTWARE -> Microsoft -> Windows NT -> CurrentVersion -> SoftwareProtectionPlatform |
将键SkipRearm
的值设为1,再用slmgr.vbs /rearm
继续重置,据说这种方法可以使用8次。
package.json
中的main
字段指向的是Library的入口,通常有3个选择:1.指向源代码入口文件,如src/index.js
;
2.指向打包后的开发版本,如dist/library.js
;
3.指向打包后的发布版本,如dist/library.min.js
。
引用Library的方式也分为两种:
1.通过script标签直接引用,适用于简单页面;
2.通过require或import方式引用,需要借助打包工具打包,适用于复杂页面。
本文探讨一下main
字段如何指定,才能兼顾各种引用方式。
第一种方式指向源码入口,这种情况仅适用于require方式引用。由于指向的是源代码,需要库使用者借助打包工具如webpack,自行对库进行打包。此方式存在以下问题:
1.webpack配置babel-loader一般会排除node_modules,意味着不会对library进行转译,可能会导致打包后的代码中包含ES6代码,造成低版本浏览器兼容问题;
2.如果library的编译需要一些特别的loader或loader配置,使用者需要在自己的配置中加上这些配置,否则会造成编译失败;
3.使用者的打包工具需要收集library的依赖,造成打包编译速度慢,影响开发体验。
总的来说,第一种方式需要使用者自行对library进行编译打包,对使用者造成额外的负担,因此源代码入口文件不适宜作为库的入口。但是,如果library的目标运行环境只是node端,由于node端不需要对源代码进行编译打包,所以这种情况下可以使用src/index.js
作为库入口。
开发版本的主要作用是便于调试,文件体积并不是开发版本所关心的问题,这是因为开发版本通常是托管在localhost上,文件大小基本没影响。
开发版本主要通过以下手段来方便调试,提升开发体验:
1.预先进行依赖收集和babel转译,即使用者不再需要对library进行这两步工作了,提高编译打包的效率;
2.尽量保留源代码的格式,保证开发版本里面的源代码基本可读;
3.保留警告信息,对开发者对库的错误或不合理调用进行提示。
其中第3点是通过库代码中添加如下类似代码实现的:
1 | if (process.env.NODE_ENV === 'development') { |
生成开发版本的似乎,webpack的DefinePlugin会将process.env.NODE_ENV
替换为development
,所以以上代码变为:
1 | if ('development' === 'development') { |
这就表示上述条件一直成立,warning信息会显示出来。
最近和iview的开发者争论一件事,即在生成library的开发版本的时候,NODE_ENV
应该设置为development
还是production
。他们认为应该设置为production
,理由是可以减小开发版本的体积。假设DefinePlugin将process.env.NODE_ENV
替换为production
,之前的示例代码会变为:
1 | if ('production' === 'development') { |
这就意味着你使用库开发应用时,不会看到任何警告信息,这不利于提前发现错误。可能有的人会说,我的源代码中没有if (process.env.NODE_ENV === 'development') {}
这类代码,所以设置为production
也不会有任何问题呀。殊不知,虽然你的源代码中这种没有这类提示代码,但是你的devependencies里面可能会有啊,这样做就会关闭依赖中的warning信息。
可能又有疑问:“引用开发版本的包体积很大,岂不是让我的应用打包上线版本很大?”其实完全不用担心,因为应用打包为上线版本时,会经过两个额外的工作:
1.使用DefinePlugin将process.env.NODE_ENV
替换为production
,关闭所有警告信息;
2.使用UglifyJsPlugin对应用代码进行minify,减小应用体积。同时会删除if ('production' === 'development') {}
这类永远不会执行的代码,进一步减小应用体积。
所以,在开发时应用开发版本,不必担心最后的应用体积。但是如果开发时是以script标签的方式引用库的开发版本,上线时应该替换为响应的发布版本。
发布版本追求的是尽量减小体积,因为相比于JS引擎解析的时间,网络传输是最慢的,所以要通过减小库的体积,减少网络传输的时间。
减小发布版本的文件体积,主要是通过将process.env.NODE_ENV
设置为production
,然后再使用UglifyJsPlugin对应用代码进行minify以及删除永不执行的代码。
那么将库的发布版本作为入口文件合不合适呢?显然不合适,因为发布版本的是经过高度压缩精简的,代码完全不可读,应用开发阶段难以调试。
发布版本是适用于在应用上线时,通过script标签形式引用。
通过上面的分析,可以发现将库的开发版本作为库的入口才是正确合理的做法,即设置"main": "dist/library.js"
。而作为库的开发者,也要遵循约定,生成库的开发版本的时候,使用development
环境变量,保留警告信息。
Webpack在当前前端工程化中占有很重要的地位,前端工程化是为了提高前端开发的效率,但是前端工程化中的工具链配置的复杂度也逐渐提高。有时候新开一个项目,光是配置这些工具就要花一两天,这对于小型项目有点得不偿失。在配置工具链的时间花费大,诚然webpack本身配置参数多,我认为更重要的原因在于平时过于依赖于vue-cli、react-scripts这类自动化生成工具,没有具体地了解每个配置项的作用。
最近要开发一个可视化的JS库,但是vue-cli、react-scripts这类自动化生成工具主要针对的是SPA,对开发Library支持不好,因而尝试下手动配置webpack、eslint、babel、prettier这些工具。好在开发的是Library,主要处理JS代码,如果是SPA,那就需要处理各种各样的前端资源,还是建议使用vue-cli、react-scripts。
本文主要是做步骤记录,用于指导以后进行简单项目的手动配置,因而本篇文章不探讨深度内容。
这块没什么好说的,借助npm init
或yarn init
可以快速地生成package.json
文件。我通常习惯于先在scripts
中把主要的开发命令写出来,以下是我的scripts
配置:
1 | { |
上面的scripts
配置也体现了下文要讲的工程目录结构,即build
里面放webpack配置文件,src
里面放源代码,详细的内容在下一节描述。
main
默认是index.js
,即指向源代码的入口文件。但是本项目主要开发的库是作为其他项目的node_modules,一般不会再对库进行转译,所以为了方便将本库集成到前段工程项目中,main
应该指向转译好的UMD格式文件。本项目的main
配置为:
1 | { |
我认为工程的目录结构非常重要,它能够反映代码的模块划分,好的目录结构让人赏心悦目、容易理解。我按照以下目录进行组织:
1 | build // 编译配置 |
Webpack 4刚发布,据说简化了配置,所以本项目就来尝尝鲜。Webpack 4相比原来需要额外安装一个webpack-cli,并且要求node版本不小于6.11.5,安装命令如下:
1 | npm install --save-dev webpack webpack-cli |
接下来就要配置webpack.dev.config.js
和webpack.prod.config.js
。webpack官方文档推荐用一个webpack.common.config.js
提取公共配置后,再用webpack-merge
合并。由于本项目配置比较简单,重复的地方不多,所以就不用引入额外的复杂度了。如果项目的配置复杂到同一种配置需要重复3次以上,那么还是需要采用webpack-merge
合并的,因为同时更改3个地方很容易出错。
本项目的webpack.dev.config.js
的配置如下:
1 | const path = require('path') |
本项目的webpack.prod.config.js
的配置如下:
1 | const path = require('path') |
以上有以下几个地方需要注意:
1.entry
中使用相对于当前目录时,./
不能省略,即./src/index.js
不能写为src/index.js
。
2.output
中的path
一定要是绝对路径;
3.libraryTarget
设为umd
以兼容浏览器和commonjs环境;
4.webpack 4新引入了mode
配置,会自动做一些优化,可以为development
或production
,不能省略mode
的配置;
5.mode
为development
时,HotModuleReplacementPlugin不是默认载入的,所以为了使开发时候能够热替换,需要手动加上这个配置;
babel负责将高语言特性JS源代码转译为低语言特性JS代码,以兼容低版本浏览器,当前推荐采用babel-preset-env
,它能根据要兼容的浏览器版本,有选择性地转译,而不是像以前一样统统转译为ES5。
使用以下命令安装babel以及配套工具:
1 | npm install --save-dev babel-core babel-preset-env babel-loader |
.babelrc
配置如下:
1 | { |
eslint能够检查源代码中的格式错误以及少量的语法错误,prettier是用来自动地格式化代码。它们的主要区别在于,eslint主要用来检查代码格式,prettier主要用来修复代码格式。虽然eslint --fix
也能自动修复一些格式错误,但只能修复少数几种格式错误,功能十分有限。prettier的格式修复功能很强,但是如果代码中有错误,例如有尾逗号、引用未知变量,prettier不管这些,仍然帮你格式化,这就让你很难提早发现代码中的错误。
eslint和prettier相爱相杀,让它们和谐相处,才能更好地为我们提供服务。总体思想是eslint的检查规则尽量与prettier的格式规则保持一致,代码先用prettier格式化之后再用eslint --fix
修复并检查。
需要先安装一下几个包:
1 | npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-prettier prettier-eslint-cli |
eslint-config-prettier
是用来将prettier的格式化规则作为eslint的检查规则,eslint-plugin-prettier
则是用来对比prettier格式化前后,代码中出现的错误。prettier-eslint-cli
是用来依次执行prettier和eslint --fix
,自动格式化代码。
.eslintrc
的配置如下:
1 | { |
需要指出的是如果使用了ES6的import
和export
,则需要配置"sourceType": "module"
。
我是无分号党,.prettierrc
配置如下:
1 | { |
.gitignore
用来排除不需要git管理的文件,配置如下:
1 | .DS_Store |
配置一个Library开发环境,分为生成package.json、规划目录结构、配置webpack、配置babel、配置eslint和prettier、配置.gitignore几个步骤。本文仅仅是流水账记录,深度不够,写到后面我都觉得乏味了,以后不写这种水文章了,匿了。
]]>理想的情况是将Node.js打包为一个单独的可执行文件,部署的时候直接拷贝过去就行了。除了部署方便外,因为不需要再拷贝源代码了,还有利于保护知识产权。
将Node.js打包为可执行文件的工具有pkg、nexe、node-packer、enclose等,从打包速度、使用便捷程度、功能完整性来说,pkg是最优秀的。这篇文章就来讲一讲半年来我使用pkg打包Node.js应用的一些经验。
pkg的打包原理简单来说,就是将js代码以及相关的资源文件打包到可执行文件中,然后劫持fs
里面的一些函数,使它能够读到可执行文件中的代码和资源文件。例如,原来的require('./a.js')
会被劫持到一个虚拟目录require('/snapshot/a.js')
。
pkg既可以全局安装也可以局部安装,推荐采用局部安装:
1 | npm install pkg --save-dev |
pkg使用比较简单,执行下pkg -h
就可以基本了解用法,基本语法是:
1 | pkg [options] <input> |
<input>
可以通过三种方式指定:
1.一个脚本文件,例如pkg index.js
;
2.package.json
,例如pkg package.json
,这时会使用package.json
中的bin
字段作为入口文件;
3.一个目录,例如pkg .
,这时会寻找指定目录下的package.json
文件,然后在找bin
字段作为入口文件。
[options]
中可以指定打包的参数:
1.-t
指定打包的目标平台和Node版本,如-t node6-win-x64,node6-linux-x64,node6-macos-x64
可以同时打包3个平台的可执行程序;
2.-o
指定输出可执行文件的名称,但如果用-t
指定了多个目标,那么就要用--out-path
指定输出的目录;
3.-c
指定一个JSON配置文件,用来指定需要额外打包脚本和资源文件,通常使用package.json
配置。
使用pkg的最佳实践是:在package.json
中的pkg
字段中指定打包参数,使用npm scripts
来执行打包过程,例如:
1 | { |
scripts
和assets
用来配置未打包进可执行文件的脚本和资源文件,文件路径可以使用glob通配符。这里就浮现出一个问题:为什么有的脚本和资源文件打包不进去呢?
要回答这个问题,就涉及到pkg打包文件的机制。按照pkg文档的说法,pkg只会自动地打包相对于__dirname
、__filename
的文件,例如path.join(__dirname, '../path/to/asset')
。至于require()
,因为require本身就是相对于__dirname
的,所以能够自动打包。假设文件中有以下代码:
1 | require('./build/' + cmd + '.js') |
这些路径都不是常量,pkg没办法帮你自动识别要打包哪个文件,所以文件就丢失了,所以这时候就使用scripts
和assets
来告诉pkg,这些文件要打包进去。那么我们怎么知道哪些文件没有被打包呢?难倒要一行行地去翻源代码吗?其实很简单,只需要把打包好的文件运行下,报错信息一般就会告诉你缺失哪些文件,并且pkg在打包过程中也会提示一些它不能自动打包的文件。
如果说pkg还有哪儿还可以改进的地方,那就是无法自动打包二进制模块*.node
文件。如果你的项目中引用了二进制模块,如sqlite3,那么你需要手动地将*.node
文件复制到可执行文件同一目录,我通常使用命令cp node_modules/**/*.node .
一键完成。但是,如果你要跨平台打包,例如在windows上打包linux版本,相应的二进制模块也要换成linux版本,通常需要你手动的下载或者编译。
那为什么pkg不能将二进制模块打包进去呢?我猜想是require载入一个js文件和node文件,它们的机制是不一样的。另外从设计来说,不自动打包二进制模块也是合理的,因为二进制模块都是平台相关的。如果我在windows上生成一个linux文件,那么就需要拉取linux版本的.node
文件,这是比较困难的。并且有些二进制模块不提供预编译版本,需要安装的时候编译,pkg再牛也不可能模拟一个其他平台的编译环境吧。nexe可以自动打包二进制模块,但是只能打包当前平台和当前版本的可执行文件。这意味着如果Node.js应用引用了二进制包,那么这个应用就不能跨平台打包了,所以我认为这方面,nexe不能算是一个好的设计。
还有一点就是关于项目中的配置文件处理,比如数据库连接参数、环境变量等。因为这些配置文件会跟着不同的部署环境进行更改,所以为了方便更改,一般不希望把配置文件打包到exe。为了避免pkg自动地将配置文件打包到exe中,代码中不要采用以下方式读取配置文件:
1 | fs.readFile(path.join(__dirname, './config.json')), callback) |
而是采用相对于process.CWD()
的方法读取:
1 | fs.readFile(path.join(process.CWD(), './config.json'), callback) |
如果配置文件是js格式的,那么不要直接require('./config')
,而是采用动态require:
1 | const config = require(process.CWD() + './config') |
另外要提及的是pkg打包之后动态载入js文件会有安全性问题,即用户可以在js文件写任何处理逻辑,注入到打包后的exe中。例如,可以读取exe里面的虚拟文件系统,把源代码导出来。所以,尽量不要采用JS作为配置文件,也不要动态载入js模块。
]]>