時間規劃在Optaplanner上的實現

  在與諸位交流中,使用較多的生產計劃和路線規劃場景中,你們最爲關注的焦點是關於時間的處理問題。確實,時間這一維度具備必定的特殊性。由於時間是一維的,體現爲經過圖形表示時,它僅能夠經過一條有向直線來表達它的時刻和方向。相對而言,空間則能夠存在多維,例如二維座標,三維空間等,甚至在生產計劃的規劃場景中,各類資源能夠表示爲多個維度。所以,時間的一維特性,決定了在規劃過程當中,須要處理它的方法也具備必定的特殊性和侷限性。本文將討論經過Optaplanner實現規劃過程當中,對於時間方面的處理方式。
在衆多規劃優化場景中,能夠概括爲兩種狀況的規劃,分別是單一維的空間維度規劃,和同時存在空間與時間兩個維度進行規劃。
其中第一種狀況,僅對一個維度進行規劃的場景,咱們能夠把這一維概括爲,僅對空間維度的規劃。例如八王后(N Qeen)問題,其規劃的目標是爲每一個王后找個適當的位置,位置就是一個最爲直觀的空間概念,所以它是一個很明確直觀的空間規劃問題。而另一些從直接字面意義上可能跟空間並無直接的關係,但其實也能夠將它視做僅有一個空間維度的規劃。這類規劃的一個特色規劃目標與目標之間沒有時序關係,即時間維度是不考慮的,例如。有一些存在時間概念的問題,其實也能夠轉化爲惟一空間維度的規劃,從而將問題簡化。例如排班過程當中,將每一個人員安排到指定的班次,雖然班次是一個時間上概念的概念,但實際對這個問題進行排班設計的時候,咱們能夠將時間轉化爲相似空間的形式處理。更直觀的說法,將班次分佈在時間軸上,按時間軸來看,各個班次就是時間軸上不一樣位置的區間,從而令問題簡化。所以,這類規則更嚴格地說,能夠理解爲不管是空間仍是時間上的規劃,均可以轉化、展開爲單一惟度的規劃問題,經過使用空間規劃的方法進行規劃建模求解;即便是時間規劃(例如排班)也不例外。
  另一種規劃,則須要同時考慮空間與時間兩個維度協同規劃。如生產計劃、帶時間窗口的車輛路線規劃等問題,就是其中的典型。以生產計劃爲例,在空間維度,須要將一個任務分配到合理的機臺,便是空間上的規劃。然而,生產計劃問題的另外一個需求是,肯定了機臺後,還要肯定到底這個任務應該在何時開始,何時結束;哪一個任務須要在哪一個任何完成後才能開始等等。這些時序邏輯相關的引出的問題,均屬於時間規劃問題。時間維度能夠與空間維度一塊兒,肯定一個活動的時空座標。此座標是一個邏輯上抽象的概念。以生產計劃爲例,兩個維度均經過平面圖形來表示時,能夠把計劃中的每一個任務,分配在指定機臺的指定時間區間上,經過下圖能夠看到,這個示意圖的水平軸(X軸)表示時間,從這個方向能夠看出一個任務哪一個時刻開始,持續多久,哪一個時刻結束。以及與該任務同處於一個空間(機臺,或產線,或車間)上的先後任務的接續關係。垂直軸(Y軸)表示空間,表示它被分配到哪一個機臺上執行。以下圖:
  針對不一樣的時間規劃要求,Optaplanner提供了3經常使用的規劃模式,分別是時間槽模式- Time Slot Pattern,時間粒模式 - Time Grain Pattern, 和時間鏈模式 - Chained Through Time Pattern.下面分別對這三種模式的特徵,適用場景和使用方法進行詳細介紹。由於翻譯準確度緣由(對本身的英文水平缺少自信:P), 下文介紹中均直接使用Time Slot, Time Grain 和 Chained Through Time.以免本文件的翻譯不當形成誤解。
 

時間槽模式 - Time slot

