1. 程式人生 > >MapReduce任務執行過程研究之Collect過程

MapReduce任務執行過程研究之Collect過程

最近一直在找工作,寫論文,對MapReduce原始碼的學習擱置了很久,想來想去認為不能放棄,有意義的事情一定要做好,要做到底,要盡力。前面的文章到後來寫的有些心不在焉,有應付之嫌,如今重新拾起,認真學習,認真寫下去。MR 2.0已經發布很久了,新架構新思想很值得學習,學無止境啊。

參考書目:

【1】《Java程式設計思想(第四版)》

【2】《Hadoop 技術內幕:深入解析MapReduce架構設計與實現原理》

【3】《Hadoop 技術內幕:深入解析Hadoop Common和HDFS架構設計與實現原理》

從Map階段的Collect過程開始吧,力求有所收穫。

看MapTask類的入口函式run,根據配置判斷啟用old mapper或new mapper。如果是前者:

準備一個MapRunner跑使用者的map函式。這個MapRunner實現了MapRunnable泛型介面,四個泛型引數分別代表map的輸入鍵值對和輸出鍵值對的型別(INKEY,INVALUE,OUTKEY,OUTVALUE)。對於MapRunner來說,兩個泛型引數來自RecordReader<INKEY,INVALUE>物件;另外兩個來自OldOutputCollector物件。後者使用MapOutputBuffer<OUTKEY,OUTVALUE>物件構造。MapOutputBuffer類實現了泛型介面MapOutputCollector,因而具有collect功能。這樣一個MapRunner就具備了讀取資料(read)和輸出資料(collect)的功能。MapRunner通過run函式使用上述兩個功能物件。

如果是後者,就有了新的一套RecordReader和OutputCollector,並使用了一個Context物件封裝上述功能,傳入run函式。不打算詳細學習這部分內容。

回到old mapper的實現中,前面提到泛型,由於對java中的泛型技術比較陌生,這裡詳細學習一下MapRunner.run方法中涉及到泛型技術,順便還有反射的內容。

首先,看RecordReader,它是一個泛型介面。使用泛型而不是普通介面的好處是,實現介面不僅僅具有了介面的功能,同時介面方法的引數和返回值支援多種型別。對於RecordReader來說,支援多種型別的key和value。如果不使用泛型,則在介面中使用key和value型別的基類,這樣就只支援基類及其派生類,不支援該派生體系外的型別。Java支援泛型方法,這使得方法能夠獨立於類產生變化【1】。如果能使用泛型方法解決問題,就不使用泛型類。MapTask類的runOldMapper方法就是一個泛型方法,其簽名如下:

private <INKEY,INVALUE,OUTKEY,OUTVALUE>
  void runOldMapper(final JobConf job,
                    final TaskSplitIndex splitIndex,
                    final TaskUmbilicalProtocol umbilical,
                    TaskReporter reporter
                    )
MapTask本身不是個泛型類。四個泛型引數在構造MapRunnable物件時使用:
MapRunnable<INKEY,INVALUE,OUTKEY,OUTVALUE> runner = ReflectionUtils.newInstance(job.getMapRunnerClass(), job);
MapRunnable也是個泛型介面,其run方法的引數RecordReader和OutputCollector使用了泛型引數:
public void run(RecordReader<K1, V1> input, OutputCollector<K2, V2> output,
                  Reporter reporter)
