[Javascript]非同步的救星Promise

Javascript Asynchronous Code

不論是撰寫前端 Javascript還是後端 NodeJS,異步操作都是無法避免的,像是向後端請求資料、處理瀏覽器事件、計時器等等都是常見的異步操作。 異步操作和我們熟悉的同步不同,當異步操作執行時,瀏覽器/NodeJS 不會等待當前操作的結果回傳後才執行下一行程式碼,他會繼續不回頭的執行下去,也就是我們常聽到的無阻塞特性 (non-blocking)。等遠端資料回傳後,瀏覽器或 NodeJS 才會通知 Javascript 引擎去呼叫使用者當初指定的 callback 做處理。

以下面程式碼為例。先定義一個函式 getRemoteData,其目的是用 Ajax 技術發出GET請求。我們運用 Javascript 中 function 可以當成變數使用的特性,將成功和失敗的 callback 設定為函式 getRemoteData 的參數,這就是 callback pattern 的撰寫方式。之後這個異步函式會在後面的例子不斷被使用到。

const getRemoteData = function (url, success, error) {
   let req = new XMLHttpRequest();
   req.responseType = 'json';
   req.open('GET', url);
   req.onload = function() {
      if(req.status == 200) {
         let data = JSON.parse(req.responseText);
         success(data);
      }
      else {
         req.onerror();
      } 
    }
   req.onerror = function () {
      if(error) {
         error(new Error(req.statusText));
      }
};
   req.send();
};

我們知道 Ajax 請求為異步操作,所以這裡程式不會等待 getRemoteData 完成後才繼續執行,而是會馬上接著跑 showStudents,但此時 showStudents 的參數 students 的結果還沒回傳,就會造成程式錯誤。

var students = null;
getRemoteData('/students', function(studentObjs) {
	 students = studentObjs; // 回傳資料
  },
  function (errorObj) {
     console.log(errorObj.message);
} );
showStudents(students); // null

要解決此問題,必須將 showStudents 放入 callback 函式,讓Ajax在資料回傳後執行 success callback 時,才執行 showStudents。

getRemoteData('/students', function(studentObjs) {
	 showStudents(studentObjs);
  },
  function (errorObj) {
     console.log(errorObj.message);
} );

這種 callback pattern 的寫法,會讓熟悉多執行緒的 C#、JAVA 使用者一開始很不習慣,其中也有許多優點與缺點,本篇專注在討論缺點:callback hell,和如何利用 Promise 來解決 callback hell 的問題。

什麼是 callback hell? 如何做初步重構?

如果有異步動作需要有順序性的呼叫,在程式撰寫上常常需要一層包一層,如下面的例子,click 事件的 callback 包了 getRemoteData 這個異步操作,getRemoteData 的 callback 又註冊了 mouseover 事件,mouseover 的 callback 又包了 getRemoteData,這種寫法會導致程式的可讀性變差,我們稱這叫做callback hell。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click',
   function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
       })
       });
} });

為了讓程式更可讀,我們利用 javascript 的 function 是 first class citizen 的特性,將這些 callback 命名成一個個易解讀的變數並分離出來,這種做法叫做 continuation-passing style (CPS)。像上面的例子,可以被重構成如下。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click', getStudentSNN);

var getStudentSNN = function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, 
		showInfoAndFindStudentGrade(info))
       .fail(showError);
} };

var showError = function() {
            console.log('Error occurred!');
};

var showInfoAndFindStudentGrade = function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
};

在這裡要順道提醒,異步操作和同步操作混用時要多多注意 closure 的特性,他會導致一些超出預期的結果,以下面 loop 內的異步呼叫為例子。

var students = [{ ssn: '111111', country: 'US' },
				{ ssn: '111112', country: 'US' },
				{ ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         function (grades) {
            showStudents(student.ssn, average(grades));
         },
         function (error) {
           console.log(error.message);
} );
} }

本程式的結果不是大家預期的:

111111 --> 80
111112 --> 78
111113 --> 90

而是

111113 --> 80
111113 --> 78
111113 --> 90

我們會發現,雖然後面的平均成績對了,但其學號都是 students 陣列的最後一位學號。 這個原因是因為 showStudents 並不是馬上執行,而是等到當時的 grades 回傳後才被呼叫,此時的 student 已經不是當時請求送出時的 student,而是被最後一次給值的 student,也就是 111113。所以我們必須要用 curry 寫法來避免這種問題產生,如下。

var students = [{ ssn: '111111', country: 'US' },
				{ ssn: '111112', country: 'US' },
				{ ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   let showGrade = function(student){
		return function (grades) {
            showStudents(student.ssn, average(grades));
    	};
	}();
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         showGrade(grades),
         function (error) {
           console.log(error.message);
} );
} }

利用 closure 的特性將 student 變數鎖在 showGrade 的 closure 之中,這樣呼叫 callback 時就會使用到當時指定的 student 變數,並達到我們原本預期的結果。

111111 --> 80
111112 --> 78
111113 --> 90

雖然 CPS 有改善程式的可讀性,但仍遠不夠應付更加複雜的應用程式操作,這時就來到本篇的重點,Promise!

初解Promise

我們常把 Promise 用來包裝異步函式,因為它能做到以下幾點:

  1. 用pipeline的方式解決callback hell問題
  2. 好的 Error handling 方式

Promise 擁有三種狀態: Pending, Fulfilled, Rejected,Pending 代表此 Promise 內的程式還未完成;Fulfilled 代表程式已執行成功,到達此狀態時執行 resolve 函式;Rejected 代表程式執行失敗,到達此狀態時執行 reject 函式。

FP

我們可以親自建構 Promise 要何時到達 Pending, Fulfilled, Rejected,如下面是我們用 ES6 的 Promise 做的展示,ES6 Promise 採用的是 Promises/A+ 的標準。我們設定 Promise:請求的結果如果回傳成功,到達 Fulfilled 執行 resolve(result);請求的結果如果回傳失敗,到達 Rejected 執行 reject 函式。

var fetchData = new Promise(function (resolve, reject) {
    // fetch data async or run long-running computation
    $.get(url, function(result){
        if (<success>) {
    	    resolve(result); // successful calllback
	    }
	    else {
            reject(new Error('Error performing this operation!'));
        }
    });
});

根據上面的範例,resolve 和 reject 函式都是”非同步操作完成”後才執行,所以在執行到 resolve 或 reject 之前,此 Promise 會一直保持在 Pending 狀態,等待遠端回傳結果,由此達成”異步操作完成後才會繼續執行”的功能。

然後,我們可以使用 Promise 的 then 指定 resolve 函式,catch 指定 reject 函式,如下:

fetchData
	.then(function(v){ console.log('Successful-' + v); })
	.catch(function(message){ console.log(message); });

resolve 和 reject 的回傳值會被包裝成新的Promise回傳,之後我們就能利用新的 Promise 繼續做串接,達到 pipeline coding 的效果,程式可讀性提升很多。

fetchData
	.then(...).then(...).then(...)  // pipeline

