async.js源碼閱讀筆記

異步編程在python中有twisted庫來處理,即使是使用tornado這樣的web框架,異步實現也是使用twisted,所以對於安裝tornado,twisted是一個必不可少的依賴包。c#的異步編程沒有做過,但是聽說很是強大,很多東西都被其它語言的實現所借鑑。而對於javascript這樣的語言,事件回調機制乃是天生具備,但是要優雅地實現異步編程,單純使用setTimeout的實現過於醜陋且不便於分析邏輯。老趙曾經實現過一個叫Wind(原名Jscex)的庫,功能也很強大,但是沒有玩過,有時間也會進行分析的。今天我們的主角是來自caolan的async庫(https://github.com/caolan/async)。

接口概覽

  • 集合函數
    • each -對數組中的每個元素執行迭代函數
    • map -通過將迭代函數應用在數組的每個元素上產生新的數組
    • filter -返回測試通過的數組元素組成的新數組
    • reject -和filter相反,返回原數組中刪除通過測試的元素的新數組
    • reduce -進行化簡運算
    • detect -檢測是否有通過測試的元素,回調參數爲通過測試的第一個元素(array element)。
    • sortBy -異步排序
    • some -檢測是否有通過測試的元素,回調參數爲是否有通過測試測試的元素(true or false)。
    • every -檢測是否每個元素都通過測試,使用和some一樣
    • concat -連接對每個元素執行map結果集爲一個數組
  • 流程控制
    • series
    • parallel
    • whilst
    • until
    • forever
    • waterfall
    • compose
    • applyEach
    • queue
    • cargo
    • auto
    • iterator
    • apply
    • nextTick
    • times

集合函數源碼分析

  • each的分析

在探究each的實現前,我們首先來看三個輔助函數_each,only_once和_eachLimit

var _each = function (arr, iterator) {         if (arr.forEach) {             return arr.forEach(iterator);         }         for (var i = 0; i < arr.length; i += 1) {             iterator(arr[i], i, arr);         }     };

_each函數僅僅是作一個Array.prototype.forEach的兼容實現

function only_once(fn) {         var called = false;         return function() {             if (called) throw new Error("Callback was already called.");             called = true;             fn.apply(root, arguments);         }     }

only_once是爲了保證傳入的參數只執行一次,這個特性會在xxxSeries函數中用到,Series系列函數與原函數的唯一區別是保證對數組的操作是順序的,在異步情況下也就是隻有完成完成前一個數組元素操作的成功回調才能繼續遍歷下一個元素.

var _eachLimit = function (limit) {         return function (arr, iterator, callback) {             callback = callback || function () {};             if (!arr.length || limit <= 0) {                 return callback();             }             var completed = 0;             var started = 0;             var running = 0;             (function replenish () {                 if (completed >= arr.length) {                     return callback();                 }                 while (running < limit && started < arr.length) {                     started += 1;                     running += 1;                     iterator(arr[started - 1], function (err) {                         if (err) {                             callback(err);                             callback = function () {};                         }                         else {                             completed += 1;                             running -= 1;                             if (completed >= arr.length) {                                 callback();                             }                             else {                                 replenish();                             }                         }                     });                 }             })();         };     };

_eachLimit返回一個包裝好的遍歷函數。在紅色代碼部分while循環中在limit限制內依次啓動遍歷器,如果有完成的回調,同時沒有遍歷完數組,則啓動下一次遍歷過程,這樣就保證每次只有limit個函數處於運行狀態。

async.each = function (arr, iterator, callback) {         callback = callback || function () {};         if (!arr.length) {             return callback();         }         var completed = 0;         _each(arr, function (x) {             iterator(x, only_once(function (err) {                 if (err) {                     callback(err);                     callback = function () {};                 }                 else {                     completed += 1;                     if (completed >= arr.length) {                         callback(null);                     }                 }             }));         });     };     async.forEach = async.each;

iterator是回調方式執行的函數,比如fs.stat(path, callback)。使用only_once的目的是爲了使傳入iterator的回調函數只執行一次,這樣避免了在一個函數內多次調用callback使的completed的計數次數與實際數組長度不符。因爲不知到哪個iterator對數組元素首先執行完成,也就無法保證執行的先後順序,最後一個執行完成的遍歷函數內執行回調函數。如果在某個遍歷函數中出現錯誤,函數立即執行錯誤回調函數。()這部分我用紅色字體標識出來了。

