Mac 高效開發(fā)指南(三)

第 5.3 章 分支與 Tag

分支

分支的本質(zhì)

分支可以簡單的理解為一個指針,指向某個提交。而每個提交都記錄了它的父提交,從而形成了一個鏈表。當(dāng)某個分支上不斷產(chǎn)生提交時,分支指向的提交就會發(fā)生改變,不斷向后移動,相當(dāng)于這個鏈表在不斷延長。

查看分支

輸入 gb,它會展示所有本地分支,等價于命令 git branch, 輸入命令 gbv 可以額外顯示每個分支的最后一次提交和這個分支跟蹤的遠(yuǎn)程分支,等價于命令 git branch -vv
輸入 gb branch_name 表示創(chuàng)建一個分支,指向當(dāng)前提交,gb branch_name commit 表示新建一個分支并指向某個 commit,注意這兩個命令都不會切換分支。
輸入 gba,查看本地和遠(yuǎn)程分支,等價于命令 git branch -a,輸入命令 gbr,查看遠(yuǎn)程分支,等價于命令 git branch --remote

刪除分支

輸入 gbd branch_name 刪除某個分支,等價于命令 git branch -d
并非所有分支都可以通過 gbd 命令刪除,可以通過 git branch --merged 來查看已經(jīng)合入到某個指針\(默認(rèn)是 HEAD\)的分支,換句話說是可以通過這個指針回溯到的分支。這個命令也簡寫為 gbm
gbm 列出的分支都是可以抵達(dá)的,因此可用 gbd 刪除,而 gbnm 列出的則是不可達(dá)的分支,因此不能用 gbd 刪除,它是 git branch --no-merged 命令的簡寫
如果真的要強(qiáng)行刪除,可以用 gbD 命令,它是 git branch -D 的簡寫

切換分支

輸入 gco branch 可以 checkout 到某個分支上,等價于命令 git checkout,注意如果有未提交的改動,請不要切換分支。
輸入 gcb new_branch 可以創(chuàng)建分支 new\_branch 并切換到這個分支上,它是 git checkout -b 命令的縮寫,等價于 git branch new_branch 和 git checkout new_branch 這兩條命令

有時候我們要從遠(yuǎn)程倉庫檢出一個新的分支,比如叫 feature 吧,有幾種思路:

git branch -t feature origin/feature
git checkout -b feature origin/feature
git checkout --track origin/feature

第一種寫法不太合適,因為它只會創(chuàng)建分支并且跟蹤遠(yuǎn)程分支,并不會切換。我想一般創(chuàng)建分支的時候都是需要切換的,否則你創(chuàng)建它干嘛呢,可以等到要切換的時候再創(chuàng)建吶。

第二種寫法稍微高級些,它和第一種寫法一致并且可以切換分支,之前的 tips 中介紹過 gcb 后面加單個參數(shù)的含義和用法,這里第二個參數(shù)表示跟著遠(yuǎn)程分支。

第三中方法最簡單,因為它參數(shù)少,而且功能和第二種寫法一樣,我給他起的別名叫 gct,對應(yīng) git checkout --track

如果想為當(dāng)前分支設(shè)置跟蹤的遠(yuǎn)程分支,輸入 gtrack 即可,不需要攜帶參數(shù),它會自動讓當(dāng)前分支跟蹤遠(yuǎn)程的同名分支

Tag

標(biāo)簽是分支功能的子集,可以理解為不能移動的分支。前文說過,分支始終指向某個鏈表的開端,是可以移動的。但 Tag 始終指向某個固定的提交,不會移動。

使用 gco 同樣可以切換到某個分支上。其他常用命令如下:

使用命令 gt tag_name 可以打 tag,它是 git tag 命令的縮寫。
使用命令 gtd tag_name 可以刪除本地 tag,它是 git tag -d 命令的縮寫

第 5.4 章 代碼修改

Stash

這個命令用來儲藏當(dāng)前未提交的改動,我配置了兩個別名:

gst:用來儲藏改動,是 git stash -u 的縮寫,-u 參數(shù)表示未跟蹤的文件\(untracked\) 也會被儲藏。
gsp :用來復(fù)原儲藏,是 git stash pop --index 的縮寫,--index 表示會試圖還原此前的索引狀態(tài)。比如原來改動了兩個文件 a 和 b,其中文件 a 已經(jīng)被添加到暫存區(qū)(add)但 b 沒有。普通的 git stash pop 會把 a 和 b 都還原為未暫存狀態(tài)。添加 --index 參數(shù)則會將 a 還原為暫存狀態(tài)。

Reset

很多人可能大概知道 reset 有三種模式,很多文章上來就開始介紹這三種模式的異同,在我看來這不是一種很好的教學(xué)方式。對于不是特別了解 Git 模型的讀者來說,有必要介紹一些基礎(chǔ)知識。

首先,在 Git 的思維中,它會管理三塊不同的區(qū)域,工作區(qū)、暫存區(qū)和歷史區(qū)。假設(shè)我們 clone
下來一個新的項目,此時三個區(qū)域內(nèi)的文件內(nèi)容是一模一樣的。此時如果輸入 git status命令會沒有任何輸出。注意,這個命令并不會記錄改動,而是時刻比較 工作區(qū)和暫存區(qū) 以及 暫存區(qū)和歷史區(qū) 之間的差異,從而得出待暫存和待提交的文件列表。

當(dāng)我們開始寫代碼時,文件發(fā)生了變動,這其實是更改了工作區(qū)中的代碼,但暫存區(qū)和歷史區(qū)是一致的,因此此時 git status 會顯示有一些文件需要暫存,但不會提示有文件需要提交。使用
git add 命令后,它會把改動的文件和要提交的部分拷貝到暫存區(qū)中,此時工作區(qū)和暫存區(qū)是一致的,但暫存區(qū)和歷史區(qū)的文件不一樣,所以不會再顯示有文件需要暫存,只會提示有文件要提交。輸入 git commit 以后三個區(qū)域內(nèi)的內(nèi)容又保持一致了,因此 git status 不會再有任何輸出。

在閱讀 reset 的用法前,請務(wù)必確保你真的讀懂了之前的兩段話,否則請讀到明白為止。

首先,reset 有兩種用法,它的第一個參數(shù)是提交的 SHA-1,第二個參數(shù)如果不寫則是整體重置,否則只重置單個文件,我們先介紹整體重置的情況。

此時,HEAD 指針一定會移動到指定的那次提交上,也就是說歷史區(qū)會與指定的那次提交保持一致。如果是用 reset --soft 參數(shù),那么重置就到此為止了。由于只有歷史區(qū)被重置了,暫存區(qū)還沒有發(fā)生變化,所以這個命令的作用相當(dāng)于撤銷了 commit,并且把他們都放入暫存區(qū)。

接下來,reset 命令會試圖把暫存區(qū)也和指定的提交保持一致。因此重置完成后,暫存區(qū)和歷史區(qū)保持一致,但工作區(qū)和暫存區(qū)會出現(xiàn)大量的不一致,所以 git status 命令會提示我們有很多文件需要暫存(add)。如果是用 reset --mixed 參數(shù)或者不加任何參數(shù),重置就會到此為止。可見這個命令相當(dāng)于撤銷了 git add 和 git commit 操作。

最后,reset 命令還有一個參數(shù)是 --hard,它會試圖把工作區(qū)也和指定的提交保持一致。這個命令是不安全的,如果工作區(qū)內(nèi)的文件還沒有提交,它就會丟失。提交過的文件可以用 git reflog 找回。重置結(jié)束后,三個區(qū)域內(nèi)的文件都和指定的提交保持一致,git status 不會有任何輸出。

總結(jié)一下,git reset 在重置版本時會做三件事:

讓歷史區(qū)與指定的提交保持一致,如果是 --soft參數(shù)則到此為止
讓暫存區(qū)和歷史區(qū)與指定的提交保持一致,如果不加參數(shù)或者是 --mixed 參數(shù)則到此為止
讓工作區(qū)、暫存區(qū)和歷史區(qū)都與指定的提交保持一致。如果是 --hard 參數(shù)就會走到這一步

注意,我們要記的是 reset 命令的本質(zhì),而不是它的外在表現(xiàn)。理解了它會做什么,就很容易預(yù)測這么做的結(jié)果

至于 reset 的另一個用法:重置文件,和上述規(guī)則類似,只是它不會改變歷史區(qū),自然也就不存在 --soft 參數(shù),其他兩個參數(shù)的用法和規(guī)則是完全一致的,只不過是對文件生效。

在實際使用中,我配置了這三個命令:

grh:讓工作區(qū)、暫存區(qū)和歷史區(qū)都與指定的提交保持一致,可以理解為撤銷所有改動,是命令 git reset --hard 的簡寫
grm:讓暫存區(qū)和歷史區(qū)與指定的提交保持一致,可以理解為撤銷 git add,是命令 git reset的縮寫,通常我會用 grm file_name 來撤銷對某個文件的暫存
grs:讓歷史區(qū)與指定的提交保持一致,可以理解為撤銷 git commit,是命令 git reset --soft 的縮寫

