糾結的連結——ln、ln -s、fs.symlink、require

KohPoll發表於2016-05-30

本文首發於我的部落格,轉載請註明出處:http://kohpoll.github.io/blog/2016/05/30/hardlink-symlink-require-in-nodejs/

最近在使用 fs.symlink 實現軟鏈時,發現文件裡面寫的是:fs.symlink(target, path);然而 man ln 的時候顯示的是:ln source_file target_file;而且,require 模組的時候其實還會處理軟鏈但是處理的又不是想象中那樣。於是,我徹底被相關東西繞暈。這篇文章算是我的學習筆記,希望對你有幫助。

inode

我們首先來看看 linux 系統裡面的一個重要概念:inode。

我們知道,檔案儲存在硬碟上,硬碟儲存的最小單位是扇區(sector,每個扇區 512 B)。而作業系統讀取檔案時,按塊讀取(連續的多個扇區),也就是說檔案存取的最小單位是塊(block,塊通常是 4 KB)。

除了檔案資料,我們還必須儲存檔案的元資訊(如:檔案大小、檔案建立者、檔案資料的塊位置、檔案讀/寫/執行許可權、檔案時間戳等等),這種儲存檔案元資訊的結構就稱為 inode。我們可以使用 stat 命令檢視檔案的 inode 資訊。在 Node 中,呼叫 fs.stat 後返回的結果中也有相關資訊

stat

每個 inode 都有一個唯一的號碼標誌,linux 系統內部使用 inode 的號碼來識別檔案,並不使用檔名。我們開啟一個檔案時,系統首先找到檔名對應的 inode 號碼,然後透過 inode 號碼獲取 inode 資訊,最後根據 inode 資訊中的檔案資料所在的 block 讀出資料。

實際上,在 linux 系統中,目錄也是一種檔案。目錄檔案包含一系列目錄項,每個目錄項由兩部分組成:所包含檔案的檔名,以及該檔名對應的 inode 號碼。我們可以使用 ls -i 來列出目錄中的檔案以及它們的 inode 號碼。這其實也解釋了僅更改目錄的讀許可權,並不能實現讀取目錄下所有檔案內容的原因,通常需要 chmod -R 來進行遞迴更改。

總結下:

  • 硬碟存取的最小單位是扇區,檔案存取的最小單位是塊(連續的扇區)

  • 儲存檔案元資訊(檔案大小、建立者、塊位置、時間戳、許可權等非資料資訊)的結構稱為 inode

  • 每個 inode 擁有一個唯一號碼,系統內部透過它來識別檔案

  • 目錄也是一種檔案,其內容包含一系列目錄項(每個目錄項由檔案的檔名和檔案對應的 inode 號碼組成)

硬連結和軟連結

硬連結

一般情況,一個檔名“唯一”對應一個 inode。但是,linux 允許多個檔名都指向同一個 inode。這表示我們可以使用不同的檔名訪問同樣的內容;對檔案內容進行修改將“反映”到所有檔案;刪除一個檔案不影響另一個檔案的訪問 。這種機制就被稱為“硬連結”。

我們可以使用 ln source target 來建立硬連結(注意:source 是本身已存在的檔案,target 是將要建立的連結)。

hard link

形象化的表示為下圖:

hard link graph

需要注意的是,只能給檔案建立硬連結,而不能給目錄建立硬連結。另外,source 檔案必須存在,否則將會報錯。

刪除一個檔案為什麼不影響另一個檔案的訪問呢?實際上,檔案 inode 中還有一個連結數的資訊,每多一個檔案指向這個 inode,該數字就會加 1,每少一個檔案指向這個 inode,該數字就會減 1,當值減到 0,系統就自動回收 inode 及其對應的 block 區域。很像是一種引用計數的垃圾回收機制。

當我們對某個檔案建立了硬連結後,對應的 inode 的連結數會是 2(原檔案本身已經有一個指向),當刪除一個檔案時,連結數變成 1,並沒達到回收的條件,所以我們還是可以訪問檔案。

軟連結