對於each對應的eachSeries,在遍歷函數對當前元素執行成功後才調用下一個遍歷函數,遍歷完成則回調錯誤函數。可以從 iterator(arr[completed], callback)看到數組遍歷的連續性。

async.eachSeries = function (arr, iterator, callback) {         callback = callback || function () {};         if (!arr.length) {             return callback();         }         var completed = 0;         var iterate = function () {             iterator(arr[completed], function (err) {                 if (err) {                     callback(err);                     callback = function () {};                 }                 else {                     completed += 1;                     if (completed >= arr.length) {                         callback(null);                     }                     else {                         iterate();                     }                 }             });         };         iterate();     };     async.forEachSeries = async.eachSeries;

eachLimit函數僅僅是加入了並行運行函數的個數限制,eachimit實現主要是使用_eachLimit包裝好的函數進行並行限制

async.eachLimit = function (arr, limit, iterator, callback) {         var fn = _eachLimit(limit);         fn.apply(null, [arr, iterator, callback]);     };     async.forEachLimit = async.eachLimit;

爲了方便後面函數的實現,對each相關函數的包裝得到下面三個函數

var doParallel = function (fn) {         return function () {             var args = Array.prototype.slice.call(arguments);             return fn.apply(null, [async.each].concat(args));         };     };     var doParallelLimit = function(limit, fn) {         return function () {             var args = Array.prototype.slice.call(arguments);             return fn.apply(null, [_eachLimit(limit)].concat(args));         };     };     var doSeries = function (fn) {         return function () {             var args = Array.prototype.slice.call(arguments);             return fn.apply(null, [async.eachSeries].concat(args));         };     };

doParallel是對async.each的包裝,實現並行執行的功能
doParallelLimit是對_eachLimit的包裝,實現最大並行數的限制
doSeries是對async.eachSeries的包裝,實現順序執行功能

  • map的實現

map的實現主要是對each的調用,將調用函數調用的結果進行回調。eachFn在實現中是調用async.each進行元素的遍歷,_map函數主要是將數組映射成鍵值對的形式{index:i,value:x}.

var _asyncMap = function (eachfn, arr, iterator, callback) {

        var results = [];         arr = _map(arr, function (x, i) {             return {index: i, value: x};         });         eachfn(arr, function (x, callback) {             iterator(x.value, function (err, v) {                 results[x.index] = v;                 callback(err);             });         }, function (err) {             callback(err, results);         });     };

關於mapSeries,mapLimit可以參照源碼進行學習

  • reduce的實現

// reduce only has a series version, as doing reduce in parallel won't
    // work in many situations.
    async.reduce = function (arr, memo, iterator, callback) {
        async.eachSeries(arr, function (x, callback) {
            iterator(memo, x, function (err, v) {
                memo = v;
                callback(err);
            });
        }, function (err) {
            callback(err, memo);
        });
    };
    // inject alias
    async.inject = async.reduce;
    // foldl alias
    async.foldl = async.reduce;

從註釋中看出,reduce只有順序版本,而沒有並行版本,對於化簡操作來說,並行實現是沒有意義的.reduce與標準的eachSeries只是在於遍歷函數上的不同,對於reduce,需要每次保存上一次的運算結果,所以最簡單的實現是將結果作爲函數參數進行傳遞,最後在回調中傳入最終結果..

  • filter的實現

filter的實現與map的實現類似,只是在遍歷的過程中進行了元素的過濾,最後將結果按原來的順序進行進行排序,然後將結果傳入回調函數.

var _filter = function (eachfn, arr, iterator, callback) {
        var results = [];
        arr = _map(arr, function (x, i) {
            return {index: i, value: x};
        });
        eachfn(arr, function (x, callback) {
            iterator(x.value, function (v) {
                if (v) {
                    results.push(x);
                }
                callback();
            });
        }, function (err) {
            callback(_map(results.sort(function (a, b) {
                return a.index - b.index;
            }), function (x) {
                return x.value;
            }));
        });
    };
filter也存在並行和順序執行兩個版本,分別通過doParallel和doSeries來實現

  • detect的實現