Promise串接的圖解可以參考如下

FP

Promise 在 Function Programming 裡類似於 Monad,關於 Monad 的圖解說明可以看這裡。簡單來說,Monad是一個值或是函式的 Wrapper,這個 Wrapper 擁有兩點定義:

  1. unit :: a -> Monad a (將 a 值用 Monad 包裝)
  2. flatMap :: Monad a -> (a -> b) -> Monad b (取出 Monad 的 a 做計算後,生成 b,將結果 b 重新包裝成新的 Monad)

在我們撰寫的 Promise 範例中,function (resolve, reject){ … } 相等於上面定義的 a 值。我們將這個函式用 Promise 包裝,之後用 then 指定 resolve 函式;用 catch 指定 reject 函式。Promise 會在執行 resolve 或 reject 時,將它們執行完的回傳結果,包裝成新的 Promise 回傳。這樣的流程符合上面定義的 Monad 功能,所以我們可以說 Promise 其實是 Monad。

###使用Promise 這節將幫助大家了解如何實際使用Promise。請看下面例子,這段程式首先呼叫後端全部學生的資料,將回傳的學生們經過排名後,用 for 迴圈檢查哪些學生的住所在 US,呼叫每一位在 US 的學生的成績後,計算平均,最後顯示在頁面。

 
getRemoteData('/students',
    function (students) {
      students.sort(function(a, b){
            if(a.ssn < b.ssn) return -1;
            if(a.ssn > b.ssn) return 1;
            return 0;
      });
      for (let i = 0; i < students.length; i++) {
         let student = students[i];
         if (student.address.country === 'US') {
            getRemoteData(`/students/${student.ssn}/grades`,
              function (grades) {
                showStudents(student, average(grades));
              },
              function (error) {
                 console.log(error.message);
}); }
} },
     function (error) {
        console.log(error.message);
} );

如果單純的使用 callback 撰寫,程式碼的可讀性會變得很差,重用性也不高,接下來我們就用 Promise 改寫。

  1. 將異步操作包成Promise (known as promisifying a function)

首先,讓我們將異步操作 getRemoteData 改寫成回傳 Promise 資料結構的 getPromiseWithRemoteData!

const getPromiseWithRemoteData = function (url) {
    return new Promise(function(resolve, reject) { // return promise
        let req = new XMLHttpRequest();
        req.responseType = 'json';
        req.open('GET', url);
        req.onload = function() {
            if(req.status == 200) {
                let data = JSON.parse(req.responseText);
                resolve(data); // being fulfilled
            }
            else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
        if(reject) {
            reject(new Error('IO Error'));
        } };
       req.send();
   });
};
  1. 將連續動作都用 then 的呼叫方式改寫,用 catch 方式補充例外處理

有了 getPromiseWithRemoteData 後,就能將上面的程式改寫成下面,程式碼一下子少了很多。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => showStudents(student, average(grades)))
                    .catch((error) => console.log(error.message))
            })
        })
    .catch((error) => console.log(error.message))

補充1. Promise.all()

根據上面的 students.map 程式碼,每個學生會經過 getPromiseWithRemoteData(

1
/students/${student.ssn}/grades
) 取得所有分數後,將平均分數顯示到頁面上,再回傳 Fulfilled Promise。但向遠端請求分數時,每個學生的分數回傳時間不一樣,會導致學生們的分數整合困難。舉例,如果我們希望得到住在 US 的學生的平均分數的再平均,根據上面的邏輯可以撰寫如下。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => average(grades))
                    .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))  // wrong answer
    .catch((error) => console.log(error.message))

這種需求用上面的寫法就會出錯,因為回傳時間不會如我們想的那樣同時到達,而是會陸陸續續回傳,導致無法算出正確的平均,此時就需要使用 Promise.all()。 Promise.all()會將一個 Promise 陣列包裝成新的 Promise 物件,此 Promise 物件會等待陣列中的每個 Promise 都變成 Fulfilled 後,才執行 resolve 函式,到達 Fulfilled 狀態,只要陣列中有一個 Promise 到 Rejected,Promise.all() 就會終止並到達 Rejected 狀態。我們可以將上面的程式改寫如下:

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
            return Promise.all(students)
                .then((student) => getPromiseWithRemoteData(`/students/${student.ssn}/grades`))
                .then((grades) => average(grades))
                .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))    
    .catch((error) => console.log(error.message))

就算陣列元素本身不是 Promise,Promise.all() 也會先將陣列元素都轉換成 Promise 元素後,再用 Promise 包裝陣列。有了Promise.all(),我們也能做到”等待多個回傳資料”的功能。

後話

熟悉 ES6 Promise 後,可以搭配學習 Generator 和 Async/Await,可以觀察到整個異步撰寫方法的演進。


範例參考與圖片來源

[FP]淺談functional programming效率優化

程式效率是常被人忽略的魔鬼

We should forget about small efficiencies, say about 97% of the time… premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

– Donald Knuth, The Art of Computer Programming

如果對 functional programming 的 coding 方式有所了解,就會知道它利用 function 去抽象化資料的操作,以語意隱藏每個步驟的實作細節,讓程式碼可讀性更高,更容易用 pipeline 的方式對資料流作分析。

但程式的 效率 和 抽象化(abstraction) 通常無法兩者兼得,像是 functional programming 最常用的 currying, recursion 等等…肯定沒有直接用 for, iterative 來的有效率,還要注意 stack overflow 等問題,但 functional programming 的編程方式有利於一些效率優化的策略,且效果顯著,本文會針對這些策略做說明。

  1. 解說 context stack 和 javascript function 的原理
  2. 三個優化方法
    • lazy evaluation 延遲執行
    • memorization 記憶法
    • recursive call optimization 尾遞歸

Context Stack 和 Javascript Function

當 JavaScript 呼叫 function 時,會在內部創建一個紀錄目前 Function 執行環境的物件 (Execution Context frame) 並放入 Function context stack。Function context stack 被 Javascript 設計用來儲存這些Execution Context frame,記錄整個程式執行的狀況。

當程式一啟動,Global Context frame 會與 Function context stack 一起生成,放在 Stack 最底端,儲存著程式的全局變數。

FP

每一次呼叫 function 都會在 context stack 產生新的 Execution Context frame,最前方的 context frame 叫做 active context,是目前 code 的執行環境。在 code 執行完成後,active context 會被移出 stack,換下一個最前方的 context 當 active context,以下圖為例就是 logger。

FP

程式碼對照

const logger = function (arguments){
	var Log = new Log4js.getLogger(arguments)
	...
}

以下用一個錯誤的 recursive code 為例子,來測試瀏覽器到達 Range Error: Maximum Call Stack Exceeded 的情況。不同瀏覽器能接受的 frame 數量不同,我用 Google Chrome v57.0 測試,發現可以接受約16035個。示意圖如下。

function longest(i) {
	console.log(i);
	longest(++i);
}
longest(1);
FP

Lazy Evaluation