軟連結類似於 windows 中的”快捷方式“。兩個檔案雖然 inode 號碼不一樣,但是檔案 A 內部會指向檔案 B 的 inode。當我們讀取檔案 A 時,系統就自動導向檔案 B,檔案 A 就是檔案 B 的軟連結(或者叫符號連結)。這表示我們同樣可以使用不同的檔名訪問同樣的內容;對檔案內容修改將”反映“到所有檔案。但是當我們刪除掉原始檔 B 時,再訪問檔案 A 時會報錯 “No such file or directory”。

我們可以使用 ln -s source target 來建立軟連結(注意:表示讓 target “指向”source)。

soft link

形象化的表示為下圖:

soft link graph

和硬連結不同,我們可以給目錄建立軟連結,這帶來許多便利。比如我們有一個模組有很多個版本,分別存放在 1.0.0、2.0.0 這樣的目錄下面,當更新模組時,只需要建立一個軟連結指向最新版本號的目錄就能很方便的切換版本。

另外,建立軟連結時,source 是可以不存在的。這很像一種”執行時“機制,而不是“編譯時”機制,建立的時候不報錯,等執行的時候發現找不到就報錯了。

danggling soft link

總結

  • 使用 ln source target 建立硬連結;使用 ln -s source target 建立軟連結

  • 硬連結不會建立額外 inode,和原始檔共用同一個 inode;軟連結會建立額外一個檔案(額外 inode),指向原始檔的 inode

  • 建立硬連結時,source 必須存在且只能是檔案;建立軟連結時,source 可以不存在而且可以是目錄

  • 刪除原始檔不會影響硬連結檔案的訪問(因為 inode 還在);刪除原始檔會影響軟連結檔案的訪問(因為指向的 inode 已經不存在了)

  • 對於已經建立的同名連結,不能再次建立,除非刪掉或者使用 -f 引數

關於軟連結的補充

上面的例子 ln -s file file-soft 給我們的感覺像是 file-soft 是“憑空”出現的。當我們跨目錄來建立軟連結時,可能會“幻想”這樣的命令也是可以生效的:ln -s ~/development/mod ~/production/dir-not-exits/mod

這裡並沒有 ~/production/dir-not-exits/ 這個目錄,而軟連結本質上是一個新的“檔案”,所以,我們不可能正確建立軟連結(會報錯說 “no such file or directory”)。

如果我們先透過 mkdir 建立好目錄 ~/production/dir-not-exits/,再進行軟連結,即可達到預期效果。

fs.symlink

在 node 中,我們可以使用方法 fs.symink(target, path) 建立軟連結(符號連結),沒有直接的方法建立硬連結(就算透過子程式的方式直接指向 shell 命令也不能跨平臺)。

如果是對目錄建立連結,請總是傳遞第三個引數 dir(雖然第三個引數只在 windows 下生效,這可以保證程式碼跨平臺):fs.symlink(target, path, 'dir')

為啥這個介面的引數會是 targetpath。實際上這是一個 linux 的 API,symlink(target, linkpath)。它是這樣描述的:建立一個名為 linkpath 的符號連結並且含有內容 target。其實就是讓 linkpath 指向 target,和 ln -s source target 功能一樣,讓 target 指向 source

是不是有點暈?其實我們只需要明白 ln -sfs.symlink 後面傳遞的兩個引數順序是一致的,只是叫法不一樣,使用起來也就沒那麼糾結了:

ln -s file file-soft # file-soft -> file
ln -s dir dir-soft # dir-soft -> dir
fs.symlinkSync('file', 'file-soft'); // file-soft -> file
fs.symlinkSync('dir', 'dir-soft', 'dir'); // dir-soft -> dir

require

在 Node 中,我們經常透過 require 來引用模組。非常有趣的是,require 引用模組時,會“考慮”符號連結,但是卻使用模組的真實路徑作為 __filename__dirname,而不是符號連結的路徑。

考慮下面的目錄結構:

- app
  - index.js // require('dep1')
  - node_modules
    - dep1 -> ../../mods/dep1 //符號連結
- mods
  - dep1
    - index.js

以及下面的檔案內容:

// index.js
console.log('index.js', __dirname, __filename);
require('dep1');