Checkout

除了可以在分支和 tag 間進(jìn)行切換外,如果 checkout 后面加上文件名,可以將尚未暫存的文件重置為初始狀態(tài)。

因此,這個命令也可以理解為僅對工作區(qū)生效的 git reset --hard,這是一個不可挽回的操作,請謹(jǐn)慎執(zhí)行。

第 5.5 章 代碼同步

Fetch

fetch 是最簡單的操作,它將遠(yuǎn)程倉庫的代碼、 分支和 tag 都下載到本地。有些 GUI 工具會定期自動執(zhí)行 fetch。

注意,如果僅僅是 fetch 代碼,并不會改變本地的代碼,僅僅是預(yù)先下載了遠(yuǎn)程倉庫的變動而已。直到我們進(jìn)行 rebase、merge、checkout、reset 等操作時,才會改動本地代碼。

Rebase or Merge

rebase 和 merge 分別是合并代碼的兩種操作。

rebase 是變基,也就是改變某次提交的父提交。假設(shè)當(dāng)前處于分支 branch_a 并執(zhí)行:

git rebase branch_b

本質(zhì)上是找到 branch_a 和 branch_b的公共祖先,然后將 branch_a 到這個公共祖先中的每一次提交,依次變基到 branch_b 上。

同樣是上面的情況,使用merge :

git merge branch_a

則會將 branch_a 到公共祖先之間的提交壓縮成一次新的提交,附加在 branch_b 的后面。

關(guān)于選擇 merge 還是 rebase,一般沒有明確的要求。前者忠實的保留了每次提交的真實情況,但是多人開發(fā)時頻繁 merge 容易導(dǎo)致時間線爆炸,影響閱讀。rebase 的優(yōu)點(diǎn)在于它創(chuàng)造出更加優(yōu)雅的提交記錄,缺點(diǎn)則是破壞了真實的提交記錄。

但假設(shè)我們有一個主分支 dev,還有一個開發(fā)分支 feature ,兩者都提交了上百次代碼,現(xiàn)在想把開發(fā)分支合入主分支,那么大概率應(yīng)該使用 merge。一方面這樣的合并次數(shù)很少,不會造成時間線爆炸,反倒是真實的保留了 feature 分支的提交記錄,最重要的是如此龐大的兩個分支合并一定會帶來大量沖突。 merge 會自動把所有提交壓縮成一個,只要解決一次沖突就行,但 rebase 的次數(shù)將會達(dá)到上百次,會出現(xiàn)大量不必要的沖突。

舉個很簡單的例子,假設(shè)提交 a 和 b 是兩個互逆的操作,那么在 merge 就互相抵消了,但如果使用 rebase,就需要解決兩次沖突。

但如果只是想從遠(yuǎn)程倉庫獲取代碼,并且更新本地的代碼,此時就更推薦 rebase 了,我配置了快捷鍵 gsfrs,它的完整定義是:

alias gsfrs='git stash;git fetch;git rebase;git stash pop;'

交互式 Rebase

對于已經(jīng)存在但還沒有推送到遠(yuǎn)程的提交記錄,我們可以使用 rebase -i 去編輯他們。假設(shè)我們想修改最近三次提交,可以輸入 gri head~3,它是完整寫法是:

git rebase -i head~3

這個命令會展示出最近的三次提交,最老的提交在最上面,最新的提交在最下面,這是因為 git 會按照從舊到新的順序編輯這些提交。展示的格式如下:

pick commit_id commit_message

我們可以隨意調(diào)整這三行的順序,相當(dāng)于改變提交的順序。如果把單詞 pick 改成 reword 或 r,就可以修改提交記錄。

git 還支持以下關(guān)鍵字:

edit 或 e:編輯此次提交
drop 或 d:刪除此次提交
fix 或 f:將此次提交與上次提交合并

Pull

pull 可以理解為一個語法糖,因為它等價于 fetch + merge,前文已經(jīng)說過日常開發(fā)時并不推薦使用 merge,只有在偶爾分支合并時才應(yīng)該使用,因此 pull 也應(yīng)該慎用。

Commit

前文說過通過交互式 rebase 可以修改歷史提交記錄,但如果只想修改上一次提交的信息,可以使用更簡單的 gca 命令,它的完整寫法是:

git commit --amend

然后編輯 commit_message 并退出即可。

我們都知道提交代碼前,需要先將改動的文件暫存,然后再提交。但如果我們想提交所有未暫存的文件,其實還有更快速的方法:gcam,它的完整寫法是:

git commit --all -m
# 實際上等價于
git add . && git commit -m

第 5.6 章 解決沖突

構(gòu)造沖突

本文主要通過一個簡單的 Demo 來演示如何在 Git 中解決沖突,以及相關(guān)名詞的基本概念。

首先我們編輯一個名為 begin 的文件:

This is first line

然后在分支 a 上增加兩行,用加號標(biāo)記出來:

 This is first line
+This is 2nd line
+This is 3rd line

再在分支 b 上增加兩行,用加號標(biāo)記出來:

 This is first line
+This is second line
+This is third line

當(dāng)我們想把分支 a 變基(rebase)到分支 b 上時,沖突必然會出現(xiàn),因為兩個分支修改了相同的行,git 不知道怎么處理了。此時有如下輸出:

可以看到文件被標(biāo)記為了 UU。U 的意思表示 updated but unmerged,兩個 U 則是說明兩個分支都做了修改,但還沒有合并。出現(xiàn) UU 基本上都意味著發(fā)生了沖突。

放棄合并

如果對解決方式?jīng)]有信心,可以暫時先放棄合并,輸入 git rebase --abort 即可。如果當(dāng)初選擇的是 merge 兩個分支,那么將是 git merge --abort。

這個命令能回退到合并分支前,但它無法在正確處理工作目錄中的變動。也就是說,在開始合并之前,務(wù)必確保自己的工作目錄是干凈的。

不過在相當(dāng)多的場合,Git 會自動做出提醒。比如當(dāng)你的工作目錄有改動時,直接就無法 rebase:

如果 merge 會影響到工作目錄的改動,Git 也會禁止你 merge,比如我隨便修改 begin 的最后一行,再執(zhí)行 merge 操作會得到如下錯誤:

所以請牢記第一點(diǎn): 在 rebase 或者 merge 之前,務(wù)必確保你的工作目錄是干凈的

沖突描述

我們來看一下沖突的文件長什么樣,注意我們是在分支 a 上 rebase 到分支 b:

可以看到兩個提交之間用 ===== 來分割,上面的部分有 <<<< 這個標(biāo)記,后面跟著一串 SHA1 值,它其實就是分支 b 指向的那次提交。

下面的部分寫得很明確,是分支 a 指向的那次提交內(nèi)容。

如果我們在分支 b 上使用 git merga branch_a,得到的效果將會是:

可見,除了對分支 b 的描述不太一樣以外,沖突的內(nèi)容是一樣的。都是上面是 b 分支的改動,下面是 a 分支的改動。

于是得出第二個結(jié)論:沖突被多個等號分割為兩部分,上面是當(dāng)前的改動,而下面是將要合入的改動

沖突文件的原理

運(yùn)行命令 git ls-files -u,其中 -u 參數(shù)用來展示還沒有合并的改動:

可見當(dāng)前存在 3 份 begin 文件,我們可以這樣查看第一個文件:

git show :1:begin

第二個文件:

git show :2:begin

這個文件又被稱為 ours,如果以這個文件的改動為準(zhǔn),將會得到等號上面的那部分結(jié)果。可以用 git diff --ours 來驗證。下圖表示當(dāng)前沖突相對于 ours 的變化(多了下面的那部分)。

第三個文件:

git show :3:begin

這個文件又被稱為 theirs,如果以這個文件的改動為準(zhǔn),將會得到等號下面的那部分結(jié)果,可以用 git diff --theirs 來驗證。下圖表示當(dāng)前沖突相對于 theirs 的變化(多了上面的那部分)。

于是得出第三個結(jié)論:“我們的”指的是已有的改動,也就是等號上面部分的改動,“他們的”則是將要合入的改動,也就是等號下面部分的改動。

比如將在分支 b 上執(zhí)行 merge a 或者在分支 a 上執(zhí)行 rebase b,此時分支 a 的改動被叫做 ours,而分支 b 的改動則被稱為 theirs。

我個人建議這樣記憶:已有的分支是原住民,也就是“我們的”,將要合入的代碼是入侵者,也就是 “他們的”。

解決沖突

最簡單的方式就是只是用某一方的改動來解決沖突,比如我認(rèn)為分支 a 的改動是無效的,分支 b 的改動才是合理的,也就以 “我們的(ours)” 改動為準(zhǔn),忽略將要合入的代碼,可以執(zhí)行命令:

git checkout --ours begin
git add begin
git commit
# 注意不用加 -m 選項,git 會默認(rèn)生成一個 merge 的 message

對應(yīng)到之前的 diff 輸出中來,如果你只想保留等號上面的部分,可以用 --ours 參數(shù),否則使用 --theirs 參數(shù)。