乘上面幾種函數的實現方式,detect在遍歷的過程中一旦測試的結果爲真,立即向回調函數傳入當前元素,否則執行回調函數

function (err) {
            main_callback();
        }
也就是說我們也可以在main_callback函數中完成檢測測試的記錄功能

var _detect = function (eachfn, arr, iterator, main_callback) {
        eachfn(arr, function (x, callback) {
            iterator(x, function (result) {
                if (result) {
                    main_callback(x);
                    main_callback = function () {};
                }
                else {
                    callback();
                }
            });
        }, function (err) {
            main_callback();
        });
    };
detect同樣提供並行和順序兩個版本,some,any,every的實現和detect大同小異,這裏就不進行解讀了.

  • concat的實現

concat用於將遍歷中的所有的回調結果連接成一個數組,結果合併調用的是Array.prototype.concat方法.實現與上述map,filter,detect類似


var _concat = function (eachfn, arr, fn, callback) {
        var r = [];
        eachfn(arr, function (x, cb) {
            fn(x, function (err, y) {
                r = r.concat(y || []);
                cb(err);
            });
        }, function (err) {
            callback(err, r);
        });
    };

  • sortBy的實現

sortBy首先使用map對數組進行索引和排序字段的鍵值映射,在map回調函數中對結果根據排序字段的值進行排序,然後向回調函數傳遞排序結果.

async.sortBy = function (arr, iterator, callback) {
        async.map(arr, function (x, callback) {
            iterator(x, function (err, criteria) {
                if (err) {
                    callback(err);
                }
                else {
                    callback(null, {value: x, criteria: criteria});
                }
            });
        }, function (err, results) {
            if (err) {
                return callback(err);
            }
            else {
                var fn = function (left, right) {
                    var a = left.criteria, b = right.criteria;
                    return a < b ? -1 : a > b ? 1 : 0;
                };
                callback(null, _map(results.sort(fn), function (x) {
                    return x.value;
                }));
            }
        });
    };

流程控制函數源碼分析

  • auto的實現

auto屬於流程控制函數,對於有依賴關係的函數調用和使用auto來實現,比如有三個函數read,write,complete,其中complete函數只有在read函數和write函數結束後才能調用,這當然可以使用each和eachSeries函數進行實現,提供auto函數將會使的有依賴關係的函數編程更加簡易.auto函數的實現稍微長了一點,我在源碼中加入了註釋.

async.auto = function (tasks, callback) {
        callback = callback || function () {};
        var keys = _keys(tasks);
        if (!keys.length) {
            return callback(null);
        }
        var results = {};
        var listeners = []; //存放有依賴需要監聽的函數
//添加監聽者輔助函數
        var addListener = function (fn) {
            listeners.unshift(fn);
        };
//移除監聽者輔助函數
        var removeListener = function (fn) {
            for (var i = 0; i < listeners.length; i += 1) {
                if (listeners[i] === fn) {
                    listeners.splice(i, 1);
                    return;
                }
            }
        };
//任務執行成功回調函數,主要是遍歷監聽隊列
        var taskComplete = function () {
            _each(listeners.slice(0), function (fn) {
                fn();
            });
        };
//添加所有函數執行完成的事件監聽
        addListener(function () {
            if (_keys(results).length === keys.length) {
                callback(null, results);
                callback = function () {};
            }
        });
//auto實現的關鍵部分,遍歷所有的任務
        _each(keys, function (k) {
            var task = (tasks[k] instanceof Function) ? [tasks[k]]: tasks[k];
//包裝任務執行回調函數,主要是對傳入參數的包裝
            var taskCallback = function (err) {
                var args = Array.prototype.slice.call(arguments, 1);
                if (args.length <= 1) {
                    args = args[0];
                }
                if (err) {
                    var safeResults = {};
                    _each(_keys(results), function(rkey) {
                        safeResults[rkey] = results[rkey];
                    });
                    safeResults[k] = args;
                    callback(err, safeResults);
                    // stop subsequent errors hitting callback multiple times
                    callback = function () {};
                }
                else {
//任務執行成功時,將鍵值加入hash表
                    results[k] = args;
//稍後執行任務完成回調函數
                    async.setImmediate(taskComplete);
                }
            };
//依賴數組
            var requires = task.slice(0, Math.abs(task.length - 1)) || [];
//綁定依賴準備函數,檢測當前results結果集中是否存在依賴函數鍵值同時任務自身又沒有執行過
            var ready = function () {
                return _reduce(requires, function (a, x) {
                    return (a && results.hasOwnProperty(x));
                }, true) && !results.hasOwnProperty(k);
            };
            if (ready()) {
//依賴準備完成,則執行當前任務
                task[task.length - 1](taskCallback, results);
            }
            else {
//否則,綁定監聽者函數,添加到監聽隊列
                var listener = function () {
                    if (ready()) {
                        removeListener(listener);
                        task[task.length - 1](taskCallback, results);
                    }
                };
                addListener(listener);
            }
        });
    };