Time Slot在應用時有一些適用條件,知足如下全部條件,才適用:
  1. 規劃實體中的規劃變量是一個時間區間;
  2. 一個規劃變量的取值最多僅可分配一個時間區間;
  3. 規劃變量對應的時間區間是等長的。
 
  對於規劃值範圍各個時間段,將其轉換爲空間上的概念更爲直觀。將時間用一個水平軸表示,在軸上劃分大小固定的區間,這些區間則能夠做爲規劃過程當中的取值範圍;在設計時,把這些區間定義成ValueRange。適用於Time slot模式狀況,有制定中小學課程表、考試安排等問題。由於大學或公開課程的計劃安排,除了排定時間外,可能還須要肯定具體的地點,也就是空間維度的規劃。此類問題一般須要將時間和空間分開來考慮,但其中的時間緯能夠經過Time slot模式轉化爲與空間規劃同樣的問題,從而令問題簡化。引用Optaplanner開發手冊的一張圖能夠清楚地看到,每個規劃實體只須要一個時間區間,且區間長短是相同的,(以下圖)。
 

    從圖中能夠看出,每門課所需的時間都是固定一小時。具體到這個模式的應用,由於其原理、結構和實現起來都至關簡單,本文不經過示例詳細講解了。可參考示例包中的Course timetabling中的設計和代碼。html

 時間粒模式 - Time Grain

  在至關多運籌優化場景中,須要規劃的時間長短是不固定的,不一樣的任務其所需的時間有長短之分。這種需求下,若使用Time slot模式就沒法實現時間上的精確規劃。那些就要使用更靈活,時間粒度更小的Time Grain模式。從Time Grain模式的名稱中的Grain能夠推測到,此模式是將時間細分紅一個一個顆粒並應用於規劃。例如能夠設定爲每1分鐘,5分鐘,30分鐘,1小時等固定的長度,爲一個Grain的長度。
Time Grain模式適用條件:
  1. 規劃變量是時間區間;
  2. 業務上對應於規劃變量的時間區間能夠不等長,但必須是Grain的倍數。
   例如經過Outlook的日曆功能建立會議時,默認狀況下每一個會議的時間,是0.5小時的倍數,也就是一個會議至少是0.5小時,或者是1小時,或1.5小時如此類推。固然若是你不使用Outlook的默認時間精度,也能夠將時間精度定到分鐘,那麼也就表示,會議的時間是1分鐘的倍數。只不過針對人的平常活動在時間上的精度,以分鐘做爲精確度其意義不太大。就如9:01分開會跟9:00開會,對於人類的活動能力來講,正常狀況下不存在任何區別。由於你從辦公室去到會議室,均可能須要花費1分鐘了;因此outlook裏默認的是半小時。那麼這個最小的時候單位 - 半小時,在Time Grain模式中,就被稱爲一個Time Grain,如下簡稱Grain。能夠先從開發手冊的圖中看到Time Grain模式所表達的意義,以下圖。
 

   從上圖能夠看到,每一個會議所需的時間長度是不相等的,可是其長度必然是一個Time Grain的倍數,從圖中上方的時間刻度能夠比劃出一個TimeGrain應該是15分鐘。例如Sales meeting佔用了4個Time Grain,即時長1小時。Time Grain模式的使用會相對Time Slot更靈活,適用範圍會更廣。經過設置可知,其實適用於Time Slot模型的情形,是徹底能夠經過TimeGrain模式實現的,只是實現起來會更復雜一些。那麼Time Grain模式的設計要點在哪裏呢?要了解其設計原理,就得先掌握Time Grain的結構及其對時間的提供方法。微信

  Time Grain中的重點在於一個Grain的設計,與Time Slot中的slot同樣,Time Grain中的Grain表示的也是一個時間區間,只不過它所表達的意義不只在於一個Time Grain的時間區間內,每一個Grain的序號也是關鍵因素,當一個Grain被分配到一個規劃變量時,Grain的序號決定了它與時間軸的映射位置。在生產計劃中,若一個Grain被分配到一個任務時,表示任務起止於這個Grain的開始時刻。 即該任務的開始時間是哪一個Grain內對應的時間區間內,那麼這個Grain的開始時間,就是這個任務的開始時間;經過這個任務的長度,推算出它須要佔用多少個Grain, 進而推算出它的結束時間會在哪一個Grain內,那麼這個Grain的結束時間,便是這個任務的結束時間。