延遲調用 (Lazy Evaluation) 的精髓在於,將需要做的操作事先宣告好,相依資料都準備齊全,在真正需要執行的地方才執行操作。這樣做的好處是只針對必要的資料做計算,不用浪費時間計算不需要用到的資料。如下圖,主流的 Javascript coding 方式是 eager evaluation,資料會在需要用到之前就全部準備好,這種做法會因為浪費無謂的計算時間,而降低程式效率,相比下來,Lazy Evaluation 就只會計算需要用到的資料。

FP

下面再進一步講解 Lazy Evaluation 帶給我們的兩個優點:

  • Avoiding needless computations 避免不必要的計算
  • Using shortcut fusion in functional libraries 使用“融合操作”提升效率

關於”避免不必要的計算”,下面給了一個簡單的例子,將 alt 與 append 兩個 Function 做”事先”組合,在 showStudent 指定的學生學號 (val) 輸入時,才執行 func1, func2 和 append。

// R為Ramda
const alt = R.curry((func1, func2, val) => func1(val) || func2(val));
const showStudent = R.compose(append('#student-info'), alt(findStudent, createNewStudent));
showStudent('444-44-4444');

這種”後執行”的模式,也得利於資料暫存,甚至能在執行時,根據輸入的資料,對一連串 pipeline 步驟做優化,我們叫它 shortcut fusion。很多 functional libraries 已經幫我們實現這種優化,以 Lodash 的 _.chain 為例子,他不只可以將一系列的 function 串起來,並在該執行的時候利用 value() 執行;還可以整合 function 的執行與暫存,使其更有效率,舉例如下。

const square = (x) => Math.pow(x, 2); // 平方
const isEven = (x) => x % 2 === 0;  // 是否偶數
const numbers = _.range(200);
const result =
	_.chain(numbers)
	 .map(square)
	 .filter(isEven)
     .take(3) // 取前三的偶數值
     .value();  //-> [0,4,16]
result.length; //-> 5

我們可以發現結果 result 的容器長度只有 5,代表 Lodash 只計算了5個數值就完成了本操作的計算。對比計算全部 200 個數字才取前三個數值回傳,效率快了不少。

關於優化方法,首先 take(3) 讓 Lodash 知道本操作,只需要取前三個通過 map 和 filter 的數值即可;再來 Lodash 運用 shortcut fusion,將 map 和 filter 操作合併 compose(filter(isEven), map(square)),如此每個數值就不需要等其他數值計算完 map 才執行 filter,而是會一次性的經過平方和偶數判斷,由此省下不必要的計算時間。如果我們在 square 和 isEven 放 console.log,如下。

square = R.compose(R.tap(() => trace('Mapping')), square);
isEven= R.compose(R.tap(() => trace('then filtering')), isEven);

我們會觀察到 console 會 print 出以下的訊息五遍:

Mapping
then filtering

Memoization 記憶法

使用暫存 (cache) 是增加效率很重要的一種做法,而 functional programming 可以將 Memoization 運用到一個極致。

Cache 的實作方式,簡單說如下。在執行費時操作前,先檢查 cache 內是否有相對應的解答,如果沒有才做計算。

Memoization 要對擁有 referential transparency 特性的 function 才適合,因為其回傳值的影響因子只有函式參數,沒有 side effect 干擾,而函數參數正能當 Cache 裡 key-value pair 中的 key。

function cachedFn (cache, fn, args) {
    let key = fn.name + JSON.stringify(args);
	if(contains(cache, key)) { // 檢查是否暫存有相對應的解答
		return get(cache, key); // 回傳暫存的結果
    } else {
    let result = fn.apply(this, args); // 執行費時操作
    put(cache, key, result);
   	return result;
	}
}

我們可以用 cachedFu 來裝飾 findStudent,讓他擁有暫存的功能!如下。

var cache = {};
cachedFn(cache, findStudent, '444-44-4444');
cachedFn(cache, findStudent, '444-44-4444'); // 第二次取暫存的數值,執行速度超快

這種架構類似於 Design pattern 中的 proxy pattern,由 cachedFn 代理 findStudent,在執行 findStudent 前確認是否 cache 已經存有相應的結果;在執行findStudent 後,將回傳的結果儲存進 cache。但這種寫法,cache 和 function 是分離的,不只測試麻煩,還需要花額外邏輯去管理不同 function 的 cache。下面再介紹更進階的做法。

在 Function 中內建 cache

// 將cache儲存在Function的prototype
Function.prototype.memoized = function () {
	let key = JSON.stringify(arguments);
	this._cache = this._cache || {}; // 產生內部的 cache
	this._cache[key] = this._cache[key] || this.apply(this, arguments); // 如果cache的 key-value pair不存在,則執行此耗時操作
	return this._cache[key];
};
Function.prototype.memoize = function () {
	let fn = this;
	if (fn.length === 0 || fn.length > 1) { // 只讓擁有一個參數的function擁有cache
		return fn; 
	}
	return function () {
 		return fn.memoized.apply(fn, arguments);
	};
};

用這種方式將 cache 裝在各自的函式內部,避免創建 Global shared cache 而污染 Global Context,又方便做測試。我們用以下的 code sample 做展示。

