普通视图

发现新文章,点击刷新页面。
昨天以前冯兄话吉博客

当AI遇到程序员如何做加法

作者 冯兄
2024年7月12日 08:00

目录


1. AI现状

人工智能正以惊人的速度发展,悄然改变着我们的生活和工作方式。从智能手机上的语音助手,到自动驾驶汽车,再到能与人类对话的ChatGPT,AI的身影无处不在。

在全球范围内,科技巨头和创新企业都在推动AI技术在各个领域的应用。比如,谷歌的AlphaFold在蛋白质结构预测方面取得了突破性进展,为医学研究带来了新的希望。在自动驾驶领域,不仅有特斯拉这样的国际巨头,中国的初创公司也在迅速崛起。以萝卜快跑为例,这家成立于2016年的公司已经在北京、广州等多个城市推出了自动驾驶出租车服务,展示了中国在AI应用方面的创新能力。

萝卜快跑后台运营调度,据说卡壳了会人工接管…

特别值得一提的是,AI大语言模型领域的发展更是令人目不暇接。OpenAI的GPT系列就像是给了计算机一个超级大脑。这个”大脑”不仅能跟你聊天扯淡,还能写代码、做翻译,甚至帮你写情书(虽然可能有点土)。想象一下,你有了一个24小时不休息的助手,而且这个助手几乎无所不知 - 这就是GPT的魔力。当然了,大模型不只有OpenAI公司开发的,其他大模型可谓百花齐放,是一个千帆竞技的场面。我们感受一下模型的智力:

gpt-3.5模型回答问题

gpt-4o模型回答问题

紧随其后的是Anthropic公司的Claude。有趣的是,Anthropic公司其实是由一群OpenAI的前核心成员创立的。这些”叛逃”的天才们带着在OpenAI积累的经验,打造出了Claude这个不甘示弱的AI助手。Claude除了能和GPT一样八面玲珑外,还特别注重伦理和安全。用大白话说,就是它不仅聪明,还懂得什么该说,什么不该说。

claude-3-5-sonnet-20240620模型回答问题

中国的AI公司也不甘落后。比如深思科技(DeepSeek)推出的模型,在某些任务上甚至能与GPT-4掰掰手腕。这说明在AI这场”军备竞赛”中,中国选手也是实力不俗。

deepseek-chat模型回答问题

说到创意领域,不得不提到Dall-E。这个由OpenAI开发的模型简直就是数字时代的毕加索。你随便说个想法,它就能画出一幅栩栩如生的图片来。想要一只戴着墨镜骑自行车的猫咪?没问题,分分钟就能搞定。

这些AI大模型的出现,就像是给人类大脑装上了一个超级外挂。它们不仅能帮我们处理繁琐的日常任务,还能激发创意,解决复杂问题。

当AI变得越来越聪明,我们人类该如何保持自己的独特价值?尤其是笔者作为一个程序员,经常思考这个问题,但这也同样值得每一个人深思熟虑。毕竟,我们不仅是这场AI革命的见证者,更是其中的参与者和塑造者。

2. 笔者与AI

作为一名程序员,笔者对AI的发展既兴奋又忐忑。兴奋的是,这些智能助手为我们打开了无限可能;忐忑的是,它们会不会有朝一日取代我们?带着这样的心情,笔者决定亲自下场,和AI来场亲密接触。

首先,笔者开发了一个名为AI. Transform Enhancer的网站。这个网站的核心理念是通过大模型和AI技术,将平时繁琐的事务交给AI辅助处理。用户只需输入自然语言和文本内容,AI就能智能处理并输出结果,实现”chat即所得”的便捷体验。这个平台使得非专业用户也能轻松进行文本转换、图表生成、音视频处理等操作。比如,你可以用自然语言简单描述一下需求,AI就能帮你进行文本转换、文件重命名,或者将你的文字转换成生动的图表。。

AI增强文本转换,不需要再写繁琐的正则表达式,自然语言输入转换

AI增强文件重命名,文件不上传服务端,js引用本地属性,基于AI批量重命名

AI增强chart生成,输入能提取数据的文本,AI辅助提取数据并生成Chart(前端基于chart.js

该项目定位为AI增强转换百宝箱,AI辅助能帮上忙的地方,就可以做成一个功能,期待更有趣的实现。

然而,和AI共舞并非总是一帆风顺。笔者也遇到了一些失败的尝试。其中一次,笔者想要用GPT生成一致性图片(consistent image),尽管使用了相同的gen_id和seed,却始终无法成功。

用的网上学的图片生成一致性prompt、gen_id、seed值都保持一致,仍无法生成一致性图片

另一次,笔者尝试用GPT生成ASCII艺术,结果发现这种方式并不合适,生成的作品远远达不到预期效果。

最开始的想法是让AI生成字体的点阵矩阵,但是发现生成内容太小根本看不出来。后来想到ASCII艺术,但生成效果不如人意

这些经历让笔者意识到,尽管AI在某些领域表现出色,但需要一个懂它、会用它的人。你越聪明,它越聪明。

3. 展望

与AI的这场初”约会”让笔者明白,AI并不是来取代我们的,而是来增强我们的能力。作为程序员,我们应该拥抱这项技术,学会与之共舞。

接下来,笔者打算把AI. Transform Enhancer这个小工具做成Utools插件,继续琢磨并开发一些有价值的AI落地功能。现在AI的信息铺天盖地,对那些没有计算机背景或者刚接触的人来说,很容易感到迷茫甚至退缩。而且,要找到真正有价值的信息也不容易。所以,我以后会致力于分享一些有价值的内容,希望能帮到更多的人。

4. 福利

AI相关程序员论坛Linux.do-Where possible begins邀请注册,每天2个名额,留言或者私信邮箱,笔者生成邀请码。

更新记录

  • 2024-12-07 18:41 首次提交文章到冯兄话吉
  • 2024-12-07 21:41 微信公众号“冯兄画戟”文章发表前重读、优化、勘误

bug现场谜之报错也识趣-客户一上班报错就消失了!

作者 冯兄
2024年2月11日 08:00

目录


1. bug现场情况

部署的是Java应用,容器中间件使用的是IBM WebSphere,数据库使用的是IBM DB2,数据源使用的是容器中间件提供的JNDI数据源服务。

程序在上班前的一段时间点击报错mybatis查询失败(sqlcode=-774 sqlstate=2D522),上班之后就恢复正常了。生产环境的日志没有有效的信息,全是-774的报错,而把报错的sql拿出来在数据库执行没有问题,并且同样的sql在客户上班后系统中执行没有问题。

粗略在网上查一下,DB2数据库sqlcode=-774的含义是:Explanation: Statement cannot be executed within a compound SQL statement.,意思是“语句不能在复合sql中执行”;sqlstate=2D522的含义是: “ATOMIC 复合语句中不允许 COMMIT 和 ROLLBACK”。初步的判断可能和数据库的事务相关。

2. 尝试破案

日志中没有有效的报错信息,只能先观察程序表象。发现,报错的现象在中午13:00之后是能够稳定复现的,在14:30之后就稳定消失。问题出现的时候,随意刷新页面就会报错,具体后台就是-774,也就是mybatis执行sql不成功。而14:30之后,随便刷新页面都不会出现问题,即使客户端并发多个请求造成响应慢但是也不会出现报错-774。如果在报错这段时间重启了应用程序,则报错就会消失。

从以上信息,大概估计,问题应该是出现在了数据库连接池上。在某个时刻,程序用数据库连接池中的连接执行了某些操作,该数据库连接就处于了“异常”状态,其他任何功能用到该“异常”数据库连接执行sql时,就会报错,不是数据库有什么问题,而是连接数据库的数据库连接有问题。这样设想也解释了两点,第一:为什么同样的sql、同一时刻在程序中执行就会报错,而拿出来到数据库客户端中执行就没有问题;第二:程序报错的时候重启,重启后为什么就正常了。原因都是报错来自于用到了数据库连接池中“异常”的数据库连接。

那具体是什么样的操作造成了数据库连接池连接的异常呢?正常在日志中应该有所体现才对,生产环境IBM Websphere的日志太不方便看了,并且也没法设置参数重启进行监控。研究决定将生产环境迁移出来一份作为测试环境。原来容器中间件为IBM WebSphere迁移到Tomcat上。迁移相关配置参考Tomcat JNDI容器数据源的配置

迁移完成后程序运行一段时间后查看日志,发现最先开始报错的日志有如下内容:Processing was cancelled ... SQLSTATE=57014,日志显示在执行一个存储过程的时候,由于某种原因(可能是时间过长)客户端主动取消了。后来在网上查到了IBM官方的一个帖子:https://www.ibm.com/support/pages/apar/IC64958。心里一下子豁然开朗了,这不就是DB2的一个bug吗?

IBM官方帖子说:“在DB2版本9上有一个bug,如果你在一个数据库连接中执行一个存储过程,因为某种原因客户端取消了,这时候在这个连接上执行的任何sql都会报错-774”。官方还提供了bug的补丁包。果然,找到报错的存储过程,处理之后,后续程序就没有出现过问题了。

这下找到了是什么样的操作导致数据库连接池中的连接“异常”了,就是DB2数据库的一个bug(存储过程被客户端取消后,使用同一连接执行sql都会报错-774),DB2在后续高版本上解决了这个bug。

3. 破案绊脚石

3.1 Tomcat JNDI容器数据源的配置

1). 配置server.xml中Resource。在GlobalNamingResources节点下增加节点Resource

  </GlobalNamingResources>
    ...
    <Resource name="jdbc/court" global="jdbc/court"
        auth="Container"
        type="javax.sql.DataSource"
        driverClassName="com.ibm.db2.jcc.DB2Driver"
        url="jdbc:db2://xxx.xxx.xxx.xxx:50000/xxx:currentSchema=xxx;currentFunctionPath=xxx;"
        username=""
        password=""
        maxActive="20"
        initialSize="0"
        minIdle="0"
        maxIdle="8"
        maxWait="10000"
        timeBetweenEvictionRunsMills="30000"
        minEvictableIdleTimeMillis="60000"
        testWhileIdle="true"
        validationQuery="select current date from sysibm.sysdummy1"
        maxAge="600000"
        rollbackOnReturn="true"
        factory="org.apache.tomcat.dbcp.dbcp.BasicDataSourceFactory"/>
    ...
  </GlobalNamingResources>

2). 配置context.xml。在Context节点下增加节点ResourceLink

<Context>
  <ResourceLink global="jdbc/court" name="jdbc/court" auth="Container" type="javax.sql.DataSource" />
</Context>

3). 配置数据源applicationContext.xml

<bean id="datasource" class="org.springframework.jndi.JndiObjectFactoryBean">
    <property name="jndiName">
        <value>java:comp/env/jdbc/court</value>
    </property>
</bean>

3.2 项目字符编码配置

由于解决问题的项目是10年前的老项目,因此项目的字符编码使用的是GBK。在迁移项目的过程中本地启动项目发现乱码,于是就尝试将xml文件的编码从GBK改为utf8。但是xml文件太多了,没有有效的工具能批量将GBK编码改为utf8编码。

实际上这个解决编码问题的思路方向走错了,如果原来项目使用的是GBK编码,那么迁移过也要使用GBK编码,而不是尝试将原来文本文件的字符编码进行修改。JVM参数指定项目字符编码的参数是:-Dfileencoding=GBK

3.3 DB2数据库迁移

DB2数据库之前没有接触过,迁移一个DB2数据库到新的服务器上也没有什么经验。走了一些弯路,主要问题是迁移表缺失、数据量不匹配。需要注意的问题是:

  1. db2lookup生成的迁移sql在目标db2服务上执行时,如果缺失表空间等元信息可能就会报错;一些sql语句可能在不同配置的环境上执行不了。关键是要验证表是否完整的迁移了,这一步是基础工作,非常重要。
  2. 一些表因为触发器、外键约束等的存在直接使用db2move命令迁移数据可能会不成功,这些表需要做单独的处理。

3.4 第一次听说currentFunctionPath这玩意

之前知道一些数据库的jdbcurl上支持配置一个currentSchema,表示默认查询的模式名,如果在jdbcurl配置上默认的schema,在程序中的sql可以省去查询语句表名前的schema。

迁移的程序启动后报错一个function找不到,但实际上数据库中该函数是存在的,将sql中函数前面加上模式名就可以调用成功。如果要将一个个函数都加上模式名实在太傻太累了,实际上这个方法是走偏了,应该上官网上找一找DB2的jdbcurl支持的参数,还真有currentFunctionPath这样的参数。有时候需要限定问题真正出现的范围,往这个范围查找。不然不从根本上解决问题会带来更多的问题或者非常大的工作量。

4. 总结

  1. 再着急的工作也一定要有个整体的方案,一些基础性的工作(例如数据迁移)是着急不来的,反而越着急,越会出问题,越影响整个进度。做整体方案的时候,那些脑海中一闪而过的担忧要仔细分析,不能心存侥幸心理、或者说后面再说。有些事情前期做成本很小,到了后期再做就会走非常多的弯路。
  2. 对于自己不熟悉的技术,一定要好好看看官网解释,不要随便找到一个网络上的命令就直接用,你们的场景可能是不太一样的,所需要的参数可能也是不同的,最好弄懂每个命令/参数的含义,不能着急,否则做了也会是有问题的。
  3. 问题一定是有原因的,可以暂时不去追根溯源,但要坚信肯定不是玄学。利用自己的知识将原因锁定在一定的范围内,大胆猜测、查资料、排查。
  4. 解决问题不能只解决表象,只解决表象可能还有大量问题等着你,实际上还是没有找到解决问题的途径。稳住参照3揪出来问题的元凶,就舒服了。

更新记录

  • 2022-08-11 18:20 首次提交文章到冯兄话吉
  • 2022-08-12 15:00 微信公众号“冯兄画戟”文章、掘金专栏发表前重读、优化、勘误

tcpdump抓包学习Nginx(反向代理),学完不怵nginx了,还总想跃跃欲试!(Nginx使用、原理完整版手册)

作者 冯兄
2023年8月4日 08:00

0. 前话

俄罗斯年轻程序员Igor Sysoev为了解决所谓C10K problem,也就是以前的Web Server不能支持超10k并发请求的问题,在2002年开启了新的Web Server的开发。

Nginx2004年在2-clause BSD证书下发布于众,根据2021年3月Web Server的调查,Nginx持有35.3%的市场占有率,为4.196亿网站提供服务。

感谢DigitalOcean公司的NGINXConfig项目,提供了很多写好的Nginx模板供下载,这样就可以在不理解Nginx配置的情况下复制粘贴配置Nginx。

这里不是说复制粘贴是不对的,而是如果只复制粘贴并不理解的话,迟早会出问题。所以,你必须理解Nginx的配置,通过学习本文,你能够:

  • 理解工具生成或者别人配置的Nginx。
  • 从0到1配置Web服务器、反向代理服务器和负载均衡服务器。
  • 优化Nginx获取最大性能。

学习本文需要有一定的Linux基础,会执行例如lscat等Linux命令,还需要你对前后端有一定的了解,不过这些对前端或者后端程序员都很容易。

1. Nginx基本介绍

Nginx是一个高性能的Web服务器,着眼于高性能、高并发和低资源消耗。尽管Nginx作为一个Web服务器被大家所熟知,它另外的一个核心功能是反向代理。

Nginx不是市场上唯一的Web服务器,它最大的竞争对手Apache HTTP Server(httpd)在1995年就发布了。人们在选择Nginx作为Web服务器时候,基于下面两点考虑:

  • 支持更高的并发。
  • 用更少的硬件资源提供静态文件服务。

Nginx和Apache谁更好的争论没有意义,如果想了解更多Nginx和Apache的区别可以参考Justin Ellingwood文章

关于Nginx对请求处理的新特点,引用Justin的文章解释如下:

Nginx在Apache之后出现,更多认识到网站业务扩大之后面临的并发性问题,所以从一开始就设计为异步、非阻塞和事件驱动连接处理的算法。

Nginx工作时候会设定worker进程(worker process),每一个worker进程都能够处理数千个连接。worker进程通过fast looping的机制来不断轮询处理事件。将具体处理请求的工作和连接解耦能够让每一个worker进程仅当新的事件触发的时候将其与一个连接关联。

Nginx基本工作原理图:

Nginx之所以能够在低资源消耗的情况下高性能提供静态文件服务,是因为它没有内置动态编程语言处理器。当一个静态文件请求到达后,Nginx就是简单的响应请求文件,并没有做什么额外的处理。

这不是说Nginx不能够整合动态编程语言处理器,它可以将请求任务代理到独立的进程上,例如PHP-FPMNode.js或者Python。一旦第三方进程处理完请求,再将响应代理回客户端,工作如图:

2. 怎么安装nginx

Nginx的安装网上示例很多,这里以Ubuntu为例:

#更新源
sudo apt update && sudo apt upgrade -y

#安装
sudo apt install nginx -y

这种方式安装Nginx成功之后,Nginx会注册为systemd系统服务,查看服务:

sudo systemctl status nginx

#如果没有注册为systemd服务,可以用service查看试下
sudo service nginx status

Nginx的配置文件经常放在/etc/nginx目录中,默认的配置端口是80,如果启动成功,可以访问得到页面:

恭喜!Nginx安装成功了!

3. Nginx配置文件管理

Nginx为静态或者动态文件提供服务,具体怎么样提供服务是由配置文件设置的。

Nginx的配置文件以.conf结尾,常常位于/etc/nginx目录中。访问/etc/nginx目录:

cd /etc/nginx

ls -lh

# drwxr-xr-x 2 root root 4.0K Apr 21  2020 conf.d
# -rw-r--r-- 1 root root 1.1K Feb  4  2019 fastcgi.conf
# -rw-r--r-- 1 root root 1007 Feb  4  2019 fastcgi_params
# -rw-r--r-- 1 root root 2.8K Feb  4  2019 koi-utf
# -rw-r--r-- 1 root root 2.2K Feb  4  2019 koi-win
# -rw-r--r-- 1 root root 3.9K Feb  4  2019 mime.types
# drwxr-xr-x 2 root root 4.0K Apr 21  2020 modules-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 modules-enabled
# -rw-r--r-- 1 root root 1.5K Feb  4  2019 nginx.conf
# -rw-r--r-- 1 root root  180 Feb  4  2019 proxy_params
# -rw-r--r-- 1 root root  636 Feb  4  2019 scgi_params
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-enabled
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 snippets
# -rw-r--r-- 1 root root  664 Feb  4  2019 uwsgi_params
# -rw-r--r-- 1 root root 3.0K Feb  4  2019

该目录中的/etc/nginx/nginx.conf就是Nginx的主配置文件。如果你打开这个配置文件,会发现很多内容,不要害怕,本文就是一点一点的要学会它。

在进行配置文件修改的时候,不建议直接修改/etc/nginx/nginx.conf,可以将之备份之后再修改:

#重命名文件
sudo mv nginx.conf nginx.conf.backup

#新建配置文件
sudo touch nginx.conf

4. Nginx配置为一个基本的Web Server

这一部分,将会从零一步步学习Nginx配置文件的书写,目的是了解Nginx配置文件的基本语法和基本概念。

4.1 写第一个配置文件

vim /etc/nginx/nginx.conf打开配置文件并更新内容:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Bonjour, mon ami!\n";
        #配置重定向
        #return 302 https://www.baidu.com$request_uri;
    }

}

重启Nginx并访问,你会得到如下信息:

curl -i http://127.0.0.1

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 19 Feb 2022 08:31:59 GMT
Content-Type: text/plain
Content-Length: 21
Connection: keep-alive

Bonjour, mon ami!

4.2 校验、重载Nginx配置文件

Nginx的配置文件是否正确可以通过-t参数校验:

sudo nginx -t

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

如果有相关的语法错误,上述命令输出结果会有相关提示。

如果你想改变Nginx的相关状态,例如重启、重载等,可以有三种办法。一是通过-s(signal)参数向Nginx发送信号;二是使用系统服务管理工具systemd或者service等;三是使用kill命令对Linux进程操作。

向Nginx发送信号

nginx信号:nginx -s reload|quit|stop|reopen,分别表示重载配置文件、优雅停止Nginx、无条件停止Nginx和重新打开log文件。

所谓的“优雅停止”Nginx,是指处理完目前的请求再停止;而“无条件停止”Nginx,相当于kill -9,进程直接被杀死。

系统服务管理Nginx

#使用systemctl
sudo systemctl start|restart|stop nginx

#或者使用service
sudo service nginx start|restart|stop

kill命令杀死进程并手动启动

#杀死主进程及各子进程
sudo kill -TERM $MASTER_PID

#指定配置文件启动Nginx
sudo /usr/sbin/nginx -c /etc/nginx/nginx.conf

4.3 理解Nginx配置文件中的Directives和Contexts

Nginx的配置文件虽然看起来只是简单的配置文本,但它是包含语法的。实际上配置文件中的内容都是DirectivesDirectives分为两种:

  • Simple Directives
  • Block Directives

Simple Directives:包含名称和空格,以分号(;)结尾。例如listenreturn等。

Block Directives:包裹在{}中,{}Simple Directives组成,称之为Contexts

Nginx配置中核心的Contexts

  • events{}:总体配置nginx如何处理请求,只能在配置文件中出现一次。
  • http{}:配置nginx如何处理http或者https请求,只能在配置文件中出现一次。
  • server{}:内嵌在http{}中,用来配置一个独立主机上指定的虚拟主机。http{}可以配置多个server{},表示多个虚拟主机。
  • main:上述3个Contexts之外的配置都在该Contex上。

在主机上设置不同的虚拟主机(多个server{}、相同server_name),监听不同的端口(listen不同):

http {
    server {
        listen 80;
        server_name localhost;

        return 200 "hello from port 80!\n";
    }


    server {
        listen 8080;
        server_name localhost;

        return 200 "hello from port 8080!\n";
    }
}

不同的虚拟主机,监听同一个端口(多个server{}、不同server_name),监听同一个端口(listen相同):

这种情况必须用域名,Nginx会将请求头中Host信息取出来和服务端配置server_name做匹配,匹配到哪个就就进入到那个处理块中。

http {
    server {
        listen 8088;
        server_name library.test;

        return 200 "your local library!\n";
    }


    server {
        listen 8088;
        server_name librarian.library.test;

        return 200 "welcome dear librarian!\n";
    }
}

当访问不同的域名时,会返回不同的结果:

curl -i http://library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:02:20 GMT
Content-Type: application/octet-stream
Content-Length: 21
Connection: keep-alive

your local library !

curl -i http://librarian.library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:04:26 GMT
Content-Type: application/octet-stream
Content-Length: 24
Connection: keep-alive

welcome dear librarian!

这样能成功的提前是指定的域名解析到同一个IP,或者在本地的hosts文件中配置好域名进行本地测试:

xx.19.146.188 library.test librarian.library.test

注意,这里return这个Directive后面跟两个参数,一个是状态码,一个是返回的文本信息,文本信息要用引号引起来。

4.4 使用Nginx作为静态文件服务器

更新Nginx配置文件如下:

events {

}

http {

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

这里对Nginx默认的展示页面做了修改,在文件/usr/share/nginx/html/assets/mystyle.css写入p {background: red;}并在html文件中引入该css,这样正常情况段落的背景会变成红色。

访问页面,展示的是index.html,但是段落的背景色没有生效。debug一下css文件:

curl -i http://fengmengzhao.hypc:8088/assets/mystyle.css

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:43:58 GMT
Content-Type: text/plain
Content-Length: 27
Last-Modified: Sun, 20 Feb 2022 08:38:54 GMT
Connection: keep-alive
ETag: "6211fe1e-1b"
Accept-Ranges: bytes

p {
    background: red;
}

注意,这里响应头信息Content-Typetext/plain,而不是text/css。也就是说Nginx将css文件做为一个普通的文本提供服务,而没有当做stylesheet,浏览器自然就不会渲染样式。

本文会在本地hosts文件增加域名解析,所以会在示例中看到对域名请求。在操作本文示例时,要根据自己环境对ip(域名)或者端口做相应修改。

4.5 Nginx中处理静态文件类型解析

实际上这里涉及到Nginx对静态文件类型解析的处理,默认不进行任何设置情况下,Nginx认为文本文件的类型是text/plain

修改配置文件如下:

events {

}

http {

    types {
        text/html html;
        text/css css;
    }

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }
}

重新访问页面,样式正常,mystyle.css文件的responseContent-Typetext/css

这里在http{}中引入了types{},通过文件的后缀映射文件的类型。需要注意,如果没有types{},nginx会认为.html文件的类型是text/html,但是一旦引入types{},nginx只会解析定义的类型映射。所以这里引入types{}后,不能只定义css的类型映射,同样要显式定义html的类型映射,否则nginx会将html解析为普通文本文件。

4.6 Nginx子配置引入

手动在http{}中增加types{}来映射文件类型对于小项目还可以,对大型项目来说手动配置就太繁琐了,Nginx提供了默认的解析映射(常常在/etc/nginx/mime.types文件中),可以通过include语法将子配置引入配置文件中。

修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

重启Nginx,自定义的css文件能够正常展示。

5. Nginx的动态路由

上面的示例非常简单,访问root定义目录下的文件,存在就返回,不存在就返回默认404页面。

接下来学习Nginx的location动态路由用法,包括重定向、重写和try_files Directive。

所谓的动态路径就是用户访问的路径到达Nginx后,Nginx如何匹配访问内容。

Location Matches

修改配置文件,如下:

events {

}

http {

    server {

        #设置默认的Content-Type text/html,否则将以流的方式下载
        default_type text/html;
        #设置字符编码为utf-8,否则页面会乱码
        charset utf-8;

        listen 80;
        server_name localhost;
        #前缀匹配,示例:http://fengmengzhao.hypc:8088/agatha----
        location /agatha {
            return 200 "前缀匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #完全匹配,示例:http://fengmengzhao.hypc:8088/agatha
        location = /agatha {
            return 200 "完全匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,默认大小写敏感,示例:http://fengmengzhao.hypc:8088/agatha01234
        #正则匹配的优先级要高于前缀匹配,低于优先前缀匹配
        location ~ /agatha[0-9]{
            return 200 "正则匹配,大小写敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,大小写不敏感,示例:http://fengmengzhao.hypc:8088/AGatHa01234
        location ~* /agatha[0-9]{
            return 200 "正则匹配,大小写不敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #优先前缀匹配,示例:http://fengmengzhao.hypc:8088/Agatha01234
        #在前缀匹配前加^~即可转化为优先前缀匹配
        location ^~ /Agatha {
            return 200 "优先前缀匹配-Miss Marple.\nHercule Poirot.\n";
        } 
    }
}

匹配规则总结:

匹配 关键字
完全 =
优先前缀 ^~
正则 ~或者~*
前缀 None

如果一个请求满足多个配置的匹配,正则匹配的优先级大于前缀匹配,而优先前缀匹配的优先级大于正则匹配,完全匹配优先级最高。

nginx中的变量(Variables

设置变量:

set $<variable_name> <variable_value>;

# set name "Farhan"
# set age 25
# set is_working true*

变量类型:

  • String
  • Integer
  • Boolean

除了自定义变量外,nginx有内置的变量,参考https://nginx.org/en/docs/varindex.html

例如,如下配置中使用内置变量:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Host - $host\ - $uri\nArgs - $args\n";
    }

}

# curl http://localhost/user?name=Farhan

# Host - localhost
# URI - /user
# Args - name=Farhan

上面使用了$host$uri$args内置变量,分别表示主机名、请求相对路径和请求参数。变量可以作为值赋值给自定义变量,例如:

events {

}

http {

    server {

        listen 80;
        server_name localhost;
        
        set $name $arg_name; # $arg_<query string name>

        return 200 "Name - $name\n";
    }

}

上面出现了$arg_*内置变量,使用$arg_<query string name>可以获取$args变量中指定的query string

重定向(Redirects)和重写(Rewrites

nginx中的重定向和其他平台上见到的重定向一样,response返回3xx的状态码和location头信息。如果是在浏览器中访问,浏览器会自动重新发起location指定的请求,地址栏url也会发生改变。

重定向示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        location = /index_page {
                return 307 https://fengmengzhao.github.io;
        }

        location = /about_page {
                return 307 https://fengmengzhao.github.io/about;
        }
    }
}

#curl -I http://localhost/about_page

HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:47:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 180
Connection: keep-alive
Location: https://fengmengzhao.github.io/about

重写(Rewrites)和重定向不一样,重写内部转发了请求,地址栏不会发生改变。示例如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        rewrite /image /assets/generate.png;
    }
}

#curl -i http://localhost/image

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:56:42 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Accept-Ranges: bytes

Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.

如果在浏览器上访问http://fengmengzhao.hypc:8088/image,即可展示图片。

try_files尝试多个文件

try_files示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files /assets/xxx.jpg /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

示例查找/assets/xxx.jpg文件,如果不存在就查找/not_found路径。

try_files常常和$uri内置变量一起使用:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files $uri /not_found;
        #当访问http://localhost返回404
        #这里表示,当访问$uri文件不存在时,尝试$uri/作为一个目录访问
        #try_files $uri $uri/ /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

6. Nginx的日志

日志位置(常常在/var/log/nginx):

ls -lh /var/log/nginx/

# -rw-r----- 1 www-data adm     0 Apr 25 07:34 access.log
# -rw-r----- 1 www-data adm     0 Apr 25 07:34 error.log

删除日志文件并reopen Nginx:

# delete the old files
sudo rm /var/log/nginx/access.log /var/log/nginx/error.log

# create new files
sudo touch /var/log/nginx/access.log /var/log/nginx/error.log

# reopen the log files
sudo nginx -s reopen

这里如果采用上面删除文件后再创建文件的方法清空日志,就需要nginx -s reopen重载Nginx,否则新的日志文件不会被写入日志,因为Nginx的输出流指向还是之前删除的日志文件。实际上这里想清空日志文件可以采用echo "" > /var/log/nginx/access.log的方法,这样就不用reopen Nginx了。

访问Nginx并查看日志:

curl -I http://localhost

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 08:35:59 GMT
# Content-Type: text/html
# Content-Length: 960
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-3c0"
# Accept-Ranges: bytes

sudo cat /var/log/nginx/access.log 

# 192.168.20.20 - - [25/Apr/2021:08:35:59 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.68.0"

默认情况下,任何访问的日志都会记录在access.log文件中,也可以通过access_log Directive来自定义路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
        
        location / {
            #日志会在默认配置日志文件输出
            return 200 "this will be logged to the default file.\n";
        }
        
        location = /admin {
            #日志会输出在/var/logs/nginx/admin.log文件中
            access_log /var/logs/nginx/admin.log;
            
            return 200 "this will be logged in a separate file.\n";
        }
        
        location = /no_logging {
            #禁止日志输出
            access_log off;
            
            return 200 "this will not be logged.\n";
        }
    }
}

location{}中可以自定义access.log的路径,也可以用access_log off来关闭log输出。

同样,error_log也可以自定义Nginx的error.log路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log;
        #return后面只能跟两个参数,这里是为了让Nginx报错,输出错误日志
        return 200 "..." "...";
    }

}

使用nginx -s reload重载Nginx:

sudo nginx -s reload

# nginx: [emerg] invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

访问错误日志文件,有同样的错误信息:

sudo cat /var/log/nginx/error.log 

# 2021/04/25 08:35:45 [notice] 4169#4169: signal process started
# 2021/04/25 10:03:18 [emerg] 8434#8434: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

Nginx error日志信息是有级别的:

  • debug:能帮忙排查哪里出错了。
  • info:可以了解但是不必要的信息。
  • notice:比info更值得了解的信息,但不知道也没什么。
  • warn:意料之外的事情发生了,哪里出问题了,但还能工作。
  • error:什么失败了的信息。
  • crit:严重问题,急需解决。
  • alert:迫在眉睫。
  • emerg:系统不稳定,十万火急。