撤銷合并

先討論 merge 的情況,此時分之關(guān)系如圖所示:

如果代碼還沒有推送到遠(yuǎn)程倉庫,只要 reset --hard 到上一次提交即可:

git reset --hard HEAD~

此時效果如圖所示:

如果已經(jīng)推送到遠(yuǎn)程倉庫,這樣 reset 就不行了,此時可以用 revert 命令。我將在后續(xù)的文章中做詳細(xì)的介紹。

第 5.7 章 Git 核心原理

假設(shè)我們有兩個分支,a 和 b,它們的提交都有一個相同的父提交(master 指向的那次提交)。如圖所示:

現(xiàn)在我們在分支 b 上,然后 rabase 到分支 a 上。如圖所示:

平時開發(fā)中經(jīng)常遇到這種情況,假設(shè)分支 a 和 b 是兩個獨(dú)立的 feature 分支,但是不小心被我們錯誤的 rebase 了。現(xiàn)在相當(dāng)于兩個 feature 分支中原本獨(dú)立的業(yè)務(wù)被揉起來了,當(dāng)然是我們不想看到的結(jié)果,那么如何撤銷呢?

一種方案是利用 reflog 命令。

利用 reflog 撤銷變基

我們先不考慮原理,直接上解決方案,首先輸入 git reflog,你會看到如下圖所示的日志:

最后的輸出其實是最早的操作,我們逐條分析下:

HEAD@{8}: 這里我們創(chuàng)建了初始的提交
HEAD@{7}:檢出了分支 a
HEAD@{6}:在分支 a 上做了一次提交,注意 master 分支沒有變動
HEAD@{5}:從分支 a 回到分支 master,相當(dāng)于向后退了一次
HEAD@{4}:檢出了分支 b
HEAD@{3}:在分支 b 上做了一次提交,注意 master 分支沒有變動
HEAD@{2}:這一步開始變基到分支 a,首先切換到分支 a 上
HEAD@{1}:把分支 b 對應(yīng)的那次提交變基到分支 a 上
HEAD@{0}:變基結(jié)束,因為是在 b 上發(fā)起的變基,所以最后還切回分支 b

如果我們想撤銷此次 rebase,只要輸入以下命令就可以了:

git reset --hard HEAD@{3}

此時再看,已經(jīng)“恢復(fù)”到 rebase 前的狀態(tài)了。的是不是感覺很神奇呢,先別著急,后面會介紹這么做的原理。

git 工作原理簡介 {#sectiongit}

為了搞懂 git 是如何工作的,以及這些命令背后的原理,我想有必要對 git 的模型有基礎(chǔ)的了解。

首先,每一個 git 目錄都有一個名為 .git 的隱藏目錄,關(guān)于 git 的一切都存儲于這個目錄里面(全局配置除外)。這個目錄里面有一些子目錄和文件,文件其實不重要,都是一些配置信息,后面會介紹其中的 HEAD 文件。子目錄有以下幾個:

info:這個目錄不重要,里面有一個 exclude 文件和 .gitignore 文件的作用相似,區(qū)別是這個文件不會被納入版本控制,所以可以做一些個人配置。
hooks:這個目錄很容易理解, 主要用來放一些 git 鉤子,在指定任務(wù)觸發(fā)前后做一些自定義的配置,這是另外一個單獨(dú)的話題,本文不會具體介紹。
objects:用于存放所有 git 中的對象,下面單獨(dú)介紹。
logs:用于記錄各個分支的移動情況,下面單獨(dú)介紹。
refs:用于記錄所有的引用,下面單獨(dú)介紹。

本文主要會介紹后面三個文件夾的作用。

git 對象

git 是面向?qū)ο蟮模?br> git 是面向?qū)ο蟮模?br> git 是面向?qū)ο蟮模?/p>

沒錯,git 是面向?qū)ο蟮模液芏鄸|西都是對象。我舉個簡單的例子,來幫助大家理解這個概念。假設(shè)我們在一個空倉庫里,編輯了 2 個文件,然后提交。此時都會有那些對象呢?

首先會有兩個數(shù)據(jù)對象,每個文件都對應(yīng)一個數(shù)據(jù)對象。當(dāng)文件被修改時,即使是新增了一個字母,也會生成一個新的數(shù)據(jù)對象。

其次,會有一個樹對象用來維護(hù)一系列的數(shù)據(jù)對象,叫樹對象的原因是它持有的不僅可以是數(shù)據(jù)對象,還可以是另一個樹對象。比如某次提交了兩個文件和一個文件夾,那么樹對象里面就有三個對象,兩個是數(shù)據(jù)對象,文件夾則用另一個樹對象表示。這樣遞歸下去就可以表示任意層次的文件了。

最后則是提交對象,每個提交對象都有一個樹對象,用來表示某一次提交所涉及的文件。除此以外,每一個提交還有自己的父提交,指向上一次提交的對象。當(dāng)然,提交對象還會包含提交時間、提交者姓名、郵箱等輔助信息,就不多說了。

假設(shè)我們只有一個分支,以上知識點(diǎn)就足夠解釋 git 的提交歷史是如何計算的了。它并不存儲完整的提交歷史,而是通過父提交的對象不斷向前查找,得出完整的歷史。

注意開頭那張圖片,分支 b 指向的提交是 9cbb015,不妨來看下它是何方神圣:

git cat-file -t 9cbb015
git cat-file -p 9cbb015

這里我們使用 cat-file 命令,其中 -t 參數(shù)打印對象的類型,-p 參數(shù)會智能識別類型,并打印其中的內(nèi)容。輸出結(jié)果如圖所示:

可見 9cbb015 是一個提交對象,里面包含了樹對象、父提交對象和各種配置信息。我們可以再打印樹對象看看:

這表示本次提交只修改了 begin 這個文件,并且輸出了 begin 這個文件對于的數(shù)據(jù)對象。

git 引用

既然 git 是面向?qū)ο蟮模敲从袥]有指針呢?還真是有的,分支和標(biāo)簽都是指向提交對象的指針。這一點(diǎn)可以驗證:

cat .git/refs/heads/a

所有的本地分支都存儲在 git/refs/heads 目錄下,每一個分支對應(yīng)一個文件,文件的內(nèi)容如圖所示:

可見,4a3a88d 剛好是本文第一張圖中分支 a 所指向的提交。

我們已經(jīng)搞明白了 git 分支的秘密,現(xiàn)在有了所有分支的記錄,又有了每次提交的父提交對象,就能夠得出像 SourceTree 或者文章開頭第一張圖那樣的提交狀態(tài)了。

至于標(biāo)簽,它其實也是一種引用,可以理解為不能移動的分支。只能永遠(yuǎn)指向某個固定的提交。

最后一個比較特殊的引用是 HEAD,它可以理解為指針的指針,為了證明這一點(diǎn),我們看看 .git/HEAD 文件:

它的內(nèi)容記錄了當(dāng)前指向哪個分支,refs/heads/b 其實是一個文件,這個文件的內(nèi)容是分支 b 指向的那個提交對象。理解這一點(diǎn)非常重要,否則你會無法理解 checkout 和 reset的區(qū)別。

這兩個命令都會改變 HEAD 的指向,區(qū)別是 checkout 不改變 HEAD 指向的分支的指向,而 reset 會。舉個例子, 在分支 b 上執(zhí)行以下兩個命令都會讓 HEAD 指向 4a3a88d 這次提交(分支 a 指向的提交):

git checkout a
git reset --hard a

但 checkout 僅改變 HEAD 的指向,不會改變分支 b 的指向。而 reset 不僅會改變 HEAD 的指向,還因為 HEAD 指向分支 b,就把 b 也指向 4a3a88d 這次提交。

git 日志

在 .git/logs 目錄中,有一個文件夾和一個 HEAD 文件,每當(dāng) HEAD 引用改變了指向的位置,就會在 .git/logs/HEAD 中添加了一個記錄。而 .git/logs/refs/heads 這個目錄中則有多個文件,每個文件對應(yīng)一個分支,記錄了這個分支 的指向位置發(fā)生改變的情況。

當(dāng)我們執(zhí)行 git reflog 的時候,其實就是讀取了 .git/logs/HEAD 這個文件。

撤銷 rebase 的原理

首先我們要排除一個誤區(qū),那就是 git 會維護(hù)每次提交的提交對象、樹對象和數(shù)據(jù)對象,但并不會維護(hù)每次提交時,各個分支的指向。在介紹分支的那一節(jié)中我們已經(jīng)看到,分支僅僅是一個保留了提交對象的文件而已,并不記錄歷史信息。即使在上一節(jié)中,我們知道分支的變化信息會被記錄下來,但也不會和某個提交對象綁定。

也就是說,git 中并不存在某次提交時的分支快照

那么我們是如何通過 reset 來撤銷 rebase 的呢,這里還要澄清另一個事實。前文曾經(jīng)說過,某個時刻下你通過 SourceTree 或者 git log 看到的分支狀態(tài),其實是由所有分支的列表、每個分支所指向的提交,和每個提交的父提交共同繪制出來的。