var rot13 = s =>
   	s.replace(/[a-zA-Z]/g, c => String.fromCharCode((c <= 'Z' ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26));
var rot13 = rot13.memoize(); // 使用cache

觀察後發現,第二次呼叫的時間比第一次呼叫的時間快約 0.7 ms!

rot13('functional_js_50_off'); // 0.733 ms
rot13('functional_js_50_off'); // second time: 0.021 ms

他們之間的交互作用如下:

FP

Functional Programming 有利於 Memoization

以化學中的溶解度為例,粉狀的溶質比塊狀的溶質更容易溶解於溶劑,是因為粉狀的溶質接觸的表面積比較大;同樣的,當我們將 function 的步驟切的越細,耦合度越低,Cache 就能做的越仔細,也就能在各種微小的地方節省時間,進而產生很大的效能進步。舉一個例子

const m_cleanInput = cleanInput.memoize(); // 輸入做暫存
const m_checkLengthSsn = checkLengthSsn.memoize(); // 檢查結果做暫存
const m_findStudent = findStudent.memoize(); // 學生結果做暫存
const showStudent = R.compose(
		map(append('#student-info')),
        liftIO,
        chain(csv),
        map(R.props(['ssn', 'firstname', 'lastname'])),
        map(m_findStudent),
        map(m_checkLengthSsn),
        lift(m_cleanInput));
showStudent('444-44-4444').run(); //-> 9.2 ms on average (no memoization)
showStudent('444-44-4444').run(); //-> 2.5 ms on average (with memoization)

由上可知,每個微小的步驟都做暫存,累積起來後,在效能方面會有很大的改善。

遞迴和尾遞迴 (tail-call optimization (TCO))

由上一節,我們已經知道要使用 Memoization,Function 必須要是 referential transparency 且參數重複的可能性大,才有做 cache 的意義。但有的 Function 參數重複的機率低,又超乎想像的耗時,又使用大量 recursive,有什麼方法能讓 recursive 變得跟 loop 一樣有效率呢? 答案是,將 recursive 寫成尾遞迴 (tail-call optimization (TCO)) 的形式。

TCO

尾遞迴如下,將遞迴的 Function 放在函式最尾端,作為最晚執行的部分。

const factorial = (n, current = 1) =>
   	(n === 1) ? current : factorial(n - 1, n * current);

在經過 ES6 compiler 的最佳化後,會跟下面的 loop 一樣快!

var factorial = function (n) {
	let result = 1;
	for(let x = n; x > 1; x--) {
		result *= x; 
	}
	return result;
}

因為尾遞迴的寫法,讓 Javascript 知道它在執行 sub factorial 時,不需要將目前的 frame 保留,因為 sub factorial 執行完後不需要回到 parent context 去做剩下的工作。所以他可以在執行 sub factorial 前就直接將目前的 Active context 給丟棄,並將 sub factorial 的 Context frame 變成新的 Active frame。這種做法不只加快效率,也避免了可能 Stack Overflow 等問題。

FP

如何將遞迴寫成尾遞迴?

首先,來一個不是尾遞迴的函式。

const factorial = (n) =>
	(n === 1) ? 1 : (n * factorial(n - 1));

他不是尾遞迴的原因,是因為在 factorial(n - 1) 執行完後,還需要回到原本呼叫 factorial(n - 1) 的父函式,進行乘 n 的動作。如果要將它轉成尾遞迴,必須依照下面步驟:

  1. 將乘法操作當成 current 參數傳入 factorial
  2. 使用 ES6 default parameters 的特性,為每個參數設定初始值

改寫的結果為:

const factorial = (n, current = 1) =>
	(n === 1) ? current : factorial(n - 1, n * current);

下面有比較,比對 TCO 和 iterative 是多麼地相似,但 TCO 可讀性又比 iterative 更高一些。

FP

再來一個範例,請將下面的 recursive function 改成 TCO

function sum(arr) {
	if(_.isEmpty(arr)) {
		return 0; 
	}
   	return _.first(arr) + sum(_.rest(arr));
	}

一樣遵照上面兩個步驟,先將 function 尾部需要做的事情移成 function 的新參數,再設定參數的 default value,改寫結果如下。

function sum(arr, acc = 0) {
   	if(_.isEmpty(arr)) {
		return 0; 
	}
   	return sum(_.rest(arr), acc + _.first(arr));
	}

在團隊合作中,有時程式的可讀性比程式效率本身來得重要,尤其是在這個電腦運算已經如此快速的年代,也因此,最近 functional programming 的撰寫方式才會流行起來。究竟要使用什麼 coding 方式去實作功能,還是取決在於不同的專案和團隊需求。

總結

  1. Functional Programing 在某些狀況,效率會比 loop 寫法 (imperative code) 來得慢。
  2. 使用 functional libraties,像是 Lodash 來達成 lazy evaluation 和 shortcut fusion 等效率優化
  3. 使用暫存的方式 (Memoization) 來避免重複計算
  4. Functional programming 的低耦合,將 Memoization 發揮到極致
  5. 用尾遞迴 (TCO) 優化 recursive

參考與圖片來源

[讀書心得]Rapid learner

推薦

rapid learner

Scott H. Young 為加拿大人,他最有名的事蹟就是在 12 個月自學完成 4 年 MIT 的 Computer Science 33 門課程,通過所有考試和測驗,而他在 Rapid learner 中要帶給我們的就是如何使用”整體性學習法”學習。雖然本書是英文,但使用的單字和文法十分直白,可以看得很快,我個人看完後,實際用在學習上效果非常顯著!想要傳送門請至HOW TO BECOME A RAPID LEARNER

下面我會先介紹我在 Rapid learner 中得到的精華,結束後會跟大家分享我實際利用書中理論的經驗。

大綱

學習是一切的根本,在這個變化快速的世界,最終都是在競爭“誰能學習得更快”! 本教材總共分六章,有系統的幫我們整理出“不會水過而無痕跡”的學習方法,在這知識恐慌的年代,無疑是一支浮木呀!

  1. Project: 用專案管理的方式制定我們的學習計畫,這裡講的跟 PMP 的 PMBOK 前兩章有 80% 相似 XD
  2. Productivity: 學習動機、能量管理、精神支柱等…都跟學習的效率息息相關,對要學習的事情充滿好奇是很重要的。
  3. Practice: 練習最重要!學習時,要 75% 的時間放在 practice 上。
  4. Insight: 將學到的東西內化成自己的見解/直覺,測試自己、改進自己、迭代自己。
  5. Memory: 快速記憶沒有明確關聯的知識 (ex. 背單字)
  6. Mastery: 學了知識後,如何進一步對待這些知識?

本教材影響我最大的是第3章 Practice 和第四章 Insight 的方法,本篇會把重點放在這兩章。

心得

第一章,制定專案

學一個技術時要專心,要堅持。給自己一到兩個月的時間專心學一個技術,比花六個月同時學三到四個技術的效果來得好。 此外,挑選適合自己的教材非常重要,花多一點時間翻閱相關教材的文字敘述風格,大略了解整個技術的背景,這個過程可以當成走馬看花來享受。 時間規劃方面可以分成 Top-down 和 Bottom up , Top-down 是由下往上推算整個學習所需要花費的時間,例如:一個禮拜讀一章,八章就要花八個禮拜,所以整個學完需要兩個月! Bottom up 是由固定時間來換算出每個階段要花多少時間完成,例如:這個技術我想花一個月完成,而我選擇的教材有四章,所以我一個禮拜要讀一章。一門技術,要淺可以淺到只知道如何使用,要深可以深到學習內部是如何實作,甚至了解背後的設計概念。制定範疇 (scope) 是專案管理非常重要的一塊,決定什麼該做,什麼不該做!達成目標的標準要遵循 SMART 原則:

  • Specific (明確): 目標要明確!ex. 在本學期電子學的課學會電子學 > 電子學期末考要80分以上 > 每次電子學作業的觀念要全部了解
  • Measurable (可測量): 可以利用測驗或其他方式審核學習目標
  • Attainable (可達成): 在規定期間內達到此目標不是天方夜譚
  • Realistic (實際): 每個階段都有觀察的指標能審核學習成效
  • Time-based (限時): 學習專案有時效性

時間可以用一週來當單位,每週一 review 一次上一週的學習成效,不要頻繁的修改目標,設定 soft deadline 給自己緩衝的時間。

第二章,能量管理

根據每週安排的進度做優先度排序,先做優先度較高的任務!可以每天早上等車或開車的時候制定 Daily goal,晚上睡覺前回顧 Daily goal。關於 Daily goal 執行上的得失心不要太重,沒完成是正常的,不要因此心裡有負擔。

再來的重點是管理 energy !! 我們大部份不是真的身體累、頭腦累,而是精神能量消耗殆盡…不要忽略補充自己的能量。 我們要盡量每天運動、培養興趣,休息時做自己有興趣的事,和家人跟朋友交流,讓自己快樂,還有精神目標,或者說是神的引導,不斷思考自己人生意義並往前邁進。

如何克服拖延症:

  1. 當想休息時,跟自己說先忍耐個10分鐘再看看,10分鐘過後如果還想休息再休息。
  2. 切換工作:1小時做A,1小時做B,互相切換防止無聊。

如何知道適合自己的學習速度? 每週測試自己,根據完成進度和精神狀況調整每週工作量,在迭代中達成平衡。

第三章,練習再練習!

根據實驗結果,我們了解到如果使用小範圍記憶法,短期內記得很清楚,但過2~3個月就會忘的一干二淨,這也是為什麼很多人期中考前讀一個禮拜就考 90 幾分,但過幾個月就忘得一乾二凈。使用整體性記憶法(the meaning of idea),雖然短期效果可能不如小範圍記憶法,但因為掌握整個大範圍知識的脈絡,可以記得比別人更長久,也更能在不同狀況下活用知識,就算忘記,recall的速度也會很快,不會像第一次學習那樣一片白紙。

整體性記憶法(the meaning of idea) 的 Mindset:

  • Focus on 大概念 (forest)
  • Focus in hard question: 專注在自己的弱點(weak point)
  • 學習生產力衡量方式 = Time(所花時間) X Intensity(學習時的痛苦 (struggle) 程度)

(1) How to find weak point quickly?

如果有歷屆考卷,直接先寫歷屆考卷,從錯的歷屆考題去學習。如果沒過去的題目,每個章節自己出題!用所學自己解惑,自己解釋。 在選擇考題時,要先選擇 high level 的問題測試自己,因為它同時包含多個概念的應用,複雜的題型可以真正檢驗出學生的弱點。 再來檢討錯的題目,找出錯誤的地方是因為哪個觀念沒有了解透徹,並找出該弱項所有相關的 low level 題型,再拼命練習。

  • 重點:先做 high level 找出弱項,再根據弱項概念找 low level 加強練習!可以訂每日目標,ex. 每天兩題 high level 題目,三題針對不會的地方的 low level 練習

(2) 使用 Active recall!練習看到知識的森林(Big Idea),而不是枝枒:

看完一個章節、一個長段落或一節課的進度後,將那個部分的概念總結成 1~7(最多) 個關鍵問題,並在題目旁邊標註書本或筆記頁碼,而那個頁碼對應到的是問題的答案。每次複習時,請直接先看題目回答問題,再翻頁數去查看解答,而不是重頭開始看筆記複習。整理問題時,要跟著考濾其是否與專案目標吻合。

關於每次回答的分數可以分成四種:

  • 0分:完全無法回答
  • 1分:可回答,但漏掉很多細節
  • 2分:可以回答重點和細節,但回想困難
  • 3分:可以輕鬆回答

把分數記錄在問題旁邊,下次複習時直接先從低分的開始測驗。

在此介紹Anki這個好用的記憶管理工具,人們通常都是拿它來背單字,但其實 Active recall 也能直接用 Anki flashcard 幫助管理記憶週期,把 review schedule 都交給Anki是很不錯的選擇。

第四章,內化成直覺

我們將 75% 的時間放在練習,25% 的時間放在形成直覺和記憶。 形成直覺的方法,建議使用 Feynman Technique method,Feynman Technique 類似自我檢驗,防止學習者自我感覺良好而不自知。 有時候考試考不好的原因,不是因為不用功,而是沒有一套好的自我檢驗的方法,所以只好靠感覺猜測自己學習的程度到哪裡,碰運氣的成份居多。 有了這一套方法,我們就能透過不斷的重複檢驗自己,來達成扎實的學習了!

Feynman Technique(add spice in food):

  1. 寫下想要測試的觀念
  2. 解釋觀念!講出來,想像解釋給5歲的小孩聽
  3. 如果說不出來,或說的冗長沒重點,代表找到弱點!
  4. 找解答,再學習

解釋時,試著用比喻來簡化問題,也要解釋”過去的自己不清楚的是哪一點,為何不清楚”等等。 想要測試的概念可以先從大方向解釋開始,再針對各別細部的點做比較精確層次的解釋。 複習 Anki字卡 (active recall) 時,如果停頓很久,代表此觀念還沒了解透徹!是使用Feynman technique的最佳時機! 此時絕對不要因為逃避而跳下一題,要特別記錄下來刻意練習!

  • 關於 Feynman technique 的耗時,給自己約30分鐘~1小時搞懂,如果超過1小時還是不懂,直接尋求幫助避免浪費時間。

透過練習時思考練習題目背後,要測試的概念。了解透徹後,透過做low-level的題目刻意練習。

第五章,記憶

人類腦袋的強項是記憶概念與關聯,所以人類對具體圖像、空間、故事、物與物的關係,學得特別好。 所以我們的目標是將複雜的概念,轉化成容易記憶的比喻或解釋,最終形成永久記憶。

我們在學習的過程中,有時候新知識的某些概念會連結到舊知識,也就是不一樣的兩個東西,會有相同的隱含概念,這叫做 Metaphor,對記憶增強非常有效。 產生Metaphor的時機是可遇不可求的,經常在使用 Feynman Technique 和 active recall 時發生! 為了讓自己更頻繁的感受到 Metaphor,要常問自己:

  • 是否能一句話說明此概念的原則?
  • 這個觀念可以給一個實際的例子嗎?
  • 可以提供圖說明嗎?
  • 這個概念像什麼?

重點其實是想視覺化的”過程”,不是視覺化的結果。有時在使用Feynman Technique時,與其一直往下追到最細節,不如使用”比喻”做代替,不要脫離自己目標的範疇。

metaphor優點:

  1. 促進記憶
  2. 促進理解

但是…

別忘記之前說的,75% 的時間用在練習,練習優先!Metaphor 只是 helper,不能走火入魔,想用它取代練習。沒有夠多練習的話,用 Metaphor 只會越來越糟,新舊概念會混在一起。

Metaphor 使用情境: 剛剛用Feynman了解了一個概念,來用metaphor加強一下記憶吧!

關於背單字,本章提供兩個方法:

  1. Link method(不適合背太多單字(聯結也是負荷))
  2. Memory Palace(適合一次背超多單字)

(1) Link method步驟:

  1. 取兩個想要連結的idea (ex. book → 書)
  2. 將兩個概念都產生視覺化的畫面(ex. 用”聽起來像…”去產生畫面,像BOOW的砸)
  3. 將兩個畫面聯想到一起 (ex. BOOW的砸書)
  4. 記在Anki不斷練習聯結 (ex. 當想不起book的意思,只要想想他唸起來像BOOW,就能聯想到砸書)

(2) Memory palace步驟:

  1. 想像在自己熟悉的地理位置(ex. 家)
  2. 在心中走過此位置,想像其中的景觀物(桌子, 沙發…)
  3. 將遇到的景觀物和要背的東西連結在一起
  4. 當要回想東西時,就在心中把位置走一遍
  5. 當要背新東西時,要先用”想像”把之前背的東西燒掉

第六章,學了知識後,你如何對待這些知識?

很少人學習完一門技術,還會繼續思考要如何對待這些學過的知識,但這其實是非常值得思考的點,因為如果學過就忘記,水過而無痕的話,我們是無法透過學習改變自己的!這樣辛苦學習就沒有意義了。

本章節提供三種方式對待學過的知識:Maintain、Master、Relearn,這三種方法的選擇必須要是有意識的決定!越早思考越好!他會間接影響我們學習的態度。

(1) Master: 更進一步精進,再建專案,進行下個學習目標

在往更高層次學習時.常會遇到的問題是沒有學習資源,所以常需要用打游擊+訂立明確的目標來提升自己。以下有建議提供大家參考。

  • 同一份專案做“重構”,用更好的架構
  • 用學到的技術做不同的專案累積經驗,每個專案都要思考有沒有更好的做法等…如果做的途中遇到不熟悉的,代表找到弱點,要快回去relearn

(2) Maintain: 主動不斷回顧,保持在熟悉階段

以下是學習示意圖。60 分到 90 分叫 learning,在同一個level做重複的學習叫 overlearning! overlearning 的重複學習,目的不是更強,而是增強記憶。 Maintain 的核心概念就是要 overlearning。

60分–(learning)–>90分–(overlearning)–>90分

學習過後,仍然持續做負擔小的練習。將進度放在 daily/weekly goal,隨著熟悉狀況慢慢減少複習時間!

Ex. 第1年放在 weekly goal –> 第2年放在 monthly goal

哪種問題是適合練習的?有足夠深度、廣度和實用度的練習!

Ex. 英文對話 > 英文單字:小專案 > 程式面試題 > 重看筆記

觀念練習方面,建議複習之前 Active recall 說過的,每章節的七個問題,每週拿來考考自己。下面整理複習的方法,relearn 也適用!

  1. 一開始先用考試評估自己,確認哪的部分很難回想(ex 看書本目錄recall / 拿面試題自己考)
  2. 考試題目推薦做注重觀念的”複雜問題”,不是考片面知識的問題。重點放在回想的過程,不是結果,所以就算一張考卷一題都寫不出來也沒關係
  3. 檢討,先做錯最多、最難回想的問題 –> practice + recall
  4. 區分”新 recall 的知識”和之前的”舊知識”的區別
  5. 即使曾經學過,某些地方仍會感覺是第一次學 –> 用第一次學習時的策略 (參考上面幾章);重新學習的知識 –> 關注在概念!

(3) Relearn,可以允許忘記,但重新學習後,知識很快就會回來

如何讓知識快點 recall?

  • 歸納出“組織本知識”的方法,把這門知識各個概念都串起來,像粽子一樣,找到一個點就能一次一起拉起來!
  • 不要重讀筆記,最好的方式是馬上找這個科目的期末考卷或面試題考自己,確認哪裡忘記,詳細方法同上面的複習策略!

使用心得

我拿我準備面試的方法來做例子。首先是拿出履歷表,把上面寫的技能全部列出來,像是 Java, Android, React 等…因為這些都是已經會的技能,所以現在的目標是快速 relearn 這些技能!

Ex. 我把網路上的面試題 (google “JAVA 面試題”) 找出來考自己一輪,把錯的、不清楚的地方列出來,由這些列出來關鍵字找出相對應的 Java 章節,重讀不清楚的部分,最後整理成一疊約20張的觀念字卡。每天去研究室等車的時候就刷一遍字卡,連續一個禮拜就可以變很熟了,完全無壓力!

tag tag

整理字卡的過程會很累,因為絞盡腦汁思考有沒有更簡短的方式陳述整個概念,但這種方式其實會進步最快,我也在這過程中學習到很多。所謂生產力 = Time X Intensity,真是一點都不假。

[讀書心得]The Effective Engineer

推薦

book cover

本書是我人生中第一本認真閱讀的”生涯成長類”書籍,以前我都將這種類型的書當成閒書在看,因為這種書往往都只是講一個概念,沒有非常具體的案例過程, 就算有也不一定是符合軟體工程師的。而這本書的不同點在於,作者 Edmond Lau 本身就曾經是Google工程師,也在Quora等…startup待過,經歷過許多風風雨雨, 並拿過往的經驗做了整理與分析後,寫成了這一本書。因此這本書的案例非常具體,除了自身經驗外,還補充了Facebook, Twitter等公司的辛酸史,並從案例裡面引出Effective engineer所需要的特質。 如果想要更詳盡的書籍介紹及購買辦法,請移步至The Effective Engineer

PS: 一個人讀只有一種想法,五個人就會有五種想法,不同想法間的所激盪的火花常常會超出想像!Soft&Share有為這本書舉辦線上讀書會,不得不推,這是我對於讀書會的心得

本書大綱

作者將本書分成三塊,教導我們如何成為有效率的軟體工程師。

1、第一塊,要建立正確的心態,改變先從自己做起

  • Focus on high leverage
  • Optimize for learning
  • Prioritize regularly

2、第二塊,拼命執行,執行,再執行。不論是程式、人、團隊、專案、公司,都是要經過不斷迭代來改善自己、靠近目標的。

  • Invest in iteration speed
  • Measure what you want to improve
  • Validate your idea early and often
  • Improve your project estimate skill

3、第三塊,建立長期的價值。高影響力的工程師,不能只是自己厲害就好,還要能帶給團隊正面的力量,幫助團隊成功! 如果一個工程師,能解決專案遇到的困難,又能凝聚團隊向心力,讓團隊向上成長,就是真神人了!

  • Balance quality with Pragmatism
  • Minimize operational burden
  • Invest in your team growth

本篇心得不會詳述書本的內容,只會點出我印象深刻的幾個部分。主要會集中在第一塊跟第二塊。

心得

第一塊,建立正確的心態

整本書用這個公式貫穿:

  • Leverage = Impact Produced / Time Invested

我們要透過改善Leverage,來成為有效率的工程師。

由這個公式來看,提升leverage有兩個方法:

  1. 縮短花費的時間或擴大產出
  2. 選擇 high leverage 的事情,但什麼才是 high leverage 的事情?

由第一點來看,就是改善自己的技術和解決問題的能力,來擴大產出;或是增加自身的專注力,市面上有很多這方面的書籍,像是蕃茄鐘方法等…都是改善專注力的好辦法, 但作者認為提高 leverage 的效果是更為巨大且立即見效的! 這可以說是一種做事的智慧,他舉了很多例子強調這一點,從個人,到團隊,到公司。

  • 再來是學習的重要性,作者強調要時時刻刻關注在成長 (Growth)! 學習屬於重要但不緊急的事情。

  • 最後是養成Prioritize的習慣

工作優先度: (1)重要又緊急的事(直接增加到產品價值) (2)重要但不緊急的事(Ex: 學習) 在(1)中,先選擇用最少“努力”達到“最大”價值的事。

如果想克服拖延症,可以用if-then方法。(Ex: “如果”這杯水喝完,我”就”學習新東西…)

第二塊,拼命執行,執行,再執行

有了好的心態後,就要加強迭代,藉由驗證不斷修正自己的方向,達成目標。 快速疊代能提供有效的學習與能力上的提升。因此,我們應該不吝惜地投資在提升疊代的速度。 疊代的速度越快,越能即時地瞭解什麼決策是有效的,什麼是無用的。

要增加迭代速度,就必須節省做”不用動腦的工作”的時間,保持的原則是:

  • 「只要需要重複執行兩次以上的工作,對值得為第三次之後的執行開發一個工具。」

能幫助節省時間的工具,不僅對個人有益,如果應用到整個團隊上,更能大幅的提升整體的效能。 還有像是改善自己的workflow、debugging flow,也可以幫助自己在以後程式出問題時更容易針對問題做修正。 舉例,假設一支程式的flow是A -> B -> C,你已經大概猜到bug是發生在B上面,你希望run code的時候,可以直接跑到B去重現bug,這樣就不用花時間在經過A的上面了。 此時軟體組件的耦合度低就很重要,才容易拉出來做單元測試,快速針對問題做測試。

  • 熟悉你的開發環境

改善你的開發環境吧! 能自動化就盡量自動化,有更快速、更方便的工具,就盡量去學,減少手動操作的時間。

  • 除了技術,也要注意人和

很多時候,我們花費最多的時間反而不是coding,而是其他有關於人的bottlenecks

再來就是需要好指標,指標不只能監控程式狀態,還會趨動整個團隊的開發方向。好的監控指標需要下面三的特點。

  1. maximize impact, 看你的目標是什麼,選對於目標有最大影響的指標,核心指標大於其他指標,當新功能使核心指標改善,卻使其他指標惡化時,就需要團隊去做取捨。
  2. actionable,因果關係明確的指標(能確實反映團隊努力的指標),像是網站的總瀏覽量/總註冊數等…就不適合,因為他不能確實反映產品新功能對指標的影響(前後立足點不同等原因),好的指標像每週活躍用戶年齡層比率等…,就能明確了解到努力是否真的有達到目標。
  3. responsive,快速反饋且干擾少,好的指標能快速得到反饋(feedback),增加迭代速度,但也要能排除或弱化其他影響因素(noises),例如per-minute response time metric的變化和雜音就太大了

定好指標後,記憶一些關鍵的數值也很重要,他們可以幫助你快速評估目前的狀況。 假如你要建立一個系統,要是你知道一些數值,像是平均從Memory讀取1MB的資料,大概要花250us 、平均從硬碟讀取1MB的資料,大概要花30ms、平均從網路讀取1MB的資料,大概要花10ms, 你可以就快速粗估整個系統所會花費的平均執行時間,然後比較已經建立好的系統的執行時間,也就可以大略的知道問題是出在哪邊。

最後,適當的懷疑數據並懂得排除不好的資料。 我們習慣用自己喜歡的方式去詮釋資料,這樣容易造成誤解而不自知。 我們要常常問自己下面這兩個問題:

  1. Is there some way to measure the progress of what I am doing?
  2. If a task I’m working on doesn’t move a core metric, is it worth doing? Or is there a missing key metric?

有好的指標,就能更全面的瞭解目前的狀況,進而安排事情的輕重緩急。

第三塊,建立長期的價值

第一點就是,要求完美常常是優秀工程師的致命傷,要懂得為現實情況去做妥協。

Ex. Code review VS 專案時程, 有時時程太趕,可以選擇先功能做出來,能執行並上線後,再重構和處理技術債的部分。

第二,找解決方案時,要選擇最穩定的做法,可靠穩定的解決技術,而不是最新最熱門的方法;因為可以降底維護新技術的成本,避免過多客製化的動作(這些其實都是能避免的)。

考慮以下情境:

  1. 引入新語言:team member熟悉嗎?容易學嗎?新進員工學習曲線?
  2. 換資料庫: 其他team的擴展效果也良好?維護和擴展成本?有比大部分的標準作法好?
  3. 遇到問題,與其開發一條客製化的解決方法,增加整個系統的複雜度..不如重新規劃整個系統架構,簡化他!(不要像Printerest初期那樣越用越複雜越難維護),要評估兩者的利益
  4. 你的data真的多到需要Cluster?別忘記Cluster也比單機更難維護和debug!
  • 做簡單的事,用公認的解決方案比較穩健,不易出錯,且支援的套件也比較多。新技術支援少、懂得人少、套件不足,startup初期還是不要搬石頭砸自己的腳。

最後是專注在團隊的成長。

一個好的文化並不是一天造成的,我們不只是要自己成為effective engineer;更要建立一個effective engineering culture。

  1. 幫助身邊的人成功
  2. 僱人是high-leverage,投資在招募好的人才
  3. 投資onbroading(員工訓練)和mentoring,形塑好的做事態度與觀念
  4. 建立分享code的機制
  5. 執行專案時隨時追蹤留下紀錄,專案結束後將經驗文件化,此為公司的眾人智慧
[自動建置實戰]Jenkins+Github+Docker

發跡

以前就聽過”好的工程師,要把自己的工作流程自動化,讓工作更有效率”一說,剛好這個禮拜研究室老師給我們放了一週假,讓我有機會玩一玩,把實驗室的一些部署自動化。

我們實驗室有很多專題生,每組專題生都要把他們的Web伺服器部署到一個人人都訪問到的地方。我們實驗室以往的做法都是,在實驗室電腦開VM,轉8080 port和3389 port出去給專題生,讓專題生他們自己遠端進他們的VM手動部署。這過程常常抱怨不斷,像是VM太慢,操作很卡等問題…而讓我想自動化的真正動機是,我和一組專題生有個共同合作的計畫,我做後端邏輯,專題生做前端UI,他們前端Ajax的部分一直出現CORS問題,導致他們要將結果部署到我的Tomcat後才能做他們前端的測試,這個部署過程太過頻繁,讓他們對VM的慢速更是無法忍受。所以我決定試著用Jenkins+Github+Docker幫他們自動部署Tomcat Web服務,來取代以往傳統的手動部署,減輕大家的痛苦。

我規劃的流程如下,專題生上傳他們的前端作品到Github,Github會觸發我們實驗室的Jenkins的作業,驅動本機電腦做docker的部署工作,整個過程都能在Jenkins看到並追蹤,部署完後,專題生就能直接用瀏覽器查看更新。

process

安裝Jenkins

Jenkins是一套好的自動化整合/部署工具,可以幫忙跑專案測試,測試完成後部署到相對應的伺服器,也可以自動寄信給開發者或者是指定的內部人員。很多人看中他良好的視覺化介面和豐富的trigger plugin,我們這次實作就會使用到Github plugin。

jenkins

我的OS是Ubuntu 16.04,而在Ubuntu安裝Jenkins基本上毫無壓力,詳細可以參考這一篇做操作。再來跟大家講一個我遇到的坑Orz,一定要設定Jenkins User,否則會一直遇到類似Jenkins script cannot open .git/FETCH_HEAD: Permission denied等…權限問題,雖然可以手動chmod更改文件權限,但設定Jenkins User比較一勞永逸。設定請參考這篇How to run Jenkins under a different user in Linux,他能解決之後run script的很多問題。

架起來後,就會出現類似以下畫面!

jenkins

因為要提供帳號給專題生做使用,所以必須設定專題生的帳號權限。

(1) 左邊欄點選“管理Jenkins”,到右邊滾輪往下拉,會看到”管理使用者”,點入。

(2) 點選左邊欄的”建立使用者”,建立給專題生的帳號

jenkins

(3) 回到儀板表,點選”管理Jenkins”的”設定全域安全性”

jenkins

(4) 關於安全性領域,選擇內建使用者資料庫,建議不要開放外人註冊比較好,雖然可以設定匿名使用者的權限,但如果有一些亂七八糟的人註冊還是會增加管理上的負擔。

jenkins

接下來是用戶權限配置,如果懶得設定可以直接選用Jenkins的專案型矩陣授權策略,這裏的矩陣是不同身份對應不同權限的一個table。 如果要和我一樣自己設定每個帳號的權限,就勾選”矩陣型安全性”,詳細設定解說可以參考這一篇,滑鼠移到每個項目上會有說明,我把重點放在設定作業的權限上,我希望學弟妹們在自動建置出問題時,能自己透過手動建置或取消建置,但無法更改建置的script的內容。打勾workspace可以讓學弟妹看到建置的程式碼。

jenkins
jenkins

設定好就按Apply和存檔,會馬上生效!

Docker部署Tomcat

docker

在開始前,希望大家都對Docker images, container的運作有了基本的認識,這裏推薦這一篇原理解說,講得非常讚!docker常被運用在微服務框架裡。一個docker一個服務,每個服務間,互相建立通道傳遞數據 (Ex. Tomcat docker和MySQL docker通訊) ,雖然也有一個docker多個服務的解決方案,但我認為遵循docker設計的情境去使用比較不會遇到奇怪的問題,畢竟docker還是不能完全當VM用,他比較像是”服務+環境設定”的打包檔。歡迎查閱這裏docker的坑

然後跟著這篇的Tomcat部署教學玩一遍,快速部署Tomcat docker! 要注意的是,Jenkins和Tomcat服務,所監聽的port都是8080,所以將docker的port映射到實體電腦的port時,請使用8080以外的port,Ex. -p 18080:8080。

我希望在Tomcat docker把後端的war檔和dependency (我後端有使用到WordNet等…) 都先部署好,測試過後再壓成image或輸出成Dockerfile。部署的shell命令如下。部署前先準備好war檔和dependency (WordNet之類)…

#!bin/bash
docker run --name servrel -p 7778:8080 -d tomcat:7.0.57-jre8 
docker cp /home/app/servrel.war servrel:/usr/local/tomcat/webapps #war檔複製到docker
#Tomcat設定等位置,可以查閱https://hub.docker.com/_/tomcat/
docker cp /home/app/wordnet servrel:/usr/local/tomcat/ #資料夾複製到docker
docker exec -it servrel /bin/bash  #使用bash進入docker查看
docker restart servrel #重開

Tomcat部署簡單,以上幾行就輕鬆解決,之後我就都使用同一個container,此時也建議docker commit將這版的container壓成image,留下紀錄。

Jenkins要驅動的script解說

短短幾行就把學弟妹的前端部署到Tomcat docker,流程為: 將GitHub的專案更新到本機端–>將本機專案更新至Tomcat docker的前端資料夾–>重啟–>印出docker資訊。

cd ~/project/servrel/servrel-front-end
git pull  #將GitHub的前端資料pull下來,各位可能問為何不直接拿workspace的內容,因為權限問題無法取用自如,所以我乾脆另外git pull
docker cp ./. servrel:/usr/local/tomcat/webapps/servrel #用name當成container的編號並對其做操作
docker restart servrel
docker inspect --format='finish! Container:   -> Host:  ' servrel  #docker inspect取得docker資訊提供給部署者

Jenkins & Github plugin建立專案

設定遠端觸發建置有很多方法,但我們在設定時一定要考慮到Jenkins的安全性,所以我以下列出的是,在安全性為”矩陣型”的狀態下,設定trigger的解決方案:

  1. 基本驗證 (Basic Authentication) 方式通過 API 驗證
  2. Build Token Root Plugin 外掛避開遠端觸發建置的身分驗證要求
  3. 安裝 GitHub Plugin 直接支援 GitHub 服務整合

以上三種選項的詳細作法可以移步至本篇部落格了解,以下我只針對安裝 GitHub Plugin 直接支援 GitHub 服務整合做解說。

(1) 使用本整合服務請先安裝GitHub plugin,一般在安裝Jenkins的時候就能先行點選安裝,安裝完要重啟Server。

(2) 進入 [管理 Jenkins] / [設定系統]

(3) 找到 GitHub Plugin Configuration,並新增一個 GitHub Server Config,千萬不要勾選 manage hook,GitHub整合服務 會自動幫我們管理,這個坑搞了我好久,詳見Jenkins GitHub Hooks Problems

jenkins
jenkins

完成後,按Apply和確定存檔。

(4) 至Jenkins左欄建立新作業 (Job),輸入新作業的名稱與類型,第一項的free-Style彈性較大。

jenkins
jenkins

(5) 設定作業

jenkins
jenkins
jenkins

完成後按Apply和確定儲存。

(6) 再來我們到要觸發的Github專案,到setting後點擊 webhooks&services

jenkins
jenkins

(7) 選擇Add service,找到Jenkins後點入,設定hook url,完成後還可以點選test service測試!

jenkins
jenkins

完成後,如果已經成功建立起trigger,我們可以看到如下畫面。

jenkins

我們可以點入任何一次建置,查看建置的過程是否符合我們預期。

jenkins

當然Jenkins的好用遠遠高於此,這只算是小試身手!等我嘗試更多功能後,再釋出給各位分享~


後續與補充

老師提議我要用MySQL取代SQLite,並架phpMyAdmin讓學妹方便更改資料庫的資料。我最近也想試試利用logstash,elsticsearch與kibana來分析log,就改成以下這種新架構。

jenkins

以上的MySQL, phpMyAdmin, logstash,elsticsearch與kibana全部都是Docker container,我用的是這個Docker ELK stack,並自己定義logstash的conf.d裡的grok,將log data轉換成各種資料欄位。elasticsearch是一個的資料搜尋引擎,它能夠以JSON的形式儲存資料,並且做即時的分析和搜尋。Kibana連接elasticsearch,把資料用圖表的方式呈現,方便開發者做監控。

完成後的監控畫面如下:

jenkins

提供我參考的ELK教學,說得非常詳細:Docker ELK Quickstart: Elasticsearch, Logstash, Kibana