默认情况下,Nginx记录所有级别的Error信息,可以通过error_log第二个参数覆写。如果要设置最低级别的日志输出为warn,更新配置文件如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log warn;

        return 200 "..." "...";
    }

}

重载Nginx并查看日志:

cat /var/log/nginx/error.log

# 2021/04/25 11:27:02 [emerg] 12769#12769: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:16

这里可以看到,没有输出之前的[notice]日志了。

7. Nginx作为反向代理服务器

7.1 什么是反向代理?

所谓的反向代理,首先是一种代理,是客户端和服务端之外的第三方。把正向代理(Forward proxy)和反向代理(Reverse proxy)比较起来看就很容易理解。

正向代理一般代理的是客户端,用户(客户端)是知道代理存在(一般是客户端配置的)。客户端对目标服务的请求会经由代理转发并将目标服务响应返回给客户端。常见的VPN代理、浏览器(设置)代理、Git(设置)代理和Fiddler抓包软件等都是正向代理。

本文中所述的“目标服务”、“被代理的上游服务”、“被代理的服务”、“服务端”均指代proxy_pass配置的被代理的服务。“代理服务”、“代理服务的服务端”指代的是Nginx提供的代理服务。

正向代理示意图:

反向代理一般代理的是服务端,客户端直接和代理服务打交道(如果有反向代理的话),而对被代理的服务一无所知。客户端请求到达代理服务之后,代理服务再将请求转发到被代理的服务并将响应返回给客户端。

反向代理示意图:

上面二图,可以理解蓝色背景的服务是相互知晓的。

Nginx作为反向代理时,处在客户端和服务端之间。客户端发送请求到Nginx(反向代理),Nginx将请求发送给服务端。一旦服务端处理完请求,会将结果返回给Nginx,Nginx再将结果返回给客户端。在这整个过程中,客户端并不知道实际上谁处理了请求(真正的处理请求并产生响应,而不是代理)。

7.2 反向代理基本原理

笔者刚接触反向代理的时候,感觉这是一个很神奇的事情。进行简单的配置就能将第三方的网站代理到自己的主机上吗?

实际上,不尽然。有些网站能够将主页代理过来,但功能不能完全使用;有些代理过来样式、图片等加载会出问题。只有理解了个中原理,才能够解释各种各样的情况。

所谓的反向代理就是将客户端发送来的请求转发给实际处理请求服务端(proxy_pass指定的服务端),服务端响应之后,再将响应代理回客户端。

既然是代理,就不仅仅简单的只做转发,在代理收到客户端请求后,准备转发到指定代理服务端之前,会对请求的header信息进行重写,例如重写规则如下(反向代理header重写章节会对规则做详细介绍):

  1. 值为空的header不会进行转发;headerkey中包含有_下划线的不会进行转发。
  2. 默认改写HostConnection两个header,分别为:Host: $proxy_hostConnection: close

如果代理服务器只是转发,还要什么代理?就像生活中的代理一样,会提供增值服务,什么事情都帮你搞定。

反向代理就是将客户端的请求,重写header信息之后,在代理服务的服务端转发请求到被代理服务,被代理服务处理请求将响应返回给代理服务,代理服务进而转发响应回客户端。

代理服务转发的请求是代理服务端重新发起,因此在客户端的浏览器或者Fiddler工具进行网络抓包是抓不到的。要看具体的代理发起网络请求需要用Wireshark工具抓包代理服务器对应的网卡。

别理解复杂了,就是客户端<--->代理服务<--->被代理服务。Nginx的反向代理默认不会改变响应的内容,被代理服务响应页面的绝对引用(/assets/image/abc.jpg)、相对引用(assets/image/abc.jpg)或者图床引用(https://image.com/image/abc.jpg)代理回客户端的时候不会发生改变。这些引用在客户端解析html时候会重新发起请求,如果请求指向了代理服务,会同样进行请求<--->代理服务<--->被代理服务这个流程。

--->表示请求,<---表示响应。

有些时候代理之后之所以情况变得复杂,是因为被代理服务存在重定向或者权鉴的约束产生的,而代理的过程就是请求<--->代理服务<--->被代理服务这么简单,并且不会改变被代理服务的响应内容。

7.3 反向代理基本配置

看一个简单的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 80;
        server_name localhost;

        location / {
                proxy_pass "https://bbs.tianya.cn/";
        }
    }
}

代理后页面如下:

因为是http反向代理了https,运营商竟然还在右下角插入了广告(https://bbs.tianya.cn/不会被插入广告)。

proxy_pass能够简单的将客户端请求转发给第三方服务端并反向代理响应结果返回给客户端。

这只是简单的代理,如果你要反向代理一个接口并且使用WebSocket,那么就要覆写header信息:

#WebSocket需要http/1.1,默认是http/1.0
proxy_http_version 1.1;
#覆写header Upgrade为$http_upgrade的值,该值为Nginx获取客户端请求过来的Upgrade头信息值
proxy_set_header Upgrade $http_upgrade;
#覆写header Connection为'upgrade'
proxy_set_header Connection 'upgrade';

7.4 Nginx反向代理地址匹配规则

客户端发送给Nginx的请求,究竟Nginx会怎样拼接到proxy_pass指定的上游服务呢?Nginx有一定的规则:

  1. 如果proxy_pass代理的上游服务是域名加端口(没有端口时默认端口为80或者443),那么客户端请求的代理路径会直接拼到上游服务地址上。示例,proxy_pass http://redis.cn就只是对域名(和端口)的代理。
  2. 如果proxy_pass代理的上游服务有请求路径,那么客户端请求的代理路径将会是把客户端请求路径裁剪掉匹配路径后再拼到上游服务地址上。示例,proxy_pass http://redis.cn/或者proxy_pass http://redis.cn/commands是有请求路径的代理。

上面1、2分别定义为“情况1”和“情况2”,下面中有引用。

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn;
        }

        #location /redis {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/redis/commands
        #   proxy_pass http://redis.cn;
        #}

        location /redis {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn//commands
            proxy_pass http://redis.cn/;
        }

        location /redis/ {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn/;
        }

        #location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands --> http://redis.cn/commands
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://redis.cn/commands/keys.html
        #   proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commandskeys.html
        #    proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands/keys.html
        #    proxy_pass http://redis.cn/commands/;
        #}

        location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands//keys.html
            proxy_pass http://redis.cn/commands/;
        }

    }
}

总结客户端请求和代理端转发请求的对应关系,如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/ http://redis.cn    
/redis http://redis.cn /redis /redis
/ http://redis.cn/   /
/ http://redis.cn/ / /
/redis http://redis.cn/ /redis /
/redis http://redis.cn/ /redis/commands //commands
/redis/ http://redis.cn/ /redis /
/redis/ http://redis.cn/ /redis/commands /commands
/redis-commands http://redis.cn/commands /redis-commands /commands
/redis-commands http://redis.cn/commands /redis-commands/keys.html /commands/keys.html
/redis-commands/ http://redis.cn/commands /redis-commands /commands
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html
/redis-commands http://redis.cn/commands/ /redis-commands /commands/
/redis-commands http://redis.cn/commands/ /redis-commands/keys.html /commands//keys.html
/redis-commands/ http://redis.cn/commands/ /redis-commands /commands/
/redis-commands/ http://redis.cn/commands/ /redis-commands/keys.html /commands/keys.html

表格中为空表示只有域名+端口的访问,没有请求路径。

代理后的请求在客户端看不到网络请求,可以用tcpdump抓包代理服务所在主机的网卡生成.cap文件,并在Wireshark中查看具体请求。

tcpdump监听命令:

#xx.19.146.188是Nginx代理IP;121.42.46.75是被代理上游服务IP,也就是redis.cn域名的解析IP
#ech0是xx.19.146.188使用的网卡IP
sudo tcpdump -i eth0 tcp port 8088 and host xx.19.146.188 or host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-2.cap

启动后,访问代理服务,数据包经过网卡eth0就会被捕捉到。将nginx-2.cap文件在Wireshark中打开即可查看具体网络包。

以下表请求为demo,抓包获取代理请求。

请求如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html

抓取请求包如图:

7.5 反向代理header重写

Nginx在服务端代理的请求和客户端发的请求不是完全相同的,主要的不同在于请求的header信息,Nginx会对客户端发过来的请求的header进行修改,规则如下:

  1. Nginx删除空值的header。Nginx这样做是因为空值的Header发送服务端也没有意义,当然利用这一点,如果想让代理不发送某个header信息,可以在配置中用proxy_set_header覆写header值为空。
  2. Nginx默认header的名称中如果包含_下划线是无效header。这个行为也可以通过配置文件中设置underscores_in_headers on来开启,否则任何含有_header信息都不会被代理到目标上游服务。
  3. 代理的Host头信息会被覆写为变量$proxy_host,该变量是被代理上游服务的IP(或域名)加端口,其值在proxy_pass中定义。
  4. 代理的Connection头信息会被覆写为”close”,该请求头告诉被代理上游服务,一旦服务端响应代理请求,该连接就会被关闭,不会被持久化(persistent)。

第3点的Host头信息覆写在Nginx的反向代理中是比较重要的,Nginx定义不同的变量代表不同的值:

  • $proxy_host:上面提过了,是默认反向代理覆写的header,其值是proxy_pass定义的上游服务IP和端口。
  • $http_host:是Nginx获取客户端请求的Host头。Nginx使用$http_作为前缀加上客户端header名称的小写,并将-符号用_替换拼接后就代表客户端实际请求的头信息。
  • $Host:常常和$http_host一样,但是会将http_host转化为小写(域名情况)并去除端口。如果http_host不存在或者是空的情况,$host的值等于Nginx配置中server_name的值。

Nginx可以通过proxy_set_header来覆写客户端发送过来请求的header再转发。除了上面说的Host头比较重要,经常用到的header还有:

  • X-Forwarded-Proto:配置值$schema。告诉上游被代理服务,原始的客户端请求是http还是https
  • X-Real-IP:配置值$remote_addr。告诉代理服务客户端的IP地址,辅助代理服务做出某种决定或者日志输出。
  • X-Forwarded-For:配置值$proxy_add_x_forwarded_for。包含请求经过每一次代理的IP。

7.6 反向代理试试,tcpdump抓包解析,探个中究竟

笔者也一直在理解这个Hosthttp请求中的作用,正常当一个http请求发送之后,tcp连接已经指定了IP和端口,那还需要Host头信息做什么呢?

首先,MDN Web DocsHost头的说明:

所有HTTP/1.1 请求报文中必须包含一个Host头字段。对于缺少Host头或者含有超过一个Host头的HTTP/1.1 请求,可能会收到400(Bad Request)状态码。

那Nginx反向代理默认对Host头覆写为$proxy_host的作用是什么,如果改写为其他会怎么样?用tcpdump工具抓包一探究竟。

看示例,反向代理http://redis.cn,配置如下(情况一):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
        }

}

最普通的反向代理设置,没有进行任何header覆写。用tcpdump工具监控网卡:

#先用ping或者nslookup找到redis.cn的IP,这里找到是121.42.46.75
#这里 host 121.42.46.75,代表过滤指定IP的包。不过滤的话包会很多,不太好看
#-c 100 捕捉到100个包,会自动退出并生产文件
#需要将cap文件Wireshark中打开
sudo tcpdump -i eth0 host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-redis-1.cap

这时候访问http://fengmengzhao.hypc:8088/,代理页面很正常:

Nginx服务端的tcpdump包也抓到了:

用Wireshark查看包请求:

修改Nginx配置proxy_set_header Host $http_host(情况二):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
            proxy_set_header Host $http_host;
        }

}

访问http://fengmengzhao.hypc:8088/,代理页面:

这是什么页面?如果直接用redis.cn的IP地址http://121.42.46.75访问,得到同样的页面。为什么?

看看抓到的包情况:

tcpdump抓包来看,该响应是正常从服务端响应的。那为何不同的Host头返回的页面会不同呢?

情况二设置proxy_set_header $http_host之后Nginx代理请求的Host为客户端请求的Host(fengmengzhao.hypc:8088),而情况一的Host为上游被代理服务的Host(redis.cn)。可能在redis.cn该域名对应的主机121.42.46.75不止提供一个80端口的服务。

这种在一个主机上提供多个域名服务(端口相同)称之为虚拟主机。理解Nginx配置文件中的Directives和Contexts章节中提到的Nginx可以设置不同域名同一端口的虚拟主机就可以实现这种情况。另外,Apache也支持配置不同域名的虚拟主机。这两种情况,归根结底都是在请求到达服务端后,服务端会获取请求中的Host头信息并匹配到不同的虚拟服务。

所以,Nginx反向代理中对Host头信息的覆写要看上游被代理服务是否有特殊需要到该信息。如果没有特殊实现上需要,默认的proxy_host就可以;如果是特殊的实现机制,就要小心对待。

这里的特殊需要是例如上面虚拟主机那种情况,Host头信息在HTTP/1.1中是必须带的。

7.7 反向代理处理相对路径问题

基于上面讲解的对反向代理的理解,我们处理一下实际工作中遇到的问题,增加对Nginx反向代理的认识。

假设被代理的上游服务是一个简单的静态页面(http://127.0.0.1:80),页面中引用了两个相同的图片,分别是绝对引用/assets/generate.png和相对引用assets/generate.png。我们进行如下的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }

}

这时候,访问http://fengmengzhao.hypc:8088/static会发现其中绝对引用(/assets/generate.png)的图片加载失败,通过浏览器网络查看,其客户端加载的请求是http://fengmengzhao.hypc:8088/assets/generate.png。该请求在我们的配置中会默认寻找root匹配(一般默认是/usr/share/nginx/html路径),找不到对应的资源。

实际上不管是绝对应用还是相对应用我们想让客户端的请求都是http://fengmengzhao.hypc:8088/static/assets/generate.png,这里可以看到,如果采用上面的代理方式,并且上游服务有绝对路径的引用,就会出现加载异常的情况。示例:

这里我们也可以看出来,Nginx反向代理默认对响应的内容是不会修改的,目标服务中相对路径或者绝对路径的引用反向代理之后返回给客户端的跟直接访问目标服务端响应是一样的。

怎么样解决呢,有如下方案:

1). 如果目标上游服务可以修改,可以将所有的绝对路径的引用改为相对路径引用。一级目录静态文件引用/assets/generate.png要改为./assets/generate.png或者assets/generate.png;二级目录静态文件引用要改为../xxx/assets/generate.png。总之,页面上绝对路径的引用要改为相对路径的引用。

2). 可以将不能正常代理的图片添加代理,如下配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }
        
        location /assets/ {
            proxy_pass http://127.0.0.1/assets/;
        }

}

这样绝对引用http://fengmengzhao.hypc:8088/assets/generate.png就能够代理到http://127.0.0.1/assets/generate.png,就能够正常加载图片了。

3). 放弃子目录的方案,用独立域名就没问题了,配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name static.fengmengzhao.hypc;

        location / {
            proxy_pass http://127.0.0.1/;
        }

}

这样访问http://static.fengmengzhao.hypc:8088就能够成功代理http://127.0.0.1了。

4). Nginx重写目标服务端响应内容

文中强调过多次,Nginx反向代理默认是不会修改目标服务端响应内容的。但Nginx也支持对响应内容进行修改,需要开启Nginx的ngx_http_sub_module

可以通过nginx -V查看是否包含http_sub_module就知道当前Nginx是否有ngx_http_sub_module模块。

开启ngx_http_sub_module模块后,修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            sub_filter 'src="/assets/' 'src="./assets/';
            sub_filter_once off;
            proxy_pass http://127.0.0.1/;
        }
        
}

通过上面的任意方法,可以获取正确的代理响应:

这里要注意一个点,当你的访问路径是http://fengmengzhao.hypc:8088/static(情况一),其响应html中有引用assets/generate.png,对该generate.png的请求路径是:http://fengmengzhao.hypc:8088/assets/gnerate.png。而当你的访问路径是http://fengmengzhao.hypc:8088/static/(情况二),其响应html同样引用assets/generate.png,对图片的请求会变为:http://fengmengzhao.hypc:8088/static/assets/generate.png。情况二访问路径和情况一的区别是URI的最后有没有跟/,如果有/结尾的话,认为当前访问是一个目录,所以其相对引用就从当前地址栏中的路径开始;如果没有/结尾的话,认为当前访问是一个文件,其相对路径就是文件所在的路径,也就是URI往前数有出现/那个层级,在这里就是根目录,所以情况一虽然是相对引用,但是请求路径还是从根目录开始。

8. Nginx作为一个负载均衡服务器

学习完反向代理,就很容易理解基于反向代理做进一步的负载均衡了。

配置示例:

events {

}

http {

    upstream backend_servers {
        server localhost:3001;
        server localhost:3002;
        server localhost:3003;
    }

    server {

        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://backend_servers;
        }
    }
}

upstream{}可以包含多个服务并且作为一个上游服务被引用。

测试负载均衡:

while sleep 0.5; do curl http://localhost; done

# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.

9. 优化Nginx性能

本文介绍三个方面优化Nginx的性能:根据主机参数调优Worker Processes及Worker Connections配置、缓存静态文件和响应数据压缩。

9.1 怎么设置工作进程数(Worker Processes)和工作连接数(Worker Connections

文章开始的时候已经提到过,Nginx会设置Worker进程并在进程间进行切换,能够同时并发处理“成千上万”个请求。可以通过status命令查看Worker进程数:

sudo systemctl status nginx

# ● nginx.service - A high performance web server and a reverse proxy server
#      Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)
#      Active: active (running) since Sun 2021-04-25 08:33:11 UTC; 5h 54min ago
#        Docs: man:nginx(8)
#     Process: 22610 ExecReload=/usr/sbin/nginx -g daemon on; master_process on; -s reload (code=exited, status=0/SUCCESS)
#    Main PID: 3904 (nginx)
#       Tasks: 3 (limit: 1136)
#      Memory: 3.7M
#      CGroup: /system.slice/nginx.service
#              ├─ 3904 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
#              ├─22611 nginx: worker process
#              └─22612 nginx: worker process

#也可以通过ps查看进程
#能够看到master进程是各个Worker进程的父进程
ps -ef | grep nginx

这里可以看到有1个master进程和2个Worker进程。Worker进程数在Nginx中很容易配置:

#一般情况,主机有多少核,就设置Worker进程的个数为多少
worker_processes 2;
#根据主机cpu核心数的不同自动设置Worker进程的个数
#worker_processes auto;

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

假设说主机有4个核心,worker_processes如果配置为4,表示每一个Worker理论上能够利用100%的cpu。worker_processes如果配置为8,表示一个Worker理论上能够利用50%的cpu,意味着当主机cpu满负荷运转时Worker每运行1分钟就需要等待一分钟。所以,worker_processes不是配置的越大越好,数量如果超出主机cpu核心数,就会有时间浪费在操作系统级别对进程的调度。

可以很方便的通过nproc命令查看主机的cpu核心数:

nproc

# 4

worker_processes auto配置会根据主机cpu核心数的不同自动设置Worker进程的个数。如果你的主机只用来运行Nginx,可以这样配置;如果主机上还有其他服务部署,要斟酌合理分配资源。

worker_connections表示一个Worker进程能够处理的最大连接数,该参数跟主机cpu core个数和一个core能打开的文件个数有关(该值可以通过命令ulimit -n查询)。

ulimit -n

# 1024

worker_connections设置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

注意,这里本文中第一次使用到events这个Context。

9.2 怎样缓存静态文件

不管使用Nginx提供什么样的服务,总是有一些静态文件(js或者css等)是不经常发生改变的,可以将它们缓存起来提高Nginx的性能。Nginx对静态文件的缓存配置非常方便:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    include /env/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        #正则匹配,大小写不敏感
        #以.css或者.js或者.jpg结尾的匹配
        location ~* \.(css|js|jpg|png)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            #1M代表一个月
            expires 1M;
        }
    }
}

像之前反向代理设置中的proxy_set_header给可以给代理到后端的请求增加header一样,使用add_header可以给response增加header

Cache-Control头信息设置为public,是在告诉client该请求内容可以被缓存。PragmaCache-Control的old version。

Vary头信息设置为Accept-Encoding,后续详解。

expires directive表示Nginx缓存响应的时间,可以帮助很方便设置响应Expires头信息,其值可以是1M(1 month)、10m/10 minutes或者24h/24 hours等。

Cache-Control告诉客户端,该response在服务端缓存,客户端可以以任意的形式缓存。另外根据Nginx的expires设置的缓存时间,增加Cache-Control: max-age=2592000,这里Cache-Control: max-age代表该response在max-age时间内不会刷新。2592000单位是秒,等于expire设置的1M(一个月,30x24x3600=2592000)。

重启Nginx之后,测试请求的响应信息:

curl -I http://fengmengzhao.hypc:8088/assets/generate.png

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Tue, 01 Mar 2022 05:04:17 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Expires: Thu, 31 Mar 2022 05:04:17 GMT #注意这个时间和下面的比较
Cache-Control: max-age=2592000
Cache-Control: public
Pragma: public
Vary: Accept-Encoding
Accept-Ranges: bytes

这里可以看到,response中已经增加了Cache-Control头信息,说明配置已经生效。至于Nginx服务端有没有缓存响应,可以用tcpdump抓包看一看,这里不再演示。

需要注意的是,如果在浏览器上访问http://fengmengzhao.hypc:8088/assets/generate.png,第一次返回的是200状态码,表示是服务端成功返回。第二次返回的是304状态码,表示浏览器根据第一次response头信息Cache-Control: public的指示,第二次访问的时候,直接使用客户端缓存。也可以通过F12打开控制台,勾选Network --> Disable Cache选项,这样浏览器端就不使用缓存。

9.3 怎样压缩响应(response)

压缩配置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /env/nginx/mime.types;
    #开启gzip,默认只对html进行压缩
    gzip on;
    #不是设置的越大越好,一般设置为1-4
    gzip_comp_level 3;
    #对css和js文件进行压缩
    gzip_types text/css text/javascript;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        
        location ~* \.(css|js|jpg)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            expires 1M;
        }
    }
}

默认nginx会对html文件进行gzip压缩,如果要对其他类型文件压缩,需要设置gzip_types text/css text/javascript;

gzip_comp_level不是设置的越大越好,一般设置为1-4。

服务端设置gzip之后,要想真正的压缩传输到客户端,客户端需要增加header信息"Accept-Encoding: gzip"才能完成服务端到客户端的压缩传输。

客户端请求没有"Accept-Encoding: gzip"的示例:

curl -I http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:30:32 GMT
# Content-Type: text/css
# Content-Length: 46887
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-b727"
# Expires: Tue, 25 May 2021 16:30:32 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Accept-Ranges: bytes

客户端请求设置”Accept-Encoding: gzip”的示例:

curl -I -H "Accept-Encoding: gzip" http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:31:38 GMT
# Content-Type: text/css
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: W/"608529d5-b727"
# Expires: Tue, 25 May 2021 16:31:38 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Content-Encoding: gzip

注意,这里response的header中有Vary: Accept-Encoding信息,该头信息告诉客户端,根据客户端设置的Accept-Encoding头信息的不同,服务端响应会发生变化。

对比压缩前后传输内容的大小:

cd ~
mkdir compression-test && cd compression-test

curl http://localhost/mini.min.css > uncompressed.css

curl -H "Accept-Encoding: gzip" http://localhost/mini.min.css > compressed.css

ls -lh

# -rw-rw-r-- 1 vagrant vagrant 9.1K Apr 25 16:35 compressed.css
# -rw-rw-r-- 1 vagrant vagrant  46K Apr 25 16:35 uncompressed.css

没压缩的版本大小是46k,而压缩后的版本大小是9.1k

10. 理解Nginx整个配置文件

完整nginx配置文件:

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;

	##
	# Logging Settings
	##

	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;

	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

	##
		# Virtual Host Configs
	##

	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;
}


#mail {
#	# See sample authentication script at:
#	# http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
# 
#	# auth_http localhost/auth.php;
#	# pop3_capabilities "TOP" "USER";
#	# imap_capabilities "IMAP4rev1" "UIDPLUS";
# 
#	server {
#		listen     localhost:110;
#		protocol   pop3;
#		proxy      on;
#	}
# 
#	server {
#		listen     localhost:143;
#		protocol   imap;
#		proxy      on;
#	}
#}

上文中已经讲解过的配置,不再做重复说明。

user www-data;设置Nginx进程的用户,这里会涉及到权限问题,如果用户为www-data读取没有权限的目录,就不能正常的提供服务,这时候查看Nginx的error日志,就会报权限相关的错。

pid /run/nginx.pid;设置nginx进程的process id。