仍是以上圖爲例,其中的Sales meeting,它的起始是在grain0內,grain0的起始時間是8:00,那麼這個會議的起始時間就是8:00。這個會議的長度是1小時,因此它佔用了4個Grain,所以,第4個Grain的結束時間就是會議的結束時間,也就是圖中Grain3的結束時間 - 9:00,是這個會議的結束時間。進一步分析也知,若這個會議時長是1:10, 那麼它的結束時間將會落於gran4內(第5個grain), 那麼它的結束時間就是grain4的結束時間 - 9:15. 所以,總結起來,咱們在實現這個模式的時候有如下要點在設計時須要注意:
  1. 設計好每一個Grain的粒度,也就是時間長度。並非粒度越細越好,例如以1秒鐘做爲一個粒度,是否是就能夠將任務的時間精度控制在1級呢?理論上是能夠的,但平常使用中不太可行。由於這樣的設計會產生過量的Grain,Grain就是Value Range,當可選值的數量過多時,整個規劃問題的規模就會增大,其時間複雜度就會指數級上升,從而令優化效果下降。
  2. 定義好每一個Grain與絕對時間的映射關係。這個模式中的Time Grain其時間上是相對的。如何理解呢?就是說,這個模式在運行的時候,會把初始化出來的Grain對象列表,以Index(Grain的序號)爲序造成一個鏈接的時間粒的序列。列表中每個具體的Grain對應的絕對時間是何時呢?是以第一個Grain做爲參照推算出來的。例如上圖中的第一個Grain - grain0它的起始時間是8:00, 那麼第6個grain - grain5的起始時間就是9:30,這個時間是經過grain0加上6個grain的時長推算出來的,也就是8:00加上1.5小時,所以獲得的是9:30。所以,當你設定Time Grain與絕對時間的對應關係時,就須要從業務上考慮,grain0的起始是什麼時刻;它決定了後續全部任務的時間。
  爲了防止同一空間上,存兩個任務時間重疊的問題,能夠根據其分配的Grain進行判斷。如示例Meeting scheduling中關於時間重疊的判斷,能夠參考MeetingAssignment類中的calculateOverlap方法,見如下代碼。
 
public int calculateOverlap(MeetingAssignment other) {
  if (startingTimeGrain == null || other.getStartingTimeGrain() == null) {
    return 0;
  }
  
int start = startingTimeGrain.getGrainIndex();   int end = start + meeting.getDurationInGrains();   int otherStart = other.startingTimeGrain.getGrainIndex();   int otherEnd = otherStart + other.meeting.getDurationInGrains();   if (end < otherStart) {     return 0;   } else if (otherEnd < start) {     return 0;   }   return Math.min(end, otherEnd) - Math.max(start, otherStart); }
 
  上述代碼是判斷兩個會議的TIme Grain, 若存在重疊,則返回重疊量,供引擎的評分機制來判斷各個solution的優劣。
 

時間鏈模式 - Chained Through Time

  前面提出的兩種時間模式,其實有較多的類似之處,都是將時間段劃分爲單個個體,再將這些個體做爲規劃變量的取值範圍,從而實現與空間規劃一致的規劃模式。但更復雜的場景下,將時間轉化爲「空間」的作法,未必能行得通。例如帶時間窗口的路徑規劃,多工序多資源生產計劃等問題,其時間維度是難以經過Time Slot或Time Grain模式實現的。我增嘗試將Time Grain模式應用於多工序多資源條件下的生產計劃規劃;其原理上是可行的,但仍然會到到一些至關難解決的問題。其中之一就是Time Grain的粒度大小問題,若須要實現精確到分鐘的計劃,當編排一個時間跨度較大的計劃時,就會引發問題規模過大的問題,從而論引擎效率驟降。另外就是實現相鄰任務的重疊和前後次序判斷時,會遇到一些難以解決的,問題須要花費較多的精力去處理。所以,Optaplanner引入了第三種時間規劃模式 - 時間鏈模式(一樣是翻譯問題,下稱Chained Through Time模式)。