首先 git/refs/heads 下的文件告訴我們有多少分支,每個文件的內(nèi)容告訴我們這個分支指向那個提交,有了這個提交不斷向前追溯就繪制出了這個分支的提交歷史。所有分子的提交歷史也就組成了我們看到的狀態(tài)。

但我們要明確:不是所有提交對象都能看到的,舉個例子如果我們把某個分支向前移一次提交,那個分支的提交線就會少一個節(jié)點(diǎn),如果沒有別的提交線包含這個節(jié)點(diǎn),這個節(jié)點(diǎn)就看不到了。

所以在 rebase 完成后,我們以為看到了下面這樣的提交線:

df0f2c5(master) --- 4a3a88d(a) --- 9cbb015(b)

實際上是這樣的:

df0f2c5(master) --- 4a3a88d(a) --- 9d0618e(b)
   |
9cbb015

master 分支上依然有分叉,原來 9cbb015 這次提交依然存在,只不過沒有分支的提交線包含它,所以無法看到而已。但是通過 reflog,我們可以找回 HEAD 頭的每一次移動,所以能看到這次提交。

當(dāng)我們執(zhí)行這個命令時:

git reset --hard HEAD@{3}

再看一次 reflog 的輸出:

HEAD@{3} 其實是它左側(cè) 9cbb015 這次提交的縮寫,所以上述命令等價于:

git reset --hard 9cbb015

前文說過,reset 不僅會移動 HEAD,還會移動 HEAD 所指向的分支,所以這個命令的執(zhí)行結(jié)果就是讓 HEAD 和分支 b 同時指向 9cbb015 這個提交,看起來像是撤銷了 rebase。

但別忘了,分支 a 的上面還是有一次提交的,9d0618e 這次提交僅僅是沒有分支指向它,所以不顯示而已。但它真實的存在著,嚴(yán)格意義上來說,我們并沒有真正的撤銷此次 rebase

第 6 章 終極武器 Zsh

Shell 是一個非常龐大的話題,它的學(xué)習(xí)路線和普通的編程語言不一致,使用場景在很多人看了也不多。但其實 Shell 是非常強(qiáng)大的膠水語言,能把其它各個模塊和系統(tǒng)很好的串聯(lián)起來,同時由于 shell 非常底層,更加接近操作系統(tǒng),所以非常用來和系統(tǒng)的軟硬件生態(tài)打交道。

與單純的研究 shell 語法,和系統(tǒng)、運(yùn)維開發(fā)不同的是,我更多的是希望降低 shell 的學(xué)習(xí)門檻,堅持實用主義,把那些能夠提高日常開發(fā)效率的知識介紹給讀者。在掌握這些基本原理后,讀者就可以根據(jù)自己的實際情況進(jìn)行定制了。本章主要分為三個階段:

首先會介紹最簡單,但是最容易被忽視的,shell 腳本的基本概念和模型。如果不了解 shell 腳本是怎么被執(zhí)行的,雖然不影響使用,但在理解更深一層的概念時就會遇到困難。

接下來是 shell 的基本語法,因為 shell 的寫法比較多,同一個功能有多種方式完成,對新人不太友好。所以這里會把所有基本的語法都整理出來,然后就只剩下拼接組合的工作了。

最后則是最精彩的部分,利用這些 shell 知識和腳本,讓自己的電腦更加強(qiáng)大,簡單,易用。

shell 的學(xué)習(xí)是持之以恒的過程,希望讀完本章后,讀者能掌握基本的概念,在后續(xù)的使用過程中不斷發(fā)現(xiàn)痛點(diǎn),解決痛點(diǎn),提高自己的能力。

第 6.1 章 Shell 基本模型與運(yùn)行原理

除了在命令行中直接輸入命令,我們還可以把多個命令匯總在一起,放在腳本中,便于后期一起執(zhí)行。本節(jié)主要介紹如何執(zhí)行 Shell 腳本。這個問題看起來很簡單, 但如果不把其中原理想清楚,會導(dǎo)致后期理解上存在偏差,容易踩坑。

假設(shè)當(dāng)前目錄下有一個 test.sh文件,內(nèi)容如下:

cd ~/Downloads

一共有三類方式執(zhí)行它:

調(diào)用解釋器執(zhí)行

顧名思義,就是把腳本的路徑傳入解釋器中去執(zhí)行:

sh test.sh
# 或者
bash test.sh

這兩者略有區(qū)別,我沒有整理過完整的差異,但至少對于 echo 命令來說,以下命令在兩種解釋器下得到的結(jié)果是不一樣的:

echo -e "hello\nworld"

bash 會正確的將 \n 解釋為換行符,sh 則不能。個人建議統(tǒng)一使用 bash 即可

利用解釋器執(zhí)行 Shell 腳本,實際上是在當(dāng)前的 Shell 環(huán)境中啟動了一個子進(jìn)程去執(zhí)行。

直接輸入文件名運(yùn)行

下面這行命令同樣可以用來執(zhí)行腳本:

./test.sh

此時要求腳本文件必須是可執(zhí)行的,否則將會報錯:

zsh: permission denied: ./test.sh

解決方法是變更文件的權(quán)限:chmod +x test.sh 然后再執(zhí)行就可以了。這種運(yùn)行方式也是新建一個子 Shell 去執(zhí)行腳本。

有的讀者可能會問,系統(tǒng)怎么知道這是 Shell 腳本而不是其他語言呢?實際上并不是通過文件名后綴來區(qū)分的,可以舉個例子:

#! /usr/bin/python

print "11"

這里用 Python 語法寫了一個腳本,但是后綴名保存為 sh,如果我們直輸入名稱去執(zhí)行,一樣可以得到正確的結(jié)果:

./py.sh
# 輸出 11

這里的 #! 被稱為 shebang,用來指定使用什么解釋器去執(zhí)行腳本。因此,規(guī)則可以簡單概括如下:

如果直接寫明了解釋器,比如 sh xxx.sh,會以顯式指定的解釋器為準(zhǔn),shebang 不生效。
如果直接寫可執(zhí)行文件的名字,則以 shebang 指定的解釋器為準(zhǔn)。
如果沒有指定 shebang,默認(rèn)是 bash,不會參考文件名的后綴。

在當(dāng)前 Shell 運(yùn)行

與前兩種方式不同的是,我們還可以在當(dāng)前的 Shell 中執(zhí)行腳本:

. ./test.sh
# 或者
source ./test.sh

這種做法的好處在于,由于不涉及到 Shell 進(jìn)程的切換,所有變量和函數(shù)的定義都是相通的。比如我在 util.sh 里面定義了函數(shù):

function sayHello {
    echo "hello, world"
}

使用方可以通過 source 命令來獲取調(diào)用函數(shù)的能力:

source util.sh
sayHello

通過 . 和 srouce 來調(diào)用腳本基本上是一致的,區(qū)別在于 source 的兼容性更好,因此更加推薦。

第 6.2 章 Shell 變量與基礎(chǔ)語法

Shell 變量

定義變量

定義變量時,千萬不要在等號的兩邊加空格,否則會報錯。正確的做法是:

a=1
echo $a

引用變量

正如上面代碼所示,用 $ 加上變量名就可以引用變量,有時候我們還會看到另一種寫法:

a=1
echo $a
# 下面這種寫法也是一樣的,而且更推薦
echo ${a}

一般來說,用大括號把變量名括起來是多此一舉,兩者作用相同。但如果我們要做字符串拼接,可以這樣寫:

a=hello
echo ${a}wrold  # 輸出 helloworld
echo $awrold    # 變量 awrold 并不存在

在 bash/zsh 的語法中,不需要使用專門的字符串拼接函數(shù),只要把兩個變量寫在一起即可。

引號

bash/zsh 中有單引號和雙引號之分,區(qū)別在于單引號中的內(nèi)容完全是字面量,甚至單引號中都無法使用轉(zhuǎn)義字符再打印出單引號。

雙引號中,如果遇到變量,將會自動轉(zhuǎn)換為變量的值。

echo "\"\""    # 輸出 ""
echo '\'\''    # 沒有輸出,因為單引號內(nèi)部都是字面量

a=1
echo "$a"      # 輸出 1
echo '$a'      # 輸出 $a,因為單引號不支持變量的展開

注意!!!在 bash 中,如果變量是一個字符串,而且字符串中含有空格,用雙引號括起來的字符串將會被解析為一個獨(dú)立的單詞,直接用 $ 則會被 bash 解析為多個參數(shù)。

假設(shè)當(dāng)前目錄下有個名叫 a b 的文件夾:

p="a b"
cd $p    # bash: cd: a: No such file or directory
# 這是因為上述命令等價于 cd a b
# 字符串 "a b" 的值被拆開傳到 cd 命令中,但是 cd 只接收一個參數(shù),導(dǎo)致路徑錯誤
# 正確的做法如下
cd "$p"
# 等價于 cd "a b",路徑正確