auto的實現大量運用局部變量的綁定,傳入綁定後的函數體.主要思路是,已執行函數保存在results中,當依賴存在與results中並且自身不在結果集中(removeListen已經保證了函數只執行一次),則執行任務,否則加入到監聽者隊列,每次有任務完成則執行監聽者隊列裏的函數(同樣是通過局部綁定的ready函數進行依賴驗證),當所有任務執行按成,最先添加到監聽者隊列的監聽者條用callback進行執行結果的傳遞.

  • waterFall的實現

看完waterFall的源碼,覺得caolan的實現確實很巧妙,利用已經實現的iterator函數進行簡單的包裝就實現了相對來說比iterator複雜不少的函數.

async.waterfall = function (tasks, callback) {
        callback = callback || function () {};
        if (tasks.constructor !== Array) {
          var err = new Error('First argument to waterfall must be an array of functions');
          return callback(err);
        }
        if (!tasks.length) {
            return callback();
        }
        var wrapIterator = function (iterator) {
            return function (err) {
                if (err) {
                    callback.apply(null, arguments);
                    callback = function () {};
                }
                else {
                    var args = Array.prototype.slice.call(arguments, 1);
                    var next = iterator.next();
                    if (next) {
                        args.push(wrapIterator(next));
                    }
                    else {
                        args.push(callback);
                    }
//利用wrap後的遍歷函數作爲iterator的回調函數,能將函數執行將iterator的執行結果直接傳入下一個遍歷器
                    async.setImmediate(function () {
                        iterator.apply(null, args);
                    });
                }
            };
        };
        wrapIterator(async.iterator(tasks))();
    };
這裏的trick就是巧妙地處理了回調函數的參數列表,使得上一次的回調結果直接傳如了下一個遍歷器,但是使用是要注意tasks表中的前一個函數的回調函數傳遞的變量個數與下個函數傳入的變量個數需要一致,否則無法完成完整的waterFall操作.

  • parallel的實現

each用於object,而map運用於數組,對於流程控制,既需要處理鍵值對參數的情況,也需要處理數組參數的情況.但是根據each和map的實現不同,map默認會將處理結果傳入回調參數,而對於each需要將結果根據參數的鍵值對進行重新映射,parallel傳入的函數需要具有下面這種形式,才能進行結果的傳遞

function(callback) {

//do some stuff

callback(null[,arg1][,arg2]);

}

var _parallel = function(eachfn, tasks, callback) {
        callback = callback || function () {};
        if (tasks.constructor === Array) {
            eachfn.map(tasks, function (fn, callback) {
                if (fn) {
                    fn(function (err) {
                        var args = Array.prototype.slice.call(arguments, 1);
                        if (args.length <= 1) {
                            args = args[0];
                        }
                        callback.call(null, err, args);
                    });
                }
            }, callback);
        }
        else {
            var results = {};
            eachfn.each(_keys(tasks), function (k, callback) {
                tasks[k](function (err) {
                    var args = Array.prototype.slice.call(arguments, 1);
                    if (args.length <= 1) {
                        args = args[0];
                    }
                    results[k] = args;
                    callback(err);
                });
            }, function (err) {
                callback(err, results);
            });
        }
    };

從實現可以看出map函數是通過調用callback.call(null, err, args)來進行參數傳遞的,而each是通過局部變量results對象以鍵值對的方式在函數執行回調函數中對結果進行存儲,當所有的並行任務處理完成後執行回調函數,對於數組類型的參數,回調函數傳入的變量類型也是數組,對於鍵值對方式傳入的參數,回調函數傳入的參數同樣是以簡直對的方式進行查詢.