Chained Through Time模式顧名思義就是應用了鏈狀結構的特性,來實現時間的規劃。它的設計思想是,規劃變量並非普通的時間或空間上的值, 而是另一個規劃實體;從而造成一個由各個首尾相接的規劃實體鏈,即Value Range的範圍就是規劃實集合自己。經過規劃實體間的鏈狀關係,來推算各個實體的起止時間。事實上,Optaplanner中將規劃實體環環相扣造成鏈的特性,其主要目的並不是爲了實現時間規劃,而是爲了解相似TSP,VRP等問題而提供的。這些問題須要規劃的,是各個節點之間造成的連通關係;在約定規則下,求解最佳連通方案。根據不一樣的場景要求,所求的目標有「最短路徑」,「最小重複節點」,「最在鏈接效率」等。在時間規劃的功能方面,其實現方式與上兩種模式相似。以生產計劃的例子來講,經過Chained Through Time模式得到各任務的鏈接關係與次序後,就能夠根據鏈中首個任務的開始時間,結合各任務的持續時間,推算出各個任務精確的起止時間了,甚至能夠精確到秒。因此此模式用於時間規劃,只是它的一個「副業」,引擎使用Chained Through Time模式時,並非直接對時間進行規劃優化,而是在優化規劃實體之間的鏈接關係;時間做爲這個規劃實體中的一個影子變量(Shadow variable)進行計算,最終經過評分機制對這個影子變量進行約束限制,從而獲得時間優化的方案。與Time Slot和Time Grain相比,Chained Through Time最大的特性是經過次序來推導時間,而另外兩種模式則是須要經過時間來反映任務之間的前後關係。
  雖然Chained Through Time模式的做用至關巨大且普遍,但該模式的設計與實現難度又是三個模式中最高的,實現起來相對複雜。下面來進一步對其進行深刻討論。
 

Chained Through Time模式的意義

  Chained Through Time模式經過對正在進行規劃的全部規劃實體創建鏈狀關係,來實現時間推導,其推導結果示意圖以下。從圖中能夠看到,分配給Ann有兩個任務(FR taxes和SP taxes),其中第一個任務FR taxes的開始時刻是固定爲本次計劃的最先時間,而第二個任務SP taxes的開始時刻,則是根據第一個任務推導出來的 - 等於第一個任務的開始時刻加上其持續時間。所以,須要在約束的限制下,引擎過過各類約束分數的判斷,生成一個相對最合理的實體鏈接方案,再在這個方案的基礎上來推導時間,或將時間歸入做爲約束條件,實現對鏈接方案的影響,從而實現了時間維度的規劃優化。
 

 

 Chained Through Time的內存模型

  規劃實體造成的鏈是由引擎自動生成的,每生成的一個方案都是由各規劃實體之間的相對位置變化而成的。在建立的這些規劃實體構成的鏈中,它會遵循如下原則:
  1. 一條鏈由一個Anchor(錨),和零或,或1個,或多個Entity(實體,其實就是規劃實體)構成;
  2. 一條鏈必須有且僅有一個Anchor(錨);
  3. 一條鏈中的Entity或Anchor之間是一對一的關係,不可出現合流或分流結構;
  4. 一條鏈中的Entity或Anchor不可出現循環。
以下圖
 

Chained Through Time模式的設計實現

  經過上面的鏈結構,咱們瞭解到,一條鏈中將會存在兩種對象,一種是Anchor, 一種是Entity.對麼它們分別表明現實場景中的什麼業務實體呢?其實Entity是其常容易理解,若是是生產計劃案例中,它表明的是每一個任務;在車輛路線規劃案例中,它表明的是每一個車輛須要途徑的派件/攬件客戶。而Anchor則表未任務所在的機臺,及各個投/攬方案中的每一車輛。所以,這兩種不一樣的對象,在內容中會造成依賴關係,即一個Entity的前一步能夠是另一個Entiy, 也能夠是一個Anchor。以生產計劃的業務場景來描述,則表示一個任務的前一個任務,能夠是另一個任務(Entity),也能夠是一個機臺(Anchor,當這個任務是這個機臺的首個任務時)。所以,在咱們設計它的時候須要把這兩種不一樣的業務實體抽象爲同一類纔有辦法實現它們之間的依賴,事實上這種抽象關係,在面向對象的原則,在業務意義上來講,是不成立的,僅僅是爲了知足它們造成同一鏈的要求才做出的計劃。以下是一個任務與機臺的類設計圖。能夠看到,我從Taskg與Machine抽象了一個父類Step(這是我想到的最合適類名了),那麼每個任務的前一個Step有多是另一個任務,也有多是一個機臺。
 