include /etc/nginx/modules-enabled/*.conf;设置include指定目录中任何.conf结尾的配置文件。该目录用来加载nginx的动态模块(本文中并没有涉及)。

http{}下,有基本的优化设置,如下:

  • sendfile on;:禁止静态文件buffering。
  • tcp_nopush on;:允许在一个响应包中发送头信息。
  • tcp_nodelay on;:静态文件快传中禁用Nagle’s Algorithm

keepalive_timeout设置http connection的连接时间。types_hash_maxsize设置Hash map的大小。

SSL的配置在本文中不做讲解。

mail Context可以将Nginx配置为一个邮件服务端,本文仅讨论Nginx作为web服务端,所以不做说明。

重点看一下如下配置:

##
# Virtual Host Configs
##

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;

该配置表示Nginx会加载/etc/nginx/conf.d//etc/nginx/sites-enabled/目录内匹配的配置。这样,一般认为这两个目录就是放置Nginx配置的最好的选择,实际上并不是。

有另外一个目录/etc/nginx/sites-available/,该目录用来存放Nginx的虚拟主机(也就是server{}块)配置。/etc/nginx/sites-enabled/目录用来存放符号链接指向目录/etc/nginx/sites-available/中配置。例如:

ln -lh /etc/nginx/sites-enabled/

# lrwxrwxrwx 1 root root 34 Apr 25 08:33 default -> /etc/nginx/sites-available/default

这样通过符号链接的方式可以激活或者禁用/etc/nginx/sites-available/目录中的配置。符号链接unlink`和创建的命令如下:

#删除符号链接,用rm也可以
sudo unlink /etc/nginx/sites-enabled/default

#创建符号链接,第一个参数是被链接的文件,第二个参数是创建符号链接的路径
#也就是,链接某个文件到某个符号链接上
sudo ln -s /etc/nginx/sites-available/nginx-handbook.conf /etc/nginx/sites-enabled/nginx-handbook 

引用

后话

本文大部分内容参考https://www.freecodecamp.org/news/the-nginx-handbook/文章翻译整理,第7. Nginx作为反向代理服务器章重点加入笔者的理解。


本书完

bug现场谜之总不能告诉客户你要按F12(打开控制台)吧?(跨域详解)

作者 冯兄
2023年8月2日 08:00

目录


1. bug现场情况

现场两套系统,集成同一个单点登录。其中一个系统跳转到另外一个系统时浏览器会刷新两次。

奇怪的是打开F12,问题就不能复现。

2. 尝试破案

打开控制台问题就解决了?真是奇怪!可能是控制台打开后,静态文件在浏览器端不再缓存造成的。

打开F12禁止控制台Network --> Disable cache设置,果然问题能够复现,前端js的请求确实是缓存的。

初步判断两次刷新原因:前端js缓存,发送异步权限数据请求接口时没有权限(第一次请求刷新),然后重定向单点登录服务获取service ticket,重新登录后,再次请求权限数据接口(第二次请求刷新),页面成功展示。

笔者对浏览器的行为不熟,这里只是猜测。

笔者系统单点登录实现的CAS接口,所以应用session过期或者失效后需要从新从单点服务处获取service ticket票据。

浏览器刷新两次fiddler抓包如图:

第一次异步请求后,由于没有权限,302重定向访问单点登录服务。这里控制台会提示跨域请求,跨域在跨域详解部分详细介绍。

前端明确说了,不是前端的问题,解决不了。

笔者公司的前端就是硬气。

在后台处理,后台是springboot项目,增加配置:

spring:
  resources:
    cache:
      cachecontrol:
        max-age: 0

前端文件不会缓存,问题解决。

禁用缓存后,不会出现地址栏刷新两次现象,fiddler抓包如图:

问题解决了,笔者对那个跨域的报错产生了兴趣。之前也看过不少跨域的文章,始终对跨域云里雾里。春节找出收藏的跨域文章,好好研读了一下,有所获,赶紧借此文分享出来。

3. 跨域详解

web开发,工作中肯定接触过如下浏览器控制台报错:

No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

请求的跨域资源responseheader中没有Access-Control-Allow-Origin信息。

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://example.com/

浏览器同源策略禁止读取跨域资源。

Access to fetch at ‘https://example.com’ from origin ‘http://localhost:3000’ has been blocked by CORS policy.

在域http://localhost:3000下访问域https://example.com资源被禁止。

了解后面的内容后,就会明白这些报错的真正含义,也就能处理跨域问题了。

3.1 何谓同域(同源)?

  1. 相同协议(http、https)
  2. 相同主机名/ip(127.0.0.1、localhost、google.com)
  3. 相同端口(80、443、8080)

满足三个条件是同源,否则就是不同的域。

注意:localhost主机名虽然在网络层最终会解析为127.0.0.1,但是对于浏览器同源策略来说,localhost127.0.0.1是不同的主机名,二者不同则为跨域。

3.2 何谓跨域(跨域怎么发生?)

1989诞生的World Wide Web最初的html只有纯文本。

世界上第一个web页面,只包含纯文本和超链接。

1993年引入<img>标签,在html渲染时允许加载图片资源,这样纯文本中就可以展示图片。像这样在html中允许加载子资源(subresource)的tag还有:

  • <ifame>
  • <link>
  • <form>
  • <audio>
  • <video>
  • <script>

诚如上面html标签语义,所谓的“子资源(subresource)”就是例如表单、文件、音视频、脚本等外部资源。

当一个域中包含有上面taghtml渲染时,就会加载subresource,当这个subresource和当前域不同源时,跨域请求就发生了。例如,在一个域中xmlhttprequestajax)请求另外一个域的接口时就是跨域请求。

3.3 跨域有什么安全问题?

一个域中加载另一个域的文件、音视频、脚本等subresource时,大部分情况不会产生什么安全问题,但是有一些情况如果不做限制,就存在安全隐患。

例如一个域中提供了基于cookie/session权鉴的发送邮件接口,该域允许任何域对该接口的跨域请求。那么恶意网站有可能在获取有效cookie后任意调用发送邮件接口攻击网站。

可能有同学疑问:发送邮件接口如果需要权鉴认证才能成功调用,别人没有认证信息如何能成功调用呢?

实际上用户在浏览器端完成登录后,用户信息就存储在浏览器端(cookie),这时打开恶意网站就有可能被恶意脚本携带用户信息完成攻击。

3.4 何谓浏览器同源策略(same-origin policy)

既然跨域请求有安全的问题,浏览器端就做了相关限制,称之为“浏览器同源策略”。

同源策略阻止读取跨域请求得到的资源。

这是广义的一个定义,实际上浏览器针对不同subresource有不同的限制策略,下面有做详细说明。

同源策略在1995年网景浏览器2.02中引入,最开始是为了保护跨域DOM而设计的。

跨域请求有三种形式:

  1. 跨域写(Cross-origin writes)
  2. 跨域内嵌(Cross-origin embeds)
  3. 跨域读(Cross-origin reads)

同源策略的规则定义如下:

  • <ifame>:跨域内嵌允许(需要合适的X-Frame-Options)。
  • <link>:跨域内嵌允许(需要合适的Content-Type)。
  • <form>:跨域写允许。
  • <audio>:跨域内嵌允许。
  • <video>:跨域内嵌允许。
  • <script>:跨域内嵌允许,某些api的调用可能会被禁止(例如ajax跨域调用)。
  • <img>:跨域内嵌允许,通过JavaScript跨域读或者在<canvas>中加载被禁止。

3.5 何谓CORS(跨域资源共享)?

浏览器的同源策略能解决很多安全的问题,但是其限制也带来了不便。

CORS(Cross-origin resource sharing)跨域资源共享就是来放宽浏览器同源策略的严格限制,便于某些场景的使用。

同域请求,如图:

跨域请求,如图:

图中涉及到preflight请求下面详解。

3.6 “简单”和“复杂”跨域请求生命历程

这里重点讲述ajax跨域请求(使用浏览器内置fetch()函数)时,其请求过程和解决办法。

一个域中ajax跨域请求另一个域的接口时,该请求的生命历程是由客户端和被请求资源服务端共同决定的。客户端的行为是浏览器同源策略指定的,被请求资源服务端行为由资源提供者具体实现提供。具体来说:

所谓的请求“生命历程”是指:该请求从浏览器发起,到服务端响应,再到浏览器读取响应结果并展示这个过程。

如果是“简单”的ajax跨域请求,那么浏览器会放行该请求,如果服务端没有包含Access-Control-Allow-Originheader信息,则浏览器会限制对请求到资源reponse的读取。

如果是“复杂”的ajax跨域请求,那么浏览器会先自行触发一个preflight请求,根据服务端的相应header信息决定是否放行客户端请求。

这里所谓的“简单”和“复杂”请求是相关规范定义的,一个“复杂”请求要至少满足如下其中一个条件:

  1. GETPOST或者HEAD请求。
  2. 请求头信息包含除AcceptAccept-Language或者Content-Language外的头信息。
  3. 请求Content-Type的值不是application/x-www-form-urlencodedmultipart/form-data或者text/plain

2.中说的头信息不包括浏览器自动给请求加入的header信息,例如origin

接下来用Crystal启动http接口服务,看看不同跨域请求的生命历程:

Crystal安装参考官方文档,脚本basic_greet.cr为:

require "kemal"

port = 4000

get "/" do
  "Hello world!"
end

get "/greet" do
  "Hey!"
end

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  "Hello, #{name}!"
end

post "/greet_str" do |env|
  name = env.params
  "Hello, 成功了!"
end

Kemal.config.port = port
Kemal.run

使用命令sudo crystal run src/basic_greet.cr启动接口服务。

0). 同域下请求

http://xx.22.27.215:4000/greet接口域下发送“简单”的ajax请求,如图:

同域下请求,一切正常,接口能发起成功并且浏览器能读取响应接口数据。

不同浏览器控制台实现方式不大相同(但实现的规范是一样的),这里以FireFox浏览器为测试浏览器。

1). “简单”的post跨域请求

天涯bbs论坛域下发送“简单”的ajax请求,如图:

接口能发起成功。但是,如上图控制台报错,浏览器同源策略禁止读取远端资源,提示CORS header ‘Access-Control-Allow-Origin’ missing,也就是说响应头信息中缺少Access-Control-Allow-Origin信息。

这里之所以是“简单”请求,是因为Content-Typetext/plain,参考上面“复杂”请求规则,不满足任意一个。

这里所以找一个http服务,是因为Crystal接口是http的,如果在https域下调用,浏览器会直接禁止https域下请求http资源。

2). “复杂”的post跨域写入

天涯bbs论坛域下发送“复杂”的ajax请求。

控制台报错如图:

网络抓包如图:

图中1.preflight请求,请求方法为OPTIONS。服务端目前没有实现OPTIONS方法实现,提示404 Not Found

图中2.为真正的POST请求,因为1.preflight请求没有获得同源策略规定的头信息,所以2.的真正POST请求被浏览器级别blocked

注意图中2.OPTIONS请求是浏览器发起的,浏览器会带上一些header信息,比如:originAccess-Control-Reqest-MethodAccess-Control-Reqest-Headers

这种情况下的请求生命历程为:先行的preflight请求404 Not Found(“身先死”),真正的POST请求没有发起成功(“出师未捷”)。也就是所谓的:“出师未捷身先死”。

那,“复杂”的跨域请求preflight要求怎样的实现呢,才能满足浏览器CORS协议的要求呢?

浏览器在发送preflight后会寻找响应中的2个header

  • Access-Control-Allow-MethodsCORS协议允许的请求方法,例如GETPOST等。
  • Access-Control-Allow-HeadersCORS协议允许的请求header,例如Content-Type等。

针对“复杂”请求的生命历程来说,上面2个header必须匹配客户端实际请求信息,否则客户端实际请求可能会被浏览器级别blocked。说白了,服务端允许发送什么样方法的请求、什么样的头信息,客户端才能够成功发送。

preflight的响应信息还可以返回2个header,告诉客户端某些信息:

  • Access-Control-Max-Age:设置preflight请求能够缓存的秒数(默认值是5)。超过设置时间,“复杂”请求发起时浏览器会重新发起preflight;在设置时间内,不再发起preflight请求(使用缓存的preflight请求)。
  • Access-Control-Allow-Credentials:设置客户端实际请求是否能携带用户信息(例如cookie)。

如果服务端没有返回上面2个header信息,不影响请求的生命历程。

也就是说,根据CORS协议,preflight请求响应头信息中要明确返回客户端实际请求的方法(通过响应头信息Access-Control-Allow-Methods值)和头信息(通过响应头信息Access-Control-Allow-Headers值),这样浏览器才会同意发送客户端实际请求。而preflight请求响应头Access-Control-Max-Age可以指定preflight请求缓存的时间,默认就是5秒钟;preflight响应头Access-Control-Allow-Credentials告诉客户端,客户端实际请求能够能携带用户信息,否则不能携带。

这里对客户端实际请求进行了代码块标注,是为了强调该请求避免和preflight请求混为一谈。当一个“复杂”的跨域请求发起的时候,首先,浏览器会发送一个preflight请求,“试探”一下服务端是否允许该跨域请求,如果允许,浏览器才允许该“复杂”请求(也就是这里所谓的客户端实际请求)紧随preflight请求之后发起,否则就会被浏览器blocked

那,按照要求实现下preflight请求吧。

修改basic_greet.cr,增加OPTIONS实现:

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
end

重启接口服务,控制台重新请求,如图:

图中通过Status 200 OK可以看出preflight请求是成功的,但是下面控制台报错:响应头信息中缺少Access-Control-Allow-Origin信息。也就是说,preflight请求是成功了,CORS协议要求必须存在的preflight请求响应头信息也存在,但是由于Access-Control-Allow-Origin头信息的缺失,浏览器同源策略限制读取请求响应内容。

修改basic_greet.cr,响应信息头增加env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
  # ...from https://www.google.com origin.
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
end

重新请求:

preflight请求成功,如图:

控制台还是有客户端实际请求报错,不过这个错误就很熟悉了:

响应头信息中缺少Access-Control-Allow-Origin信息被浏览器禁止读取响应内容。接口中增加响应header信息:

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
  "Hello, #{name}!"
end

重新请求:

“复杂”请求的preflight客户端实际请求都成功了,实现了“复杂”请求的跨域资源共享。

“复杂”请求整个生命历程,概括如图:

4. 总结

  • 看似玄学的问题,其背后都有一定的原因。能不能准确的识别出来取决于对问题涉及的相关知识广度和深度的了解,抱着好奇心多了解多涉猎能增加知识的广度,抱着探索的意志剖析技术点及其源头能深挖知识的深度。
  • 成熟的程序员不是任何知识都懂,而是当遇到问题涉及自己不懂的地方时,能迅速识别出盲区并学习掌握。
  • 跨域总结:由于浏览器跨域请求存在安全隐患,所以浏览器制定了同源策略进行跨域请求等行为的限制(一定程度)。跨域资源共享基于一定的规则放宽了同源策略严格限制,使得不同域之间数据交互更加方便。跨域的问题一旦产生(前端后完全分离项目尤其常见),需要前后端共同努力解决。一个接口是否允许被跨域请求是由服务端接口头信息告诉浏览器的,而客户端请求参数的设置,尤其涉及到cookie信息携带等配置需要客户端了解个中原理才能完成。

引用

更新记录

  • 2022-02-07 18:10 首次提交文章到冯兄话吉
  • 2022-02-08 18:27 微信公众号“冯兄画戟”文章发表前重读、优化、勘误。
  • 2022-02-09 12:40 掘金专栏发表前重读、优化、勘误。

相关文章推荐

程序员春节想“弯道超车”?冯兄支招另辟蹊径,或大有可为!

作者 冯兄
2023年7月22日 08:00

目录


1. 终止“循环”,新的“开端”

一年一度的农历春节来了,一两周的假期总算可以全身心放松下来陪一陪家人,放空一下自我。

不知道是不是90后都那样,但至少有一小波那么群人(点名“宇宙尽头是考公”的“考公人”和“我秃了,也变强了”的程序员),他们“休息”会有一种“罪恶感”,一闲下来焦虑感就挥之不去。现在这个状态是不是在浪费时间?是不是不该“玩”?是不是该去学习的?是不是要做套卷子?是不是要逛一逛技术论坛?是不是要把技术视频看完?……

程序员是一个勤奋的群体,或者说是一个勤劳的群体会更贴切。工作中受客户、同事和领导的气,多少次暗暗发誓要做出改变,春节这个弯道超车的机会这群人不会错过。

可是一两周时间能做出什么改变?研究一下新的技术?读一本技术书籍?看一个系列视频教程?这些都能够帮助成长,但这些完成后新年去上班发现还是跳入去年的“循环”,受气!发誓!再受气!再发誓!跳不出这个恶性的圈子。

平时就“卷”不过同事,如何能实现“弯道超车”跳出“循环”呢?

实际上这里说的“弯道超车”要看和谁比,如果和没有“卷”过的同事比,那来年去十有八九还是“卷”不过,终止不了“循环”。

冯兄这里说的“弯道超车”更多的是和自己比,今天比较昨天自己是否有进步?这一季度比较上一季度自己是否有进步?今年比较去年自己是否有进步?如果有,那也不能说是“弯道超车”了,顶多算是没有原地踏步而已。

那怎样才算“弯道超车”呢?如何在这一两周的时间内实现“愿旧年,胜新年”的蜕变呢?

冯兄科目二考试两次有没有通过,教练给的新年寄语:“愿新年,胜旧年。”

先来听一个故事吧。

2. 另辟蹊径,可“下见小潭”

2.1 别人挖矿我挖井

中学时代经常会看一些课外读物,比如读者、意林、故事汇等杂志。有一些故事第一次看“惊为天人”,留下深刻的印象。

其中一个故事,在记忆的角落中很显眼:

20世纪50年代传言阿塔卡马沙漠下面埋藏着丰富的铜矿和铝矿甚至还有金矿,周边国家的人都拼命往这片沙漠里涌去,想要找到矿区获取财富。十八岁的智利小伙子巴特拉·切格莱特就是其中之一:

每天,巴特拉和人们一起寻找矿区的所在,不过每天都无功而返,付出巨大的体力还是小问题,最大的问题是没水喝,所以人们不得不隔几天就跑到沙漠边的镇上去取水。那天,巴特拉也和别人一样从沙漠边的小镇上取水,回到沙漠后,他看着眼前密密麻麻的寻矿人,突然心里产生了一丝怀疑:这么多人挖矿,到底有谁能挖到?更何况就算是挖到了,也很难说清究竟是谁挖到了,到时候因为相互抢夺而闹出惨剧都有可能,矿能不能挖到不一定,但是有一样东西却是每个人都需要的,那就是饮用水。既然这样,那么大家在沙漠里挖矿,我为什么不在沙漠里挖井呢?

后来小伙子放弃挖矿开始组织人挖井:

十年后,人们终于从这里挖到了铜矿和铝矿甚至是金矿,虽然确实有少数人得到了一些财富,但那时候的巴特拉却早已经是拥有亿万财富的“水富翁”了。

完整的故事内容请查看:沙漠里的水富翁

故事是否是杜撰的没必要去深究,但当时中学生身份看到这个故事还是大受震撼,别人挖矿我挖井,不和别人一起“卷”,也就多了一条通向成功的“小路”。

2.2 你是否在“卷”?

“卷”是近几年比较流行的词,但也有点滥用了,很多人一说到什么事情就说“太卷了,要躺平”,实际上并没有理解“卷”和良性竞争的关系。

冯兄认为一件事情是“卷”还是良性竞争,针对某个人来定义才有意义。

高考百万大军过独木桥,班级的第一、二名拼命学习把对方比下去,高考对于他们来说这不叫“卷”。而文化课成绩不好但明明有体育天赋的特长生不走艺术生这条道路,和班级里第一、二名拼成绩,正常高考对于有体育天赋这孩子来说就是“卷”。

当你参与到一件事情当中,判断是“卷”在其中还是在针尖对麦芒力争上游,要看你的初心,你的特长,你对这件事情的定位。

如果你很明确做这件事想要得到什么,初心在这里,有自信能做好,那么加油吧少年,你的付出一定会有所收获的。但是如果你人云亦云,亦步亦趋,盲目跟潮流做这件事,即使再努力也很难成功,甚至会越使劲,越“卷”在其中,越身心疲惫,到头来也是竹篮打水,收获寥寥。

诚然,随着人生经历的推进,一个人可自由自配的时间就越来越少。象牙塔内,莘莘学子有大把的时间,选择多一份努力和勇敢,可能收获就是巨大的明显的。到了社会中,大部分人都是被社会潮流裹挟着前进,自己独立可支配的时间寥寥无几,这时候的努力和勇敢可能不像学生时代那样一定能有什么收获,更多的是疲惫、困顿和迷茫。

但是,不管是在象牙塔内还是在社会的裹挟中,不管是学业蒸蒸日上亦或事业爱情一塌糊涂,能够清晰判定“我是被卷在当下还是激流勇进正当时”是非常重要的。

故事的主人公另辟蹊径“别人挖矿我挖井”,取得了成功,是聪明的人。挖矿者中少数人得到了一些财富,这波少数人肯定是激流勇进的人。其中还有一部分人具有坚定的信念、充足的准备和不懈的努力,但是最终失败了,这部分人也是值得尊重的。剩下那些“随大流”、碰运气和没有明确规划的挖矿者,他们是被“卷”在其中的人,也注定是不会成功。

被“卷”就意味着将来的“炮灰”。

那么,你是不是被“卷”呢?这是没有通用答案的问题,只有个人分析自己当前的处境(例如:你是否目标明确,是否充满激情,是否意志坚定,是否付出了很多辛苦,这些辛苦是否让你一步步成长或者给予了你正向的价值反馈等)才能够得出结论。

2.3 碎片化时间

那,冯兄给支招今年春节不被“卷”,另辟蹊径进入一片新的天地。

利用碎片化时间创作,由被动接收知识的输入者,变为主动分享知识的输出者。

“创作”这个词可能很吓人,认为只有专家写书才称之为“创作”,实际上,只要输出对多人有价值的内容就是创作,可以是一篇博文、一个短视频、一本教程小册等。

程序员也只有开始了创作才开始了积累和沉淀,才开始走向成熟。

为什么要强调碎片化时间呢?

一是因为工作后本职的工作就会压的人喘不过气,根本没有时间让你个人学习或者创作,你必须要挤时间。

二是因为碎片化时间凑在一起确实比你想象的多,也能创造出超乎你的想象价值。

哪些碎片化的时间呢?

地铁上、I/O阻塞时(工作中等待时)、休息间歇、问题发生时(随手记录)、灵感突来时(突然想起来好主意)等等。

3. 创作分享能带来什么?

能帮助创作者梳理知识体系,不断拓宽知识广度。创作者会思考我都懂哪些知识,这些知识点体系结构是什么样的,哪些分享出去对别人有价值。

能帮助创作者不断探索、学习,不断增进知识深度。创作者要分享一个主题,首先对自己分享的内容肯定要搞明白,进一步尝试把别人讲明白。这本身是一个很好的学透一个知识点方法。

能帮助创作者积累、沉淀和复用,这是一笔财富。如果创作者把一个知识点搞透了,下次同样的问题就不用重新研究了,甚至可以直接拿来用。如果这次的问题更加深入,研究之后就可以创作一篇该主题的高级篇了,再下次遇到问题就是站在自己“巨人的肩膀”上了。

能够帮助其他人成长。赠人玫瑰,手有余香,岂不知程序员在编程的时候都是在“Google这段代码该如何写”,找到的答案都是千千万万个程序员分享出来的。

可以看出来,创作者创作一个有价值的内容,其对别人的帮助要远远小于自己的成长。

很多人可能有疑问,碎片化时间拿来逛逛技术社区、刷一刷视频教程还可以,用来创作不行吧?看一看冯兄的实践经验吧。

4. 冯兄创作从0到1

冯兄话吉这个博客冯兄大概从2015年最开始接触编程的时候就创建了,那时候就有意识将自己学习的知识记录下来,例如学习gitlinux的文章,现在还时常能够用到。

工作后,也会隔三差五的总结输出一些文章,但是都没有认真的当创作来对待。2021年已经是冯兄工作的第五个年头了,阳历年末对自己的程序员生涯做了个总结,也是平庸程序员的一个写照。冯兄希望自己2022年有一个新的“开端”,终止没有什么成长的2021年那样的“循环”。

怎么行动呢?第一件事就是要认真对待“创作”,希望能够对已有的知识体系做一个梳理,总结创作出一些有价值的内容。

4.1 创作之本

所谓创作之本,指的是愿景。

冯兄希望“用通俗易懂的文字将计算机的一些基础内容讲透彻,帮助初学者和计算机学习迷茫的人”。

4.2 创作之标

所谓创作之标,指的是标准。

冯兄给自己定如下标准:

  1. 一篇技术文章必须自己搞懂相关技术再创作发表,要保证言之有物,表之有据。
  2. 文章要“三番五次”校验勘误,杜绝逻辑错误、错别字等。目前文章发表三处:冯兄话吉博客、“冯兄画戟”微信公众号和冯兄画戟掘金社区,按照时间顺序先后发表在三个平台上,发表前都会对全文进行校验、勘误和优化(这些都是碎片化时间完成的)。
  3. 网友对文章的讨论、疑问,不管是否能帮忙解答,都要做出回应。

4.3 创作之术

所谓的创作之术,指的是工具。

使用的工具有:

  1. Windows平台:gvim坚果云WSL2git
  2. Android平台:Markor易码(EasyMarkdown)罗技k380键盘
  3. ipad:iVim坚果云向日葵罗技k380键盘

工作时可以使用Windows平台;坐地铁、等公交时可以使用Android平台;家中不想拿出电脑或者临时处理一些问题可以使用ipad。

关键之处是这三个平台可以通过坚果云进行同步,随时随地都能满足你创作的需求。

接下来详细讲述一下冯兄在这三个平台是是如何创作及打通它们的:

Windows平台

程序员一般都是用Markdown写文章,Win平台上优秀的工具有很多,例如Typora,冯兄使用的gvim纯文本编辑器。

冯兄话吉博客有一个包含.md文档的_posts本地目录,把这个目录链接到坚果云上,这样本地上传、修改文档都能够即时同步到坚果云上,如图:

微信公众号不支持Markdown语法,可以参考markdown-weixin将Markdown文本内容转化为公众号文章。

冯兄把Gitee仓库作为图床使用,需要先将冯兄话吉博客Github仓库同步到Gitee平台上(Gitee页面支持从Github导入仓库),其他平台上发表文章前用shell脚本将文章内图片引用改为Gitee超链接引用,如图:

shell脚本的执行是在Win本地安装的WSL2上进行的,Win上先提交到Github远程仓库,然后在WSL2实例上拉取下来。想了解更多WSL2的内容,可以参考:Windows10 WSL2体验如此丝滑(Windows上使用完整服务的Linux)

这样在Windows平台上创作的文章可以发表在Github博客上;将Markdown中图片引用转为Gitee引用后,可以直接粘贴发表在掘金社区上;通过markdown-weixin转换工具发表在微信公众号上。

Android平台

Android平台和Windows平台上.md文档的同步需要用坚果云支持的WebDAV协议。易码APP支持通过WebDAV协议连接坚果云,但是易码的Markdown文档编辑功能不太好用,冯兄使用易码只是作为WebDAV的一个客户端,文档编辑使用Markor APP,但是Markor不支持WebDAV协议,要想易码同步坚果云的文档也同步到Markor上要借住手机的内部存储。具体做法是:

1). 在易码上增加一个新的笔记库指向手机内部存储/EasyMarkdown。操作如下:

2). Markor上设置工作目录为手机内部存储/EasyMarkdown,操作如下:

这样,当在Windows平台上创建或者更新文档后,更新内容会同步到坚果云,手机端易码通过WebDAV同步坚果云文档。需要在手机端修改文档时,在易码上将坚果云笔记库对应文档移动到自定义手机内部存储的笔记库中,实际上就是将坚果云远端文档下载到手机本地内部存储目录,这样打开Markor读取同样手机本地内部存储目录,就能够显示并编辑文档。

手机端编辑文档结束想同步回Windows平台,同样需要在易码中移动自定义手机内部存储的笔记库对应文档到坚果云笔记库。移动完成后,文档通过坚果云自动同步能够同步到Windows平台。

易码上操作移动文档到不同笔记库的操作如图:

冯兄为ipad买了一个k380蓝牙键盘,也可以连手机,家中如果嫌手机打字不方便,可以连上蓝牙键盘,只要思路顺,也能写到起飞。

ipad

发现了Android平台上方便的方法后,ipad上就不常用来编辑文档了,主要是用向日葵远程Windows平台来提交git、文章发表,图片处理等操作。

冯兄在ipad上使用iVim编辑文档,需要有一定的vim基础,

其他平台文档同步到ipad也是通过坚果云,打开ipad坚果云客户端将文章发送到iVim即可开始编辑,编辑完成后在vim命令模式下输入:ishare可选择保存到坚果云目录中,即完成同步。

iVim要进行一个重要的配置是:快捷键增加/减小字体,编辑文档时将字体适当调大,再配上蓝牙键盘就能丝滑的书写了。

iVim.vimrc中增加配置:

#增加或者减小字体大小
#同时按住ctrl键和数字0可以增加字体的大小
nnoremap <c-0> :ifont +<cr>
#同时按住ctrl键和数字9可以减小字体的大小
nnoremap <c-9> :ifont -<cr>

更多iVim配置可以参考:iVim vimrc配置

2022年,作为一个程序员,如果还在困顿、迷茫,不知道如何成长,那么就开始处女创作吧,成长就从创作开始,在那里,别有洞天!

最后,祝愿程序员群体:2021终止不好的“循环”,2022启动美好的“开端”;2022年“冯兄发髻”国运高照吃面上岸!

更新记录

  • 2022-01-28 16:32 手机端编辑
  • 2022-01-29 14:50 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-30 10:27 掘金专栏发表前重读、优化、勘误

相关文章推荐

bug现场谜之困在“init”方法上的那些时间!

作者 冯兄
2023年7月16日 08:00

目录


1. bug现场情况

现场将在Tomcat 8.5中运行的war包迁移到jetty 9.4.19上,启动容器后报错:

org.springframework.context.ApplicationContextException: Failed to start bean 'stompWebSocketHandlerMapping'; nested exception is java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:176) ~[spring-context-4.2.3.RELEASE.jar:4.2.3.RELEASE]
    ...
    ...

NoSuchMethodError应该是看到后最有头绪一个错误了:“在加载到JVM的对应类中找不到当前调用的方法”。

如果编译环境中对应类没有对应的方法,是不能编译成功的(集成开发环境会报错)。如果编译成功后部署时候报错NoSuchMethodError,说明运行时和编译时依赖的类不一致。

这里说的“编译时依赖”指的是:构建工具在编译时CLASSPATH中依赖的class;“运行时依赖”指的是:JVM实例运行时加载到JVM中的class。对于同一个class loader,只会成功加载一次class

上面的异常堆栈显示:类org.eclipse.jetty.websocket.server.WebSocketServerFactory没有构造方法WebSocketServerFactory(javax.servlet.ServletContext)。那就看一看运行时依赖的类有没有对应的构造方法吧。

现场的情况下,只能用javap命令,但是首先你要找到这个类是从哪个jar包加载的,如何根据类找到加载的jar包路径在接下来的尝试破案做进一步说明。

javap -cp lib/websocket/websocket-server-9.4.41.v20210516.jar org.eclipse.jetty.websocket.server.WebSocketServerFactory

注意这里如果使用 javap -cp lib/websocket/* xxxx这样指定classpath*配置的方式无效,但是对于javac命令是有效的。

这不是存在WebSocketServerFactory(ServletContext context)构造方法吗???

后来笔者又尝试了多种途径确认这个构造方法是存在的,但是却报错NoSuchMethodError,网上一大堆找“java.lang.nosuchmethoderror but method exists”,无果。因为网上说的最后都证明确实没有对应的方法。

但本案发现场的情况是它有啊!现场变得诡异起来了!难道笔者找到了一个超级bug?直觉告诉我100%不会,一定是自己哪块错了。

2. 尝试破案

回顾一下案发现场的情况,报错java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V,可是通过javap工具反编译明明有构造方法WebSocketServerFactory(javax.servlet.ServletContext)啊!

一般NoSuchMethodError异常有两种情况:

  1. classpth中该方法的类在多个jar包中,而JVM加载的jar包的那个类没有该方法。
  2. 只有一个jar包,jar包中的类没有该方法。

这两种情况归根结底是JVM运行时加载的类中确实缺失了方法。但是上面遇到的问题查找加载类是存在报错的构造方法的。

如果JVMclasspth中有多个包存在同一个class,到底JVM会加载哪个包中的class是平台相关的(Linux系统和Windows系统上可能加载的不是同一个jar包)。需要注意:JVMclasspth下的jar包中load对应的class文件,这跟jar包的命名没有关系。

可以通过以下方法根据报错信息定位加载的jar包:

1). JVM使用参数-verbose:class,这个参数能够输出加载classjar包绝对路径。

2). 使用java代码:

Class<?> clazz = null;
try {
     clazz = Class.forName("org.eclipse.jetty.websocket.server.WebSocketServerFactory");
} catch (ClassNotFoundException e) {
     e.printStackTrace();
}
CodeSource cs = clazz.getProtectionDomain().getCodeSource();
String location = cs.getLocation().getPath();
System.out.println(location);

3). 使用linux命令:

for file in *.jar; do
  echo $file;
  jar tvf $file |grep WebSocketServerFactory
done

在允许重启系统或者启动的JVM中设置了--verbose:class参数的话“1)”方法是最方便的,可以直接在日志中查找对用的类。

不允许重启JVM的话可以采用方法“2)”,但是要指定正确的classpath,否则加载不到对应的类。查找classpath可以从jvm对应的进程中查找。

对于springboot框架打成的jar包,一般依赖都打进在jar包中了;对于severlet容器使用的war包,依赖除了WEB-INF/lib外还包括容器安装目录下的lib包;对于普通的jar包,依赖可能定义在了MANIFEST元文件中(更多关于MANIFEST内容可以参考:https://fengmengzhao.github.io/2021/12/18/bug-scene-of-old-jar-classpath-mystery.html

如果想查找指定目录的哪个jar包含有某个class,可以使用“3)”方法,列出jar中包含的文件清单并查找匹配。

为什么要费劲找到报错类是从哪个jar包中加载的呢?一来jar包一般能提供版本相关的信息;二来javap命令是需要指定jar包作为classpath才能成功反编译。

使用javap命令反编译,语法如下:

#这种方式是指定类信息和类所在的jar包为classpath反编译
javap [-verbose] -cp /some/path/to/lib/xxx.jar com.xx.SomeClass

#这种方式是将class文件从jar中解压,直接反编译class文件
mkdir dir
cd dir
jar xvf ../SomeClass-belong-to.jar
javap [-verbose] com/xx/SomeClass.class

javap-verbose参数展示class文件的详细编译信息,如果只想判断是是否有某个方法,可以不加-verbose参数。

通过上面的方法确认本示例的情况:明明方法存在啊,为什么NoSuchMethodError,百思不得其解!

3. 真相浮出水面

怎么办呢?问题总是要解决的。

在开发环境上准备调试代码,突然意识到报错中的init是不是一个普通方法啊?

赶快看看反编译的代码发现确实没有init普通方法,只有init构造方法。问题就出在这里,查了一下发现jetty9.3升级到9.4的时候对WebSocketServerFactoryinit普通方法改为构造方法

这是笔者的一个知识误区,以为WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V是一个构造方法,实际上如果是构造方法报错是长这样的:

10:24:09.590 [main] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.NoSuchMethodError: org.springframework.boot.builder.SpringApplicationBuilder.<init>([Ljava/lang/Object;)V

普通方法和构造方法实际上就是.init().<init>()的区别。

这里报错中.<init>([Ljava/lang/Object;)V.表示是一个方法的调用;<init>表示构造方法的调用;[表示一个数组;Ljava/lang/Object;表示java.lang.object对象;V表示返回类型是void。实际上就是SpringApplicationBuilder(java.lang.Object...)的构造方法,方法的参数是java.lang.Object数组。这种写法和class文件的内部表示是一致的。jvm更多内部实现 类型表示参考:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html

对于一个程序员来说,异常的的堆栈信息是司空见惯的,也就懒得深究其中的一些玄机,果然“报应不爽”!出来混,迟早要还的。

4. 总结

  1. 很多看似玄学的bug解释不了,最后原因总是归结为“知识的盲区”。很多知识不必懂的很深入,但是基本的东西要了解,此时“不求甚解”,彼时“这是玄学?”。
  2. 有些时候会无意识的想当然一些结论(比如本示例中.init()方法自然认为是构造方法)。没办法十分敲定的东西,要多查一查,多一份思路。
  3. 排查问题,针对一个思路要充满信心,即使这个思路不能解决问题,至少也要能得出这条思路的结论。不能急躁、粗心、盲目尝试。思路窄了,就停下来,明天再尝试,避免进入死胡同。

更新记录

  • 2022-01-24 16:10 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-26 15:20 掘金专栏发表前重读、优化、勘误

学习达梦数据库

作者 冯兄
2023年7月13日 08:00

目录


1. 用户创建及权限相关

1.1 用户创建并赋予权限

#创建新的用户(禁用超级管理员账号)
create user <用户名> identified by <PWD>
#分配权限
grant resource,public,vti to <用户名>;
#收回权限
revoke "RESOURCE" from <用户名>

1.2 查看用户及权限

#查看用户
select * form dba_users;

#DBA 拥有构建数据库的全部特权,只有 DBA 才可以创建数据库结构 
#RESOURCE 可以创建和删除角色 
#PUBLIC 只能查询相关的数据字典表 
#VTI 具有系统动态视图的查询权限,VTI 默认授权给 DBA 且可转授 
#SOI 具有系统表的查询权限 
select * from session_roles # 查看用户权限

1.3 用户、角色、资源权限理解

达梦数据库的权限分为两类:数据库权限(系统权限)和对象权限。实际上这二者的划分是根据对数据库对象的操作不同而区别的,对数据库对象的增(create)、删(drop)、改(alter)和备份(backup)的权限称之为数据库权限(系统权限),而对数据库对象的访问权限称之为对象权限

注意,这里的“对数据库对象的访问”不仅仅指代查询,而是对数据库对象内容的“增删改查”等。例如,对于表和视图数据库对象来说,所谓的对象权限包括:SELECTINSERTDELETEUPDATEREFERENCESSELECT FOR DUMP

达梦数据库每创建一个用户就默认创建一个和该用户同名的模式,默认情况下,该用户对自己默认同名模式内对象资源有对象权限,而数据库权限为空。也就是说,默认普通用户只能对自己模式内对象数据进行增删改查等,不能进行新建表、删除表、新建视图等操作。

要想让普通用户具有数据库权限需显式授权,可以使用SYSDBA或者有授权权限的用户对普通用户授权数据库权限。比如可以授予普通用户create schema权限,这样普通的用户就能够创建schema,但是shema的归属(或者说授权)只能给自己。

授权的方式可以使用grant语句或者使用达梦manager客户端图形化操作。

达梦数据库内置了5个重要角色,一个普通用户默认的角色是PUBLICSOI。实际上为了让普通用户能够在所属的schema下具有部分数据库权限,可以再赋予VTIRESOURCE角色。这些角色的权限如下:

  • DBA:DM 数据库系统中对象与数据操作的最高权限集合,拥有构建数据库的全部特权,只有 DBA 才可以创建数据库结构。
  • RESOURCE:可以创建数据库对象,对有权限的数据库对象进行数据操纵,不可以创建数据库结构。
  • PUBLIC:不可以创建数据库对象,只能对有权限的数据库对象进行数据操纵。
  • VTI:具有系统动态视图的查询权限,VTI 默认授权给 DBA 且可转授。例如v$ciphers的查询权限。
  • SOI:具有系统表的查询权限。

在使用manager达梦管理工具图形化新建表的时候,命名有create table权限却会报错没有v$ciphers系统视图或者系统对象(例如表)的查询权限,这时候要赋予用户VTISOI角色。

达梦数据库没有属主的概念(和PostgreSQL不同),只有所属schema,而schema有属主(授权用户)的概念。

PostgreSQL表和schema都有属主的概念。

2. 用户密码相关

2.1 密码修改

#修改用户密码。
alter user <用户名> identified by <PWD>;    

2.2 密码策略修改

达梦数据库密码策略:

  • 0 无策略
  • 1 禁止与用户名相同
  • 2 口令长度不小于 9 l
  • 4 至少包含一个大写字母(A-Z) l
  • 8 至少包含一个数字(0-9) l
  • 16 至少包含一个标点符号(英文输入法状态下,除“和空格外的所有符号)

口令策略可单独应用,也可组合应用。组合应用时,如需要应用策略1 和 4,则设置口令策略为 1+4=5 即可。

修改密码策略:

#设置用户口令策略。策略为长度至少为8位,包含数字、字母和特殊字符。
#对已有账户修改密码策略
alter user <用户名> PASSWORD_POLICY 31;

#设置系统密码策略
SP_SET_PARA_VALUE(1, ‘PWD_POLICY’, 31);

3. 修改用户资源限制

#语法是:alter user <用户名> limit KEY VALUE,VALUE可以是unlimited,表示无限制
alter user <用户名> limit session_per_user <用户允许的session数>, connection_idle_time <连接空闲时间>, password_life_time <密码有效时长>, password_reuse_time <reuse时间>, password_reuse_max <reuse最大时间>, connect_time <连接超时时间>, cpu_per_session <数值>, cpu_per_call <数值>, read_per_call <数值> mem_space <数值>

#设置用户口令的有效时长。单位:天,unlimited设置无限制
alter user <用户名> limit password_life_time 90;
#设置用户口令过期后可使用天数。过期后,禁止执行除修改口令外的其他操作。
alter user <用户名> limit password_grace_time 10;
#设置密码连接错误最大次数
alter user <用户名> limit failed_login_attemps 5;
#设置连续错误后锁定时间;单位:分钟
alter user <用户名> limit password_lock_time 5;
#设置用户最大空闲时间
alter user <用户名> limit connect_idle_time 30;

#限制TEST账号只允许通过指定网段IP访问数据库:
ALTER USER TEST ALLOW_IP "10.201.34.*","192.168.*.*";
#解除IP访问限制
ALTER USER TEST ALLOW_IP null ;

#用户解除锁定
alter user TEST account unlock;

4. 审计相关

#开启审计开关
# 登录SYSAUDITOR用户或者其他拥有审计权限用户
SP_SET_ENABLE_AUDIT(1); 
#开启覆盖所有用户的语句级审计日志,中间一个参数是用户
#语句级别的审计只针对用户
SP_AUDIT_STMT(‘ALL’,’SYSDBA’,’ALL’);
#设置单个审计文件的大小
#设置单个审计文件大小为1G
sp_set_para_value(1,‘AUDIT_MAX_FILE_SIZE’,1024);

#查看审计日志
select * from V@AUDITRECORDS where USERNAME = 'SYSDBA' order by OPTIME DESC;
#或者使用达梦自带日志分析工具analyze,图形化界面展示日志

5. 版本和license

#查看安装包相关信息,例如:2-2-18-21.08.10-xxx-xxx-SEC SPE Pack13
select id_code;

#查看DM版本
select * from v$version;

#查看license信息
select * form v$license;

6. 问题记录

6.1 达梦数据库获取一个表所有字段的拼接串

达梦数据库想要获取一张表的所有字段并且用’,’将各个字段按照顺序拼接起来,形成例如’select a, b, c, d from t_xxxx’这样的形式。

达梦的表all_tab_columns存储有各个表以及对应的字段,可以通过函数wm_concat将行转列。具体sql如下:

/opt/dmdbms/bin/disql user_name/'"passwd"'@xx.xx.xx.xx:xx -e "select wm_concat(column_name) from all_tab_columns where owner='TYYW2_LCBA_DATA' and table_name = '$line'" | tail -n 1

但是有一个问题,如果行的个数过多,查询出来的结果会被截取,这样拼接出来的结果就可能不完整。有可能是达梦的一个bug。通过shell找折中的办法解决:

#tail -n +10  --> 从第10行开始到
#tr -s '\n' '\n' --> 删除空行
#tr '\n' ',' --> 换行符替换为',',也就是就每一行用','连起来
/opt/dmdbms/bin/disql user_name/'"passwd"'@ip:port -c "set heading off" -e "select wm_concat(column_name) from all_tab_columns where owner='TYYW2_LCBA_DATA' and table_name = '$line'" | tail -n +10|tr -s '\n' '\n'|tr '\n' ','

6.2 达梦数据迁移整个数据目录并重新启动数据库

从一台服务器迁移达梦数据库数据目录到另一台服务器之后,启动报错找不到.DBF文件。

首先迁移到目标服务器之后,可以修改dm.ini文件对应的路径,但是修改之后还是报错。原来有的路径是在数据目录的dm.ctl中写死的,这时候就要修改dm.ctl文件里的路径。

但是dm.ctl是二机制文件,不能进行修改。达梦提供了工具可以经二进制文件转化为文本文件,修改后,再转化为二进制文件:

#将dm.ctl二进制文件转化为dmctl.txt文本文件
./dmctlcvt TYPE=1 SRC=$DATADIR/dm.ctl DEST=$DATADIR/dmctl.txt

#修改文件中的路径并保存
vim $DATADIR/dmctl.txt

#将dmctl.txt文本文件转化为dm.ctl二进制文件
./dmctlcvt TYPE=2 SRC=$DATADIR/dmctl.txt DEST=$DATADIR/dm.ctl

6.3 达梦数据库dmfldr导入clob字段200多k数据报错

测试clob字段,如果该字段大一点,使用dmfldr导入就会报错,提示数据格式有误。

可以任建一个表,建一个clob字段,导入如下测试数据,就会报错。

DM官方给的解释是dmfldr在load数据的时候可能有限制,只能说这明显是一个bug。


bug现场谜之超级权限的root用户也存在“创建文件失败”的时候?

作者 冯兄
2023年7月5日 08:00

目录


1. bug现场情况

现场ETL抽数报错“创建文件失败”,无法将数据通过达梦dmfldr工具导入数据库中。

ETL的实现是,首先通过sql查询将数据库数据导出为dat文本文件,实际上就是一个csv文件,用分隔符$将每一列数据隔开,用换行符\r\n将每一行隔开;然后程序中调用shell命令借助于数据库load工具(本例中达梦数据load工具为dmfldrPostgreSQL数据load可以使用psql工具)将文本csv数据导入目标数据库。比如对于PostgreSQL数据库:

目标表target_table为:

学号 || 姓名 || 年龄 || 得分

导出的csv文件student_score.dat为:

set client_encoding to 'UTF8'
COPY student_score from stdin WITH DELIMITER '$' ESCAPE E'\\' CSV;
00001$冯兄_01$30$75
00002$冯兄_02$31$85
00003$冯兄_03$32$95
00004$冯兄_04$33$65
00005$冯兄_05$34$55

shell命令导入csv文件到表中:

psql -h $HOST -p $PORT -d $DB -U $DB_USER -f /path/to/student_score.dat

正常如上面那样使用psql客户端导入数据是需要输入密码的,可以使用免密的方式,如在客户端程序所在的主机的~/.pgpass中增加:$HOST:$DB:$DB_USER:$DB_PASSWD

本例中的达梦数据库也一样,生成的dat文件后,程序执行shell命令,通过dmfldr工具将数据导入目标表。

通过看日志,发现本例中是生成了dat文件,只是在导入的时候报错“创建文件失败”:

Caused by: xxxxException: xxxx错误:创建文件失败
    at xxxx.Execute.execute(Execute.java:239)

2. 尝试破案

由于报错摸不到头脑,先上后台用命令尝试是否成功导入:

为什么说“报错摸不到头脑”?因为程序是以root用户运行的,不存在权限问题,并且shell命令导入数据所需要的csv数据文件和参数ctrl文件都已经生成了,为什么会“创建文件失败”?

达梦dmfldr工具导入csv数据的方式如下:

./dmfldr userid=$DB_USER/$DB_PASSWD@$HOST:$PORT control=\'/path/to/test.ctrl\'

如果DB_PASSWD中含有特殊字符,可以使用'"$DB_PASSWD"'方法逃逸特殊字符。

test.ctrl文件示例:

OPTIONS (
DIRECT = false
rows = 50000
skip = 0
ERRORS = 0
)
LOAD DATA
INFILE '/path/to/*.dat' STR X '0D0A'
APPEND
INTO TABLE $TABLE
FIELDS '$'

更多达梦dmfldr工具使用参考官方文档:https://eco.dameng.com/docs/zh-cn/pm/getting-started-dmfldr.html

报错“文件少列”,意思是用$隔开的列的个数和目标表列的个数不匹配,但实际上经过确认csv文件数据列的个数和目标的数据列个数是相同的。

通过增加列(不是说少列吗,那就就是增加$)、删除字段数据等多种方法反复尝试,最后确认导入csv不能成功的原因是字符编码的问题,默认dmfldr使用的GBK编码,但是csv文件是UTF-8编码,用GBK解码UTF-8文件就出现各种奇怪的报错。

期间会报各种错误,如“字符串被截断”、“数据格式不正确”等。

dmfldr工具默认使用GBK编码没法改变,想着是不是可以更改系统使用字符编码为GBK,让导出的文件跟随系统编码为GBK,这样应该就能够导入了。

尝试修改当前终端系统字符集:

#查看本地字符集
locale

#查看所有本地支持的字符集
locale -a

#更改字符集,要选择locale -a展示支持的字符集
export LAGNG=zh_CN.gbk

实际上这里称“字符集”为“字符编码”更为准确,理解字符集与字符编码区别,参考文章:https://fengmengzhao.github.io/2015/07/30/computer-character-coding-styles.html

修改后,重启系统,发现系统界面、日志到处是乱码,导出的文件编码还是UTF-8编码,实际上说明导出文件的编码不会随运行系统字符集改变而改变,这也是开发的规范。

只能联系产品的研发修改代码了吗?

3. 真相浮出水面

第二天将前一天的验证结论又确认了一遍,本地使用命令导入数据时,字符编码存在问题。在ctrl文件中加入参数CHARACTER_CODE = 'UTF-8'之后数据能正确导入。

就要联系产品提bug的时候,突然想到不是有传说中的Arthas存在吗?可以做到在线反编译、修改代码、重新编译并重新加载类。上Arthas!玩一玩。

Arthas的安装不再赘述,文档很清楚。

使用命令启动并连接Arthas

#启动Arthas
#注意替换$PID,$PID是运行的JVM进程pid,通过命令ps -ef |grep xxx 获取
nohup java -Xbootclasspath/a:/opt/jdk-1.8/lib/tools.jar -jar ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -pid $PID -target-ip 127.0.0.1 -telnet-port 9658 -http-port 9563 -core ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -agent ~/.arthas/lib/3.5.4/arthas/arthas-agent.jar &

#确认Arthas是否启动成功,上面设置telnet的端口号为9658,该端口可以修改
netstat -nalp |grep 9658

#连接Arthas
telnet 127.0.0.1 9658 

注意:用命令行启动Arthas进程后,立即用命令ps -ef |grep arthas能看到一个进程,说明Arthas在启动中,过一会儿进程消失,说明Arthas已经启动成功或者失败。如果成功的话,使用netstat -anlp |grep $PID能看到Arthas启动是指定的telnet监听端口。找不到指定的telnet监听端口说明没有启动成功,需要查看~/log/arthas/arthas.log日志文件。

使用Arthas修改代码并重新编译:

#根据报错日志,找到报错类Execute
sc *Execute

#反编译运行class文件为源代码
jad --source-only xxxx.Execute > /tmp/Execute.java

#修改源代码
#ctrl文件中增加字符编码设置:CHARACTER_CODE = 'UTF-8'

#查找该类的类加载器hash值
sc -d *Execute |grep classLoaderHash

#在线编译修改
mc -c $CLASSLOADER_HASH /tmp/Execute.java

#重新热加载class
redefine -c $CLASSLOADER_HASH /tmp/xxxx/Execute.class

重新执行ETL程序,发现还是报同样的错。后台查看ctrl文件内已加上了UTF-8字符编码的设置,手动执行dmfldr收入导入,能够导入成功。

可为什么还报错呢?这时候意识到可能程序执行的shell命令和笔者后台执行的命令不一致Arthas不是能wath参数吗?走一波:

#查看方法入参、类成员信息、返回信息、异常信息
#params是参数 target是当前类成员信息 returnObj是方法返回值 throwExp是抛出异常信息
#-x 2 表示递归层级 -e 表示异常时抛出
watch xxxx.Execute exec "{params, target, returnObj, throwExp}" -e -x 2

Arthas还可以使用OGNL表达式,例如:watch xxx.FileDAO TransString @org.apache.commons.io.IOUtils@toByteArray(params[0].getBinaryStream()) -b -e -x 2,这里@OGNL调用类静态成员或者方法的写法。

arthas执行静态方法、属性

#调用静态属性
ognl '@全路径类目@静态属性名'

#ognl执行静态方法
ognl '@全路径类目@静态方法名("参数")'

#ognl参数的使用
ognl '#value1=@com.shirc.arthasexample.ognl.OgnlTest@getPerson("src",18), #value2=@com.shirc.arthasexample.ognl.OgnlTest@setPerson(#value1) ,{#value1,#value2}' -x 2

更多OGNL用法请参考:https://commons.apache.org/proper/commons-ognl/language-guide.html

重新执行程序,控制台得到程序完整的执行command是:

/path/to/dmfldr userid=$DB_USER/'"DB_PASSWD"'@$HOST:$PORT control=\'/path/to/*.ctrl\' character_code=\'utf-8\' log=\'/path/logs/dmfldrLog/fldr.log.2022-01-05\' badfile=\'/path/logs/dmfldrLog/fldr.bad.2022-01-05\'

复制命令,手动在后台执行以下,报错“创建文件失败”,和日志中的报错一致。知道问题是哪里了,应该就是日志文件创建的时候缺少目录,造成不能创建日志文件报错。

手动创建日志文件目录:mkdir -p /path/dmfldrLog,重新执行导入命令,执行成功。重新执行ETL抽数程序,也成功,破案了!

4. 总结

  1. 实际上在笔者自己后台执行dmfldr命令的时候就走偏了,手动执行的命令和程序执行的命令不一致,结果自己的命令出新的bug以为就是问题所在,方向没找对,陷得更深了。
  2. 第一时间应该要用Arthas,当时现场环境只有JRE,笔者懒了,也付出了代价。
  3. 报错日志(本例中是“创建文件失败”,最后排查实际上问题就是一个日志文件路径目录不存在,造成dmfldr不能创建日志文件)很重要,查bug的时候多联系报错信息,能有助于查错不跑偏方向。
  4. 如果在不知道代码的情况下,Arthas真是一个利器,能极大提高排查问题的效率。大神总说工具不重要,实际上大神对工具都运用自如了,才说不重要。Arthas工具值得Java程序员好好学习。
  5. 不能先入为主,看到“创建文件失败”,认为以root启动的程序就能够创建文件成功,本例中是用root身份执行了dmfldr命令,关键是命令中带有绝对路径的日志路径,由于路径目录不存在,dmfldr工具就报错了。关键不在于是否是root的问题,而是dmfldr在没有目录的情况下不会自动创建目录。
  6. 实际上这里ETL程序通过shell调用第三方程序,要考虑周全第三方程序可能的报错,否则就会出现类似bug

更新记录

  • 2022-01-07 18:16 “冯兄画戟”微信公众号文章发表前重读、优化、勘误
  • 2022-01-20 10:13 增加arthas启动判断内容
  • 2022-01-21 22:35 掘金专栏发表前重读、优化、勘误

相关文章推荐

五年前“冯兄很笨,但他很勤奋”,五年后“好吧,冯兄很用功,但还是很平庸!”,谁为冯兄五年来被“辜负”的努力负责?

作者 冯兄
2023年6月20日 08:00

目录


1. 他十年努力,被辜负了?

冯兄有天在Hack News上看到一个热议的帖子https://news.ycombinator.com/item?id=29545069,看完后心情久久不能平静,Phil Jones四十岁开始在https://www.quora.com/平台上作为一个问题回答者,十年来共回答了11000多个提问。现在他要退出quora平台开始玩编程和音乐。文中Phil Jones说十年来她付出了很多,然而收获并不匹配,十年的努力被”辜负”了,该谁来负责?

跟着冯兄看看帖子的内容:

作者10年来在quora平台回答了超过11000个提问,然而作者要退出quora平台了,作者并不是因为quora惹恼了他而痛苦退出,也不是被平台抛弃。相反作者说quora是一个优秀的平台。

作者说自己是quora控,控制不住自己浏览quora并回答提问者的问题。可是当作者翻看之前自己回答的时候发现回答中有很多东西大有可为,但是从来没有真正做过。另外很多和时事相关的回答随着时间流逝过时了,那些自己花费数小时写下的苦口婆心劝别人改变心意的回答可能别人并不一定会听。

作者在quora平台上花费了数千小时(如果没有过万)写作,回答了超11000个提问和超5000个草稿。这些草稿因为没有润色就从来没有发布过。粉丝们关注作者,给作者点赞,作者在quora上成为了”顶流”的”up主”。

然而最终静下心来思考,作者说他十年的努力被”辜负”了,这里的”辜负”不是指经济意义上的、不是社交意义上的,也不是个人成长意义上的。作者开始在quora上写作的时候40岁了,如今50岁了,quora上那些回答成了作者十年来最大的项目和成就。

作者并没有否定在quora上写作那十年,那是一件很值得自豪的事情。但是它并没有形成价值的积累,最开始花费数小时写回答,获得了关注、赞和极大的满足感,但是慢慢的边际收益就越来越小了。十年来作者的朋友有的出书了、有的学术上小有成就、有的在影视界有所建树。而作者的成就是一堆quora平台上的回答。并不是说这些回答没有价值,而是面对十年来付出的时间、精力和才智,这十年是被”辜负”了。

作者提到10年前写的一篇杂志文章比这10年来写的11000篇quora回答挣更多的钱,这里不是说钱的问题,但是那篇杂志文章可以放在简历中增彩,而很少quora的回答可以给履历加分。实际上,作者意识到这些情况已经很多年了,但是每次作者都是快速打开quora,查看通知,为无意义的政治问题争论不休或者解释互联网上已经有很好定义的通用编程语言概念,意识过来发现一天的时间都浪费在quora上了。

帖子的原文链接为:http://exquora.thoughtstorms.info/

他十年努力,被辜负了吗?估计只有Jones Phil本人才能准确回答这个问题,不过这不重要,冯兄为他感到高兴,在50岁退出quora玩编程和音乐,他以后肯定不会再辜负自己的努力了。

那到底是谁辜负了Jones Phil十年的努力呢?已过而立之年的冯兄说不出为什么心里空落落的,Jones Phil十年来坚持做一件事并且这件事也做成功了,到头来却得到了”不值得”。

Jones Phil用十年的时间得到一个教训,相信这个教训代价很大,也很有价值。冯兄回顾一下五年来的工作经历,希望能在Jones Phil的教训中反思,为2022年开启一个好的开端。

2. 冯兄这些年

冯兄普通二本学校毕业,为了续时间找工作上了三年研究生,文科专业。2017年正式参加工作已经快满五年了。五年来冯兄见证了自己的平庸,也慢慢习惯了平庸。

上学时想,冯兄如今24,到30而立之年应该能干好一件事。如今我拿不出什么…

刚工作时想,指导冯兄代码的30岁架构师水平也就那样,三年内我比他更优秀,现在呢?还难望其项背。

2.1 入门编程

冯兄研究生开始接触编程,最开始在学校的信息化部门打杂工,接触htmlcssJavaScript,心里想着这个专业适合自己。网上找教程学,每天晚上自习课后去信息化教室看视频。

清晰记得有一天晚上把RGB颜色表给打印出来,心里想着要把它背会。总之,感觉上学这么多年找到一个正道,当成宝贝似的。

那时候2015年还不知道web服务端和客户端之分,心里想我们访问的网页在哪里维护呢?感觉很神秘。

后来接触linuxgitvimjavaweb,看视频学习linux基本知识;注册github账号,克隆别人的项目改造,学习vim命令并强制自己使用vim编辑文档;网上到处找别人开源的项目学习源代码。

印象最深的寒假回家硬盘里下载了linux高级课程学习,但当时也没学多少。那个时候谈不上技术的热爱但热情是一定有的,心里想将来能找到一个编程的工作,成为程序员应该就很厉害了。研究生期间做了两个开源项目,一个是生成适合程序员的简历,一个是麻雀虽小五脏俱全的图书管理系统,这个开源在github都有几十个fork。

2.2 学而"实习"之

很荣幸研究生期间遇到了一个好的导师,导师曾经给冯兄一个研究课题,一周后冯兄就主动放弃了,真不是做学术研究那块料,真切体会到数学基础、智力与别人的差距。

导师还帮忙推荐一个他大学时间同一个宿舍同学的公司实习,这个公司是做跨境电商的,导师同学在公司做CTO,给我布置了一个任务”抓取各大电商境外业务(如天猫国际、京东国际等品牌)的商品价格等信息”。

实习公司用的是PHP技术,当时就打印了一本厚厚的PHP书,每天下班公司人走光了,冯兄就在那里学习,晚上回去也继续看书,对PHP语言有基本的了解。

实习的公司离住的地方远,早上六点五十起床用开水冲一个鸡蛋花(那时候全部的家当就是一个热水壶,一个茶缸,一台风扇和被褥),七点匆匆去赶地铁,再晚就是人山人海了,把《Modern Operating System》英文原版打印出来,坐地铁是始发站,有位置坐,就抱着厚厚的书看。

公司是九点半开门,八点半左右就能到公司园区,进不去公司就在园区的凳子上看书。那段时间了解了操作系统进程、线程、虚拟内存、竞态等概念。

实习虽然没有参与到项目中,但也感受了真实的工作环境。导师的同学非常忙,中午基本不休息,晚上也很少加班,到点就下班了。工作五年后冯兄想来,这实际上很厉害。当然了,加班不加班和公司、团队文件自己的关注点有关,但是导师CTO同学身上那种搞技术的热情和效率让冯兄大受震撼,看到他那么忙,冯兄几乎不好意思去问问题,感觉耽误人家时间。

最后一个多月时间产出了爬虫程序,爬取了几百万条数据。由于导师的关系,纯属在那里历练,没给公司产出任何价值。实习这段时间见识了真正的企业环境,懂得了企业中基本的人际交往。

2.3 哭出来就好了

实习结束马上进入9月份的找工作时候,9月到11月面试了一二十家企业,基本没有进入3面的。上午跑校招或者去面试,下午没面试就在家里看书,刷LeetCode上的算法题,看java基础的东西,晚上七点到九点去奥林匹克森林公园跑步,偶尔会看看电视剧。每天是很充实,但慢慢就变得焦虑了。

有时候冯兄会对自己说,难道真的不适合程序员这条道路?但箭在弦上,不得不发。有一次去北航参加招聘会,那是一个普通的下午,食堂还没开饭,进去坐着回忆一两个月来奔走面试还没有什么结果就哭了,没有声音,泪水真的像泉眼一样往面庞上流,不过在别人学校哭,也不算那么丢人。

哭出来就好了,整理一下心情重新出发,11月份主动联系了现工作公司问为什么还没有复试,因为技术面试表现还可以,公司说以为人在外地,没有安排,马上安排起来HR面试,没过多久就接到了录入通知。那是一个月黑风高的夜晚,收到HR电话,整个人感觉卸掉了包袱,变轻快了,终于有公司肯要我了。冯兄对自己说,只要给我一个机会,一定会做出成绩的。

2.4 五年来冯兄很用功,但还是很平庸

2017年2月进入了公司实习,相比较之前实习的公司,这是一个大公司,大的办公区和规范的流程。但是很快就有烦恼困扰冯兄,公司基于第三方平台做大屏项目,所做的工作无非就是拖拽控件和调样式,和做PPT一样,没什么技术含量。初入职场冯兄就是要把它做好,仔细查看了产品说明文档,思考实现上的更多可能性,做东西的时候思路也更多。有一个功能在我参与项目之后团队才知道原来可以这样用。

7月份毕业了,女朋友也跟着冯兄来一线城市闯荡。当时冯兄心潮澎湃,心中充斥着”我来了,大有可为!”的激情。清晰的记得,下班后去和女友去市场买菜,冯兄都心不在焉的,想这样”会不会太浪费时间,是不是应该用这些时间学习才对?”。那时候也确实拼,在出租屋里,早上六点多起床看书、写文章,怕吵着女友睡觉买了个可调亮度的台灯,把亮度调到最温和。

工作中冯兄很幸运遇到了一个好领导,可以说是一个贵人。领导很赏识冯兄,除工作外经常谈职业生涯规划,谈个人发展,关键是给予很多的信任和鼓励。在领导的关心和帮助下,冯兄在入职一年半就获得了职级晋升。这中间也想过多次换工作,但换工作需要自身本领硬,为了在繁忙的工作中能够抽出时间来学习,记得冯兄有一段时间利用中午去园区看书。那期间买了一个看电纸书的kindle,会下载技术书籍,坐地铁等碎片化时间都会用来看书。

转眼来到了2019年,冯兄携女友去了另外一个一线城市。刚毕业在一个出租屋早上读书怕吵到女友睡觉时想”要是有两个屋能隔开多好”,现在实现了,新租的一房一厅。可是这三年冯兄的激情消退了,在公司中也成为了”老人”,过着和尚撞钟的生活。这三年冯兄很少早起看书,也很少思考进步和发展,但是却非常忙。或者说因为非常忙,冯兄忘记了思考和看书,项目任务铺天盖地,冯兄疲于应对,似乎成了工厂中的机器,一切都变得是被动的。这三年比刚毕业的那两年早上能起来在出租屋看书的日子更辛苦,似乎也更勤奋。但是得到了什么呢?当时心潮澎湃的大有可为,如今变成了习惯了的”逆来顺受”。三年来,冯兄的博客很少更新了,也没有深入研究技术,那到底都干什么了?工作当然是干项目呗,只是项目压的人喘不过气来思考了。失去了思考的能力,冯兄在这三年里”堕落”了。当初那个拖拽控件像做PPT一样的工作都能做出花来的冯兄,如今只会亦步亦趋步履仓皇而毫无方向。

3. 论平庸

优秀是一种习惯,平庸也是。优秀的人会说自己很平庸,那是一种自谦;有平庸的人会说自己终将变得优秀,那是一种自信;也有平庸的人的说自己确实很平庸,那是一种自知。

冯兄是一个有自知之明的人,实际上根本不需要五年的工作证明冯兄的平庸,一个小学学习差、中学复读一年考上普通二本的冯兄能优秀到哪里去?至少能证明智力平庸。冯兄还有社交恐惧症,情商平庸。智商和情商双平庸,是平庸无疑了。

既然冯兄已经认识到自己的平庸,那么拥抱平庸对冯兄来说是大有裨益的,怎么说呢?

当冯兄写一篇技术博客的时候,会彻底搞清楚写的是什么,花更多的时间去验证去理解而不敢乱写误导了别人。花更多时间有什么关系呢?请记住冯兄很平庸。

当冯兄研究一个技术问题的时候,看一遍官方文档不懂就看两遍、三遍,查更多的资料去了解,编更多的demo去验证。别人1天掌握的东西冯兄3天掌握了有什么关系呢?请记住冯兄很平庸。

当冯兄看到技术牛人解决问题的思路如此清晰,产出架构的效率如此高效,就连文档也画的很漂亮而感叹差距的时候,想的是如何提高自己的能力而不是自怨自艾。别人五年就成架构师了有什么关系呢?请记住冯兄很平庸。

是的,根据二八定律,只有20%优秀的人。大部分人都很平庸,如果能够拥抱平庸并尝试做一些不简单的事情,平庸还是不会变,但确实会变得不简单。

当冯兄写一篇技术博客的时候,不贪图量的多少而追求质的增加,产出的每一篇文章都能帮助自己理解技术并帮助别人成长。这就是不简单。

当冯兄在研究一个技术问题的时候,看了三遍文档还有那么多依赖的知识没搞明白,没有真正的理解技术原理。这时候冯兄不放弃,从最开始、最基本的知识学习,抽丝剥茧一点点消化最终掌握这项技术。这就是不简单。

当冯兄和牛人合作被人按在地上碾压的时候,冯兄站起来拍一拍身上的灰尘,总结那些被自己忽略的细节,被牛人碾压的地方,悄悄记录下来,有机会就学习补充。这就是不简单。

平庸加上不简单就等于小跑着奔向优秀了。用个公式就是:平庸 + 不简单 >> 优秀

4. 论"搬砖"的艺术

冯兄还没有参加工作的时候,只是听说程序员”搬砖”,知道”搬砖”就是工作的意思,但实际上没有理解其中真正的内涵。

工作后,突然有一天冯兄悟了,每天在公司的工作就是数据库的”增删改查”,这和工地上搬砖何其相似啊。”搬砖”意味着工作没有技术含量,重复但量多还不得不做。

编码只是程序员工作的小部分,实际上企业雇佣程序员就是来解决问题的,很大部分时间可能在对接客户、梳理需求、理解需求、架构设计、编码实现、单元测试、整理发布包、现场实施、解决线上问题等等。即使在真正写代码的时,也大部分时间在看代码或者在搜索引擎中找答案复制粘贴。

如果你接手的是新项目,那么恭喜你是很幸运的,因为工作中大部分接手的项目都是已经存在但是要增加需求或者是复用某个项目开发。这就到替别人填坑或者给别人背锅的时候了。

总之,程序员和其他职业人员没有什么区别。但是编程这个行业有自己的特点,那就是程序员要不断的学习才能跟进技术的发展。所以喜欢编程的程序员爱好学习新知识、肯钻研。

冯兄是自学的编程,一路走来在编程下的功夫很多,也跳了很多坑,像Jones Phil那样冯兄认为很多自己努力是被辜负了或者说可以做的更好。

冯兄曾多次在搜索引擎中寻找”编程的核心是什么?”,并没有得到完美的答案。至今冯兄也给不出这个问题的答案,但是五年来工作对编程的理解积累出对”搬砖”艺术的一些理解:

开发语言框架、编程工具只是辅助编程的工具,不能沉迷

一个公司一般有自己主流的编程语言,可能是java也可能是go或者其他,工作当中用到这个语言及相关框架当然要深入学习,但是不能太沉迷于各个框架学习,尤其不能停留在使用层次,而不去研究背后的原理。如果这样你下的功夫再多,也只是对一个框架熟悉而已,不能提高自己的编程能力。

编程语言也要从语言层次去多了解其他语言,这里说的语言层次不是指语言的类库实现,而是语言的解决问题的方式,比如编程范式、动态/静态类型、强/弱类型等。

不要沉迷于vimIDE等主题样式的过多研究,除非你想编写一套样式。如果你感觉别人的主题很酷,有时间可以配置一个,但是不要浪费太多的时间,这对你程序员核心能力的提高不会太多。

程序员要好读书,并且求甚解

编程当中经常会遇到很多问题,如果都”好读书,不求甚解”,那么就会收获很少,很多东西感觉像玄学一样。这样久而久之,会慢慢地失去自信并对编程失去兴趣。

如果能将遇到有价值的问题研究透彻,并且将自己的心得记录、分享出来,会大有裨益。当然,这里还涉及到一个问题就是下面说的知识体系,人的精力是有限的,如果是你知识体系外的一个问题没弄明白就没必要纠结,就像用git时候可能你明明就没有改到冲突代码结果一拉取冲突了,这时候先网上查查或者请教别人解决问题,而不要花费太多时间研究,除非你是在研究git,那需要系统的学习才能解决你的问题,在网上随便找乱尝试浪费时间也不会有结果。

用博客将你平时研究明白的知识一点点记录下来,并能够给别人讲明白。这样你才是真正掌握了知识。

如果想创建免费的个人博客,可以参考:https://fengmengzhao.github.io/2018/06/07/step-by-step-learn-to-build-an-awesome-jekyll-blog-free-hostd-by-github.html

人的精力是有限的,定义自己的知识体系往深度发展

和编程相关要学习的计算机科学知识有很多:操作系统、编程语言、数据库、网络等等,每一个分支都不是那么容易能研究透彻的。而一个人的精力是有限的,不可能各个分支都成为专家。定义自己的知识体系,在知识体系内往深度发展能带来质的蜕变。避免出现今天研究后端发现没那么容易,明天就开始研究前端发现前端更难掌握,这种小猫钓鱼的学习情况,最后会对计算机科学失去信心。

计算机科学各个分支是相关联系的,发展知识体系的广度开阔视野很必要

如果把知识体系比作一颗大树,那么上面说的知识深度就是说”这棵树的根能够扎多深”,那么知识广度就是说”这棵树能分出多少枝杈”,二者也是相辅相成的。枝杈茂盛了能帮助根扎得更深,根越深就有更多的机会长出新的枝杈。

当一个后端开发被安排前端活的时候,不要带着偏见和抗拒。前端无非是用另外一种编程语言处理不同范畴的问题而已。对一个开发来说这是扩展知识广度增加知识体系枝杈的时候。

知识深度决定了你有多强,知识的广度决定了能变得多强。

要低头看路,但也要抬头看天

马克思说:”资本主义来到世间,每个毛孔里都沾满了血和肮脏的东西”,这句话可能只有被残酷的社会毒打过才能有深刻的体会。在资本的糖衣炮弹诱惑和皮鞭驱打下,程序员打工人双眼布满了血丝,身体被掏空了,精神上对自己”哀其不争,怒气不奋”。这群人经常默默心里说:”老子明天开始背面试题,受够了,不干了!”、”整天干这样的活把人都干废了,要好好看书换一家工作”、”撒币领导真的无脑,不想再多呆一天”…。可是疲惫的工作下班回家是晚上10点了,很难抽出时间来学习,早上更是起不来,即使用意志强行起来了,发现不知道该做点什么。

这是大部分程序员打工人的困境,也是一个恶性循环。付出了精力和时间,但是个人没有成长,每天兜兜转转一晃可能就是三五年。这时候能引领打工人走出困境的只有自己,资本把打工人当做机器驱使,程序员打工人要赋予自己灵魂,低头看路做项目完成工作没问题,也是职责所在,一定要时不时抬头看天思考:”我今天有成长吗?我是不是陷入了恶性循环但没有做任何改变的尝试?是不是一直待在舒适区而自我感觉良好?…“。冯兄也是在困境中的人,或者说任何人在他所在的阶段都不同程度的存在所谓的恶性循环,如果不知道该怎们做,可以尝试:

  1. 做别人做不了的。任何人都具有自己的优势,结合自己的长处去发展,你就已经赢了很多人。比如冯兄能多看懂几个英文单词,就找一些国外高质量博文翻译,一来提升自己,二来帮助初学者。
  2. 做别人不愿意做的。似乎程序员人人都不愿意写文档,如果能把一个项目文档写的体体面面,这是一个强大的能力。

要低头看路,同时别忘了抬头看天,看一看初心,看一看自己的收获,看一看接下来的路是不是自己想要的,看一看要不要做一点什么…

5. 愿:功不唐捐

那,到底谁该为Jones Phil那十年被辜负的努力负责呢? 应该是他发现自己是个quora控但没有做出改变的勇气。五年来冯兄很用功,还是很平庸,谁来负责呢?应该是压低的头只顾看路的仓皇,都不记得抬头看一下天的那份思考。

愿2022年:

和冯兄一样平庸的程序员都能努力做到平庸但不简单,记着平庸加上不简单就是朝着优秀的方向小跑。

那些佝偻着、绷直着和半躺着身体改代码的程序员能够对bug手到擒来兵不血刃。

那些心理默念或者大声说出来”见证奇迹的时候到了”的程序员能够收获多一点自信和笑容,少一点落寞和失望。

那些掉落在桌子上、地板上的头发能够兑现:”我秃了,但我也强了”。

那些心理憋着一股气受够了的程序员能够多一点勇气和行动,打破恶性循环,逆境而生。

那些被别人碾压、抓耳挠腮说学不会怀疑自己是否适合编程的程序员能够找准自己的定位和方向,心急吃不了热豆腐,要慢慢变强。

那些还没踏入职场在辛苦找工作以及那些由于种种原因离开了原工作还在找工作的程序员能够遇到伯乐,择良木而栖。

所有的程序员打工人能够低头看路并抬头看天,日拱一卒,功不唐捐。

6. 引用

更新记录

  • 2022-01-17 12:34 修改引用中原文链接错误

bug现场谜之古老的jar包classpath玄机

作者 冯兄
2023年6月18日 08:00

目录


1. bug现场情况

现场从Docker上迁移一个应用到Linux主机。

使用命令docker exec -it $CONTAINER /bin/sh进入容器,ps -ef发现只有一个进程:jar -jar xxx.jar。将jar包解压缩,如图所示:

和springboot的jar包结构不一样,这里面直接是class、配置文件及META-INF目录。

看了一下env环境变量,也没有CLASSPATH值。心里想着奇怪(那些依赖jar包是从哪里加载的呢?),但是也没有想明白咋回事,暂且不管

把jar包迁移到Linux服务器上,尝试用java -jar xxx.jar启动,果然报错一大堆基础的类找不到。这时候突然想起在原docker容器jar包同目录中有一个SYNC_lib目录,该目录似乎包含了jar包依赖的第三方包。

SYNC_lib目录也迁移到jar包同级目录上,指定classpath重新启动:java -cp .:SYNC_lib/*: xxx.jar。这时候相关的类都加载了,有一个报错是数据库的驱动不是最新的。将原驱动备份,复制一个新的驱动到SYNC_lib目录内:

mv SYNC_lib/Postgresql-old-version.jar SYNC_lib/Postgresql-old-version.jar.bak
cp /path/Postgresql-new-version.jar SYNC_lib/

自信满满地使用java -cp .:SYNC_lib/*: xxx.jar重新启动,报错:ClassNotFoundException: org.postgresql.Driver not found

什么情况?明明新的驱动包已经在classpath里面了,为什么会找不到class呢?笔者还特意将新的驱动包解压缩确认是能够找到org.postgresql.Driver类的。

更奇怪的是切换到旧版本的驱动包就能够加载org.postgresql.Driver驱动类了(通过JVM参数-verbose能在日志中打印出加载的详细类和对应的jar包)。

Docker学习参考https://fengmengzhao.github.io/2021/06/25/docker-handbook-2021.html

2. 尝试破案

总结一下案发现场疑点:

  • 问题1:容器内的java进程用java -jar xxx.jar启动,命令行和CLASSPATH环境变量都没有指定SYNC_lib路径,该JVM实例是怎么加载这些第三方jar包的?
  • 问题2:迁移后指定classpath启动jar包,只是替换了classpath下jar包的版本,竟然报错ClassNotFoundException

没思路了,只能手动写个代码看看从指定的classpath下能不能加载对应的class:

import java.security.*;

public class FindClass {

    public static void main(String args[]) {
        Class<?> clazz = null;
        try {
            clazz = Class.forName("com.uxun.uxunplat.util.OperateResult");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        CodeSource cs = clazz.getProtectionDomain().getCodeSource();
        String location = cs.getLocation().getPath();
        System.out.println(location);
    }
}

结果:能成功加载org.postgresql.Driver,说明新的驱动jar包是没问题的,命令行参数-classpath(或者-cp)的设定方法也是正确的。

百思不得其解……

3. 真相浮出水面

突然,灵机一动,可以不使用jar包启动,而是直接启动包含main()方法的class类,说不定是jar包在作妖。

main()方法启动:

#这里在-cp中增加xxx.jar
#也就是将之前-jar启动的应用加入到classpath中
java -cp .:SYNC_lib/*:xxx.jar: com.xxx.xxx

应用成功启动了!

果不其然,是jar包在作妖。此时笔者再次打开jar,这次没有忽略任何细节,打开META-INF\MANIFEST.MF文件,如图:

原来玄机在这里,捶胸顿足,悔之晚矣!

META-INF\MANIFEST.MF文件是jar包的元数据文件,该文件指明了:

  1. Main-Class:该jar包的入口类(包含main方法的类)。
  2. Class-Path:依赖jar包的classpath路径。jar包路径之间使用空格分隔。

破案:

  • 问题1:容器内的jar包没设置classpath也能够加载第三方依赖,不是玄学,而是classpath在jar包内指定了。
  • 问题2:替换新的驱动包报错ClassNotFoundException,是因为在MANIFEST.MF文件中定义的classpath会覆盖掉命令行中指定的-classpath参数设置。也就是说命令行中正确指定的-classpath实际上并没有生效(不是参数设定错误,而是参数被覆盖了)。

实际上,回到”刀耕火种”的时代,在没有构建工具(例如antmaven等)时,构建一个有第三方依赖的java程序,可以使用命令:

#参数c表示创建jar归档文件
#参数v表示打印详细日志
#参数f表示指定jar的名称,这里是xxx.jar
#参数m表示指定元数据信息文件,这里是MANIFEST.txt
jar cvfm xxx.jar MANIFEST.txt com.xxx.xxx

关于jar打包和MANIFEST.MF更多内容参考:https://docs.oracle.com/javase/tutorial/deployment/jar/downman.html

现代开发java程序用IDE集成开发环境,不需要手动敲命令。例如,用IDEA导出一个jar包:

1). 在项目中增加一个Artifacts

2). 执行构建,导出jar文件。

即使现代程序开发用IDE方便了很多,基础知识(如jar包中MANIFEST到底是什么?有什么作用?)的掌握有利于对编程体系的理解。

4. 总结

  • 不要浅尝辄止看一个有疑问的点。如果你跳过这个点,可能你就偏离了找到bug的方向,再回到正确的方向上会更费劲。本例中没有深入思考为什么在没有指定classpath的情况下,java -jar xxx.jar能够正常运行;也没有打开jar包时顺便看看MANIFEST.MF文件内容。如果这两个任意一个做了,在前30%时间内就能破案。
  • 有时候看到的现象不只是一个bug引起的,做好控制变量尝试,准确定位造成异常的原因。避免一锅粥,乱尝试,最后身心疲惫,脑子就不清晰了。本例中迁移后连接的库是高版本的Postgresql库,用低版本的驱动会报错。升级高版本后,报错ClassNotFoundException,要确信不是高版本驱动不可用,而是依赖jar包加载有问题,这时候千万不能跑偏。
  • 基于认知,把确定能推出来的结论找出来。本例中替换驱动jar包后,报ClassNotFoundException,实际上可以认定命令行参数没有最终起作用(本例被jar包内MANIFEST文件覆盖了)。当然了,认知可能会有盲区,多一步验证,如果发现认知盲区,要搞明白关联知识。

更新记录

  • 2022-01-12 18:10 掘金专栏发表前重读、优化、勘误

相关文章推荐

log4j2 JNDI解析LDAP协议变量远程加载class存在漏洞(可被攻击执行任意代码)

作者 冯兄
2023年6月11日 08:00

目录


1. log4j2在程序员圈子火了-java程序员的不眠夜

2021年12月10日一觉醒来,发现程序员社交网站上全是lo4j2相关的内容。

实际上2021年11月24日,阿里云安全团队已经向Apache官方团队报告了Apache Log4j2远程代码执行漏洞。之后陆续国内多家机构监测到Apache Log4j2存在任意代码执行的漏洞。2021年12月10日阿里云再次报告官方2.15-rc1版本存在漏洞绕过,建议升级2.15.0版本。网上出现了Apache Log4j2任意远程代码执行漏洞的攻击代码,仿佛一夜间大家才紧张起来,也有网友感叹“第一次感受到互联网的脆弱”。

1.1 log4j日志简介

java有很多优秀的日志框架,并且设计是解耦的。接口层比如:SL4jApache commons logging(JCL);实现层比如:log4jlogbakjava util logging(JUL)等。

log4j只是Apache出品的java日志框架的一种实现,所以不是说用了Tomcat就一定用了log4j框架输出日志,主要看应用是集成了哪种日志框架。

使用sl4j + log4j2需要在pom.xml中引入依赖:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.5</version>
</dependency>

本项目demo中没有使用sl4j,直接引入log4j-api:2.14.1.jarlog4j-core:2.14.1.jar

简单的配置文件log4j.properties的demo如下:

#This is a log4j property
log4j.rootLogger=INFO, STDOUT
log4j.logger.deng=INFO
log4j.appender.STDOUT=org.apache.log4j.ConsoleAppender
log4j.appender.STDOUT.layout=org.apache.log4j.PatternLayout
log4j.appender.STDOUT.layout.ConversionPattern=%5p [%t] (%F\:%L) - %m%n

日志的级别分为:All < Trace < Debug < Info < Warn < Error < Fatal

All:最低层级,用于打开所有日志;Trace:表示程序推进;Debug;表示调试信息;Info:表示程序的运行过程;Warn:表示警告日志;Error:表示错误日志;Fatal:表示严重错误,将会导致应用程序退出。

更多java日志框架内容参考:https://fengmengzhao.github.io/2018/06/12/detailed-explanation-of-java-logging-framework.html

1.2 判断是否使用有漏洞的log4j日志框架

log4j存在漏洞的版本是2.x <= 2.15-rc1。查看对应的依赖是否存在有漏洞版本即可。具体方法:

  1. maven或者gradle构建项目,查看对应的配置文件,是否有相应的log4j依赖。
  2. 非第三方构建项目,找到项目的依赖CLASSPAHT,查看是否有相应的log4j相关jar(log4j-api:***.jarlog4j-core:***.jar)包依赖。
  3. 查看CLASSPAHT下是否有log4j.properties配置文件。

查看CLASSPAHT的办法可以用ps -ef |grep $PID,查到对应的进程信息里面-cp参数值为classpath。对于springboot项目可以使用命令mkdir xxx && cd xxx && jar xvf ../xx.jar解压后找到lib目录查看。

如果上面能够查到对应版本的jar包,很可能使用log4j作为日志框架,存在漏洞风险。

1.3 建议措施

  • 正式方案
  • 临时方案
    • 可升级jdk版本至6u2117u2018u19111.0.1以上,可以在一定程度上限制JNDI等漏洞利用方式。
    • 对于大于2.10版本的Log4j,可设置系统属性log4j2.formatMsgNoLookups或者环境变量LOG4J_FORMAT_MSG_NO_LOOKUPStrue
    • 对于2.0-beta9 to 2.10.0的版本,可以删除对应的JndiLookup类:zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class

针对上面临时方案中的升级JDK版本,本代码示例在Windows JDK 1.8.0_212-b10也能够成功复现攻击,所以最好按照正式方案解决问题。

2. 漏洞分析

log4j作为一个优秀的日志框架,提供了以某种约定的格式来获取到运行环境中配置信息的能力,称为looksup。例如提供了以下配置:

<properties>
    <property name="logPath">${sys.catalina.home}/xmlogs</property>
</properties>

后续在代码中就可以通过${logPath}来获取该属性的值。运行时log4j提供的looksup实现会解析${开头的变量,并且支持多种协议。例如通过LDAP查找变量;通过docker查找变量等,详细参考:https://www.docs4dev.com/docs/zh/log4j2/2.x/all/manual-lookups.html

这次log4j的漏洞就是利用java的jndiAPI访问LDAP服务来远程加载类,攻击者可以写任意恶意远程代码在类加载的时候执行达到攻击目的。

代码层次分析参考:https://mp.weixin.qq.com/s/K74c1pTG6m5rKFuKaIYmPg

3. 攻防演练

3.1 攻

根据漏洞分析,我们想要模拟“攻”,需要准备4个东西:

  1. 集成了log4j2的java项目。本示例:log4j的版本是1.14.1,JDK环境1.8.0_121
  2. 编译好的恶意class文件。本示例:恶意class文件在Windows环境运行弹出计算器,在Unix环境运行列出运行环境监听的所有端口并生成图片test.jpg到程序运行目录
  3. LDAP服务。本示例:使用marshalsec-0.0.3-SNAPSHOT-all.jar启动一个简单的LDAP服务。
  4. 远程获取恶意class文件http服务。本示例:使用Python启动http服务,

从git上克隆项目到本地,github项目地址:https://github.com/FengMengZhao/apache-log4j2-bug.git,gitee备份项目地址:https://gitee.com/fengmengzhao/apache-log4j2-bug.git

1). 导入maven项目到IDE中,或者使用mvn命令行下载maven依赖。

2). 将Log4jRCE.java类本地编译生成的class文件,该class文件即为恶意远程class文件,将该class提供为http可访问服务。可以将java和class文件copy到一个目录中,在该目录中启动简单的python http server:

java源代码文件实际上并不需要,这里复制java源文件是为了验证http远程可访问。

#进入class文件所在的目录执行
/usr/bin/python3 -m http.server

服务端启动如图,默认端口是8000:

http访问java文件验证:

3). 在项目tool目录下找到文件marshalsec-0.0.3-SNAPSHOT-all.jar,该jar包用于创建简单的LDAP服务。启动LDAP服务,默认监听1389端口号,需要指定2)中启动的远程http服务:

#可以在最后加上一个参数指定LDAP服务端口,模式是1389
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://ip:port:8000/#Log4jRCE

启动LDAP服务后,默认监听1389端口,如图:

4). 执行log4j的main方法,即可验证攻击完成:

在Windows平台上运行,弹出计算器:

在Unix平台上运行,获取后台监听端口及进程,并将内容在运行目录生成test.jpg

如果不能够成功复现攻击,报错11:54:23.960 [main] ERROR log4j - Reference Class Name: foo,可能是JDK版本过高。降低版本到1.8.0_191之下再尝试。

3.2 防

参考1.3中的正式方案,将log4j版本升级到2.15.0,执行log4j的main方法不会再jndi远程加载class。

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.15.0</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.15.0</version>
</dependency>

如果是现场不方便重新打包,可将包下载,替换目标容器lib下对应的jar包。

3.3 攻方"有道"

本demo示例基于对技术的好奇和探索而创建,切忌用于线上生产项目的攻击!!!

4. 引用


学习DB2数据库

作者 冯兄
2022年8月12日 08:00

目录


1. 数据库基础命令

#1. 切换到数据库安装用户
su - db2inst1

#2. 连接到数据库
db2 connect to <数据库> #本地
db2 connect to <数据库> user <用户名> using <密码> #远端

#3. 强制所有应用断开数据库连接
db2 force application all

#4. 备份/还原整个数据库
db2 backup db <数据库>
db2 restore db <数据库>

#5. 查看所有数据库连接
db2 list application

#6. 启动/关闭数据库
db2start/db2stop

#7. 执行sql
db2 -tvf *.sql #中间出现错误不会断开
db2 -txvf *.sql #中间出现错误会断开,并提示错误

#8获取日志路径
db2 get db cfg for bpfdb3 #获取实例日志路径
db2 get dbm cfg | grep DIAGPATH #获取诊断日志路径

2. 数据库结构/数据导入导出

db2结构导出

db2lookup -d <数据库> -u <用户> -o <导出脚本.sql>

注意:导出的结构中可能有源数据库中自定义的表空间,如果导入目标数据库中没有相应的表空间就会报错,为了保持迁移表的一致性,在执行sql的时候使用db2 -txvf *.sql,中间如果出现错误会断开,需要把所有的错误都解决了。

db2move数据导出导入

#导出数据
db2move <数据库> export -l lobs

#导入数据
db2move <数据库> import -l lobs

注意:这里的-l参数指的是lobpaths,而不是日志。如果有lob相关的字段,需要指定路径将lob数据单独放在指定路径下。在数据导入的时候同样要指定lob路径,这样lob字段的数据才能够导入。

db2move命令参数参考相关文档:https://www.ibm.com/docs/en/db2/10.5?topic=commands-db2move-database-movement-tool

3. 数据库表空间

表空间创建示例:

db2 "create regular tablespace  tablespace1 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace1' 5g) bufferpool bp32k"
db2 "create regular tablespace  tablespace2 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace2' 10g) bufferpool bp32k"
db2 "create regular tablespace  tablespace3 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace3' 2g) bufferpool bp32k"

4. 参考链接

bug现场谜之报错也识趣-客户一上班报错就消失了!

作者 冯兄
2022年8月11日 08:00

目录


1. bug现场情况

部署的是Java应用,容器中间件使用的是IBM WebSphere,数据库使用的是IBM DB2,数据源使用的是容器中间件提供的JNDI数据源服务。

程序在上班前的一段时间点击报错mybatis查询失败(sqlcode=-774 sqlstate=2D522),上班之后就恢复正常了。生产环境的日志没有有效的信息,全是-774的报错,而把报错的sql拿出来在数据库执行没有问题,并且同样的sql在客户上班后系统中执行没有问题。

粗略在网上查一下,DB2数据库sqlcode=-774的含义是:Explanation: Statement cannot be executed within a compound SQL statement.,意思是“语句不能在复合sql中执行”;sqlstate=2D522的含义是: “ATOMIC 复合语句中不允许 COMMIT 和 ROLLBACK”。初步的判断可能和数据库的事务相关。

2. 尝试破案

日志中没有有效的报错信息,只能先观察程序表象。发现,报错的现象在中午13:00之后是能够稳定复现的,在14:30之后就稳定消失。问题出现的时候,随意刷新页面就会报错,具体后台就是-774,也就是mybatis执行sql不成功。而14:30之后,随便刷新页面都不会出现问题,即使客户端并发多个请求造成响应慢但是也不会出现报错-774。如果在报错这段时间重启了应用程序,则报错就会消失。

从以上信息,大概估计,问题应该是出现在了数据库连接池上。在某个时刻,程序用数据库连接池中的连接执行了某些操作,该数据库连接就处于了“异常”状态,其他任何功能用到该“异常”数据库连接执行sql时,就会报错,不是数据库有什么问题,而是连接数据库的数据库连接有问题。这样设想也解释了两点,第一:为什么同样的sql、同一时刻在程序中执行就会报错,而拿出来到数据库客户端中执行就没有问题;第二:程序报错的时候重启,重启后为什么就正常了。原因都是报错来自于用到了数据库连接池中“异常”的数据库连接。

那具体是什么样的操作造成了数据库连接池连接的异常呢?正常在日志中应该有所体现才对,生产环境IBM Websphere的日志太不方便看了,并且也没法设置参数重启进行监控。研究决定将生产环境迁移出来一份作为测试环境。原来容器中间件为IBM WebSphere迁移到Tomcat上。迁移相关配置参考Tomcat JNDI容器数据源的配置

迁移完成后程序运行一段时间后查看日志,发现最先开始报错的日志有如下内容:Processing was cancelled ... SQLSTATE=57014,日志显示在执行一个存储过程的时候,由于某种原因(可能是时间过长)客户端主动取消了。后来在网上查到了IBM官方的一个帖子:https://www.ibm.com/support/pages/apar/IC64958。心里一下子豁然开朗了,这不就是DB2的一个bug吗?

IBM官方帖子说:“在DB2版本9上有一个bug,如果你在一个数据库连接中执行一个存储过程,因为某种原因客户端取消了,这时候在这个连接上执行的任何sql都会报错-774”。官方还提供了bug的补丁包。果然,找到报错的存储过程,处理之后,后续程序就没有出现过问题了。

这下找到了是什么样的操作导致数据库连接池中的连接“异常”了,就是DB2数据库的一个bug(存储过程被客户端取消后,使用同一连接执行sql都会报错-774),DB2在后续高版本上解决了这个bug。

3. 破案绊脚石

3.1 Tomcat JNDI容器数据源的配置

1). 配置server.xml中Resource。在GlobalNamingResources节点下增加节点Resource

  </GlobalNamingResources>
    ...
    <Resource name="jdbc/court" global="jdbc/court"
        auth="Container"
        type="javax.sql.DataSource"
        driverClassName="com.ibm.db2.jcc.DB2Driver"
        url="jdbc:db2://xxx.xxx.xxx.xxx:50000/xxx:currentSchema=xxx;currentFunctionPath=xxx;"
        username=""
        password=""
        maxActive="20"
        initialSize="0"
        minIdle="0"
        maxIdle="8"
        maxWait="10000"
        timeBetweenEvictionRunsMills="30000"
        minEvictableIdleTimeMillis="60000"
        testWhileIdle="true"
        validationQuery="select current date from sysibm.sysdummy1"
        maxAge="600000"
        rollbackOnReturn="true"
        factory="org.apache.tomcat.dbcp.dbcp.BasicDataSourceFactory"/>
    ...
  </GlobalNamingResources>

2). 配置context.xml。在Context节点下增加节点ResourceLink

<Context>
  <ResourceLink global="jdbc/court" name="jdbc/court" auth="Container" type="javax.sql.DataSource" />
</Context>

3). 配置数据源applicationContext.xml

<bean id="datasource" class="org.springframework.jndi.JndiObjectFactoryBean">
    <property name="jndiName">
        <value>java:comp/env/jdbc/court</value>
    </property>
</bean>

3.2 项目字符编码配置

由于解决问题的项目是10年前的老项目,因此项目的字符编码使用的是GBK。在迁移项目的过程中本地启动项目发现乱码,于是就尝试将xml文件的编码从GBK改为utf8。但是xml文件太多了,没有有效的工具能批量将GBK编码改为utf8编码。

实际上这个解决编码问题的思路方向走错了,如果原来项目使用的是GBK编码,那么迁移过也要使用GBK编码,而不是尝试将原来文本文件的字符编码进行修改。JVM参数指定项目字符编码的参数是:-Dfileencoding=GBK

3.3 DB2数据库迁移

DB2数据库之前没有接触过,迁移一个DB2数据库到新的服务器上也没有什么经验。走了一些弯路,主要问题是迁移表缺失、数据量不匹配。需要注意的问题是:

  1. db2lookup生成的迁移sql在目标db2服务上执行时,如果缺失表空间等元信息可能就会报错;一些sql语句可能在不同配置的环境上执行不了。关键是要验证表是否完整的迁移了,这一步是基础工作,非常重要。
  2. 一些表因为触发器、外键约束等的存在直接使用db2move命令迁移数据可能会不成功,这些表需要做单独的处理。

3.4 第一次听说currentFunctionPath这玩意

之前知道一些数据库的jdbcurl上支持配置一个currentSchema,表示默认查询的模式名,如果在jdbcurl配置上默认的schema,在程序中的sql可以省去查询语句表名前的schema。

迁移的程序启动后报错一个function找不到,但实际上数据库中该函数是存在的,将sql中函数前面加上模式名就可以调用成功。如果要将一个个函数都加上模式名实在太傻太累了,实际上这个方法是走偏了,应该上官网上找一找DB2的jdbcurl支持的参数,还真有currentFunctionPath这样的参数。有时候需要限定问题真正出现的范围,往这个范围查找。不然不从根本上解决问题会带来更多的问题或者非常大的工作量。

4. 总结

  1. 再着急的工作也一定要有个整体的方案,一些基础性的工作(例如数据迁移)是着急不来的,反而越着急,越会出问题,越影响整个进度。做整体方案的时候,那些脑海中一闪而过的担忧要仔细分析,不能心存侥幸心理、或者说后面再说。有些事情前期做成本很小,到了后期再做就会走非常多的弯路。
  2. 对于自己不熟悉的技术,一定要好好看看官网解释,不要随便找到一个网络上的命令就直接用,你们的场景可能是不太一样的,所需要的参数可能也是不同的,最好弄懂每个命令/参数的含义,不能着急,否则做了也会是有问题的。
  3. 问题一定是有原因的,可以暂时不去追根溯源,但要坚信肯定不是玄学。利用自己的知识将原因锁定在一定的范围内,大胆猜测、查资料、排查。
  4. 解决问题不能只解决表象,只解决表象可能还有大量问题等着你,实际上还是没有找到解决问题的途径。稳住参照3揪出来问题的元凶,就舒服了。

更新记录

  • 2022-08-11 18:20 首次提交文章到冯兄话吉
  • 2022-08-12 15:00 微信公众号“冯兄画戟”文章、掘金专栏发表前重读、优化、勘误

tcpdump抓包学习Nginx(反向代理),学完不怵nginx了,还总想跃跃欲试!(Nginx使用、原理完整版手册)

作者 冯兄
2022年2月4日 08:00

0. 前话

俄罗斯年轻程序员Igor Sysoev为了解决所谓C10K problem,也就是以前的Web Server不能支持超10k并发请求的问题,在2002年开启了新的Web Server的开发。

Nginx2004年在2-clause BSD证书下发布于众,根据2021年3月Web Server的调查,Nginx持有35.3%的市场占有率,为4.196亿网站提供服务。

感谢DigitalOcean公司的NGINXConfig项目,提供了很多写好的Nginx模板供下载,这样就可以在不理解Nginx配置的情况下复制粘贴配置Nginx。

这里不是说复制粘贴是不对的,而是如果只复制粘贴并不理解的话,迟早会出问题。所以,你必须理解Nginx的配置,通过学习本文,你能够:

  • 理解工具生成或者别人配置的Nginx。
  • 从0到1配置Web服务器、反向代理服务器和负载均衡服务器。
  • 优化Nginx获取最大性能。

学习本文需要有一定的Linux基础,会执行例如lscat等Linux命令,还需要你对前后端有一定的了解,不过这些对前端或者后端程序员都很容易。

1. Nginx基本介绍

Nginx是一个高性能的Web服务器,着眼于高性能、高并发和低资源消耗。尽管Nginx作为一个Web服务器被大家所熟知,它另外的一个核心功能是反向代理。

Nginx不是市场上唯一的Web服务器,它最大的竞争对手Apache HTTP Server(httpd)在1995年就发布了。人们在选择Nginx作为Web服务器时候,基于下面两点考虑:

  • 支持更高的并发。
  • 用更少的硬件资源提供静态文件服务。

Nginx和Apache谁更好的争论没有意义,如果想了解更多Nginx和Apache的区别可以参考Justin Ellingwood文章

关于Nginx对请求处理的新特点,引用Justin的文章解释如下:

Nginx在Apache之后出现,更多认识到网站业务扩大之后面临的并发性问题,所以从一开始就设计为异步、非阻塞和事件驱动连接处理的算法。

Nginx工作时候会设定worker进程(worker process),每一个worker进程都能够处理数千个连接。worker进程通过fast looping的机制来不断轮询处理事件。将具体处理请求的工作和连接解耦能够让每一个worker进程仅当新的事件触发的时候将其与一个连接关联。

Nginx基本工作原理图:

Nginx之所以能够在低资源消耗的情况下高性能提供静态文件服务,是因为它没有内置动态编程语言处理器。当一个静态文件请求到达后,Nginx就是简单的响应请求文件,并没有做什么额外的处理。

这不是说Nginx不能够整合动态编程语言处理器,它可以将请求任务代理到独立的进程上,例如PHP-FPMNode.js或者Python。一旦第三方进程处理完请求,再将响应代理回客户端,工作如图:

2. 怎么安装nginx

Nginx的安装网上示例很多,这里以Ubuntu为例:

#更新源
sudo apt update && sudo apt upgrade -y

#安装
sudo apt install nginx -y

这种方式安装Nginx成功之后,Nginx会注册为systemd系统服务,查看服务:

sudo systemctl status nginx

#如果没有注册为systemd服务,可以用service查看试下
sudo service nginx status

Nginx的配置文件经常放在/etc/nginx目录中,默认的配置端口是80,如果启动成功,可以访问得到页面:

恭喜!Nginx安装成功了!

3. Nginx配置文件管理

Nginx为静态或者动态文件提供服务,具体怎么样提供服务是由配置文件设置的。

Nginx的配置文件以.conf结尾,常常位于/etc/nginx目录中。访问/etc/nginx目录:

cd /etc/nginx

ls -lh

# drwxr-xr-x 2 root root 4.0K Apr 21  2020 conf.d
# -rw-r--r-- 1 root root 1.1K Feb  4  2019 fastcgi.conf
# -rw-r--r-- 1 root root 1007 Feb  4  2019 fastcgi_params
# -rw-r--r-- 1 root root 2.8K Feb  4  2019 koi-utf
# -rw-r--r-- 1 root root 2.2K Feb  4  2019 koi-win
# -rw-r--r-- 1 root root 3.9K Feb  4  2019 mime.types
# drwxr-xr-x 2 root root 4.0K Apr 21  2020 modules-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 modules-enabled
# -rw-r--r-- 1 root root 1.5K Feb  4  2019 nginx.conf
# -rw-r--r-- 1 root root  180 Feb  4  2019 proxy_params
# -rw-r--r-- 1 root root  636 Feb  4  2019 scgi_params
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-enabled
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 snippets
# -rw-r--r-- 1 root root  664 Feb  4  2019 uwsgi_params
# -rw-r--r-- 1 root root 3.0K Feb  4  2019

该目录中的/etc/nginx/nginx.conf就是Nginx的主配置文件。如果你打开这个配置文件,会发现很多内容,不要害怕,本文就是一点一点的要学会它。

在进行配置文件修改的时候,不建议直接修改/etc/nginx/nginx.conf,可以将之备份之后再修改:

#重命名文件
sudo mv nginx.conf nginx.conf.backup

#新建配置文件
sudo touch nginx.conf

4. Nginx配置为一个基本的Web Server

这一部分,将会从零一步步学习Nginx配置文件的书写,目的是了解Nginx配置文件的基本语法和基本概念。

4.1 写第一个配置文件

vim /etc/nginx/nginx.conf打开配置文件并更新内容:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Bonjour, mon ami!\n";
        #配置重定向
        #return 302 https://www.baidu.com$request_uri;
    }

}

重启Nginx并访问,你会得到如下信息:

curl -i http://127.0.0.1

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 19 Feb 2022 08:31:59 GMT
Content-Type: text/plain
Content-Length: 21
Connection: keep-alive

Bonjour, mon ami!

4.2 校验、重载Nginx配置文件

Nginx的配置文件是否正确可以通过-t参数校验:

sudo nginx -t

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

如果有相关的语法错误,上述命令输出结果会有相关提示。

如果你想改变Nginx的相关状态,例如重启、重载等,可以有三种办法。一是通过-s(signal)参数向Nginx发送信号;二是使用系统服务管理工具systemd或者service等;三是使用kill命令对Linux进程操作。

向Nginx发送信号

nginx信号:nginx -s reload|quit|stop|reopen,分别表示重载配置文件、优雅停止Nginx、无条件停止Nginx和重新打开log文件。

所谓的“优雅停止”Nginx,是指处理完目前的请求再停止;而“无条件停止”Nginx,相当于kill -9,进程直接被杀死。

系统服务管理Nginx

#使用systemctl
sudo systemctl start|restart|stop nginx

#或者使用service
sudo service nginx start|restart|stop

kill命令杀死进程并手动启动

#杀死主进程及各子进程
sudo kill -TERM $MASTER_PID

#指定配置文件启动Nginx
sudo /usr/sbin/nginx -c /etc/nginx/nginx.conf

4.3 理解Nginx配置文件中的Directives和Contexts

Nginx的配置文件虽然看起来只是简单的配置文本,但它是包含语法的。实际上配置文件中的内容都是DirectivesDirectives分为两种:

  • Simple Directives
  • Block Directives

Simple Directives:包含名称和空格,以分号(;)结尾。例如listenreturn等。

Block Directives:包裹在{}中,{}Simple Directives组成,称之为Contexts

Nginx配置中核心的Contexts

  • events{}:总体配置nginx如何处理请求,只能在配置文件中出现一次。
  • http{}:配置nginx如何处理http或者https请求,只能在配置文件中出现一次。
  • server{}:内嵌在http{}中,用来配置一个独立主机上指定的虚拟主机。http{}可以配置多个server{},表示多个虚拟主机。
  • main:上述3个Contexts之外的配置都在该Contex上。

在主机上设置不同的虚拟主机(多个server{}、相同server_name),监听不同的端口(listen不同):

http {
    server {
        listen 80;
        server_name localhost;

        return 200 "hello from port 80!\n";
    }


    server {
        listen 8080;
        server_name localhost;

        return 200 "hello from port 8080!\n";
    }
}

不同的虚拟主机,监听同一个端口(多个server{}、不同server_name),监听同一个端口(listen相同):

这种情况必须用域名,Nginx会将请求头中Host信息取出来和服务端配置server_name做匹配,匹配到哪个就就进入到那个处理块中。

http {
    server {
        listen 8088;
        server_name library.test;

        return 200 "your local library!\n";
    }


    server {
        listen 8088;
        server_name librarian.library.test;

        return 200 "welcome dear librarian!\n";
    }
}

当访问不同的域名时,会返回不同的结果:

curl -i http://library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:02:20 GMT
Content-Type: application/octet-stream
Content-Length: 21
Connection: keep-alive

your local library !

curl -i http://librarian.library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:04:26 GMT
Content-Type: application/octet-stream
Content-Length: 24
Connection: keep-alive

welcome dear librarian!

这样能成功的提前是指定的域名解析到同一个IP,或者在本地的hosts文件中配置好域名进行本地测试:

xx.19.146.188 library.test librarian.library.test

注意,这里return这个Directive后面跟两个参数,一个是状态码,一个是返回的文本信息,文本信息要用引号引起来。

4.4 使用Nginx作为静态文件服务器

更新Nginx配置文件如下:

events {

}

http {

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

这里对Nginx默认的展示页面做了修改,在文件/usr/share/nginx/html/assets/mystyle.css写入p {background: red;}并在html文件中引入该css,这样正常情况段落的背景会变成红色。

访问页面,展示的是index.html,但是段落的背景色没有生效。debug一下css文件:

curl -i http://fengmengzhao.hypc:8088/assets/mystyle.css

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:43:58 GMT
Content-Type: text/plain
Content-Length: 27
Last-Modified: Sun, 20 Feb 2022 08:38:54 GMT
Connection: keep-alive
ETag: "6211fe1e-1b"
Accept-Ranges: bytes

p {
    background: red;
}

注意,这里响应头信息Content-Typetext/plain,而不是text/css。也就是说Nginx将css文件做为一个普通的文本提供服务,而没有当做stylesheet,浏览器自然就不会渲染样式。

本文会在本地hosts文件增加域名解析,所以会在示例中看到对域名请求。在操作本文示例时,要根据自己环境对ip(域名)或者端口做相应修改。

4.5 Nginx中处理静态文件类型解析

实际上这里涉及到Nginx对静态文件类型解析的处理,默认不进行任何设置情况下,Nginx认为文本文件的类型是text/plain

修改配置文件如下:

events {

}

http {

    types {
        text/html html;
        text/css css;
    }

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }
}

重新访问页面,样式正常,mystyle.css文件的responseContent-Typetext/css

这里在http{}中引入了types{},通过文件的后缀映射文件的类型。需要注意,如果没有types{},nginx会认为.html文件的类型是text/html,但是一旦引入types{},nginx只会解析定义的类型映射。所以这里引入types{}后,不能只定义css的类型映射,同样要显式定义html的类型映射,否则nginx会将html解析为普通文本文件。

4.6 Nginx子配置引入

手动在http{}中增加types{}来映射文件类型对于小项目还可以,对大型项目来说手动配置就太繁琐了,Nginx提供了默认的解析映射(常常在/etc/nginx/mime.types文件中),可以通过include语法将子配置引入配置文件中。

修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

重启Nginx,自定义的css文件能够正常展示。

5. Nginx的动态路由

上面的示例非常简单,访问root定义目录下的文件,存在就返回,不存在就返回默认404页面。

接下来学习Nginx的location动态路由用法,包括重定向、重写和try_files Directive。

所谓的动态路径就是用户访问的路径到达Nginx后,Nginx如何匹配访问内容。

Location Matches

修改配置文件,如下:

events {

}

http {

    server {

        #设置默认的Content-Type text/html,否则将以流的方式下载
        default_type text/html;
        #设置字符编码为utf-8,否则页面会乱码
        charset utf-8;

        listen 80;
        server_name localhost;
        #前缀匹配,示例:http://fengmengzhao.hypc:8088/agatha----
        location /agatha {
            return 200 "前缀匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #完全匹配,示例:http://fengmengzhao.hypc:8088/agatha
        location = /agatha {
            return 200 "完全匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,默认大小写敏感,示例:http://fengmengzhao.hypc:8088/agatha01234
        #正则匹配的优先级要高于前缀匹配,低于优先前缀匹配
        location ~ /agatha[0-9]{
            return 200 "正则匹配,大小写敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,大小写不敏感,示例:http://fengmengzhao.hypc:8088/AGatHa01234
        location ~* /agatha[0-9]{
            return 200 "正则匹配,大小写不敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #优先前缀匹配,示例:http://fengmengzhao.hypc:8088/Agatha01234
        #在前缀匹配前加^~即可转化为优先前缀匹配
        location ^~ /Agatha {
            return 200 "优先前缀匹配-Miss Marple.\nHercule Poirot.\n";
        } 
    }
}

匹配规则总结:

匹配 关键字
完全 =
优先前缀 ^~
正则 ~或者~*
前缀 None

如果一个请求满足多个配置的匹配,正则匹配的优先级大于前缀匹配,而优先前缀匹配的优先级大于正则匹配,完全匹配优先级最高。

nginx中的变量(Variables

设置变量:

set $<variable_name> <variable_value>;

# set name "Farhan"
# set age 25
# set is_working true*

变量类型:

  • String
  • Integer
  • Boolean

除了自定义变量外,nginx有内置的变量,参考https://nginx.org/en/docs/varindex.html

例如,如下配置中使用内置变量:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Host - $host\ - $uri\nArgs - $args\n";
    }

}

# curl http://localhost/user?name=Farhan

# Host - localhost
# URI - /user
# Args - name=Farhan

上面使用了$host$uri$args内置变量,分别表示主机名、请求相对路径和请求参数。变量可以作为值赋值给自定义变量,例如:

events {

}

http {

    server {

        listen 80;
        server_name localhost;
        
        set $name $arg_name; # $arg_<query string name>

        return 200 "Name - $name\n";
    }

}

上面出现了$arg_*内置变量,使用$arg_<query string name>可以获取$args变量中指定的query string

重定向(Redirects)和重写(Rewrites

nginx中的重定向和其他平台上见到的重定向一样,response返回3xx的状态码和location头信息。如果是在浏览器中访问,浏览器会自动重新发起location指定的请求,地址栏url也会发生改变。

重定向示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        location = /index_page {
                return 307 https://fengmengzhao.github.io;
        }

        location = /about_page {
                return 307 https://fengmengzhao.github.io/about;
        }
    }
}

#curl -I http://localhost/about_page

HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:47:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 180
Connection: keep-alive
Location: https://fengmengzhao.github.io/about

重写(Rewrites)和重定向不一样,重写内部转发了请求,地址栏不会发生改变。示例如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        rewrite /image /assets/generate.png;
    }
}

#curl -i http://localhost/image

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:56:42 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Accept-Ranges: bytes

Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.

如果在浏览器上访问http://fengmengzhao.hypc:8088/image,即可展示图片。

try_files尝试多个文件

try_files示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files /assets/xxx.jpg /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

示例查找/assets/xxx.jpg文件,如果不存在就查找/not_found路径。

try_files常常和$uri内置变量一起使用:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files $uri /not_found;
        #当访问http://localhost返回404
        #这里表示,当访问$uri文件不存在时,尝试$uri/作为一个目录访问
        #try_files $uri $uri/ /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

6. Nginx的日志

日志位置(常常在/var/log/nginx):

ls -lh /var/log/nginx/

# -rw-r----- 1 www-data adm     0 Apr 25 07:34 access.log
# -rw-r----- 1 www-data adm     0 Apr 25 07:34 error.log

删除日志文件并reopen Nginx:

# delete the old files
sudo rm /var/log/nginx/access.log /var/log/nginx/error.log

# create new files
sudo touch /var/log/nginx/access.log /var/log/nginx/error.log

# reopen the log files
sudo nginx -s reopen

这里如果采用上面删除文件后再创建文件的方法清空日志,就需要nginx -s reopen重载Nginx,否则新的日志文件不会被写入日志,因为Nginx的输出流指向还是之前删除的日志文件。实际上这里想清空日志文件可以采用echo "" > /var/log/nginx/access.log的方法,这样就不用reopen Nginx了。

访问Nginx并查看日志:

curl -I http://localhost

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 08:35:59 GMT
# Content-Type: text/html
# Content-Length: 960
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-3c0"
# Accept-Ranges: bytes

sudo cat /var/log/nginx/access.log 

# 192.168.20.20 - - [25/Apr/2021:08:35:59 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.68.0"

默认情况下,任何访问的日志都会记录在access.log文件中,也可以通过access_log Directive来自定义路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
        
        location / {
            #日志会在默认配置日志文件输出
            return 200 "this will be logged to the default file.\n";
        }
        
        location = /admin {
            #日志会输出在/var/logs/nginx/admin.log文件中
            access_log /var/logs/nginx/admin.log;
            
            return 200 "this will be logged in a separate file.\n";
        }
        
        location = /no_logging {
            #禁止日志输出
            access_log off;
            
            return 200 "this will not be logged.\n";
        }
    }
}

location{}中可以自定义access.log的路径,也可以用access_log off来关闭log输出。

同样,error_log也可以自定义Nginx的error.log路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log;
        #return后面只能跟两个参数,这里是为了让Nginx报错,输出错误日志
        return 200 "..." "...";
    }

}

使用nginx -s reload重载Nginx:

sudo nginx -s reload

# nginx: [emerg] invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

访问错误日志文件,有同样的错误信息:

sudo cat /var/log/nginx/error.log 

# 2021/04/25 08:35:45 [notice] 4169#4169: signal process started
# 2021/04/25 10:03:18 [emerg] 8434#8434: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

Nginx error日志信息是有级别的:

  • debug:能帮忙排查哪里出错了。
  • info:可以了解但是不必要的信息。
  • notice:比info更值得了解的信息,但不知道也没什么。
  • warn:意料之外的事情发生了,哪里出问题了,但还能工作。
  • error:什么失败了的信息。
  • crit:严重问题,急需解决。
  • alert:迫在眉睫。
  • emerg:系统不稳定,十万火急。

默认情况下,Nginx记录所有级别的Error信息,可以通过error_log第二个参数覆写。如果要设置最低级别的日志输出为warn,更新配置文件如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log warn;

        return 200 "..." "...";
    }

}

重载Nginx并查看日志:

cat /var/log/nginx/error.log

# 2021/04/25 11:27:02 [emerg] 12769#12769: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:16

这里可以看到,没有输出之前的[notice]日志了。

7. Nginx作为反向代理服务器

7.1 什么是反向代理?

所谓的反向代理,首先是一种代理,是客户端和服务端之外的第三方。把正向代理(Forward proxy)和反向代理(Reverse proxy)比较起来看就很容易理解。

正向代理一般代理的是客户端,用户(客户端)是知道代理存在(一般是客户端配置的)。客户端对目标服务的请求会经由代理转发并将目标服务响应返回给客户端。常见的VPN代理、浏览器(设置)代理、Git(设置)代理和Fiddler抓包软件等都是正向代理。

本文中所述的“目标服务”、“被代理的上游服务”、“被代理的服务”、“服务端”均指代proxy_pass配置的被代理的服务。“代理服务”、“代理服务的服务端”指代的是Nginx提供的代理服务。

正向代理示意图:

反向代理一般代理的是服务端,客户端直接和代理服务打交道(如果有反向代理的话),而对被代理的服务一无所知。客户端请求到达代理服务之后,代理服务再将请求转发到被代理的服务并将响应返回给客户端。

反向代理示意图:

上面二图,可以理解蓝色背景的服务是相互知晓的。

Nginx作为反向代理时,处在客户端和服务端之间。客户端发送请求到Nginx(反向代理),Nginx将请求发送给服务端。一旦服务端处理完请求,会将结果返回给Nginx,Nginx再将结果返回给客户端。在这整个过程中,客户端并不知道实际上谁处理了请求(真正的处理请求并产生响应,而不是代理)。

7.2 反向代理基本原理

笔者刚接触反向代理的时候,感觉这是一个很神奇的事情。进行简单的配置就能将第三方的网站代理到自己的主机上吗?

实际上,不尽然。有些网站能够将主页代理过来,但功能不能完全使用;有些代理过来样式、图片等加载会出问题。只有理解了个中原理,才能够解释各种各样的情况。

所谓的反向代理就是将客户端发送来的请求转发给实际处理请求服务端(proxy_pass指定的服务端),服务端响应之后,再将响应代理回客户端。

既然是代理,就不仅仅简单的只做转发,在代理收到客户端请求后,准备转发到指定代理服务端之前,会对请求的header信息进行重写,例如重写规则如下(反向代理header重写章节会对规则做详细介绍):

  1. 值为空的header不会进行转发;headerkey中包含有_下划线的不会进行转发。
  2. 默认改写HostConnection两个header,分别为:Host: $proxy_hostConnection: close

如果代理服务器只是转发,还要什么代理?就像生活中的代理一样,会提供增值服务,什么事情都帮你搞定。

反向代理就是将客户端的请求,重写header信息之后,在代理服务的服务端转发请求到被代理服务,被代理服务处理请求将响应返回给代理服务,代理服务进而转发响应回客户端。

代理服务转发的请求是代理服务端重新发起,因此在客户端的浏览器或者Fiddler工具进行网络抓包是抓不到的。要看具体的代理发起网络请求需要用Wireshark工具抓包代理服务器对应的网卡。

别理解复杂了,就是客户端<--->代理服务<--->被代理服务。Nginx的反向代理默认不会改变响应的内容,被代理服务响应页面的绝对引用(/assets/image/abc.jpg)、相对引用(assets/image/abc.jpg)或者图床引用(https://image.com/image/abc.jpg)代理回客户端的时候不会发生改变。这些引用在客户端解析html时候会重新发起请求,如果请求指向了代理服务,会同样进行请求<--->代理服务<--->被代理服务这个流程。

--->表示请求,<---表示响应。

有些时候代理之后之所以情况变得复杂,是因为被代理服务存在重定向或者权鉴的约束产生的,而代理的过程就是请求<--->代理服务<--->被代理服务这么简单,并且不会改变被代理服务的响应内容。

7.3 反向代理基本配置

看一个简单的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 80;
        server_name localhost;

        location / {
                proxy_pass "https://bbs.tianya.cn/";
        }
    }
}

代理后页面如下:

因为是http反向代理了https,运营商竟然还在右下角插入了广告(https://bbs.tianya.cn/不会被插入广告)。

proxy_pass能够简单的将客户端请求转发给第三方服务端并反向代理响应结果返回给客户端。

这只是简单的代理,如果你要反向代理一个接口并且使用WebSocket,那么就要覆写header信息:

#WebSocket需要http/1.1,默认是http/1.0
proxy_http_version 1.1;
#覆写header Upgrade为$http_upgrade的值,该值为Nginx获取客户端请求过来的Upgrade头信息值
proxy_set_header Upgrade $http_upgrade;
#覆写header Connection为'upgrade'
proxy_set_header Connection 'upgrade';

7.4 Nginx反向代理地址匹配规则

客户端发送给Nginx的请求,究竟Nginx会怎样拼接到proxy_pass指定的上游服务呢?Nginx有一定的规则:

  1. 如果proxy_pass代理的上游服务是域名加端口(没有端口时默认端口为80或者443),那么客户端请求的代理路径会直接拼到上游服务地址上。示例,proxy_pass http://redis.cn就只是对域名(和端口)的代理。
  2. 如果proxy_pass代理的上游服务有请求路径,那么客户端请求的代理路径将会是把客户端请求路径裁剪掉匹配路径后再拼到上游服务地址上。示例,proxy_pass http://redis.cn/或者proxy_pass http://redis.cn/commands是有请求路径的代理。

上面1、2分别定义为“情况1”和“情况2”,下面中有引用。

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn;
        }

        #location /redis {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/redis/commands
        #   proxy_pass http://redis.cn;
        #}

        location /redis {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn//commands
            proxy_pass http://redis.cn/;
        }

        location /redis/ {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn/;
        }

        #location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands --> http://redis.cn/commands
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://redis.cn/commands/keys.html
        #   proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commandskeys.html
        #    proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands/keys.html
        #    proxy_pass http://redis.cn/commands/;
        #}

        location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands//keys.html
            proxy_pass http://redis.cn/commands/;
        }

    }
}

总结客户端请求和代理端转发请求的对应关系,如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/ http://redis.cn    
/redis http://redis.cn /redis /redis
/ http://redis.cn/   /
/ http://redis.cn/ / /
/redis http://redis.cn/ /redis /
/redis http://redis.cn/ /redis/commands //commands
/redis/ http://redis.cn/ /redis /
/redis/ http://redis.cn/ /redis/commands /commands
/redis-commands http://redis.cn/commands /redis-commands /commands
/redis-commands http://redis.cn/commands /redis-commands/keys.html /commands/keys.html
/redis-commands/ http://redis.cn/commands /redis-commands /commands
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html
/redis-commands http://redis.cn/commands/ /redis-commands /commands/
/redis-commands http://redis.cn/commands/ /redis-commands/keys.html /commands//keys.html
/redis-commands/ http://redis.cn/commands/ /redis-commands /commands/
/redis-commands/ http://redis.cn/commands/ /redis-commands/keys.html /commands/keys.html

表格中为空表示只有域名+端口的访问,没有请求路径。

代理后的请求在客户端看不到网络请求,可以用tcpdump抓包代理服务所在主机的网卡生成.cap文件,并在Wireshark中查看具体请求。

tcpdump监听命令:

#xx.19.146.188是Nginx代理IP;121.42.46.75是被代理上游服务IP,也就是redis.cn域名的解析IP
#ech0是xx.19.146.188使用的网卡IP
sudo tcpdump -i eth0 tcp port 8088 and host xx.19.146.188 or host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-2.cap

启动后,访问代理服务,数据包经过网卡eth0就会被捕捉到。将nginx-2.cap文件在Wireshark中打开即可查看具体网络包。

以下表请求为demo,抓包获取代理请求。

请求如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html

抓取请求包如图:

7.5 反向代理header重写

Nginx在服务端代理的请求和客户端发的请求不是完全相同的,主要的不同在于请求的header信息,Nginx会对客户端发过来的请求的header进行修改,规则如下:

  1. Nginx删除空值的header。Nginx这样做是因为空值的Header发送服务端也没有意义,当然利用这一点,如果想让代理不发送某个header信息,可以在配置中用proxy_set_header覆写header值为空。
  2. Nginx默认header的名称中如果包含_下划线是无效header。这个行为也可以通过配置文件中设置underscores_in_headers on来开启,否则任何含有_header信息都不会被代理到目标上游服务。
  3. 代理的Host头信息会被覆写为变量$proxy_host,该变量是被代理上游服务的IP(或域名)加端口,其值在proxy_pass中定义。
  4. 代理的Connection头信息会被覆写为”close”,该请求头告诉被代理上游服务,一旦服务端响应代理请求,该连接就会被关闭,不会被持久化(persistent)。

第3点的Host头信息覆写在Nginx的反向代理中是比较重要的,Nginx定义不同的变量代表不同的值:

  • $proxy_host:上面提过了,是默认反向代理覆写的header,其值是proxy_pass定义的上游服务IP和端口。
  • $http_host:是Nginx获取客户端请求的Host头。Nginx使用$http_作为前缀加上客户端header名称的小写,并将-符号用_替换拼接后就代表客户端实际请求的头信息。
  • $Host:常常和$http_host一样,但是会将http_host转化为小写(域名情况)并去除端口。如果http_host不存在或者是空的情况,$host的值等于Nginx配置中server_name的值。

Nginx可以通过proxy_set_header来覆写客户端发送过来请求的header再转发。除了上面说的Host头比较重要,经常用到的header还有:

  • X-Forwarded-Proto:配置值$schema。告诉上游被代理服务,原始的客户端请求是http还是https
  • X-Real-IP:配置值$remote_addr。告诉代理服务客户端的IP地址,辅助代理服务做出某种决定或者日志输出。
  • X-Forwarded-For:配置值$proxy_add_x_forwarded_for。包含请求经过每一次代理的IP。

7.6 反向代理试试,tcpdump抓包解析,探个中究竟

笔者也一直在理解这个Hosthttp请求中的作用,正常当一个http请求发送之后,tcp连接已经指定了IP和端口,那还需要Host头信息做什么呢?

首先,MDN Web DocsHost头的说明:

所有HTTP/1.1 请求报文中必须包含一个Host头字段。对于缺少Host头或者含有超过一个Host头的HTTP/1.1 请求,可能会收到400(Bad Request)状态码。

那Nginx反向代理默认对Host头覆写为$proxy_host的作用是什么,如果改写为其他会怎么样?用tcpdump工具抓包一探究竟。

看示例,反向代理http://redis.cn,配置如下(情况一):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
        }

}

最普通的反向代理设置,没有进行任何header覆写。用tcpdump工具监控网卡:

#先用ping或者nslookup找到redis.cn的IP,这里找到是121.42.46.75
#这里 host 121.42.46.75,代表过滤指定IP的包。不过滤的话包会很多,不太好看
#-c 100 捕捉到100个包,会自动退出并生产文件
#需要将cap文件Wireshark中打开
sudo tcpdump -i eth0 host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-redis-1.cap

这时候访问http://fengmengzhao.hypc:8088/,代理页面很正常:

Nginx服务端的tcpdump包也抓到了:

用Wireshark查看包请求:

修改Nginx配置proxy_set_header Host $http_host(情况二):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
            proxy_set_header Host $http_host;
        }

}

访问http://fengmengzhao.hypc:8088/,代理页面:

这是什么页面?如果直接用redis.cn的IP地址http://121.42.46.75访问,得到同样的页面。为什么?

看看抓到的包情况:

tcpdump抓包来看,该响应是正常从服务端响应的。那为何不同的Host头返回的页面会不同呢?

情况二设置proxy_set_header $http_host之后Nginx代理请求的Host为客户端请求的Host(fengmengzhao.hypc:8088),而情况一的Host为上游被代理服务的Host(redis.cn)。可能在redis.cn该域名对应的主机121.42.46.75不止提供一个80端口的服务。

这种在一个主机上提供多个域名服务(端口相同)称之为虚拟主机。理解Nginx配置文件中的Directives和Contexts章节中提到的Nginx可以设置不同域名同一端口的虚拟主机就可以实现这种情况。另外,Apache也支持配置不同域名的虚拟主机。这两种情况,归根结底都是在请求到达服务端后,服务端会获取请求中的Host头信息并匹配到不同的虚拟服务。

所以,Nginx反向代理中对Host头信息的覆写要看上游被代理服务是否有特殊需要到该信息。如果没有特殊实现上需要,默认的proxy_host就可以;如果是特殊的实现机制,就要小心对待。

这里的特殊需要是例如上面虚拟主机那种情况,Host头信息在HTTP/1.1中是必须带的。

7.7 反向代理处理相对路径问题

基于上面讲解的对反向代理的理解,我们处理一下实际工作中遇到的问题,增加对Nginx反向代理的认识。

假设被代理的上游服务是一个简单的静态页面(http://127.0.0.1:80),页面中引用了两个相同的图片,分别是绝对引用/assets/generate.png和相对引用assets/generate.png。我们进行如下的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }

}

这时候,访问http://fengmengzhao.hypc:8088/static会发现其中绝对引用(/assets/generate.png)的图片加载失败,通过浏览器网络查看,其客户端加载的请求是http://fengmengzhao.hypc:8088/assets/generate.png。该请求在我们的配置中会默认寻找root匹配(一般默认是/usr/share/nginx/html路径),找不到对应的资源。

实际上不管是绝对应用还是相对应用我们想让客户端的请求都是http://fengmengzhao.hypc:8088/static/assets/generate.png,这里可以看到,如果采用上面的代理方式,并且上游服务有绝对路径的引用,就会出现加载异常的情况。示例:

这里我们也可以看出来,Nginx反向代理默认对响应的内容是不会修改的,目标服务中相对路径或者绝对路径的引用反向代理之后返回给客户端的跟直接访问目标服务端响应是一样的。

怎么样解决呢,有如下方案:

1). 如果目标上游服务可以修改,可以将所有的绝对路径的引用改为相对路径引用。一级目录静态文件引用/assets/generate.png要改为./assets/generate.png或者assets/generate.png;二级目录静态文件引用要改为../xxx/assets/generate.png。总之,页面上绝对路径的引用要改为相对路径的引用。

2). 可以将不能正常代理的图片添加代理,如下配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }
        
        location /assets/ {
            proxy_pass http://127.0.0.1/assets/;
        }

}

这样绝对引用http://fengmengzhao.hypc:8088/assets/generate.png就能够代理到http://127.0.0.1/assets/generate.png,就能够正常加载图片了。

3). 放弃子目录的方案,用独立域名就没问题了,配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name static.fengmengzhao.hypc;

        location / {
            proxy_pass http://127.0.0.1/;
        }

}

这样访问http://static.fengmengzhao.hypc:8088就能够成功代理http://127.0.0.1了。

4). Nginx重写目标服务端响应内容

文中强调过多次,Nginx反向代理默认是不会修改目标服务端响应内容的。但Nginx也支持对响应内容进行修改,需要开启Nginx的ngx_http_sub_module

可以通过nginx -V查看是否包含http_sub_module就知道当前Nginx是否有ngx_http_sub_module模块。

开启ngx_http_sub_module模块后,修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            sub_filter 'src="/assets/' 'src="./assets/';
            sub_filter_once off;
            proxy_pass http://127.0.0.1/;
        }
        
}

通过上面的任意方法,可以获取正确的代理响应:

这里要注意一个点,当你的访问路径是http://fengmengzhao.hypc:8088/static(情况一),其响应html中有引用assets/generate.png,对该generate.png的请求路径是:http://fengmengzhao.hypc:8088/assets/gnerate.png。而当你的访问路径是http://fengmengzhao.hypc:8088/static/(情况二),其响应html同样引用assets/generate.png,对图片的请求会变为:http://fengmengzhao.hypc:8088/static/assets/generate.png。情况二访问路径和情况一的区别是URI的最后有没有跟/,如果有/结尾的话,认为当前访问是一个目录,所以其相对引用就从当前地址栏中的路径开始;如果没有/结尾的话,认为当前访问是一个文件,其相对路径就是文件所在的路径,也就是URI往前数有出现/那个层级,在这里就是根目录,所以情况一虽然是相对引用,但是请求路径还是从根目录开始。

8. Nginx作为一个负载均衡服务器

学习完反向代理,就很容易理解基于反向代理做进一步的负载均衡了。

配置示例:

events {

}

http {

    upstream backend_servers {
        server localhost:3001;
        server localhost:3002;
        server localhost:3003;
    }

    server {

        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://backend_servers;
        }
    }
}

upstream{}可以包含多个服务并且作为一个上游服务被引用。

测试负载均衡:

while sleep 0.5; do curl http://localhost; done

# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.

9. 优化Nginx性能

本文介绍三个方面优化Nginx的性能:根据主机参数调优Worker Processes及Worker Connections配置、缓存静态文件和响应数据压缩。

9.1 怎么设置工作进程数(Worker Processes)和工作连接数(Worker Connections

文章开始的时候已经提到过,Nginx会设置Worker进程并在进程间进行切换,能够同时并发处理“成千上万”个请求。可以通过status命令查看Worker进程数:

sudo systemctl status nginx

# ● nginx.service - A high performance web server and a reverse proxy server
#      Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)
#      Active: active (running) since Sun 2021-04-25 08:33:11 UTC; 5h 54min ago
#        Docs: man:nginx(8)
#     Process: 22610 ExecReload=/usr/sbin/nginx -g daemon on; master_process on; -s reload (code=exited, status=0/SUCCESS)
#    Main PID: 3904 (nginx)
#       Tasks: 3 (limit: 1136)
#      Memory: 3.7M
#      CGroup: /system.slice/nginx.service
#              ├─ 3904 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
#              ├─22611 nginx: worker process
#              └─22612 nginx: worker process

#也可以通过ps查看进程
#能够看到master进程是各个Worker进程的父进程
ps -ef | grep nginx

这里可以看到有1个master进程和2个Worker进程。Worker进程数在Nginx中很容易配置:

#一般情况,主机有多少核,就设置Worker进程的个数为多少
worker_processes 2;
#根据主机cpu核心数的不同自动设置Worker进程的个数
#worker_processes auto;

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

假设说主机有4个核心,worker_processes如果配置为4,表示每一个Worker理论上能够利用100%的cpu。worker_processes如果配置为8,表示一个Worker理论上能够利用50%的cpu,意味着当主机cpu满负荷运转时Worker每运行1分钟就需要等待一分钟。所以,worker_processes不是配置的越大越好,数量如果超出主机cpu核心数,就会有时间浪费在操作系统级别对进程的调度。

可以很方便的通过nproc命令查看主机的cpu核心数:

nproc

# 4

worker_processes auto配置会根据主机cpu核心数的不同自动设置Worker进程的个数。如果你的主机只用来运行Nginx,可以这样配置;如果主机上还有其他服务部署,要斟酌合理分配资源。

worker_connections表示一个Worker进程能够处理的最大连接数,该参数跟主机cpu core个数和一个core能打开的文件个数有关(该值可以通过命令ulimit -n查询)。

ulimit -n

# 1024

worker_connections设置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

注意,这里本文中第一次使用到events这个Context。

9.2 怎样缓存静态文件

不管使用Nginx提供什么样的服务,总是有一些静态文件(js或者css等)是不经常发生改变的,可以将它们缓存起来提高Nginx的性能。Nginx对静态文件的缓存配置非常方便:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    include /env/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        #正则匹配,大小写不敏感
        #以.css或者.js或者.jpg结尾的匹配
        location ~* \.(css|js|jpg|png)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            #1M代表一个月
            expires 1M;
        }
    }
}

像之前反向代理设置中的proxy_set_header给可以给代理到后端的请求增加header一样,使用add_header可以给response增加header

Cache-Control头信息设置为public,是在告诉client该请求内容可以被缓存。PragmaCache-Control的old version。

Vary头信息设置为Accept-Encoding,后续详解。

expires directive表示Nginx缓存响应的时间,可以帮助很方便设置响应Expires头信息,其值可以是1M(1 month)、10m/10 minutes或者24h/24 hours等。

Cache-Control告诉客户端,该response在服务端缓存,客户端可以以任意的形式缓存。另外根据Nginx的expires设置的缓存时间,增加Cache-Control: max-age=2592000,这里Cache-Control: max-age代表该response在max-age时间内不会刷新。2592000单位是秒,等于expire设置的1M(一个月,30x24x3600=2592000)。

重启Nginx之后,测试请求的响应信息:

curl -I http://fengmengzhao.hypc:8088/assets/generate.png

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Tue, 01 Mar 2022 05:04:17 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Expires: Thu, 31 Mar 2022 05:04:17 GMT #注意这个时间和下面的比较
Cache-Control: max-age=2592000
Cache-Control: public
Pragma: public
Vary: Accept-Encoding
Accept-Ranges: bytes

这里可以看到,response中已经增加了Cache-Control头信息,说明配置已经生效。至于Nginx服务端有没有缓存响应,可以用tcpdump抓包看一看,这里不再演示。

需要注意的是,如果在浏览器上访问http://fengmengzhao.hypc:8088/assets/generate.png,第一次返回的是200状态码,表示是服务端成功返回。第二次返回的是304状态码,表示浏览器根据第一次response头信息Cache-Control: public的指示,第二次访问的时候,直接使用客户端缓存。也可以通过F12打开控制台,勾选Network --> Disable Cache选项,这样浏览器端就不使用缓存。

9.3 怎样压缩响应(response)

压缩配置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /env/nginx/mime.types;
    #开启gzip,默认只对html进行压缩
    gzip on;
    #不是设置的越大越好,一般设置为1-4
    gzip_comp_level 3;
    #对css和js文件进行压缩
    gzip_types text/css text/javascript;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        
        location ~* \.(css|js|jpg)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            expires 1M;
        }
    }
}

默认nginx会对html文件进行gzip压缩,如果要对其他类型文件压缩,需要设置gzip_types text/css text/javascript;

gzip_comp_level不是设置的越大越好,一般设置为1-4。

服务端设置gzip之后,要想真正的压缩传输到客户端,客户端需要增加header信息"Accept-Encoding: gzip"才能完成服务端到客户端的压缩传输。

客户端请求没有"Accept-Encoding: gzip"的示例:

curl -I http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:30:32 GMT
# Content-Type: text/css
# Content-Length: 46887
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-b727"
# Expires: Tue, 25 May 2021 16:30:32 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Accept-Ranges: bytes

客户端请求设置”Accept-Encoding: gzip”的示例:

curl -I -H "Accept-Encoding: gzip" http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:31:38 GMT
# Content-Type: text/css
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: W/"608529d5-b727"
# Expires: Tue, 25 May 2021 16:31:38 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Content-Encoding: gzip

注意,这里response的header中有Vary: Accept-Encoding信息,该头信息告诉客户端,根据客户端设置的Accept-Encoding头信息的不同,服务端响应会发生变化。

对比压缩前后传输内容的大小:

cd ~
mkdir compression-test && cd compression-test

curl http://localhost/mini.min.css > uncompressed.css

curl -H "Accept-Encoding: gzip" http://localhost/mini.min.css > compressed.css

ls -lh

# -rw-rw-r-- 1 vagrant vagrant 9.1K Apr 25 16:35 compressed.css
# -rw-rw-r-- 1 vagrant vagrant  46K Apr 25 16:35 uncompressed.css

没压缩的版本大小是46k,而压缩后的版本大小是9.1k

10. 理解Nginx整个配置文件

完整nginx配置文件:

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;

	##
	# Logging Settings
	##

	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;

	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

	##
		# Virtual Host Configs
	##

	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;
}


#mail {
#	# See sample authentication script at:
#	# http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
# 
#	# auth_http localhost/auth.php;
#	# pop3_capabilities "TOP" "USER";
#	# imap_capabilities "IMAP4rev1" "UIDPLUS";
# 
#	server {
#		listen     localhost:110;
#		protocol   pop3;
#		proxy      on;
#	}
# 
#	server {
#		listen     localhost:143;
#		protocol   imap;
#		proxy      on;
#	}
#}

上文中已经讲解过的配置,不再做重复说明。

user www-data;设置Nginx进程的用户,这里会涉及到权限问题,如果用户为www-data读取没有权限的目录,就不能正常的提供服务,这时候查看Nginx的error日志,就会报权限相关的错。

pid /run/nginx.pid;设置nginx进程的process id。

include /etc/nginx/modules-enabled/*.conf;设置include指定目录中任何.conf结尾的配置文件。该目录用来加载nginx的动态模块(本文中并没有涉及)。

http{}下,有基本的优化设置,如下:

  • sendfile on;:禁止静态文件buffering。
  • tcp_nopush on;:允许在一个响应包中发送头信息。
  • tcp_nodelay on;:静态文件快传中禁用Nagle’s Algorithm

keepalive_timeout设置http connection的连接时间。types_hash_maxsize设置Hash map的大小。

SSL的配置在本文中不做讲解。

mail Context可以将Nginx配置为一个邮件服务端,本文仅讨论Nginx作为web服务端,所以不做说明。

重点看一下如下配置:

##
# Virtual Host Configs
##

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;

该配置表示Nginx会加载/etc/nginx/conf.d//etc/nginx/sites-enabled/目录内匹配的配置。这样,一般认为这两个目录就是放置Nginx配置的最好的选择,实际上并不是。

有另外一个目录/etc/nginx/sites-available/,该目录用来存放Nginx的虚拟主机(也就是server{}块)配置。/etc/nginx/sites-enabled/目录用来存放符号链接指向目录/etc/nginx/sites-available/中配置。例如:

ln -lh /etc/nginx/sites-enabled/

# lrwxrwxrwx 1 root root 34 Apr 25 08:33 default -> /etc/nginx/sites-available/default

这样通过符号链接的方式可以激活或者禁用/etc/nginx/sites-available/目录中的配置。符号链接unlink`和创建的命令如下:

#删除符号链接,用rm也可以
sudo unlink /etc/nginx/sites-enabled/default

#创建符号链接,第一个参数是被链接的文件,第二个参数是创建符号链接的路径
#也就是,链接某个文件到某个符号链接上
sudo ln -s /etc/nginx/sites-available/nginx-handbook.conf /etc/nginx/sites-enabled/nginx-handbook 

引用

后话

本文大部分内容参考https://www.freecodecamp.org/news/the-nginx-handbook/文章翻译整理,第7. Nginx作为反向代理服务器章重点加入笔者的理解。


本书完

bug现场谜之总不能告诉客户你要按F12(打开控制台)吧?(跨域详解)

作者 冯兄
2022年2月2日 08:00

目录


1. bug现场情况

现场两套系统,集成同一个单点登录。其中一个系统跳转到另外一个系统时浏览器会刷新两次。

奇怪的是打开F12,问题就不能复现。

2. 尝试破案

打开控制台问题就解决了?真是奇怪!可能是控制台打开后,静态文件在浏览器端不再缓存造成的。

打开F12禁止控制台Network --> Disable cache设置,果然问题能够复现,前端js的请求确实是缓存的。

初步判断两次刷新原因:前端js缓存,发送异步权限数据请求接口时没有权限(第一次请求刷新),然后重定向单点登录服务获取service ticket,重新登录后,再次请求权限数据接口(第二次请求刷新),页面成功展示。

笔者对浏览器的行为不熟,这里只是猜测。

笔者系统单点登录实现的CAS接口,所以应用session过期或者失效后需要从新从单点服务处获取service ticket票据。

浏览器刷新两次fiddler抓包如图:

第一次异步请求后,由于没有权限,302重定向访问单点登录服务。这里控制台会提示跨域请求,跨域在跨域详解部分详细介绍。

前端明确说了,不是前端的问题,解决不了。

笔者公司的前端就是硬气。

在后台处理,后台是springboot项目,增加配置:

spring:
  resources:
    cache:
      cachecontrol:
        max-age: 0

前端文件不会缓存,问题解决。

禁用缓存后,不会出现地址栏刷新两次现象,fiddler抓包如图:

问题解决了,笔者对那个跨域的报错产生了兴趣。之前也看过不少跨域的文章,始终对跨域云里雾里。春节找出收藏的跨域文章,好好研读了一下,有所获,赶紧借此文分享出来。

3. 跨域详解

web开发,工作中肯定接触过如下浏览器控制台报错:

No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

请求的跨域资源responseheader中没有Access-Control-Allow-Origin信息。

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://example.com/

浏览器同源策略禁止读取跨域资源。

Access to fetch at ‘https://example.com’ from origin ‘http://localhost:3000’ has been blocked by CORS policy.

在域http://localhost:3000下访问域https://example.com资源被禁止。

了解后面的内容后,就会明白这些报错的真正含义,也就能处理跨域问题了。

3.1 何谓同域(同源)?

  1. 相同协议(http、https)
  2. 相同主机名/ip(127.0.0.1、localhost、google.com)
  3. 相同端口(80、443、8080)

满足三个条件是同源,否则就是不同的域。

注意:localhost主机名虽然在网络层最终会解析为127.0.0.1,但是对于浏览器同源策略来说,localhost127.0.0.1是不同的主机名,二者不同则为跨域。

3.2 何谓跨域(跨域怎么发生?)

1989诞生的World Wide Web最初的html只有纯文本。

世界上第一个web页面,只包含纯文本和超链接。

1993年引入<img>标签,在html渲染时允许加载图片资源,这样纯文本中就可以展示图片。像这样在html中允许加载子资源(subresource)的tag还有:

  • <ifame>
  • <link>
  • <form>
  • <audio>
  • <video>
  • <script>

诚如上面html标签语义,所谓的“子资源(subresource)”就是例如表单、文件、音视频、脚本等外部资源。

当一个域中包含有上面taghtml渲染时,就会加载subresource,当这个subresource和当前域不同源时,跨域请求就发生了。例如,在一个域中xmlhttprequestajax)请求另外一个域的接口时就是跨域请求。

3.3 跨域有什么安全问题?

一个域中加载另一个域的文件、音视频、脚本等subresource时,大部分情况不会产生什么安全问题,但是有一些情况如果不做限制,就存在安全隐患。

例如一个域中提供了基于cookie/session权鉴的发送邮件接口,该域允许任何域对该接口的跨域请求。那么恶意网站有可能在获取有效cookie后任意调用发送邮件接口攻击网站。

可能有同学疑问:发送邮件接口如果需要权鉴认证才能成功调用,别人没有认证信息如何能成功调用呢?

实际上用户在浏览器端完成登录后,用户信息就存储在浏览器端(cookie),这时打开恶意网站就有可能被恶意脚本携带用户信息完成攻击。

3.4 何谓浏览器同源策略(same-origin policy)

既然跨域请求有安全的问题,浏览器端就做了相关限制,称之为“浏览器同源策略”。

同源策略阻止读取跨域请求得到的资源。

这是广义的一个定义,实际上浏览器针对不同subresource有不同的限制策略,下面有做详细说明。

同源策略在1995年网景浏览器2.02中引入,最开始是为了保护跨域DOM而设计的。

跨域请求有三种形式:

  1. 跨域写(Cross-origin writes)
  2. 跨域内嵌(Cross-origin embeds)
  3. 跨域读(Cross-origin reads)

同源策略的规则定义如下:

  • <ifame>:跨域内嵌允许(需要合适的X-Frame-Options)。
  • <link>:跨域内嵌允许(需要合适的Content-Type)。
  • <form>:跨域写允许。
  • <audio>:跨域内嵌允许。
  • <video>:跨域内嵌允许。
  • <script>:跨域内嵌允许,某些api的调用可能会被禁止(例如ajax跨域调用)。
  • <img>:跨域内嵌允许,通过JavaScript跨域读或者在<canvas>中加载被禁止。

3.5 何谓CORS(跨域资源共享)?

浏览器的同源策略能解决很多安全的问题,但是其限制也带来了不便。

CORS(Cross-origin resource sharing)跨域资源共享就是来放宽浏览器同源策略的严格限制,便于某些场景的使用。

同域请求,如图:

跨域请求,如图:

图中涉及到preflight请求下面详解。

3.6 “简单”和“复杂”跨域请求生命历程

这里重点讲述ajax跨域请求(使用浏览器内置fetch()函数)时,其请求过程和解决办法。

一个域中ajax跨域请求另一个域的接口时,该请求的生命历程是由客户端和被请求资源服务端共同决定的。客户端的行为是浏览器同源策略指定的,被请求资源服务端行为由资源提供者具体实现提供。具体来说:

所谓的请求“生命历程”是指:该请求从浏览器发起,到服务端响应,再到浏览器读取响应结果并展示这个过程。

如果是“简单”的ajax跨域请求,那么浏览器会放行该请求,如果服务端没有包含Access-Control-Allow-Originheader信息,则浏览器会限制对请求到资源reponse的读取。

如果是“复杂”的ajax跨域请求,那么浏览器会先自行触发一个preflight请求,根据服务端的相应header信息决定是否放行客户端请求。

这里所谓的“简单”和“复杂”请求是相关规范定义的,一个“复杂”请求要至少满足如下其中一个条件:

  1. GETPOST或者HEAD请求。
  2. 请求头信息包含除AcceptAccept-Language或者Content-Language外的头信息。
  3. 请求Content-Type的值不是application/x-www-form-urlencodedmultipart/form-data或者text/plain

2.中说的头信息不包括浏览器自动给请求加入的header信息,例如origin

接下来用Crystal启动http接口服务,看看不同跨域请求的生命历程:

Crystal安装参考官方文档,脚本basic_greet.cr为:

require "kemal"

port = 4000

get "/" do
  "Hello world!"
end

get "/greet" do
  "Hey!"
end

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  "Hello, #{name}!"
end

post "/greet_str" do |env|
  name = env.params
  "Hello, 成功了!"
end

Kemal.config.port = port
Kemal.run

使用命令sudo crystal run src/basic_greet.cr启动接口服务。

0). 同域下请求

http://xx.22.27.215:4000/greet接口域下发送“简单”的ajax请求,如图:

同域下请求,一切正常,接口能发起成功并且浏览器能读取响应接口数据。

不同浏览器控制台实现方式不大相同(但实现的规范是一样的),这里以FireFox浏览器为测试浏览器。

1). “简单”的post跨域请求

天涯bbs论坛域下发送“简单”的ajax请求,如图:

接口能发起成功。但是,如上图控制台报错,浏览器同源策略禁止读取远端资源,提示CORS header ‘Access-Control-Allow-Origin’ missing,也就是说响应头信息中缺少Access-Control-Allow-Origin信息。

这里之所以是“简单”请求,是因为Content-Typetext/plain,参考上面“复杂”请求规则,不满足任意一个。

这里所以找一个http服务,是因为Crystal接口是http的,如果在https域下调用,浏览器会直接禁止https域下请求http资源。

2). “复杂”的post跨域写入

天涯bbs论坛域下发送“复杂”的ajax请求。

控制台报错如图:

网络抓包如图:

图中1.preflight请求,请求方法为OPTIONS。服务端目前没有实现OPTIONS方法实现,提示404 Not Found

图中2.为真正的POST请求,因为1.preflight请求没有获得同源策略规定的头信息,所以2.的真正POST请求被浏览器级别blocked

注意图中2.OPTIONS请求是浏览器发起的,浏览器会带上一些header信息,比如:originAccess-Control-Reqest-MethodAccess-Control-Reqest-Headers

这种情况下的请求生命历程为:先行的preflight请求404 Not Found(“身先死”),真正的POST请求没有发起成功(“出师未捷”)。也就是所谓的:“出师未捷身先死”。

那,“复杂”的跨域请求preflight要求怎样的实现呢,才能满足浏览器CORS协议的要求呢?

浏览器在发送preflight后会寻找响应中的2个header

  • Access-Control-Allow-MethodsCORS协议允许的请求方法,例如GETPOST等。
  • Access-Control-Allow-HeadersCORS协议允许的请求header,例如Content-Type等。

针对“复杂”请求的生命历程来说,上面2个header必须匹配客户端实际请求信息,否则客户端实际请求可能会被浏览器级别blocked。说白了,服务端允许发送什么样方法的请求、什么样的头信息,客户端才能够成功发送。

preflight的响应信息还可以返回2个header,告诉客户端某些信息:

  • Access-Control-Max-Age:设置preflight请求能够缓存的秒数(默认值是5)。超过设置时间,“复杂”请求发起时浏览器会重新发起preflight;在设置时间内,不再发起preflight请求(使用缓存的preflight请求)。
  • Access-Control-Allow-Credentials:设置客户端实际请求是否能携带用户信息(例如cookie)。

如果服务端没有返回上面2个header信息,不影响请求的生命历程。

也就是说,根据CORS协议,preflight请求响应头信息中要明确返回客户端实际请求的方法(通过响应头信息Access-Control-Allow-Methods值)和头信息(通过响应头信息Access-Control-Allow-Headers值),这样浏览器才会同意发送客户端实际请求。而preflight请求响应头Access-Control-Max-Age可以指定preflight请求缓存的时间,默认就是5秒钟;preflight响应头Access-Control-Allow-Credentials告诉客户端,客户端实际请求能够能携带用户信息,否则不能携带。

这里对客户端实际请求进行了代码块标注,是为了强调该请求避免和preflight请求混为一谈。当一个“复杂”的跨域请求发起的时候,首先,浏览器会发送一个preflight请求,“试探”一下服务端是否允许该跨域请求,如果允许,浏览器才允许该“复杂”请求(也就是这里所谓的客户端实际请求)紧随preflight请求之后发起,否则就会被浏览器blocked

那,按照要求实现下preflight请求吧。

修改basic_greet.cr,增加OPTIONS实现:

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
end

重启接口服务,控制台重新请求,如图:

图中通过Status 200 OK可以看出preflight请求是成功的,但是下面控制台报错:响应头信息中缺少Access-Control-Allow-Origin信息。也就是说,preflight请求是成功了,CORS协议要求必须存在的preflight请求响应头信息也存在,但是由于Access-Control-Allow-Origin头信息的缺失,浏览器同源策略限制读取请求响应内容。

修改basic_greet.cr,响应信息头增加env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
  # ...from https://www.google.com origin.
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
end

重新请求:

preflight请求成功,如图:

控制台还是有客户端实际请求报错,不过这个错误就很熟悉了:

响应头信息中缺少Access-Control-Allow-Origin信息被浏览器禁止读取响应内容。接口中增加响应header信息:

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
  "Hello, #{name}!"
end

重新请求:

“复杂”请求的preflight客户端实际请求都成功了,实现了“复杂”请求的跨域资源共享。

“复杂”请求整个生命历程,概括如图:

4. 总结

  • 看似玄学的问题,其背后都有一定的原因。能不能准确的识别出来取决于对问题涉及的相关知识广度和深度的了解,抱着好奇心多了解多涉猎能增加知识的广度,抱着探索的意志剖析技术点及其源头能深挖知识的深度。
  • 成熟的程序员不是任何知识都懂,而是当遇到问题涉及自己不懂的地方时,能迅速识别出盲区并学习掌握。
  • 跨域总结:由于浏览器跨域请求存在安全隐患,所以浏览器制定了同源策略进行跨域请求等行为的限制(一定程度)。跨域资源共享基于一定的规则放宽了同源策略严格限制,使得不同域之间数据交互更加方便。跨域的问题一旦产生(前端后完全分离项目尤其常见),需要前后端共同努力解决。一个接口是否允许被跨域请求是由服务端接口头信息告诉浏览器的,而客户端请求参数的设置,尤其涉及到cookie信息携带等配置需要客户端了解个中原理才能完成。

引用

更新记录

  • 2022-02-07 18:10 首次提交文章到冯兄话吉
  • 2022-02-08 18:27 微信公众号“冯兄画戟”文章发表前重读、优化、勘误。
  • 2022-02-09 12:40 掘金专栏发表前重读、优化、勘误。

相关文章推荐

程序员春节想“弯道超车”?冯兄支招另辟蹊径,或大有可为!

作者 冯兄
2022年1月22日 08:00

目录


1. 终止“循环”,新的“开端”

一年一度的农历春节来了,一两周的假期总算可以全身心放松下来陪一陪家人,放空一下自我。

不知道是不是90后都那样,但至少有一小波那么群人(点名“宇宙尽头是考公”的“考公人”和“我秃了,也变强了”的程序员),他们“休息”会有一种“罪恶感”,一闲下来焦虑感就挥之不去。现在这个状态是不是在浪费时间?是不是不该“玩”?是不是该去学习的?是不是要做套卷子?是不是要逛一逛技术论坛?是不是要把技术视频看完?……

程序员是一个勤奋的群体,或者说是一个勤劳的群体会更贴切。工作中受客户、同事和领导的气,多少次暗暗发誓要做出改变,春节这个弯道超车的机会这群人不会错过。

可是一两周时间能做出什么改变?研究一下新的技术?读一本技术书籍?看一个系列视频教程?这些都能够帮助成长,但这些完成后新年去上班发现还是跳入去年的“循环”,受气!发誓!再受气!再发誓!跳不出这个恶性的圈子。

平时就“卷”不过同事,如何能实现“弯道超车”跳出“循环”呢?

实际上这里说的“弯道超车”要看和谁比,如果和没有“卷”过的同事比,那来年去十有八九还是“卷”不过,终止不了“循环”。

冯兄这里说的“弯道超车”更多的是和自己比,今天比较昨天自己是否有进步?这一季度比较上一季度自己是否有进步?今年比较去年自己是否有进步?如果有,那也不能说是“弯道超车”了,顶多算是没有原地踏步而已。

那怎样才算“弯道超车”呢?如何在这一两周的时间内实现“愿旧年,胜新年”的蜕变呢?

冯兄科目二考试两次有没有通过,教练给的新年寄语:“愿新年,胜旧年。”

先来听一个故事吧。

2. 另辟蹊径,可“下见小潭”

2.1 别人挖矿我挖井

中学时代经常会看一些课外读物,比如读者、意林、故事汇等杂志。有一些故事第一次看“惊为天人”,留下深刻的印象。

其中一个故事,在记忆的角落中很显眼:

20世纪50年代传言阿塔卡马沙漠下面埋藏着丰富的铜矿和铝矿甚至还有金矿,周边国家的人都拼命往这片沙漠里涌去,想要找到矿区获取财富。十八岁的智利小伙子巴特拉·切格莱特就是其中之一:

每天,巴特拉和人们一起寻找矿区的所在,不过每天都无功而返,付出巨大的体力还是小问题,最大的问题是没水喝,所以人们不得不隔几天就跑到沙漠边的镇上去取水。那天,巴特拉也和别人一样从沙漠边的小镇上取水,回到沙漠后,他看着眼前密密麻麻的寻矿人,突然心里产生了一丝怀疑:这么多人挖矿,到底有谁能挖到?更何况就算是挖到了,也很难说清究竟是谁挖到了,到时候因为相互抢夺而闹出惨剧都有可能,矿能不能挖到不一定,但是有一样东西却是每个人都需要的,那就是饮用水。既然这样,那么大家在沙漠里挖矿,我为什么不在沙漠里挖井呢?

后来小伙子放弃挖矿开始组织人挖井:

十年后,人们终于从这里挖到了铜矿和铝矿甚至是金矿,虽然确实有少数人得到了一些财富,但那时候的巴特拉却早已经是拥有亿万财富的“水富翁”了。

完整的故事内容请查看:沙漠里的水富翁

故事是否是杜撰的没必要去深究,但当时中学生身份看到这个故事还是大受震撼,别人挖矿我挖井,不和别人一起“卷”,也就多了一条通向成功的“小路”。

2.2 你是否在“卷”?

“卷”是近几年比较流行的词,但也有点滥用了,很多人一说到什么事情就说“太卷了,要躺平”,实际上并没有理解“卷”和良性竞争的关系。

冯兄认为一件事情是“卷”还是良性竞争,针对某个人来定义才有意义。

高考百万大军过独木桥,班级的第一、二名拼命学习把对方比下去,高考对于他们来说这不叫“卷”。而文化课成绩不好但明明有体育天赋的特长生不走艺术生这条道路,和班级里第一、二名拼成绩,正常高考对于有体育天赋这孩子来说就是“卷”。

当你参与到一件事情当中,判断是“卷”在其中还是在针尖对麦芒力争上游,要看你的初心,你的特长,你对这件事情的定位。

如果你很明确做这件事想要得到什么,初心在这里,有自信能做好,那么加油吧少年,你的付出一定会有所收获的。但是如果你人云亦云,亦步亦趋,盲目跟潮流做这件事,即使再努力也很难成功,甚至会越使劲,越“卷”在其中,越身心疲惫,到头来也是竹篮打水,收获寥寥。

诚然,随着人生经历的推进,一个人可自由自配的时间就越来越少。象牙塔内,莘莘学子有大把的时间,选择多一份努力和勇敢,可能收获就是巨大的明显的。到了社会中,大部分人都是被社会潮流裹挟着前进,自己独立可支配的时间寥寥无几,这时候的努力和勇敢可能不像学生时代那样一定能有什么收获,更多的是疲惫、困顿和迷茫。

但是,不管是在象牙塔内还是在社会的裹挟中,不管是学业蒸蒸日上亦或事业爱情一塌糊涂,能够清晰判定“我是被卷在当下还是激流勇进正当时”是非常重要的。

故事的主人公另辟蹊径“别人挖矿我挖井”,取得了成功,是聪明的人。挖矿者中少数人得到了一些财富,这波少数人肯定是激流勇进的人。其中还有一部分人具有坚定的信念、充足的准备和不懈的努力,但是最终失败了,这部分人也是值得尊重的。剩下那些“随大流”、碰运气和没有明确规划的挖矿者,他们是被“卷”在其中的人,也注定是不会成功。

被“卷”就意味着将来的“炮灰”。

那么,你是不是被“卷”呢?这是没有通用答案的问题,只有个人分析自己当前的处境(例如:你是否目标明确,是否充满激情,是否意志坚定,是否付出了很多辛苦,这些辛苦是否让你一步步成长或者给予了你正向的价值反馈等)才能够得出结论。

2.3 碎片化时间

那,冯兄给支招今年春节不被“卷”,另辟蹊径进入一片新的天地。

利用碎片化时间创作,由被动接收知识的输入者,变为主动分享知识的输出者。

“创作”这个词可能很吓人,认为只有专家写书才称之为“创作”,实际上,只要输出对多人有价值的内容就是创作,可以是一篇博文、一个短视频、一本教程小册等。

程序员也只有开始了创作才开始了积累和沉淀,才开始走向成熟。

为什么要强调碎片化时间呢?

一是因为工作后本职的工作就会压的人喘不过气,根本没有时间让你个人学习或者创作,你必须要挤时间。

二是因为碎片化时间凑在一起确实比你想象的多,也能创造出超乎你的想象价值。

哪些碎片化的时间呢?

地铁上、I/O阻塞时(工作中等待时)、休息间歇、问题发生时(随手记录)、灵感突来时(突然想起来好主意)等等。

3. 创作分享能带来什么?

能帮助创作者梳理知识体系,不断拓宽知识广度。创作者会思考我都懂哪些知识,这些知识点体系结构是什么样的,哪些分享出去对别人有价值。

能帮助创作者不断探索、学习,不断增进知识深度。创作者要分享一个主题,首先对自己分享的内容肯定要搞明白,进一步尝试把别人讲明白。这本身是一个很好的学透一个知识点方法。

能帮助创作者积累、沉淀和复用,这是一笔财富。如果创作者把一个知识点搞透了,下次同样的问题就不用重新研究了,甚至可以直接拿来用。如果这次的问题更加深入,研究之后就可以创作一篇该主题的高级篇了,再下次遇到问题就是站在自己“巨人的肩膀”上了。

能够帮助其他人成长。赠人玫瑰,手有余香,岂不知程序员在编程的时候都是在“Google这段代码该如何写”,找到的答案都是千千万万个程序员分享出来的。

可以看出来,创作者创作一个有价值的内容,其对别人的帮助要远远小于自己的成长。

很多人可能有疑问,碎片化时间拿来逛逛技术社区、刷一刷视频教程还可以,用来创作不行吧?看一看冯兄的实践经验吧。

4. 冯兄创作从0到1

冯兄话吉这个博客冯兄大概从2015年最开始接触编程的时候就创建了,那时候就有意识将自己学习的知识记录下来,例如学习gitlinux的文章,现在还时常能够用到。

工作后,也会隔三差五的总结输出一些文章,但是都没有认真的当创作来对待。2021年已经是冯兄工作的第五个年头了,阳历年末对自己的程序员生涯做了个总结,也是平庸程序员的一个写照。冯兄希望自己2022年有一个新的“开端”,终止没有什么成长的2021年那样的“循环”。

怎么行动呢?第一件事就是要认真对待“创作”,希望能够对已有的知识体系做一个梳理,总结创作出一些有价值的内容。

4.1 创作之本

所谓创作之本,指的是愿景。

冯兄希望“用通俗易懂的文字将计算机的一些基础内容讲透彻,帮助初学者和计算机学习迷茫的人”。

4.2 创作之标

所谓创作之标,指的是标准。

冯兄给自己定如下标准:

  1. 一篇技术文章必须自己搞懂相关技术再创作发表,要保证言之有物,表之有据。
  2. 文章要“三番五次”校验勘误,杜绝逻辑错误、错别字等。目前文章发表三处:冯兄话吉博客、“冯兄画戟”微信公众号和冯兄画戟掘金社区,按照时间顺序先后发表在三个平台上,发表前都会对全文进行校验、勘误和优化(这些都是碎片化时间完成的)。
  3. 网友对文章的讨论、疑问,不管是否能帮忙解答,都要做出回应。

4.3 创作之术

所谓的创作之术,指的是工具。

使用的工具有:

  1. Windows平台:gvim坚果云WSL2git
  2. Android平台:Markor易码(EasyMarkdown)罗技k380键盘
  3. ipad:iVim坚果云向日葵罗技k380键盘

工作时可以使用Windows平台;坐地铁、等公交时可以使用Android平台;家中不想拿出电脑或者临时处理一些问题可以使用ipad。

关键之处是这三个平台可以通过坚果云进行同步,随时随地都能满足你创作的需求。

接下来详细讲述一下冯兄在这三个平台是是如何创作及打通它们的:

Windows平台

程序员一般都是用Markdown写文章,Win平台上优秀的工具有很多,例如Typora,冯兄使用的gvim纯文本编辑器。

冯兄话吉博客有一个包含.md文档的_posts本地目录,把这个目录链接到坚果云上,这样本地上传、修改文档都能够即时同步到坚果云上,如图:

微信公众号不支持Markdown语法,可以参考markdown-weixin将Markdown文本内容转化为公众号文章。

冯兄把Gitee仓库作为图床使用,需要先将冯兄话吉博客Github仓库同步到Gitee平台上(Gitee页面支持从Github导入仓库),其他平台上发表文章前用shell脚本将文章内图片引用改为Gitee超链接引用,如图:

shell脚本的执行是在Win本地安装的WSL2上进行的,Win上先提交到Github远程仓库,然后在WSL2实例上拉取下来。想了解更多WSL2的内容,可以参考:Windows10 WSL2体验如此丝滑(Windows上使用完整服务的Linux)

这样在Windows平台上创作的文章可以发表在Github博客上;将Markdown中图片引用转为Gitee引用后,可以直接粘贴发表在掘金社区上;通过markdown-weixin转换工具发表在微信公众号上。

Android平台

Android平台和Windows平台上.md文档的同步需要用坚果云支持的WebDAV协议。易码APP支持通过WebDAV协议连接坚果云,但是易码的Markdown文档编辑功能不太好用,冯兄使用易码只是作为WebDAV的一个客户端,文档编辑使用Markor APP,但是Markor不支持WebDAV协议,要想易码同步坚果云的文档也同步到Markor上要借住手机的内部存储。具体做法是:

1). 在易码上增加一个新的笔记库指向手机内部存储/EasyMarkdown。操作如下:

2). Markor上设置工作目录为手机内部存储/EasyMarkdown,操作如下:

这样,当在Windows平台上创建或者更新文档后,更新内容会同步到坚果云,手机端易码通过WebDAV同步坚果云文档。需要在手机端修改文档时,在易码上将坚果云笔记库对应文档移动到自定义手机内部存储的笔记库中,实际上就是将坚果云远端文档下载到手机本地内部存储目录,这样打开Markor读取同样手机本地内部存储目录,就能够显示并编辑文档。

手机端编辑文档结束想同步回Windows平台,同样需要在易码中移动自定义手机内部存储的笔记库对应文档到坚果云笔记库。移动完成后,文档通过坚果云自动同步能够同步到Windows平台。

易码上操作移动文档到不同笔记库的操作如图:

冯兄为ipad买了一个k380蓝牙键盘,也可以连手机,家中如果嫌手机打字不方便,可以连上蓝牙键盘,只要思路顺,也能写到起飞。

ipad

发现了Android平台上方便的方法后,ipad上就不常用来编辑文档了,主要是用向日葵远程Windows平台来提交git、文章发表,图片处理等操作。

冯兄在ipad上使用iVim编辑文档,需要有一定的vim基础,

其他平台文档同步到ipad也是通过坚果云,打开ipad坚果云客户端将文章发送到iVim即可开始编辑,编辑完成后在vim命令模式下输入:ishare可选择保存到坚果云目录中,即完成同步。

iVim要进行一个重要的配置是:快捷键增加/减小字体,编辑文档时将字体适当调大,再配上蓝牙键盘就能丝滑的书写了。

iVim.vimrc中增加配置:

#增加或者减小字体大小
#同时按住ctrl键和数字0可以增加字体的大小
nnoremap <c-0> :ifont +<cr>
#同时按住ctrl键和数字9可以减小字体的大小
nnoremap <c-9> :ifont -<cr>

更多iVim配置可以参考:iVim vimrc配置

2022年,作为一个程序员,如果还在困顿、迷茫,不知道如何成长,那么就开始处女创作吧,成长就从创作开始,在那里,别有洞天!

最后,祝愿程序员群体:2021终止不好的“循环”,2022启动美好的“开端”;2022年“冯兄发髻”国运高照吃面上岸!

更新记录

  • 2022-01-28 16:32 手机端编辑
  • 2022-01-29 14:50 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-30 10:27 掘金专栏发表前重读、优化、勘误

相关文章推荐

bug现场谜之困在“init”方法上的那些时间!

作者 冯兄
2022年1月16日 08:00

目录


1. bug现场情况

现场将在Tomcat 8.5中运行的war包迁移到jetty 9.4.19上,启动容器后报错:

org.springframework.context.ApplicationContextException: Failed to start bean 'stompWebSocketHandlerMapping'; nested exception is java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:176) ~[spring-context-4.2.3.RELEASE.jar:4.2.3.RELEASE]
    ...
    ...

NoSuchMethodError应该是看到后最有头绪一个错误了:“在加载到JVM的对应类中找不到当前调用的方法”。

如果编译环境中对应类没有对应的方法,是不能编译成功的(集成开发环境会报错)。如果编译成功后部署时候报错NoSuchMethodError,说明运行时和编译时依赖的类不一致。

这里说的“编译时依赖”指的是:构建工具在编译时CLASSPATH中依赖的class;“运行时依赖”指的是:JVM实例运行时加载到JVM中的class。对于同一个class loader,只会成功加载一次class

上面的异常堆栈显示:类org.eclipse.jetty.websocket.server.WebSocketServerFactory没有构造方法WebSocketServerFactory(javax.servlet.ServletContext)。那就看一看运行时依赖的类有没有对应的构造方法吧。

现场的情况下,只能用javap命令,但是首先你要找到这个类是从哪个jar包加载的,如何根据类找到加载的jar包路径在接下来的尝试破案做进一步说明。

javap -cp lib/websocket/websocket-server-9.4.41.v20210516.jar org.eclipse.jetty.websocket.server.WebSocketServerFactory

注意这里如果使用 javap -cp lib/websocket/* xxxx这样指定classpath*配置的方式无效,但是对于javac命令是有效的。

这不是存在WebSocketServerFactory(ServletContext context)构造方法吗???

后来笔者又尝试了多种途径确认这个构造方法是存在的,但是却报错NoSuchMethodError,网上一大堆找“java.lang.nosuchmethoderror but method exists”,无果。因为网上说的最后都证明确实没有对应的方法。

但本案发现场的情况是它有啊!现场变得诡异起来了!难道笔者找到了一个超级bug?直觉告诉我100%不会,一定是自己哪块错了。

2. 尝试破案

回顾一下案发现场的情况,报错java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V,可是通过javap工具反编译明明有构造方法WebSocketServerFactory(javax.servlet.ServletContext)啊!

一般NoSuchMethodError异常有两种情况:

  1. classpth中该方法的类在多个jar包中,而JVM加载的jar包的那个类没有该方法。
  2. 只有一个jar包,jar包中的类没有该方法。

这两种情况归根结底是JVM运行时加载的类中确实缺失了方法。但是上面遇到的问题查找加载类是存在报错的构造方法的。

如果JVMclasspth中有多个包存在同一个class,到底JVM会加载哪个包中的class是平台相关的(Linux系统和Windows系统上可能加载的不是同一个jar包)。需要注意:JVMclasspth下的jar包中load对应的class文件,这跟jar包的命名没有关系。

可以通过以下方法根据报错信息定位加载的jar包:

1). JVM使用参数-verbose:class,这个参数能够输出加载classjar包绝对路径。

2). 使用java代码:

Class<?> clazz = null;
try {
     clazz = Class.forName("org.eclipse.jetty.websocket.server.WebSocketServerFactory");
} catch (ClassNotFoundException e) {
     e.printStackTrace();
}
CodeSource cs = clazz.getProtectionDomain().getCodeSource();
String location = cs.getLocation().getPath();
System.out.println(location);

3). 使用linux命令:

for file in *.jar; do
  echo $file;
  jar tvf $file |grep WebSocketServerFactory
done

在允许重启系统或者启动的JVM中设置了--verbose:class参数的话“1)”方法是最方便的,可以直接在日志中查找对用的类。

不允许重启JVM的话可以采用方法“2)”,但是要指定正确的classpath,否则加载不到对应的类。查找classpath可以从jvm对应的进程中查找。

对于springboot框架打成的jar包,一般依赖都打进在jar包中了;对于severlet容器使用的war包,依赖除了WEB-INF/lib外还包括容器安装目录下的lib包;对于普通的jar包,依赖可能定义在了MANIFEST元文件中(更多关于MANIFEST内容可以参考:https://fengmengzhao.github.io/2021/12/18/bug-scene-of-old-jar-classpath-mystery.html

如果想查找指定目录的哪个jar包含有某个class,可以使用“3)”方法,列出jar中包含的文件清单并查找匹配。

为什么要费劲找到报错类是从哪个jar包中加载的呢?一来jar包一般能提供版本相关的信息;二来javap命令是需要指定jar包作为classpath才能成功反编译。

使用javap命令反编译,语法如下:

#这种方式是指定类信息和类所在的jar包为classpath反编译
javap [-verbose] -cp /some/path/to/lib/xxx.jar com.xx.SomeClass

#这种方式是将class文件从jar中解压,直接反编译class文件
mkdir dir
cd dir
jar xvf ../SomeClass-belong-to.jar
javap [-verbose] com/xx/SomeClass.class

javap-verbose参数展示class文件的详细编译信息,如果只想判断是是否有某个方法,可以不加-verbose参数。

通过上面的方法确认本示例的情况:明明方法存在啊,为什么NoSuchMethodError,百思不得其解!

3. 真相浮出水面

怎么办呢?问题总是要解决的。

在开发环境上准备调试代码,突然意识到报错中的init是不是一个普通方法啊?

赶快看看反编译的代码发现确实没有init普通方法,只有init构造方法。问题就出在这里,查了一下发现jetty9.3升级到9.4的时候对WebSocketServerFactoryinit普通方法改为构造方法

这是笔者的一个知识误区,以为WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V是一个构造方法,实际上如果是构造方法报错是长这样的:

10:24:09.590 [main] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.NoSuchMethodError: org.springframework.boot.builder.SpringApplicationBuilder.<init>([Ljava/lang/Object;)V

普通方法和构造方法实际上就是.init().<init>()的区别。

这里报错中.<init>([Ljava/lang/Object;)V.表示是一个方法的调用;<init>表示构造方法的调用;[表示一个数组;Ljava/lang/Object;表示java.lang.object对象;V表示返回类型是void。实际上就是SpringApplicationBuilder(java.lang.Object...)的构造方法,方法的参数是java.lang.Object数组。这种写法和class文件的内部表示是一致的。jvm更多内部实现 类型表示参考:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html

对于一个程序员来说,异常的的堆栈信息是司空见惯的,也就懒得深究其中的一些玄机,果然“报应不爽”!出来混,迟早要还的。

4. 总结

  1. 很多看似玄学的bug解释不了,最后原因总是归结为“知识的盲区”。很多知识不必懂的很深入,但是基本的东西要了解,此时“不求甚解”,彼时“这是玄学?”。
  2. 有些时候会无意识的想当然一些结论(比如本示例中.init()方法自然认为是构造方法)。没办法十分敲定的东西,要多查一查,多一份思路。
  3. 排查问题,针对一个思路要充满信心,即使这个思路不能解决问题,至少也要能得出这条思路的结论。不能急躁、粗心、盲目尝试。思路窄了,就停下来,明天再尝试,避免进入死胡同。

更新记录

  • 2022-01-24 16:10 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-26 15:20 掘金专栏发表前重读、优化、勘误

学习达梦数据库

作者 冯兄
2022年1月13日 08:00

目录


1. 用户创建及权限相关

1.1 用户创建并赋予权限

#创建新的用户(禁用超级管理员账号)
create user <用户名> identified by <PWD>
#分配权限
grant resource,public,vti to <用户名>;
#收回权限
revoke "RESOURCE" from <用户名>

1.2 查看用户及权限

#查看用户
select * form dba_users;

#DBA 拥有构建数据库的全部特权,只有 DBA 才可以创建数据库结构 
#RESOURCE 可以创建和删除角色 
#PUBLIC 只能查询相关的数据字典表 
#VTI 具有系统动态视图的查询权限,VTI 默认授权给 DBA 且可转授 
#SOI 具有系统表的查询权限 
select * from session_roles # 查看用户权限

1.3 用户、角色、资源权限理解

达梦数据库的权限分为两类:数据库权限(系统权限)和对象权限。实际上这二者的划分是根据对数据库对象的操作不同而区别的,对数据库对象的增(create)、删(drop)、改(alter)和备份(backup)的权限称之为数据库权限(系统权限),而对数据库对象的访问权限称之为对象权限

注意,这里的“对数据库对象的访问”不仅仅指代查询,而是对数据库对象内容的“增删改查”等。例如,对于表和视图数据库对象来说,所谓的对象权限包括:SELECTINSERTDELETEUPDATEREFERENCESSELECT FOR DUMP

达梦数据库每创建一个用户就默认创建一个和该用户同名的模式,默认情况下,该用户对自己默认同名模式内对象资源有对象权限,而数据库权限为空。也就是说,默认普通用户只能对自己模式内对象数据进行增删改查等,不能进行新建表、删除表、新建视图等操作。

要想让普通用户具有数据库权限需显式授权,可以使用SYSDBA或者有授权权限的用户对普通用户授权数据库权限。比如可以授予普通用户create schema权限,这样普通的用户就能够创建schema,但是shema的归属(或者说授权)只能给自己。

授权的方式可以使用grant语句或者使用达梦manager客户端图形化操作。

达梦数据库内置了5个重要角色,一个普通用户默认的角色是PUBLICSOI。实际上为了让普通用户能够在所属的schema下具有部分数据库权限,可以再赋予VTIRESOURCE角色。这些角色的权限如下:

  • DBA:DM 数据库系统中对象与数据操作的最高权限集合,拥有构建数据库的全部特权,只有 DBA 才可以创建数据库结构。
  • RESOURCE:可以创建数据库对象,对有权限的数据库对象进行数据操纵,不可以创建数据库结构。
  • PUBLIC:不可以创建数据库对象,只能对有权限的数据库对象进行数据操纵。
  • VTI:具有系统动态视图的查询权限,VTI 默认授权给 DBA 且可转授。例如v$ciphers的查询权限。
  • SOI:具有系统表的查询权限。

在使用manager达梦管理工具图形化新建表的时候,命名有create table权限却会报错没有v$ciphers系统视图或者系统对象(例如表)的查询权限,这时候要赋予用户VTISOI角色。

达梦数据库没有属主的概念(和PostgreSQL不同),只有所属schema,而schema有属主(授权用户)的概念。

PostgreSQL表和schema都有属主的概念。

2. 用户密码相关

2.1 密码修改

#修改用户密码。
alter user <用户名> identified by <PWD>;    

2.2 密码策略修改

达梦数据库密码策略:

  • 0 无策略
  • 1 禁止与用户名相同
  • 2 口令长度不小于 9 l
  • 4 至少包含一个大写字母(A-Z) l
  • 8 至少包含一个数字(0-9) l
  • 16 至少包含一个标点符号(英文输入法状态下,除“和空格外的所有符号)

口令策略可单独应用,也可组合应用。组合应用时,如需要应用策略1 和 4,则设置口令策略为 1+4=5 即可。

修改密码策略:

#设置用户口令策略。策略为长度至少为8位,包含数字、字母和特殊字符。
#对已有账户修改密码策略
alter user <用户名> PASSWORD_POLICY 31;

#设置系统密码策略
SP_SET_PARA_VALUE(1, ‘PWD_POLICY’, 31);

3. 修改用户资源限制

#语法是:alter user <用户名> limit KEY VALUE,VALUE可以是unlimited,表示无限制
alter user <用户名> limit session_per_user <用户允许的session数>, connection_idle_time <连接空闲时间>, password_life_time <密码有效时长>, password_reuse_time <reuse时间>, password_reuse_max <reuse最大时间>, connect_time <连接超时时间>, cpu_per_session <数值>, cpu_per_call <数值>, read_per_call <数值> mem_space <数值>

#设置用户口令的有效时长。单位:天,unlimited设置无限制
alter user <用户名> limit password_life_time 90;
#设置用户口令过期后可使用天数。过期后,禁止执行除修改口令外的其他操作。
alter user <用户名> limit password_grace_time 10;
#设置密码连接错误最大次数
alter user <用户名> limit failed_login_attemps 5;
#设置连续错误后锁定时间;单位:分钟
alter user <用户名> limit password_lock_time 5;
#设置用户最大空闲时间
alter user <用户名> limit connect_idle_time 30;

#限制TEST账号只允许通过指定网段IP访问数据库:
ALTER USER TEST ALLOW_IP "10.201.34.*","192.168.*.*";
#解除IP访问限制
ALTER USER TEST ALLOW_IP null ;

#用户解除锁定
alter user TEST account unlock;

4. 审计相关

#开启审计开关
# 登录SYSAUDITOR用户或者其他拥有审计权限用户
SP_SET_ENABLE_AUDIT(1); 
#开启覆盖所有用户的语句级审计日志,中间一个参数是用户
#语句级别的审计只针对用户
SP_AUDIT_STMT(‘ALL’,’SYSDBA’,’ALL’);
#设置单个审计文件的大小
#设置单个审计文件大小为1G
sp_set_para_value(1,‘AUDIT_MAX_FILE_SIZE’,1024);

#查看审计日志
select * from V@AUDITRECORDS where USERNAME = 'SYSDBA' order by OPTIME DESC;
#或者使用达梦自带日志分析工具analyze,图形化界面展示日志

5. 版本和license

#查看安装包相关信息,例如:2-2-18-21.08.10-xxx-xxx-SEC SPE Pack13
select id_code;

#查看DM版本
select * from v$version;

#查看license信息
select * form v$license;

6. 问题记录

6.1 达梦数据库获取一个表所有字段的拼接串

达梦数据库想要获取一张表的所有字段并且用’,’将各个字段按照顺序拼接起来,形成例如’select a, b, c, d from t_xxxx’这样的形式。

达梦的表all_tab_columns存储有各个表以及对应的字段,可以通过函数wm_concat将行转列。具体sql如下:

/opt/dmdbms/bin/disql user_name/'"passwd"'@xx.xx.xx.xx:xx -e "select wm_concat(column_name) from all_tab_columns where owner='TYYW2_LCBA_DATA' and table_name = '$line'" | tail -n 1

但是有一个问题,如果行的个数过多,查询出来的结果会被截取,这样拼接出来的结果就可能不完整。有可能是达梦的一个bug。通过shell找折中的办法解决:

#tail -n +10  --> 从第10行开始到
#tr -s '\n' '\n' --> 删除空行
#tr '\n' ',' --> 换行符替换为',',也就是就每一行用','连起来
/opt/dmdbms/bin/disql user_name/'"passwd"'@ip:port -c "set heading off" -e "select wm_concat(column_name) from all_tab_columns where owner='TYYW2_LCBA_DATA' and table_name = '$line'" | tail -n +10|tr -s '\n' '\n'|tr '\n' ','

6.2 达梦数据迁移整个数据目录并重新启动数据库

从一台服务器迁移达梦数据库数据目录到另一台服务器之后,启动报错找不到.DBF文件。

首先迁移到目标服务器之后,可以修改dm.ini文件对应的路径,但是修改之后还是报错。原来有的路径是在数据目录的dm.ctl中写死的,这时候就要修改dm.ctl文件里的路径。

但是dm.ctl是二机制文件,不能进行修改。达梦提供了工具可以经二进制文件转化为文本文件,修改后,再转化为二进制文件:

#将dm.ctl二进制文件转化为dmctl.txt文本文件
./dmctlcvt TYPE=1 SRC=$DATADIR/dm.ctl DEST=$DATADIR/dmctl.txt

#修改文件中的路径并保存
vim $DATADIR/dmctl.txt

#将dmctl.txt文本文件转化为dm.ctl二进制文件
./dmctlcvt TYPE=2 SRC=$DATADIR/dmctl.txt DEST=$DATADIR/dm.ctl

6.3 达梦数据库dmfldr导入clob字段200多k数据报错

测试clob字段,如果该字段大一点,使用dmfldr导入就会报错,提示数据格式有误。

可以任建一个表,建一个clob字段,导入如下测试数据,就会报错。

DM官方给的解释是dmfldr在load数据的时候可能有限制,只能说这明显是一个bug。


bug现场谜之超级权限的root用户也存在“创建文件失败”的时候?

作者 冯兄
2022年1月5日 08:00

目录


1. bug现场情况

现场ETL抽数报错“创建文件失败”,无法将数据通过达梦dmfldr工具导入数据库中。

ETL的实现是,首先通过sql查询将数据库数据导出为dat文本文件,实际上就是一个csv文件,用分隔符$将每一列数据隔开,用换行符\r\n将每一行隔开;然后程序中调用shell命令借助于数据库load工具(本例中达梦数据load工具为dmfldrPostgreSQL数据load可以使用psql工具)将文本csv数据导入目标数据库。比如对于PostgreSQL数据库:

目标表target_table为:

学号 || 姓名 || 年龄 || 得分

导出的csv文件student_score.dat为:

set client_encoding to 'UTF8'
COPY student_score from stdin WITH DELIMITER '$' ESCAPE E'\\' CSV;
00001$冯兄_01$30$75
00002$冯兄_02$31$85
00003$冯兄_03$32$95
00004$冯兄_04$33$65
00005$冯兄_05$34$55

shell命令导入csv文件到表中:

psql -h $HOST -p $PORT -d $DB -U $DB_USER -f /path/to/student_score.dat

正常如上面那样使用psql客户端导入数据是需要输入密码的,可以使用免密的方式,如在客户端程序所在的主机的~/.pgpass中增加:$HOST:$DB:$DB_USER:$DB_PASSWD

本例中的达梦数据库也一样,生成的dat文件后,程序执行shell命令,通过dmfldr工具将数据导入目标表。

通过看日志,发现本例中是生成了dat文件,只是在导入的时候报错“创建文件失败”:

Caused by: xxxxException: xxxx错误:创建文件失败
    at xxxx.Execute.execute(Execute.java:239)

2. 尝试破案

由于报错摸不到头脑,先上后台用命令尝试是否成功导入:

为什么说“报错摸不到头脑”?因为程序是以root用户运行的,不存在权限问题,并且shell命令导入数据所需要的csv数据文件和参数ctrl文件都已经生成了,为什么会“创建文件失败”?

达梦dmfldr工具导入csv数据的方式如下:

./dmfldr userid=$DB_USER/$DB_PASSWD@$HOST:$PORT control=\'/path/to/test.ctrl\'

如果DB_PASSWD中含有特殊字符,可以使用'"$DB_PASSWD"'方法逃逸特殊字符。

test.ctrl文件示例:

OPTIONS (
DIRECT = false
rows = 50000
skip = 0
ERRORS = 0
)
LOAD DATA
INFILE '/path/to/*.dat' STR X '0D0A'
APPEND
INTO TABLE $TABLE
FIELDS '$'

更多达梦dmfldr工具使用参考官方文档:https://eco.dameng.com/docs/zh-cn/pm/getting-started-dmfldr.html

报错“文件少列”,意思是用$隔开的列的个数和目标表列的个数不匹配,但实际上经过确认csv文件数据列的个数和目标的数据列个数是相同的。

通过增加列(不是说少列吗,那就就是增加$)、删除字段数据等多种方法反复尝试,最后确认导入csv不能成功的原因是字符编码的问题,默认dmfldr使用的GBK编码,但是csv文件是UTF-8编码,用GBK解码UTF-8文件就出现各种奇怪的报错。

期间会报各种错误,如“字符串被截断”、“数据格式不正确”等。

dmfldr工具默认使用GBK编码没法改变,想着是不是可以更改系统使用字符编码为GBK,让导出的文件跟随系统编码为GBK,这样应该就能够导入了。

尝试修改当前终端系统字符集:

#查看本地字符集
locale

#查看所有本地支持的字符集
locale -a

#更改字符集,要选择locale -a展示支持的字符集
export LAGNG=zh_CN.gbk

实际上这里称“字符集”为“字符编码”更为准确,理解字符集与字符编码区别,参考文章:https://fengmengzhao.github.io/2015/07/30/computer-character-coding-styles.html

修改后,重启系统,发现系统界面、日志到处是乱码,导出的文件编码还是UTF-8编码,实际上说明导出文件的编码不会随运行系统字符集改变而改变,这也是开发的规范。

只能联系产品的研发修改代码了吗?

3. 真相浮出水面

第二天将前一天的验证结论又确认了一遍,本地使用命令导入数据时,字符编码存在问题。在ctrl文件中加入参数CHARACTER_CODE = 'UTF-8'之后数据能正确导入。

就要联系产品提bug的时候,突然想到不是有传说中的Arthas存在吗?可以做到在线反编译、修改代码、重新编译并重新加载类。上Arthas!玩一玩。

Arthas的安装不再赘述,文档很清楚。

使用命令启动并连接Arthas

#启动Arthas
#注意替换$PID,$PID是运行的JVM进程pid,通过命令ps -ef |grep xxx 获取
nohup java -Xbootclasspath/a:/opt/jdk-1.8/lib/tools.jar -jar ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -pid $PID -target-ip 127.0.0.1 -telnet-port 9658 -http-port 9563 -core ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -agent ~/.arthas/lib/3.5.4/arthas/arthas-agent.jar &

#确认Arthas是否启动成功,上面设置telnet的端口号为9658,该端口可以修改
netstat -nalp |grep 9658

#连接Arthas
telnet 127.0.0.1 9658 

注意:用命令行启动Arthas进程后,立即用命令ps -ef |grep arthas能看到一个进程,说明Arthas在启动中,过一会儿进程消失,说明Arthas已经启动成功或者失败。如果成功的话,使用netstat -anlp |grep $PID能看到Arthas启动是指定的telnet监听端口。找不到指定的telnet监听端口说明没有启动成功,需要查看~/log/arthas/arthas.log日志文件。

使用Arthas修改代码并重新编译:

#根据报错日志,找到报错类Execute
sc *Execute

#反编译运行class文件为源代码
jad --source-only xxxx.Execute > /tmp/Execute.java

#修改源代码
#ctrl文件中增加字符编码设置:CHARACTER_CODE = 'UTF-8'

#查找该类的类加载器hash值
sc -d *Execute |grep classLoaderHash

#在线编译修改
mc -c $CLASSLOADER_HASH /tmp/Execute.java

#重新热加载class
redefine -c $CLASSLOADER_HASH /tmp/xxxx/Execute.class

重新执行ETL程序,发现还是报同样的错。后台查看ctrl文件内已加上了UTF-8字符编码的设置,手动执行dmfldr收入导入,能够导入成功。

可为什么还报错呢?这时候意识到可能程序执行的shell命令和笔者后台执行的命令不一致Arthas不是能wath参数吗?走一波:

#查看方法入参、类成员信息、返回信息、异常信息
#params是参数 target是当前类成员信息 returnObj是方法返回值 throwExp是抛出异常信息
#-x 2 表示递归层级 -e 表示异常时抛出
watch xxxx.Execute exec "{params, target, returnObj, throwExp}" -e -x 2

Arthas还可以使用OGNL表达式,例如:watch xxx.FileDAO TransString @org.apache.commons.io.IOUtils@toByteArray(params[0].getBinaryStream()) -b -e -x 2,这里@OGNL调用类静态成员或者方法的写法。

arthas执行静态方法、属性

#调用静态属性
ognl '@全路径类目@静态属性名'

#ognl执行静态方法
ognl '@全路径类目@静态方法名("参数")'

#ognl参数的使用
ognl '#value1=@com.shirc.arthasexample.ognl.OgnlTest@getPerson("src",18), #value2=@com.shirc.arthasexample.ognl.OgnlTest@setPerson(#value1) ,{#value1,#value2}' -x 2

更多OGNL用法请参考:https://commons.apache.org/proper/commons-ognl/language-guide.html

重新执行程序,控制台得到程序完整的执行command是:

/path/to/dmfldr userid=$DB_USER/'"DB_PASSWD"'@$HOST:$PORT control=\'/path/to/*.ctrl\' character_code=\'utf-8\' log=\'/path/logs/dmfldrLog/fldr.log.2022-01-05\' badfile=\'/path/logs/dmfldrLog/fldr.bad.2022-01-05\'

复制命令,手动在后台执行以下,报错“创建文件失败”,和日志中的报错一致。知道问题是哪里了,应该就是日志文件创建的时候缺少目录,造成不能创建日志文件报错。

手动创建日志文件目录:mkdir -p /path/dmfldrLog,重新执行导入命令,执行成功。重新执行ETL抽数程序,也成功,破案了!

4. 总结

  1. 实际上在笔者自己后台执行dmfldr命令的时候就走偏了,手动执行的命令和程序执行的命令不一致,结果自己的命令出新的bug以为就是问题所在,方向没找对,陷得更深了。
  2. 第一时间应该要用Arthas,当时现场环境只有JRE,笔者懒了,也付出了代价。
  3. 报错日志(本例中是“创建文件失败”,最后排查实际上问题就是一个日志文件路径目录不存在,造成dmfldr不能创建日志文件)很重要,查bug的时候多联系报错信息,能有助于查错不跑偏方向。
  4. 如果在不知道代码的情况下,Arthas真是一个利器,能极大提高排查问题的效率。大神总说工具不重要,实际上大神对工具都运用自如了,才说不重要。Arthas工具值得Java程序员好好学习。
  5. 不能先入为主,看到“创建文件失败”,认为以root启动的程序就能够创建文件成功,本例中是用root身份执行了dmfldr命令,关键是命令中带有绝对路径的日志路径,由于路径目录不存在,dmfldr工具就报错了。关键不在于是否是root的问题,而是dmfldr在没有目录的情况下不会自动创建目录。
  6. 实际上这里ETL程序通过shell调用第三方程序,要考虑周全第三方程序可能的报错,否则就会出现类似bug

更新记录

  • 2022-01-07 18:16 “冯兄画戟”微信公众号文章发表前重读、优化、勘误
  • 2022-01-20 10:13 增加arthas启动判断内容
  • 2022-01-21 22:35 掘金专栏发表前重读、优化、勘误

相关文章推荐

❌
❌