parallel同樣提供普通和parallelLimit兩個版本,後者同樣是調用_mapLimit和_eachLimit的實現方式.

  • series的實現

series的實現與parallel類似,只是把函數實現內部的版本改爲mapSeries和eachSeries.

  • iterator的實現

iterator函數實現了傳統的迭代器功能,返回的迭代器支持next方法訪問遍歷器中的下一個對象.

async.iterator = function (tasks) {
        var makeCallback = function (index) {
            var fn = function () {
                if (tasks.length) {
                    tasks[index].apply(null, arguments);
                }
                return fn.next();
            };
            fn.next = function () {
                return (index < tasks.length - 1) ? makeCallback(index + 1): null;
            };
            return fn;
        };
        return makeCallback(0);
    };
沒執行一次iterator對象便會返回下一個遍歷器對象,同時也支持使用next方式訪問.

  • apply的分析

apply比較簡單,主要是對回調類型的函數進行包裝,簡化代碼複雜度.

async.apply = function (fn) {
        var args = Array.prototype.slice.call(arguments, 1);
        return function () {
            return fn.apply(
                null, args.concat(Array.prototype.slice.call(arguments))
            );
        };
    };
從源碼可以看出,apply實際上做了一件很簡單的事情,返回一個包裝好的函數,將回調函數(比如parallel傳入的)推到fn的已有參數後面作爲該函數的callback,前面我們說過只有

function(callback) {

//do some stuff

callback(null[,arg1][,arg2]);

}

形式的參數才能正確傳遞參數.

  • whilst的分析

whilst不斷進行test測試,如通過則循環執行iterator函數,知道不能通過測試爲止,成功則執行回調函數callback

async.whilst = function (test, iterator, callback) {
        if (test()) {
            iterator(function (err) {
                if (err) {
                    return callback(err);
                }
                async.whilst(test, iterator, callback);
            });
        }
        else {
            callback();
        }
    };
需要注意的是這裏需要保存測試過程中的條件變量,以使得iterator的改變會影響test的結果,通常使用與函數同一個scope的變量即可.

doWhilst只是一個變體,這裏不作分析.

  • until的分析

與whilst類似,只是終止條件是滿足測試,僅貼出源碼


async.until = function (test, iterator, callback) {
        if (!test()) {
            iterator(function (err) {
                if (err) {
                    return callback(err);
                }
                async.until(test, iterator, callback);
            });
        }
        else {
            callback();
        }
    };

  • queue的分析

queue提供了比較豐富的方法,worker函數同時對concurrency個queue中的元素進行操作,每次執行unshift和push操作都會激發_insert函數中的async.setmmediate(qprocess),開啓處理進程,開始處理後workers數+1,當有workers數小於concurrency數時可以持續向隊列中插入數據,如果並行達到最大數量,那麼只能等待前面的進程執行完畢,在進程回調函數中有q.process()即是啓動下一次進程.

async.queue = function (worker, concurrency) {
//默認並行數爲1
        if (concurrency === undefined) {
            concurrency = 1;
        }
//包裝unshift和push操作,插入後開啓進程
        function _insert(q, data, pos, callback) {
          if(data.constructor !== Array) {
              data = [data];
          }
          _each(data, function(task) {
              var item = {
                  data: task,
                  callback: typeof callback === 'function' ? callback : null
              };
              if (pos) {
                q.tasks.unshift(item);
              } else {
                q.tasks.push(item);
              }
//最大並行數飽和時執行saturated回調函數
              if (q.saturated && q.tasks.length === concurrency) {
                  q.saturated();
              }
//啓動進程
              async.setImmediate(q.process);
          });
        }
        var workers = 0;
        var q = {
            tasks: [],
            concurrency: concurrency,
            saturated: null,
            empty: null,
            drain: null,
            push: function (data, callback) {
              _insert(q, data, false, callback);
            },
            unshift: function (data, callback) {
              _insert(q, data, true, callback);
            },
            process: function () {
                if (workers < q.concurrency && q.tasks.length) {
                    var task = q.tasks.shift();
//隊列中的元素爲空時回調empty
                    if (q.empty && q.tasks.length === 0) {
                        q.empty();
                    }
                    workers += 1;
                    var next = function () {
                        workers -= 1;
                        if (task.callback) {
                            task.callback.apply(task, arguments);
                        }
//隊列數爲空,且正在執行的進程爲空時執行回調函數drain
                        if (q.drain && q.tasks.length + workers === 0) {
                            q.drain();
                        }
                        q.process();
                    };
                    var cb = only_once(next);
                    worker(task.data, cb);
                }
            },
            length: function () {
                return q.tasks.length;
            },
            running: function () {
                return workers;
            }
        };
        return q;
    };

  • cargo的分析