再看runner的構造,使用ReflectionUtils的靜態方法實現,該類是MapReduce提供的一個反射工具類。newInstance方法是個靜態泛型方法:
public static <T> T newInstance(Class<T> theClass, Configuration conf) {
    T result;
    try {
      Constructor<T> meth = (Constructor<T>) CONSTRUCTOR_CACHE.get(theClass);
      if (meth == null) {
        meth = theClass.getDeclaredConstructor(EMPTY_ARRAY);
        meth.setAccessible(true);
        CONSTRUCTOR_CACHE.put(theClass, meth);
      }
      result = meth.newInstance();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
    setConf(result, conf);
    return result;
  }

該方法的作用是根據給定的型別和配置建立物件。在Java中,一個static方法無法訪問泛型類的泛型引數。因此,如果static方法需要使用泛型能力,就必須使其成為泛型方法【1】。靜態泛型方法經常用於一些工具類作為建立物件的工具。具體看方法實現,首先檢視快取中有沒有該型別的構造方法物件,這個快取物件是這樣實現的:

 /** 
   * Cache of constructors for each class. Pins the classes so they
   * can't be garbage collected until ReflectionUtils can be collected.
   */
  private static final Map<Class<?>, Constructor<?>> CONSTRUCTOR_CACHE = 
    new ConcurrentHashMap<Class<?>, Constructor<?>>();

ConcurrentHashMap允許併發的讀取和寫入,在修改完成之前,讀取者無法看到。構造器快取儲存了很多型別的構造器物件,因此併發訪問是必需的。從註釋中看出,將構造器物件存入快取,使得這些構造器所屬類的物件不致被垃圾回收,除非整個工具類被垃圾回收。這裡還有一個細節,在宣告容器物件時泛型引數列表重複出現。使用泛型方法的型別引數推斷特性可以簡化程式碼。容器的建立可以通過下面類來實現:
public class Maps{
	public static <K,V> Map<K,V> map(){
		return new HashMap<K,V>();
	}
	public static <K,V> ConcurrentHashMap<K,V> cMap(){
		return new ConcurrentHashMap<K,V>();
	}
	
	public static void main(String[] args){
		Map<Class<?>, Constructor<?>> CONSTRUCTOR_CACHE = Maps.cMap();
	}
}

現在構造器快取就使用工具類Maps的靜態方法建立,而引數K和V可以由型別推斷機制得知。回到newInstance方法中,getDeclaredConstructor方法返回指定類的構造器物件,其引數即表示構造器的引數列表。Constructor物件的newInstance方法的作用相當於呼叫該構造器所屬類的構造器生成一個類的例項。Class物件的newInstance方法有相同的功能,實際上內部呼叫的也是Constructor物件的newInstance方法:
final Constructor<T> c = getConstructor0(empty, Member.DECLARED);
cachedConstructor = c;
Constructor<T> tmpConstructor = cachedConstructor;
return tmpConstructor.newInstance((Object[])null);

上面四句就是使用Class的newInstance構造物件的大概過程。

回到run方法,MapRunnable的run有兩種實現:一是普通的MapRunner,另一個是多執行緒MultithreadedMapRunner,這裡先學習前者。每個MapRunner物件中都有一個Mapper泛型物件用於執行使用者提交的Map函式。

this.mapper = ReflectionUtils.newInstance(job.getMapperClass(), job);
mapper.map(key, value, output, reporter);

mapper的構造也是通過反射實現,使用者提交作業時指定的是Mapper的Class類名。由此看來,MapReduce程式設計框架帶給使用者的方便之處與Java的反射機制是分不開的。

前面的文章提到過,collect方法是由使用者的map函式呼叫的,例如Grep應用的mapper類RegexMap類中的map函式:

public void map(K key, Text value,
                  OutputCollector<Text, LongWritable> output,
                  Reporter reporter)
    throws IOException {
    String text = value.toString();
    Matcher matcher = pattern.matcher(text);
    while (matcher.find()) {
      output.collect(new Text(matcher.group(group)), new LongWritable(1));
    }

這裡實際呼叫的是OldOutputCollector的collect方法,該方法通過partitioner獲得key/value所在分割槽後,組成一個三元組以引數傳遞給MapOutputBuffer的collect方法:
collector.collect(key, value, partitioner.getPartition(key, value, numPartitions));

collect過程會將三元組寫入環形緩衝區,詳見前面文章,這裡學習一些語言和設計上的特性。collect過程與spill過程同步進行,併發控制通過MapOutputBuffer的可重入互斥鎖spillLock控制:
private final ReentrantLock spillLock = new ReentrantLock();

Thinking in Java中對可重入互斥鎖的解釋為:ReentrantLock允許你嘗試著獲取但最終未獲取鎖,這樣如果其他人已經獲取了這個鎖,那你就可以決定離開去執行其他一些事情,而不是等待直至這個鎖被釋放。在Java中顯式使用鎖物件Lock的情況比較少,因為Lock物件必須顯式地建立、鎖定和釋放。但有時synchronized關鍵字不能實現一些特殊需求:嘗試著獲取鎖且最終獲取失敗,或者嘗試獲取鎖一段時間,然後放棄。這裡舉一個書上的例子:

private ReentrantLock lock = new ReentrantLock();
	public void untimed(){
		boolean captured = lock.tryLock();
		try{
			System.out.println("tryLock(): " + captured);
		}finally{
			if(captured)
				lock.unlock();
		}
	}

lock.tryLock()如果沒有獲得鎖,captured為false,此時不會阻塞執行緒,而是會繼續執行下面語句輸出一行。在finally塊中,根據是否捕獲到鎖來釋放鎖。另外,在ReentrantLock上阻塞的任務具備可以被中斷的能力,這與在synchronized方法或臨界區上阻塞的任務不同(後者是不可中斷的阻塞,不會丟擲InterruptedException異常)。在collect方法中之所以使用可重入鎖,我想就是因為使用上述後一種特性微笑,使其在阻塞時可中斷,丟擲異常。在MapOutputBuffer中還定義了兩個條件變數spillReady和spillDone

private final Condition spillDone = spillLock.newCondition();
private final Condition spillReady = spillLock.newCondition();

這兩個Condition物件通過互斥鎖物件建立。在Java中Condition物件可以完成同步的功能,其操作方式類似訊號量的操作,提供了await和signal以及signalAll方法,對應於Object中的wait,notify和notifyAll方法。Condition通常與Lock一起使用。在這裡,startSpill方法中呼叫signalAll方法喚醒等待鎖的執行緒:
spillReady.signal();

準備開始spill過程。當緩衝區滿時,不能繼續寫入緩衝區,collect執行緒等待:
spillDone.await();

在看緩衝區的消費者spill執行緒spillThread的run方法框架(保留有關同步的部分):
   public void run() {
        spillLock.lock();
        spillThreadRunning = true;
        try {
          while (true) {
            spillDone.signal();
            while (kvstart == kvend) {
              spillReady.await();
            }
            try {
              spillLock.unlock();
              sortAndSpill();
           }finally {
              spillLock.lock();
              ...
              kvstart = kvend;
              bufstart = bufend;
            }
          }
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
        } finally {
          spillLock.unlock();
          spillThreadRunning = false;
        }
      }
    }

首先獲得鎖,然後喚醒在spillDone上等待的collect執行緒,表明spill溢寫結束,可以繼續寫入緩衝區了。當進入正常寫入狀態後(kvstart==kvend),呼叫spillReady.await(),掛起spill執行緒,暫停溢寫,直到collect執行緒再次呼叫startSpill方法。當spill執行緒被喚醒並再次獲得鎖時,呼叫sortAndSpill對緩衝區資料進行依次快速排序然後寫入磁碟。

注意,在呼叫await,signal和signalAll之前必須擁有鎖,這裡兩個Condition變數在使用前必須擁有鎖spillLock。collect同步控制的核心邏輯如下,用於對比:

     spillLock.lock();
      try {
        boolean kvfull;
        do {
          // sufficient acct space
          kvfull = kvnext == kvstart;
          final boolean kvsoftlimit = ((kvnext > kvend)
              ? kvnext - kvend > softRecordLimit
              : kvend - kvnext <= kvoffsets.length - softRecordLimit);
          if (kvstart == kvend && kvsoftlimit) {
            startSpill();
          }
          if (kvfull) {
            while (kvstart != kvend) {
              spillDone.await();
            }
          }
        } while (kvfull);
      } finally {
        spillLock.unlock();
      }

有一個疑問,如果spill執行緒發現collect正在寫緩衝區而掛起,那麼spill獲得的鎖就掛起了,collect就獲得不到鎖了,也就無法呼叫startSpill方法去喚醒掛起的spill執行緒,這樣豈不是死鎖了?

研究了相關資料,找到答案:await操作在呼叫時會先釋放鎖,然後掛起執行緒,並將執行緒加入一個等待佇列。在呼叫signal時會讓等待佇列中第一個執行緒重新獲得鎖,並繼續執行。這樣spill掛起時,spillLock被釋放掉,collect執行緒會持續獲得鎖,直到滿足spill條件,呼叫startSpill方法,喚醒掛起的spill執行緒,當collect釋放鎖時,spill執行緒會重新獲得spillLock,並繼續執行。

使用Condition物件的目的相當於在鎖上加上一個條件,實現更精細的同步控制。這裡在同一個鎖spillLock上使用了兩個條件,spillReady條件表示只有緩衝區滿足一定條件才能發生spill,讀取緩衝區(消費者行為);spillDone條件表示只有滿足一定條件(在這裡只有緩衝區滿的時候collect執行緒才掛起,即便是spill正在進行,緩衝區依然可以寫入,讀寫不衝突,這體現了環形緩衝區的優勢)才能寫緩衝區(生產者行為)。

這個地方關於條件的鎖的理解有什麼錯誤或不足請不吝賜教。關於緩衝區的操作比較複雜,參考書目【2】第8章內容中作者針對各類情況給出了圖文描述,推薦閱讀。

看看sortAndSpill做了些什麼,首先對bufstart和bufend下標之間的資料排序:

sorter.sort(MapOutputBuffer.this, kvstart, endPosition, reporter);

sorter是一個IndexSorter介面型別,其實現有HeapSort和QuickSort兩種,這裡採用的是後者快速排序。關於快速排序的實現請參考另一篇短文。接著按照分割槽依次將記錄寫入臨時檔案,如果有Combiner,先進行combine。最後記錄分割槽的元資訊到spillRecord。仔細觀察combiner發現,它其實是一個reducer,呼叫reduce函式,並通過collect過程輸出結果:
 Reducer<K,V,K,V> combiner = ReflectionUtils.newInstance(combinerClass, job);
      try {
        CombineValuesIterator<K,V> values = 
          new CombineValuesIterator<K,V>(kvIter, comparator, keyClass,  valueClass, job, Reporter.NULL,  inputCounter);
        while (values.more()) {
          combiner.reduce(values.getKey(), values, combineCollector, Reporter.NULL);
          values.nextKey();
        }
      } finally {
        combiner.close();
      }

在呼叫combine方法前,指定了combineCollector的writer物件用於在reduce函式呼叫collect時輸出結果。

最後研究一下Hadoop中的序列化【3】。Hadoop重新定義了序列化的機制,原因是:在Java序列化的過程中,序列化輸出中儲存了大量的附加資訊,導致序列化結果膨脹,對於需要儲存和處理大規模資料的Hadoop來說,需要一個新的序列化機制。使用Java實現物件的序列化簡單概括為:

1. 實現Serializable介面。

2. 在某種OutputStream的基礎上建立ObjectOutputStream物件。

3. 呼叫writeObject方法進行序列化。

反序列化的過程類似,只需要使用對應的輸入流,並呼叫readObject即可。Hadoop對序列化過程的優化為:同一個類物件的序列化結果只輸出一份元資料;重用物件,在已有物件上進行反序列化操作。

具體的實現機制是:

可序列化的物件需要實現Writable介面,該介面含有兩個方法:write(DataOutput out) 和 readFields(DataInput in)。前者藉助Java的DataOutput類物件將Java原生型別以位元組形式寫入二進位制流,後者從二進位制流中讀取欄位。

Hadoop還提供了帶有比較功能的WritableComparable介面,具有高效比較能力的RawComparator介面。前者兼具比較和序列化的功能;後者可以比較流中讀取的未被反序列化為物件的記錄,節省了建立物件的開銷,十分高效。

Hadoop中的Text型別即為一種常見的鍵型別,其宣告如下:

public class Text extends BinaryComparable
    implements WritableComparable<BinaryComparable> {}

首先它是一種BinaryComparable,然後它具有序列化和比較功能。Java為每個基本型別提供了對應的Writable類,short和char型別可以存入int型別。對於整型,有定長和變長兩種版本,前者序列化後資料是定長的,後者可根據資料的實際長度變化,節約儲存空間。如果需要序列化不同型別的物件到某個欄位,可使用ObjectWritable類:
public class ObjectWritable implements Writable, Configurable {

  private Class declaredClass;//實際型別的類物件
  private Object instance;//需要序列化的物件
  private Configuration conf;

  public ObjectWritable() {}
  
  public ObjectWritable(Object instance) {
    set(instance);
  }

  public ObjectWritable(Class declaredClass, Object instance) {
    this.declaredClass = declaredClass;
    this.instance = instance;
  }
...
}

它的write方法中,分別處理了null、Java陣列、String、Java基本型別、列舉型別和Writable子類六種情況。在輸出時要記住物件的實際型別,因為傳遞給instance欄位可能是實際型別的父型別。在readFields方法中,使用工廠方法根據傳入的Class物件,建立Writable物件。

Hadoop還提供了簡單的序列化框架API。通過Serialization實現獲得一個Serializer物件,可將一個物件轉換為一個位元組流的實現例項。在collect過程中,會將key/value序列化到緩衝區中。這裡使用了Serializer:

 private final Serializer<K> keySerializer;
 private final Serializer<V> valSerializer;

Serializer有兩種實現,一種是JavaSerializationSerializer,一種是WritableSerializer。看後者的實現:
static class WritableSerializer implements Serializer<Writable> {
    private DataOutputStream dataOut;
    public void open(OutputStream out) {
      if (out instanceof DataOutputStream) {
        dataOut = (DataOutputStream) out;
      } else {
        dataOut = new DataOutputStream(out);
      }
    }
    public void serialize(Writable w) throws IOException {
      w.write(dataOut);
    }
    public void close() throws IOException {
      dataOut.close();
    }
  }

使用Serializer序列化首先通過open方法開啟,傳入一個輸出流物件;然後呼叫serialize方法將物件序列化到流中,該方法實際呼叫的是Writable物件的write方法,傳入的就是開啟的流;最後使用close方法關閉。在collect階段將key和value序列化:
    keySerializer.serialize(key);
    valSerializer.serialize(value);

注意JavaSerializationSerializer直接採用了Java的序列化機制,因此效率不高。另外,與Serializer對應的是Deserializer,用於反序列化,不贅述。今後遇到與序列化相關的功能時,再繼續學習。

Collect過程結束,以後可能會就某個話題有補充。下篇計劃學習Reduce任務的過程。

2014.04.01

相關推薦

MapReduce任務執行過程研究Collect過程

最近一直在找工作,寫論文,對MapReduce原始碼的學習擱置了很久,想來想去認為不能放棄,有意義的事情一定要做好,要做到底,要盡力。前面的文章到後來寫的有些心不在焉,有應付之嫌,如今重新拾起,認真學習,認真寫下去。MR 2.0已經發布很久了,新架構新思想很值得學習,學無止

MapReduce任務執行到running job卡住

(1) 環境:Centos6.4、JDK1.7、hadoop-2.5.0-cdh5.3.3 (2) 問題:之前使用Apache的hadoop跑各種MR應用均沒出現問題,然而使用CDH版的hadoop執行到running job卻卡住了。 配置好偽分散式的hadoop叢集,啟

mapreduce執行過程

hash 寫入 fileinput 集群 reduce tin combine utf keyvalue 1.首先是map獲取分片,分片的大小和分片規則取決於文件輸入的格式,FileInputFormat是輸入格式的一個基類,FileInputFormat下有幾個重要的子類

研究任務】linux系統開機啟動過程

邏輯 color 提示 not 讀取 兩個 引導程序 配置信息 函數名 總覽加載BIOS一個特殊的應將電路在CPU的一個引腳上產生一個RESET邏輯值,然後會把一些寄存器(包括cs和eip)設置成固定的值然後執行在物理地址為0xFFFF FFF0處找到的代碼,硬件把這個地址

Mybatis是如何執行你的SQL的(SQL執行過程,引數解析過程,結果集封裝過程

Myabtis的SQL的執行是通過SqlSession。預設的實現類是DefalutSqlSession。通過原始碼可以發現,selectOne最終會呼叫selectList這個方法。 1 @Override 2 public <E> List<E> select

記一次使用crontab計劃任務執行python指令碼所遇問題及處理的過程

今天把一個python指令碼遷移到Centos7,用crontab執行,期間遇到很多錯誤,最終把所遇問題一一處理,感覺有必要把處理過程記錄下來 1、問題環境 Centos7 x64 python2.7 和python 3.5 有安裝virtualenvwrappe

MapReduce執行原理 MapReduce的原理及執行過程 Combiner

MapReduce的原理及執行過程   MapReduce簡介 MapReduce是一種分散式計算模型,是Google提出的,主要用於搜尋領域,解決海量資料的計算問題。 MR有兩個階段組成:Map和Reduce,使用者只需實現map()和reduce(

Spark任務執行過程簡介

--executor-memory 每一個executor使用的記憶體大小 --total-executor-cores    整個application使用的核數 1.提交一個spark程式到spark叢集,會產生哪些程序?     

Oracle PLSQLl的多執行緒程式設計架構 儲存過程中使用多執行緒 定時任務 作業排程計劃 JOB SCHEDULE

  基於Oracle plsql的多執行緒程式設計架構 (附儲存過程) 1年前 1413 作者介紹 馮守東,北京科訊華通科技發展有限公司高階專案經理。超12年Oracle開發及管理經驗,多年運營商和政府企業級系統運維經驗,曾獲得東軟最佳設計方案獎。熟悉Weblogic、TU

spring boot中得定時任務執行一段時間後突然停了 排查過程

在spring boot 專案中設定了一些定時任務,前幾天還執行得好好的,突然有一天就不再執行了,基本上呢都是執行了四天左右,定時任務停掉不在運行了,然後重啟程式定時任務就好使了,出現這麼兩次,第三次是在重啟以後第三天出現定時任務不再執行。感覺莫名其妙,查了好多資料,以下是關於我查到的關於定時任

hadoop概念-MapReduce各個執行階段及Shuffle過程詳解

MapReduce各個執行階段 (1)MapReduce框架使用InputFormat模組做Map前的預處理,比如驗證輸入的格式是否符合輸入定義;然後,將輸入檔案切分為邏輯上的多個InputSplit,InputSplit是MapReduce對檔案進行處理和運算的輸入單位

spark on yarn圖形化任務監控利器:History-server幫你理解spark的任務執行過程

在spark on yarn任務進行時,大家都指導用4040埠監控(預設是,設定其他或者多個任務同時會遞增等例外); 辣麼,任務結束了,還要看圖形化介面,那就要開history-server了。CDH安裝spark on yarn的時候,就自動安裝了history的例項。

spark任務執行過程的原始碼分析

spark任務執行的原始碼分析 在整個spark任務的編寫、提交、執行分三個部分:① 編寫程式和提交任務到叢集中 ②sparkContext的初始化③觸發action運算元中的runJob方法,執行任務 (1)程式設計程式並提交到叢集: ①程式設計spark程式的程式碼②打成jar包到叢集中執行③使用s

hadoop2 作業執行過程map過程

在執行MAP任務之前,先了解一下它的容器和它容器的領導:container和nodemanager NodeManager NodeManager(NM)是YARN中每個節點上的代理,它管理Hadoop叢集中的單個計算節點,包括與ResourceManager保持通訊,監督

Oracle定時任務執行儲存過程帶引數

儲存過程: create or replace procedure pro_test (retCode out number, retMsg out varchar2) is  vcrm v_prod_inst%ROWTYPE; TYPE ref_cursor_type I

MapReducereducer任務執行流程詳解

第一階段是 Reducer 任務會主動從 Mapper 任務複製其輸出的鍵值對。Mapper 任務可能會有很多,因此 Reducer 會複製多個 Mapper 的輸出。第二階段是把複製到 Reducer 本地資料,全部進行合併,即把分散的資料合併成一個大的資料。再對合並後的資

Android簽名機制---簽名過程具體解釋

先來 文件內容 rfi eating general class stat ket 寫文章 一、前言又是過了好長時間,沒寫文章的雙手都有點難受了。今天是聖誕節,還是得上班。由於前幾天有一個之前的同事,在申請微信SDK的時候,遇到簽名的問題,問了我一下,結果把我難倒了。。我

ThinkPHP執行調用存儲過程添加日誌

exists sys 地址 pcre 調用 all fault php代碼 tab 本文出至:新太潮流網絡博客 //PHP代碼部分 /** * [LogAdd 操作日誌] * @param [string] $userid [用戶的ID] * @p

【shiro】登錄經歷的流程(執行ShiroAccountRealm doGetAuthenticationInfo經歷的過程

tor quest count ont lin etsec ret ebs com http://jinnianshilongnian.iteye.com/blog/2025656 攔截器機制。 在這裏 @Bean(name = "shiroFilter") pu

Java調用ARM模板執行Azure Rest建立VM過程

sna string happy data- 交互 disk view manual name Azure Resource Manager 提供一致的管理層,用於管理通過 Azure PowerShell、Azure CLI、Azure 門戶、REST API 和開發工