時間推算方法

  Chained Through Time模式與其兩種時間規劃模式不一樣,本質上它並不對時間進行規劃,只對實體之間的關係進行規劃優化。所以,在引擎每個原子操做中須要經過對VariableListener接口的實現,來對時間進行推算,並在完成推算後,由引擎經過評分機制進行約束評分。一個Move有可能對應多個原子操做,一個Move的操做種類,能夠參見開發 手冊中關於Move Selector一章,在之後對引擎行爲進行深刻分析的文章中,我將會寫一篇關於Move Seletor的文件,來揭示引擎的運行原理。在須要進行時間推算時,能夠經過實現接口的afterVariableChanged方法,對當前所處理的規劃實體的時間進行更新。由於Chained Through Timea模式下,全部已初始化的規劃實體都處在一條鏈上;所以,當一個規劃實體的時間被更新後,跟隨着它的後一個規劃實體的時間也須要被更新,如此類推,直到鏈上最後一個實體,或出現一個時間正好不須要更新的規劃實體,即該規劃實體前面的全部實體的時間出現更新後,其時間不用變化,那麼鏈上從它日後的規劃實體的時候也無需更新。網絡

  如下是VariableListener接口的afterVariableChanged及其處理方法。ide

// 實現VariableListener的類
public class StartTimeUpdatingVariableListener implements VariableListener<Task> {

    // 實現afterVariableChanged方法
    @Override
    public void afterVariableChanged(ScoreDirector scoreDirector, Task task) {
        updateStartTime(scoreDirector, task);
    }

    @Override
    public void beforeEntityAdded(ScoreDirector scoreDirector, Task task) {
        // Do nothing
    }

    @Override
    public void afterEntityAdded(ScoreDirector scoreDirector, Task task) {
        updateStartTime(scoreDirector, task);
    }
    .
    .
    .
}    

 

//當一個任務的時候被更新時,順着鏈將它後面全部任務的時候都更新
protected void updateStartTime(ScoreDirector scoreDirector, Task sourceTask) {
     Step previous = sourceTask.getPreviousStep();
     Task shadowTask = sourceTask;
     Integer previousEndTime = (previous == null ? null : previous.getEndTime());
     Integer startTime = calculateStartTime(shadowTask, previousEndTime);
     while (shadowTask != null && !Objects.equals(shadowTask.getStartTime(), startTime)) {
          scoreDirector.beforeVariableChanged(shadowTask, "startTime");
          shadowTask.setStartTime(startTime);
          scoreDirector.afterVariableChanged(shadowTask, "startTime");
          previousEndTime = shadowTask.getEndTime();
          shadowTask = shadowTask.getNextTask();
          startTime = calculateStartTime(shadowTask, previousEndTime); 
     }
}

 

規劃實體的設計

  上一步咱們介紹瞭如何經過鏈在引擎的運行過程當中進行時間推算,那麼如何設定才能讓引擎能夠執行VariableListener中的方法呢,這就須要在規劃實體的設計過程當中,反映出Chained Through Time的特性了。咱們以上面的類圖爲例,理解下面其設計要求,在此示例中,把Task做爲規劃實體(Planning Entity), 那麼在Task類中須要定義一個Planning Variable(genuine planning variable), 它的類型是Step,它表示當前Task的上一個步驟(多是另外一個Task,也多是一Machine). 此外,在 @PlanningVariable註解中,添加graphType = PlanningVariableGraphType.CHAINED說明。以下代碼:工具

// Planning variables: changes during planning, between score calculations.
    @PlanningVariable(valueRangeProviderRefs = {"machineRange", "taskRange"},
            graphType = PlanningVariableGraphType.CHAINED)
    private Step previousStep;

  以上代碼說明,規劃實體(Task)的genuine planning variable名爲previousStep, 它的Value Range有兩個來源,分別是機臺列表(machineRange)和任務列表(taskRange),而且添加了屬性grapType=planningVariableGraphType.CHAINED, 代表將應用Chained Through Time模式運行。優化

  有了genuine planning variable, 還須要Shadow variable, 所謂的Shadow variable,在Chained Through Time模式下有兩種做用,分別是:ui

  1. 用於創建兩個對象(Entity或Anchor)之間的雙向依賴關係;即示例中的Machine與Task, 相鄰的兩個Task。google

  2. 用於指定當genuine planning variable的值在規劃運算過程產生變化時,須要更改哪一個變量;即上面提到的開始時間。spa