// dep1/index.js
console.log('dep1', __dirname, __filename);
console.log(module.paths);

執行 node index.js 後輸出是下面這樣:

index.js /Users/kohpoll/Workspace/test/app /Users/kohpoll/Workspace/test/app/index.js

dep1 /Users/kohpoll/Workspace/test/mods/dep1 /Users/kohpoll/Workspace/test/mods/dep1/index.js
[ '/Users/kohpoll/Workspace/test/mods/dep1/node_modules',
  '/Users/kohpoll/Workspace/test/mods/node_modules',
  '/Users/kohpoll/Workspace/test/node_modules',
  '/Users/kohpoll/Workspace/node_modules',
  '/Users/kohpoll/node_modules',
  '/Users/node_modules',
  '/node_modules' ]

我們發現,index.js 可以成功的 require('dep1')。這很好啊,這讓我們除錯本地開發中的 npm 模組很方便。我們只需要去 require 模組的檔案所在的 node_modules 下面建立一個符號連結就行了。

但是在模組 dep1 中,__dirname__filename 都變成了模組實際的路徑,更要命的是模組查詢路徑 module.paths 也變成了從實際路徑開始查詢。

這會帶來什麼問題?

再考慮下面的目錄結構:

- app
  - index.js // require('dep1')
  - node_modules
    - dep1 -> ../../mods/dep1 // require('dep2')
    - dep2 -> ../../mods/dep2 // 符號連線
- mods
  - dep1
    - index.js
  - dep2
    - index.js

以及下面的檔案內容:

// index.js
console.log('index.js', __dirname, __filename);
require('dep1');

// dep1/index.js
console.log('dep1', __dirname, __filename);
console.log(module.paths);
require('dep2');

// dep2/index.js
console.log('dep2', __dirname, __filename);
console.log(module.paths);

當我們再執行 node index.js 時,輸出是下面這樣:

index.js /Users/kohpoll/Workspace/test/app /Users/kohpoll/Workspace/test/app/index.js

dep1 /Users/kohpoll/Workspace/test/mods/dep1 /Users/kohpoll/Workspace/test/mods/dep1/index.js
[ '/Users/kohpoll/Workspace/test/mods/dep1/node_modules',
  '/Users/kohpoll/Workspace/test/mods/node_modules',
  '/Users/kohpoll/Workspace/test/node_modules',
  '/Users/kohpoll/Workspace/node_modules',
  '/Users/kohpoll/node_modules',
  '/Users/node_modules',
  '/node_modules' ]
  
module.js:339
    throw err;
    ^
Error: Cannot find module 'dep2'
    at Function.Module._resolveFilename (module.js:337:15)
    at Function.Module._load (module.js:287:25)
    at Module.require (module.js:366:17)
    at require (module.js:385:17)
    at Object.<anonymous> (/Users/kohpoll/Workspace/test/mods/dep1/index.js:6:1)
    at Module._compile (module.js:435:26)
    at Object.Module._extensions..js (module.js:442:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:311:12)
    at Module.require (module.js:366:17)

發現了嗎?dep1 根本就 require 不到 dep2,因為 dep2 不在它的查詢路徑裡面!

關於這個問題,github 上有一個冗長的 issue 在討論。問題解決起來確實很麻煩,而且會 break 掉一大堆已有功能,所以,最終的結論是在找到更好的方法前給 node v6 增加了一個 --preserve-symlinks 選項來禁止這種 require 的行為,而是使用全新的 require 邏輯。有興趣和閒情的可以去圍觀:https://github.com/nodejs/node/issues/3402(真的好長......)。

至於全新的 require 邏輯會不會有新的坑,在沒有具體實踐前,我也不知道。

那我們上面的情況有辦法解決嗎?其實也有,那就是將目錄結構調整成下面這樣,從而讓 dep2 能在 dep1 的查詢路徑裡面:

- app
  - index.js // require('dep1')
  - node_modules
    - dep1 -> ../../mods/node_modules/dep1 // 符號連結
    - dep2 -> ../../mods/node_modules/dep2 // 符號連結
- mods
  - node_modules
    - dep1
      - index.js
    - dep2
      - index.js

參考連結

相關文章