再次強(qiáng)調(diào),這個問題只在 bash 中存在,如果我們平時在終端中使用 zsh,但是腳本用 bash 執(zhí)行,就會遇到這個問題。

*nix 系統(tǒng)下任何文件夾、文件名嚴(yán)禁留空格,嚴(yán)禁帶中文,利人利己!

變量作用域

默認(rèn)情況下,變量的作用域是當(dāng)前的 Shell,即使變量定義在函數(shù)中也是如此:

function t() {
    temp=111
}

echo $temp   # 沒有輸出,變量 temp 未定義
t            # 調(diào)用函數(shù),函數(shù)內(nèi)部會定義變量
echo $temp   # 輸出 111

因此函數(shù)內(nèi)部的變量要加上 local 關(guān)鍵字才不會污染全局作用域:

function t() {
    local temp=111
}

t            # 調(diào)用函數(shù)
echo $temp   # 沒有輸出

環(huán)境變量

bash/zsh 中的變量分為普通變量和環(huán)境變量兩種,區(qū)別就在于,當(dāng)我們從當(dāng)前的 shell 中打開一個新的 subshell 時,環(huán)境變量會被 subshell 繼承,普通變量則不會。

a=1        # 定義一個普通變量 a
bash       # 打開一個新的 
echo $a    # 沒有輸出,因為 subshell 只會繼承父 shell 的環(huán)境變量

我們可以驗證下變量 a 確實是普通變量,而不是環(huán)境變量:

# 單獨(dú)運(yùn)行 set 命令可以打印出所有普通變量
set | grep 'a=' # 能看到變量 a 的定義

# 單獨(dú)運(yùn)行 env 命令可以打印出所有的環(huán)境變量
env | grep 'a=' # 沒有輸出,說明變量 a 不是環(huán)境變量

因此,如果想讓某個變量的作用域延伸到 subshell 中,就需要把它定義為環(huán)境變量。有兩種寫法都可以做到:

 export a=1
 declare -x a=1

除了在定義時導(dǎo)出為環(huán)境變量,也可以把已經(jīng)定義過的普通變量導(dǎo)出為環(huán)境變量:

export a
declare -x a

默認(rèn)全局變量

PWD

記錄當(dāng)前所在的目錄,通過 echo $PWD 查看

OLDPWD

表示上一次所在的目錄,輸入減號 - 可以快速跳轉(zhuǎn)到上一次所在的目錄

特殊變量

bash/zsh 中還有一些約定好的只讀變量,它們的值在腳本的運(yùn)行過程中動態(tài)確定,常見的有:

$0:表示腳本名字,可能是相對路徑,當(dāng)我們執(zhí)行 bash a/b/c/d.sh 是,$0 的值是 a/b/c/d.sh
$1、$2、……、$10:用來表示參數(shù),$1 表示第一個參數(shù),以此類推
$#:表示參數(shù)個數(shù)
$?:表示上一個命令的執(zhí)行結(jié)果,0 表示正常結(jié)束,非 0 表示出現(xiàn)錯誤

第 2、3 條規(guī)則在函數(shù)內(nèi)部同樣適用。

基礎(chǔ)語法

條件判斷