,對於第一個做用,其代碼體現以下,在規劃實體(Task)中,以@AnchorShadowVariable註解,並在該註解的sourceVariableName中指定該Shadow Variable在鏈上的前一個對象指向的是哪一個變量。翻譯

    // Shadow variables
    // Task nextTask inherited from superclass
    @AnchorShadowVariable(sourceVariableName = "previousStep")
    private Machine machine;

  上述代碼說明成員machine是一個Anchor Shadow Variable, 在鏈上,它鏈接的前一個實體是實體類的一個成員 - previousStep.

  Chained Through Time中的鏈須要造成雙向關係(bi-directional),下圖是路線規劃示例中。一個客戶與上一個停靠點之間的雙向關係。

   在規劃實體(Task)中咱們已經定義了前一個Step,並以@AnchorShadowVariable註解標識。而雙向關係中的另外一方,則須要在相鄰節點中的前一個節點定義。經過鏈的內存模型,咱們能夠知道,在生產計劃示例中,一個實體的前一個節點的類型多是另外一個Task, 也要能是一個Machine, 所以,前一個節點指向後一個節點的規劃變量,只能在Task與Machine的共同父類中定義,也就是須要在Step中實現。所以,在Step類中須要定義另外一個Shadow Variable, 由於相對於Task中的Anchor Shadow variable, 它是反現的,所以,它須要經過@InverseRelationShadowVariable註解,說明它在鏈上起到反向鏈接做用,即它是指向後一個節點的。代碼以下:

@PlanningEntity
public abstract class Step{

    // Shadow variables
    @InverseRelationShadowVariable(sourceVariableName = "previousStep")
    protected Task nextTask;
    .
    .
    .
}

  能夠從代碼中看到,Step類也是一個規劃實體.其中的一個成員nextTask, 它的類型是Task,它表示在鏈中指向後面的Entity. 你們能夠想一下,爲何它能夠是一個Task, 而無需是一個Step。

  經過上述設計,已經實現了Chained Through Time的基本模式,可能你們還會問,上面咱們實現了VariableListener, 引擎是如何觸發它的呢。這就須要用到另一種Shadow Variable了,這種Shadow Varible是用於實如今運算過程當中執行額外處理的,所以稱爲Custom Shadow Variable.

// 自定義Shadow Variable, 它表示當 genuine被引擎改變時,須要處理哪一個變量。 
@CustomShadowVariable(variableListenerClass = StartTimeUpdatingVariableListener.class,
            sources = {@PlanningVariableReference(variableName = "previousStep")})
    private Integer startTime; // 由於時間在規劃過程當中以相對值進行運算,所以以整數表示。

  上面的代碼經過@CustomShadowVariable註解,說明了Task的成員startTime是一個自定義的Shadow Variable. 同時在註解中添加了variableListenerClass屬性,其值指定爲剛纔咱們定義的,實現了VariableListener接口的類 - StartTimeUpdatingVariableListener,同時,能冠軍sources屬性指定,當前Custom Shadow Variable是跟隨着genuine variable - previousStep的變化而變化的。

  至此,關於Chained Through Time中的關鍵要點已所有設計實現,具體的使用能夠參照示例包中有用到此模式的代碼。

 

總結

  關於時間的規劃,在實際的系統開發時,並不僅本文描述的那麼簡單,關於最爲複雜的Chained Through Time模式,你們能夠經過本文了解其概念、結構和要點,再結合示例包中的代碼進來理解,才能掌握其要領。且現實項目中也有許許多多的個性規則和要求,須要經過你們的技巧來實現;但萬變不離其宗,全部處理特殊狀況的技巧,都須要甚至Optaplanner這些既有特性。所以,你們能夠先經過示例包中的代碼將這些特性掌握,再進行更復雜狀況下的設計開如。將來若時間容許,我將分享我在項目中遇到的一些特殊,甚至是苛刻的規則要求,及其處理辦法。

 

如需瞭解更多關於Optaplanner的應用,請發電郵致:kentbill@gmail.com
或到討論組發表你的意見:https://groups.google.com/forum/#!forum/optaplanner-cn
如有須要可添加本人微信(13631823503)或QQ(12977379)實時溝通,但因本人平常工做繁忙,經過微信,QQ等工具可能沒法深刻溝通,較複雜的問題,建議以郵件或討論組方式提出。(討論組屬於google郵件列表,國內網絡可能較難訪問,需自行解決)

相關文章
相關標籤/搜索