cargo與queue的唯一不同是,cargo中的任務是同時執行,如果執行期間有新條目加入,則只能等待.與queue代碼相比,最大的不同是在process函數中首先判斷是否在執行,如果正在執行則等待,當有函數成功返回時,process重新啓動.還有一點是tasks.splice(0,payload),可見cargo是每次從tasks數組中拿出payload個任務在worker中執行,捏可以提供順序,並行等方式執行這些任務.

async.cargo = function (worker, payload) {
        var working = false,
            tasks = [];
        var cargo = {
            tasks: tasks,
            payload: payload,
            saturated: null,
            empty: null,
            drain: null,
            push: function (data, callback) {
                if(data.constructor !== Array) {
                    data = [data];
                }
                _each(data, function(task) {
                    tasks.push({
                        data: task,
                        callback: typeof callback === 'function' ? callback : null
                    });
                    if (cargo.saturated && tasks.length === payload) {
                        cargo.saturated();
                    }
                });
//在下一個空閒點執行process,通常是創建cargo加入任務之後
                async.setImmediate(cargo.process);
            },
            process: function process() {
//如果正在執行,立即返回
                if (working) return;
                if (tasks.length === 0) {
                    if(cargo.drain) cargo.drain();
                    return;
                }
                var ts = typeof payload === 'number'
                            ? tasks.splice(0, payload)
                            : tasks.splice(0);
                var ds = _map(ts, function (task) {
                    return task.data;
                });
                if(cargo.empty) cargo.empty();
                working = true;
                worker(ds, function () {
                    working = false;
                    var args = arguments;
                    _each(ts, function (data) {
                        if (data.callback) {
                            data.callback.apply(null, args);
                        }
                    });
//回調成功時重新開啓進程
                    process();
                });
            },
            length: function () {
                return tasks.length;
            },
            running: function () {
                return working;
            }
        };
        return cargo;
    };

實用函數分析

async庫提供一些實用函數比如日誌記錄,函數結果緩存,消除衝突函數,這裏主要解析一下memoize函數.memoize是這樣一個處理流程,如果緩存memo存在hash值對應的回調函數結果,則返回立即傳遞結果到傳入的回調函數執行,否則如果鍵存在與隊列中,則添加到鍵對應的數組,如果隊列中也不存在則調用當前函數,在回調函數中,緩存回調函數的結果,同時對原函數執行的結果執行回調操作.

async.memoize = function (fn, hasher) {
        var memo = {};
        var queues = {};
        hasher = hasher || function (x) {
            return x;
        };
        var memoized = function () {
            var args = Array.prototype.slice.call(arguments);
            var callback = args.pop();
            var key = hasher.apply(null, args);
            if (key in memo) {
                callback.apply(null, memo[key]);
            }
            else if (key in queues) {
//當fn正在執行時,queues的鍵對應的數組已經建立,可以繼續添加回調函數
                queues[key].push(callback);
            }
            else {
                queues[key] = [callback];
                fn.apply(null, args.concat([function () {
                    memo[key] = arguments;
                    var q = queues[key];
                    delete queues[key];
//回調隊列中所有的回調函數,包括fn執行過程中添加的回調函數
                    for (var i = 0, l = q.length; i < l; i++) {
                      q[i].apply(null, arguments);
                    }
                }]));
            }
        };
        memoized.memo = memo;
        memoized.unmemoized = fn;
        return memoized;
    };

小結

從源碼閱讀的情況來看caolan的js確實寫的很紮實,看源碼的過程中學到很多tricks,有些在平常的編程實踐中沒有想到但很實用的東西.弄清楚了各部分的回調關係,async的實現也沒有那麼複雜,閱讀源碼之後,對async庫使用的理解也會加強很多,希望以後能擠出時間寫出更多的源碼閱讀筆記.由於編程功力不深厚,難免有不準確,不全面的地方,望指正.








發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章