shell 的判斷有兩種寫法,分別是 [ 和 [[,舉個例子,下面兩種寫法都是正確的:

abc="1"
if [[ $abc = "1" ]]; then
    echo "equal"
fi

# 或者
if [ $abc = "1" ]; then
    echo "equal"
fi

雖然兩者看起來很類似,但 [ 很早就有了,它的本質(zhì)是調(diào)用內(nèi)置的 test 指令,而 [[ 的誕生則相對晚的多,它是 bash/zsh 的語法。

在實際開發(fā)中,兩者的細(xì)節(jié)差異較大,對初學(xué)者非常不友好,我的建議是統(tǒng)一使用 [[。

shell 中的判斷可以分為數(shù)字比較、字符串比較和文件判斷等幾大類。

數(shù)字判斷

判斷兩個數(shù)字相等有三種寫法:單等號、雙等號或 -eq 關(guān)鍵字:

[[ $abc = 1 ]] && echo "yes" || echo "not"
[[ $abc == 1 ]] && echo "yes" || echo "not"
# 或者 
[[ $abc -eq 1 ]] && echo "yes" || echo "not"
# 輸出結(jié)果都是 yes

不等號可以用 != 或 -ne 表示,大于號可以用 > 或者 -gt 來表示,小于號用 < 或者 -lt 表示。這幾個英文單詞不必記憶。

但 shell 不支持 “大于等于”、“小于等于”這些判斷,前者用 -ge 表示,后者用 -le 表示。

字符串判斷

字符串的判等和數(shù)字一致,不同的是可以判斷字符串是否為空:

str="" 
# 未定義和長度為零的字符串都算空字符串
[[ -z $str ]] && echo "yes" || echo "not" # 輸出 yes
[[ -n $str ]] && echo "yes" || echo "not" # 輸出 not

字符串還支持模式匹配:

str="hello"
[[ $str == he* ]] && echo "yes" || echo "not"
# 模式匹配,以 he 開頭的單詞都能匹配,hello 滿足要求,所以輸出 yes

文件判斷

文件判斷有以下幾種:

if [[ -e file ]]判斷是否存在,不限制類型
if [[ -f file ]]判斷文件是否存在,必須是普通類型的文件,不能是文件夾
if [[ -d file ]]判斷文件夾是否存在,必須是文件夾,不能是文件

邏輯運(yùn)算符

其它語言的幾種邏輯運(yùn)算符可以正常使用:

[[ ! $str == h*lo || 1 = 1 ]] && echo "yes" || echo "not"
# 第一個判斷取反,結(jié)果為 false,但第二個判斷為 true,所以最終效果是輸出 yes

[[ $str == h*lo && 1 = 2 ]] && echo "yes" || echo "not"
# 第二個判斷為 false,所以輸出 not

if 語句

完成的 if 語句如下:

if [[ expression_1 ]]; then
   echo "condition 1"
elif [[ expression_2 ]]; then
   echo "condition 2"
else
   echo "condition else"
fi

其中 elif 和 else 語句都是可省略的,因此最簡單的 if 語句是:

if [[ expression_1 ]]; then
   echo "condition 1"
fi

循環(huán)

for 循環(huán)的語法和 if 比較類似:

for f in `ls`; do
    echo $f
done

第 6.3 章 命令串聯(lián)

管道

管道是 shell 中最常用的概念之一,它允許不同腳本、命令之間互相傳遞數(shù)據(jù),舉一個最常見的例子:

ls | grep 'a'

默認(rèn)情況下,命令 ls 會把當(dāng)前目錄下的文件輸出到屏幕上,但如果通過管道符號 |,它就會把輸出結(jié)果傳遞給下一個命令。

命令 grep 恰好支持從管道中讀取數(shù)據(jù),因此上面這行腳本的含義實際上是在當(dāng)前目錄內(nèi)尋找名稱含有字母 a 的文件

我們可以自己模擬一下:

function before {
    echo 'output'
}

function after {
    read in
    echo "Read from pipiline: "${in}
}

before | after
# 輸出結(jié)果為:
# Read from pipiline: output

重定向

說到管道,就不得提提它的孿生兄弟:重定向,最簡單的使用場景就是把原本輸出到屏幕的內(nèi)容,重定向到文件中。

當(dāng)然,這只是重定向最簡單的用途,如果不了解背后的運(yùn)行原理,就會影響到后續(xù)的使用。

首先,*nix 系統(tǒng)中有三種特殊的文件描述符,其中 0 表示標(biāo)準(zhǔn)輸入,它一般指的是我們的鍵盤,1 表示標(biāo)準(zhǔn)輸出,2 表示錯誤輸出,它們一般都表示屏幕。所以 Shell 可以理解為一個盒子,它從 0(標(biāo)準(zhǔn)輸入,也就是鍵盤)讀取命令,沒有錯誤的話就輸出到 1(標(biāo)準(zhǔn)輸出),命令執(zhí)行錯誤的話輸出到 2(錯誤輸出),最終都會在屏幕上顯示出來。

舉一個例子,請看下面這行代碼:

ls exist.sh not_exist.sh 1>success 2>fail

這行代碼的意思首先是要展示兩個文件,假設(shè)一個文件存在,另一個文件不存在(從名字就能看出來了),這樣會產(chǎn)生一行標(biāo)準(zhǔn)輸出和一行錯誤輸出。1>success 的意思是把標(biāo)準(zhǔn)輸出重定向到 success 這個文件,類似的,2 > fail 表示把錯誤信息輸出到 fail 這個文件。

類似的語法還可以寫成:

ls exist.sh not_exist.sh >success 2>&1

這是因為如果 > 前面不加數(shù)字,默認(rèn)是標(biāo)準(zhǔn)輸出。而 2>&1 則表示讓錯誤輸出使用和標(biāo)準(zhǔn)輸出相同的重定向方式。因此這個命令等價于 ls exist.sh not_exist.sh 1>success 2>success。

從嚴(yán)格意義上講,使用 2>&1 的效率更高一些,因為它會復(fù)用標(biāo)準(zhǔn)輸出的管道。

過濾輸出

有了上述背景的積累,我們來看一個實際的問題。有時候在 Shell 腳本中我們只希望用到一個命令的功能, 但不希望它產(chǎn)生任何輸出,此時可以使用如下命令:

command > /dev/null 2>&1

這行命令表示把標(biāo)準(zhǔn)輸出和錯誤輸出都重定向到 /dev/null 文件,只是一個特定的文件,可以理解為\黑洞\。因為任何內(nèi)容都可以寫入這個文件,但對這個文件的讀取永遠(yuǎn)會返回 EOF,也就是輸入的任何內(nèi)容都會被拋棄掉。

上述命令還可以簡寫為 command &>/dev/null,沒有什么理由和解釋,只不過是 > /dev/null 2>&1 縮略寫法。

除了使用 > /dev/null 這種寫法,還可以使用 >&-,它不表示重定向,而是表示直接關(guān)閉某種輸出。自然屏幕上也就沒有任何內(nèi)容了。

更多類似的技巧請參考這篇文章:Difference between 2>&-, 2>/dev/null, |&, &>/dev/null and >/dev/null 2>&1
輸入重定向

如果要想拷貝某個文件中的內(nèi)容到剪貼板,笨的人打開文件按下 Command + A 和 Command + C,聰明一些的人會輸入下面這個命令:

cat file | pbcopy

這種寫法其實還可以再提高一下效率,因為它會讀取文件,然后把原本輸出到標(biāo)準(zhǔn)輸出(屏幕)的內(nèi)容通過管道轉(zhuǎn)到 pbcopy 這個命令上。

更高效、更直接的寫法如下:

pbcopy < file

這樣可以減少一次 IO 操作

函數(shù)返回值

在函數(shù)的結(jié)尾可以使用 return 關(guān)鍵字,然而需要注意的是,調(diào)用函數(shù)后的返回結(jié)果,并不是 return 的內(nèi)容,而是 echo 的內(nèi)容。至于 return 的內(nèi)容,則可以通過 $? 這個特殊變量來讀取。

function foo {
    echo 'output'
    return 1
}

a=`foo`
echo $?        # 輸出 1
echo $a        # 輸出 output

在 if 語句中,除了可以進(jìn)行普通的判斷外,還可以直接根據(jù)命令的執(zhí)行結(jié)果進(jìn)行判斷。此時讀取的依然是 return 的結(jié)果。

前文中提過,正常執(zhí)行的命令返回值是 0,對應(yīng)到 if 語句中則是 true 分支:

function foo {
    return 1
}

if test ; then
    echo "1"
else
    echo "0"
fi

# 因為函數(shù)返回 1,表示執(zhí)行失敗,所以最終輸出 0

前面曾經(jīng)介紹過如何判斷當(dāng)前目錄下是否存在某個文件,放到 if 中就可以寫為:

if `ls | grep -q 'a'` ; then
    echo "yes"
else
    echo "no"
fi

這里雖然介紹的是函數(shù)返回值,但對整個腳本同樣適用

第 6.4 章 Shell 錯誤處理

本文前面部分內(nèi)容摘錄自阮一峰老師的:Bash 腳本 set 命令教程,主要是文章寫得太好了。

開啟錯誤處理

使用 shell 中的錯誤處理有助于我們發(fā)現(xiàn)錯誤,更好的調(diào)試代碼。

檢測未定義變量

首先,set -u 可以在遇到未定義變量時拋出錯誤,而不是忽略它。比如:

echo $bar

這里的變量 bar 沒有定義,shell 的默認(rèn)方案是忽略掉它。這就可能帶來隱藏的問題,所以通過 set -u 選項來強(qiáng)制報錯:

set -u
echo $bar

此時會得到報錯 ./test.sh: line 2: bar: unbound variable

報錯時退出

如果某個命令執(zhí)行錯了,可能會導(dǎo)致后續(xù)一系列命令執(zhí)行出錯。既不利于調(diào)試,也會導(dǎo)致很多意想不到的結(jié)果,所以可以用 set -e 選項來強(qiáng)制報錯時退出執(zhí)行腳本。

set -e
bbbb
ssss

如果不加上 set -e 會得到兩行報錯,因為 bbbb 和 ssss 都是不存在的指令。而加上以后,這里只會有一個報錯就立刻 exit 了。

需要注意的是,如果我們用管道的寫法,得到的返回值是最后一個命令的返回值,如果中間的命令出錯,是不能被 set -e 捕獲的,比如:

set -e

bs | ls
echo 'reach here'

得到的輸出結(jié)果將是:

aaa.sh: line 3: bs: command not found
test.sh
reach here

可見 bs 這個指令雖然不存在,但程序還是沒有退出,而是執(zhí)行到了結(jié)尾。因此 set -e 通常需要配合 set -o pipfail 來使用,這樣管道中的任何一個指令出錯,都會導(dǎo)致程序退出。

調(diào)試執(zhí)行

如果想知道每一行都執(zhí)行了什么代碼,可以用 set -x 選項,通常我們在 Jenkins 等工具里可以這么用,方便追查問題。比如:

set -x
ls

我們會得到:

+ ls
test.sh

以加號開頭的行就是文件的原始內(nèi)容了。

exit 鉤子

總結(jié)一下第一段的內(nèi)容,我們在任何 shell 腳本的開頭都應(yīng)該加上這行標(biāo)記:

set -euo pipefail

表示遇到錯誤指令或未定義的變量時立刻退出。當(dāng)然,如果需要調(diào)試,可以改成 set -euxo pipefail。

我們知道退出是靠 exit 命令來實現(xiàn)的,也就是說上述錯誤最終都會調(diào)用到 exit 命令,有沒有辦法捕獲這個退出呢?

最簡單做法當(dāng)然是封裝 exit,比如:

function bs_exit() {
    echo "exit" && exit $1
}

但如果項目中已有大量的 exit,就需要我們手動替換。雖然成本能接受,但如果可以用 AOP 的方式來 hook exit 命令,肯定是最理想的。

這也是本文的重點(diǎn),經(jīng)過查閱資料,我們可以這樣寫:

function finish {
  err=$?
  if [[ $err == 1 ]]; then
    echo '1'
  fi
}

trap finish EXIT

這里的 trap 是一個內(nèi)置命令,用來捕捉發(fā)送給程序的信號。它接受兩個參數(shù),第一個是處理信號的方式,第二個則是信號名。

比如當(dāng)我們使用 exit 命令來退出腳本時,實際上是發(fā)送了 EXIT 信號,于是會被捕獲,并調(diào)用 finish 函數(shù)。函數(shù)內(nèi)部可以拿到 exit 后面的狀態(tài),因此可以區(qū)分用戶是通過 exit 1 還是 exit 2 來退出的,方便執(zhí)行對應(yīng)的操作。

這種寫法的另一個好處在于它是全局的,比如當(dāng)我的 shell 腳本存在嵌套調(diào)用關(guān)系時,只要在入口處定義一次就好,它可以自動捕獲 subshell 的退出狀態(tài)。如果用之前 bs_exit 這種封裝,就需要在所有腳本里面都把這個函數(shù) source 進(jìn)來,成本也更高。

代碼調(diào)試

如果只想檢查腳本的語法但不執(zhí)行,可以用 sh -n 命令。如果你的腳本是一個有破壞性或者很耗時的操作,可以用這個技巧來調(diào)試語法。比如:

bash -n test.sh

此外,我們還可以增強(qiáng) set -x 指令的效果,上文說過被執(zhí)行的指令前面會有 + 的前綴,它其實是是通過一個叫做 PS4 的環(huán)境變量來控制的。我們可以修改這個變量:

export PS4='+{$LINENO:${FUNCNAME[0]}} '

這里會顯示代碼所在行數(shù)(LINENO)和當(dāng)前函數(shù)名(FUNCNAME[0]),輸出效果如下:

+{11:} trap finish EXIT
+{13:} fff
./test.sh: line 13: fff: command not found
+{13:} finish
+{2:finish} err=127
+{3:finish} [[ 127 == 1 ]]
+{6:finish} echo 127

這個 PS4 變量的修改還是很有用的,因此可以放到 .zshrc 里面去。

第 6.5 章 必會系統(tǒng)命令

grep

grep 命令很容易學(xué)習(xí),它主要有兩種使用方式,一種是單獨(dú)使用,比如搜索某個文件中的內(nèi)容:

grep 'content' file.txt

或者從標(biāo)準(zhǔn)輸入中搜索內(nèi)容:

echo 'something' | grep 'some'

要想掌握好 grep,重點(diǎn)在于了解它的各種參數(shù)。下面是一些常用的參數(shù),如果不記得,后續(xù)可以用 man grep 命令來查閱。

grep 在搜索時,默認(rèn)是大小寫敏感的,但如果要搜索 mysql,它可能寫做 mysql 也可能寫做 MySQL,這就可能存在搜索不到的問題,此時可以用 -i 參數(shù):

echo 'MySQL' | grep -i 'mysql'

如果使用 -n 參數(shù)可以打印匹配行的行號,使用 -H 參數(shù)可以打印匹配文件的文件名。

默認(rèn)情況下,如果某個二進(jìn)制文件中含有搜索的關(guān)鍵詞,會顯示 Binary file ... matches,使用 -I 選項可以忽略二進(jìn)制文件,使用 -a選項可以把二進(jìn)制文件當(dāng)做文本文件來處理,從而輸出匹配的部分。

默認(rèn)情況下 grep 會展示匹配的那一行,如果想查看上下文,可以使用 -A、-B 和 -C 這三個參數(shù):

-A 3:展示匹配行以及后面的 3 行
-B 3:展示匹配行以及前面的 3 行
-C 3:展示匹配行以及前后的 3 行,等價于 -A 3 -B 3

另外一些常用的選項包括 -v,表示只顯示那些不匹配的行,-o 表示只顯示匹配的部分,-q 表示不輸出內(nèi)容,通常與 if 連用。

xargs

在前面的章節(jié)中我們介紹過,可以通過管道將多個命令串聯(lián)起來,前提是管道后面的命令要支持從標(biāo)準(zhǔn)輸入中讀取數(shù)據(jù),比如前文的 grep 命令。

然而有些命令并不支持從標(biāo)準(zhǔn)輸入中讀取,比如這樣寫是無效的:

echo 'file_name' | rm

此時我們可以借助 xargs 命令:

echo "a" | xargs rm

這條命令的原理是,xargs 會把換行符、空格、制表符、EOF等符號做為分隔符,把輸入的內(nèi)容切分為一個數(shù)組,并把數(shù)組中每一個元素作為參數(shù),放到后面的命令中執(zhí)行,用偽代碼來寫就是:

for arg in read_input; do
    rm arg
done

很常見的一個坑就是,如果文件名帶有空格,比如 hello world 就會被 xargs 截斷為兩個參數(shù),顯然不符合預(yù)期。不過一般對內(nèi)容或者文件進(jìn)行過濾時,我們都會使用 grep 或 find,這兩個命令都有辦法配合 xargs。

ls | grep 'a' | tr "\n" "\0" | xargs -0 rm

用 grep 的話會繁瑣一些,需要用 tr 命令把換行符轉(zhuǎn)換成特殊字符 \0,再利用 xargs 的 -0 參數(shù),根據(jù)文檔所述,這個參數(shù)會把分隔符指定為 -0,從而避免了文件名中含有空格的影響。

用 find 也是類似的原理:

find . -print0 | xargs -0 rm

只不過它自帶了 -print0選項,寫法更簡單。

sed

sed 誕生于 1977 年,已經(jīng) 41 歲了,這么一位叔叔級別的命令至今還活躍在各種 Shell 腳本中,由此可見它是多么重要。

Mac 自帶的時 BSD 版本的 sed,因為功能較弱,我不推薦使用,建議使用 gsed,如無特殊說明,下文的介紹都是針對 gsed的。

brew install coreutils
which gsed
# /usr/local/bin/gsed

sed 和 grep 的用法類似,都是 sed pattern file 或者 echo 'xx' | sed pattern,也就是說第二個參數(shù)可以是文件,也可以從標(biāo)準(zhǔn)輸入流中讀取。

最標(biāo)準(zhǔn)的用法是進(jìn)行文本替換(也可以用 tr 命令實現(xiàn)):

echo "a b\nc d"
# a b
# c d
echo "a b\nc d" | gsed 's/a/aa/g'
# aa b
# c d

有時候我們可能不止使用一次 sed,此時可以用 -e 參數(shù)把多個命令串聯(lián)起來:

echo "a b\nc d" | gsed -e 's/a/aa/g' -e 's/b/bb/g'

在 gsed 中,還可以使用 Shell 里定義的變量:

old=a
new=aa
echo "a b\nc d" | gsed "s/$old/$new/g"

我推薦用 gsed 是因為它有一個 -i 選項,可以對文件進(jìn)行原地修改:

gsed -i 's/a/aa/g' file

sed 最核心的部分在于這里的 s/a/aa/g,它由若干個斜杠組成(其實也不一定要用斜杠,只要保持一致就行)。這里的 s 表示替換,a 表示待匹配的內(nèi)容,支持正則,aa 表示替換后的內(nèi)容,g 表示全部替換,更多的用法有:

$0 當(dāng)前記錄(這個變量中存放著整個行的內(nèi)容)
1~n 當(dāng)前記錄的第n個字段,字段間由FS分隔
FS 輸入字段分隔符 默認(rèn)是空格或Tab
NF 當(dāng)前記錄中的字段個數(shù),就是有多少列
NR 已經(jīng)讀出的記錄數(shù),就是行號,從1開始,如果有多個文件話,這個值也是不斷累加中
FNR 當(dāng)前記錄數(shù),與NR不同的是,這個值會是各個文件自己的行號
RS 輸入的記錄分隔符, 默認(rèn)為換行符

這些用法雖然看起來復(fù)雜,但是和 vim 一樣,每個部分就幾種寫法,然后自行排列組合即可。

gsed 在默認(rèn)情況下,會把輸入的每一行都輸出一遍,它有一個常用的選項是 -n,表示不輸出任何一行。通常與 p 命令合用,這個命令可以打印匹配的行,類似于 grep 的效果。

awk

awk 是和 sed 同時代的命令,并稱為文本處理兩大神器。個人認(rèn)為 sed 的強(qiáng)大之處在于文本匹配后的處理,而 awk 則更適合文本的結(jié)構(gòu)化處理。

這里以獲取 ip 地址的命令來介紹下:

ifconfig | sed -n -e '/127.0.0.1/d' -e '/inet /p' | awk '{print $2}'

這里 awk 的用法其實很簡單,就是打印第二列。awk 的核心在于內(nèi)建的變量:
0 當(dāng)前記錄(這個變量中存放著整個行的內(nèi)容)1~$n 當(dāng)前記錄的第n個字段,字段間由FS分隔
FS 輸入字段分隔符 默認(rèn)是空格或Tab
NF 當(dāng)前記錄中的字段個數(shù),就是有多少列
NR 已經(jīng)讀出的記錄數(shù),就是行號,從1開始,如果有多個文件話,這個值也是不斷累加中。
FNR 當(dāng)前記錄數(shù),與NR不同的是,這個值會是各個文件自己的行號
RS 輸入的記錄分隔符, 默認(rèn)為換行符

awk 一個很常見的用法是 -f 參數(shù),可以指定輸入字段的分隔符:

echo "a;b;c" | awk -F';' '{print $2}'

其實理論上來說,awk 比 sed 還要強(qiáng)大,因為它是一個圖靈完備的語言,支持 for 循環(huán)等等編程思想。建議感興趣的讀者閱讀 AWK 簡明教程 了解更多 awk 的使用技巧

第 6.6 章 高效終端使用指南

別名 alias

基本用法

對于特別長的命令,可以使用 alias 來簡化它,比如前文介紹的:

alias gg="git log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%ci) %C(bold blue) <%an>%Creset' --abbrev-commit"

如果存在同名的別名、函數(shù)、內(nèi)置命令等,調(diào)用優(yōu)先級是:

別名 > 單數(shù) > 內(nèi)置命令 > $PATH 路徑下的可執(zhí)行文件。

一般我們只用 alias 來簡化固定的長命令,由于別名不支持參數(shù),所以復(fù)雜的處理流程建議通過定義函數(shù)來解決。

高級別名

除了普通的 alias,我們還可以創(chuàng)建 后綴 alias 和 全局 alias。創(chuàng)建后綴 alias 的寫法是:

alias -s txt='less -r'

它表示對于任意命令 xxx.txt,都會被重寫為 less -r xxx.txt,也就是原來的命令作為別名的后綴出現(xiàn)。上面這個 alias 的作用是當(dāng)我們要輸出某個 txt 的文件內(nèi)容時,只要輸入 xxx.txt 就可以了,無需更多的命令。

全局 alias 就更強(qiáng)大了,它會對整個命令進(jìn)行匹配和替換,舉個栗子:

alias -g L='| less'

以前如果想用 less 去查看一個文件需要寫成 cat xxx | less,由于有了全局別名,現(xiàn)在只要寫成 cat xxx L 即可。

如果輸入命令 alias xxx L -r 它會被替換成 alias xxx | less -r。

相信讀者已經(jīng)能理解后綴 alias 和全局 alias 的用法,但請慎用,尤其是全局 alias,類似于 C 語言的宏定義,濫用可能會帶來一些危險,建議先看看大神是怎么用的,這里面提供了很多 alias,如果不是必要,我建議盡量避免自行添加。

查看定義

如果只是想查看別名或者函數(shù)的定義,可以使用 which 命令:

但如果拿到別人的配置腳本,想自行定制。顯然只知道定義是不夠的,還得知道這個 alias 或者函數(shù)是在哪個文件里被定義的,這樣才好去修改,此時建議使用我配置的 bswhich 命令:

這是因為查找 alias 定義位置和函數(shù)定義位置的方法還不一樣,完整的寫法是:

function bswhich() {
    if `type $1 | grep -q 'is a shell function'`; then
        type $1
        which $1
    elif `type $1 | grep -q 'is an alias'`; then
        PS4='+%x:%I>' zsh -i -x -c '' |& grep '>alias ' | grep "${1}="
    fi
}

Autojump

如果不想每次都輸入 cd 再輸入 ls,那么 autojump 是必裝的神器:

brew install autojump

它會記住每一次 cd 的路徑,并且保存在數(shù)據(jù)中,以后我們可以直接輸入 j + 關(guān)鍵字,從而避免頻繁的 cd。

終端命令自動補(bǔ)全

輸入快捷鍵 Ctrl + E 可以根據(jù)當(dāng)前提示快速補(bǔ)全,快捷鍵 ; 可以補(bǔ)全并執(zhí)行

終端 Finder 模擬器:r

系統(tǒng)的 Finder 其實并沒那么好用,最大的問題在于沒法和 Shell 有效的交互,比如復(fù)制移動文件、在當(dāng)前文件夾位置打開終端都很不方便。

作為程序員,我推薦使用 Ranger 來瀏覽文件目錄,它是一個使用 Vim 鍵位映射的文件管理工具。

使用快捷鍵 r 來打開 ranger,它的完整定義是:alias r='source ranger',這樣做的好處在于當(dāng) Ranger 中目錄發(fā)生變化時,可以改變外部 Shell 的路徑。

在 Ranger 中,使用 j/k 來上下移動光標(biāo),h/l 來進(jìn)行目錄的前進(jìn)和后退。

常用的操作有:

zh:切換是否顯示系統(tǒng)隱藏文件,按一次打開,再按一次關(guān)閉
x:安全刪除文件(放入垃圾箱中而不是 rm)
yy:復(fù)制,dd:剪貼,pp:粘貼,空格鍵多選文件
gh:進(jìn)入用戶目錄($HOME)
yn:復(fù)制文件名,yd 復(fù)制文件夾名,yp 復(fù)制完整路徑名
:j:和 autojump 一樣,輸入要跳轉(zhuǎn)的地方
Ctrl + f:利用 fzf 搜索文件
f:當(dāng)前目錄內(nèi)過濾文件名
du:查看當(dāng)前目錄內(nèi)各文件夾大小
oo:在 Finder 中打開,op 或回車鍵:使用系統(tǒng)默認(rèn)的程序打開,oc:使用 VSCode 打開(如果已經(jīng)有 VSCode 進(jìn)程,為了加快速度,則使用已存在的)
m:添加書簽,um:選擇要刪除的書簽,```:展示書簽

fzf:模糊搜索神器

fzf 是一個模糊搜索神器,^t 是特定語義下的補(bǔ)全快捷鍵,^i 是默認(rèn)快捷鍵,很少用到:

輸入 kill 然后按下 ^t 鍵,就會打開 fzf 補(bǔ)全界面,通過輸入進(jìn)程名來獲取到 PID
類似的還有輸入 ssh、export、unset、unlias 等命令
按下 alt + c 可以列出當(dāng)前目錄下的文件夾,并快速進(jìn)入
按下 ^g,會自動補(bǔ)全 autojump 的路徑列表
按下 ^r 進(jìn)入命令歷史模式,此時也會自動打開 fzf 補(bǔ)全界面,自動補(bǔ)全命令
注意此時的補(bǔ)全并不會自動執(zhí)行,只會把命令粘貼到命令行中,如果想要按下回車后自動執(zhí)行,可以用快捷鍵 ^x^r 來觸發(fā)

fzf 甚至還支持為自定義的命令添加補(bǔ)全,具體做法可以參考:Examples (completion)

第 6.7 章 常用命令推薦

bsfn:查找文件名

如果你想查找文件夾內(nèi)的某個文件,可以使用 find 命令,但默認(rèn)的 find 命令并不支持表達(dá),所以我在 personalized.sh文件中封裝了 bsfn 函數(shù),它接受一個參數(shù),可以精確匹配,也可以寫正則表達(dá)式:

比如這里我們搜索所有以 BBA 開頭,中間字符不限,以 Plugin 結(jié)尾的文件。

bsgrep:查找內(nèi)容

簡單的裝了 grep,如果不加路徑,則表示在當(dāng)前目錄下遞歸搜索。

bsfilename: 獲取文件名

這個命令可以從完整的文件路徑中獲取不帶后綴的文件名,比如

bsfilename ~/Desktop/test.py
# 輸出結(jié)果: test

bsof: 檢查系統(tǒng)端口占用

可以通過系統(tǒng)的 lsof -i:port 來檢查哪個程序占用了 port 端口,但有時候我們不想記參數(shù),或者想查找某個程序占用了哪些端口,此時可以使用 bsof。

比如查看 redis 進(jìn)程占用了哪些端口,可以輸入 bsof redis,查看哪些進(jìn)程占用了 80 端口可以輸入 bsof :80,如下圖所示:

bszip: 壓縮文件

這個命令可以快速壓縮文件,用法 bszip path_to_file,它會讀取要壓縮的文件(夾)名,然后在當(dāng)前目錄生成同名的 zip 文件

bswhich:查看定義

如果拿到別人的配置腳本,想自行定制。顯然只知道定義是不夠的,還得知道這個 alias 或者函數(shù)是在哪個文件里被定義的,這樣才好去修改,此時建議使用我配置的 bswhich 命令:

bswhich ip
bswhich gg

bssize:查看文件和文件夾大小

bssize 后面的參數(shù)可以是文件名,表示查看這個文件的大小。也可以是文件夾名,表示查看文件夾大小和文件夾內(nèi)各子目錄的大小。

bssize . 表示查看當(dāng)前目錄大小和子目錄大小,bssize / 表示查看系統(tǒng)磁盤的使用情況。具體效果如圖所示

c:使用 VSCode 編輯

首先需要集成 VSCode 的命令行工具,步驟可以參考這個鏈接

這個命令有三種用法:

如果不加任何參數(shù),會使用 VSCode 打開當(dāng)前文件夾
如果參數(shù)所代表的文件或文件夾存在,會用 VSCode 打開指定的文件夾
如果參數(shù)代表的文件不存在,會用 autojump 打開指定路徑并使用 VSCode 編輯

ow:快速打開 xcode 工程

自動查找當(dāng)前目錄下的 xcworkspace 和 xcodeproj 文件并打開, 也可以指定路徑。

proxy:展示和切換系統(tǒng)代理

如果想使用 Charles 抓包,則輸入 p on 即可將系統(tǒng)的 HTTP 和 HTTPS 代理設(shè)置為 127.0.0.1:8888

如果想使用 Shadowsocks 科學(xué)上網(wǎng),則輸入 p g 即可將系統(tǒng)的 socks 代理設(shè)置為 localhost:14179,需要自行修改端口號

如果不想使用代理,輸入 p off 可以禁用所有代理,恢復(fù)默認(rèn)設(shè)置。

輸入 p s 可以查看當(dāng)前的系統(tǒng)代理:

ppjson:終端 json 格式化

用法:

echo '{"hello": "world"}' | ppjson

效果:

encode64 和 urltool

這幾個小命令可以快速實現(xiàn)一些編碼和解碼工作:

encode64 你好
# 5L2g5aW9
decode64 5L2g5aW9
# 你好%
urlencode https://baidu.com
# https%3A%2F%2Fbaidu.com
urldecode https%3A%2F%2Fbaidu.com
# https://baidu.com

全局別名

如果只想看某個輸出的前 3 行,可以用 cat xxx H 3,這是因為 H 被全局重命名為 | head -n
如果是看輸出的后 3 行,可以用 cat xxx T 3,其中 T 被全局重命名為 | tail -n
如果是看輸出的指定行數(shù),比如第 1、3、7 行,可以用 cat xxx R 1 3 7, 其中 R 被全局重命名為 | row
如果要看某個輸出的某幾列,比如倒數(shù)第一列,可以用 cat xxx C -1,其中 C 被全局重命名為 | column
如果要在 less 中查看某個超長的輸出,可以用 cat xxx L,其中 L 被全局重命名為 | L
如果要忽略某條命令的報錯,可以用 command NE,其中 NE 被全局重命名為 2> /dev/null
如果要某個命令完全不輸出內(nèi)容,可以用 command NUL,其中 NUL被全局重命名為 > /dev/null 2>&1

文本處理

使用 column 獲取指定的列,或使用 row 獲取指定的行:
echo "a b c" | column 1 3
# a c
echo "a\nb\nc\n" | row 1 3
# a
# c
使用 ncolumn 過濾指定的列,或使用 nrow 過濾指定的行:
echo "a b c" | ncolumn 2
# a c
echo "a\nb\nc\n" | nrow 2
# a
# c

使用 average 對列求平均,使用 add 對列求和:
echo "1\n3\n5" | average
# 5
echo "1\n3\n5" | add
# 9

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,702評論 2 370