1. 程式人生 > >Binder系列6—獲取服務(getService)

Binder系列6—獲取服務(getService)

一、 獲取服務

在Native層的服務註冊,我們選擇以media為例來展開講解,先來看看media的類關係圖。

1.1 類圖

點選檢視大圖

get_media_player_service

圖解:

  • 藍色: 代表獲取MediaPlayerService服務相關的類;
  • 綠色: 代表Binder架構中與Binder驅動通訊過程中的最為核心的兩個類;
  • 紫色: 代表註冊服務和獲取服務的公共介面/父類;

二. 獲取Media服務

2.1 getMediaPlayerService

[-> framework/av/media/libmedia/IMediaDeathNotifier.cpp]

sp<IMediaPlayerService>&
IMediaDeathNotifier::getMediaPlayerService()
{
    Mutex::Autolock _l(sServiceLock);
    if
(sMediaPlayerService == 0) { sp<IServiceManager> sm = defaultServiceManager(); //獲取ServiceManager sp<IBinder> binder; do { //獲取名為"media.player"的服務 【見2.2】 binder = sm->getService(String16("media.player")); if (binder != 0) { break
; } usleep(500000); // 0.5s } while (true); if (sDeathNotifier == NULL) { sDeathNotifier = new DeathNotifier(); //建立死亡通知物件 } //將死亡通知連線到binder 【見流程14】 binder->linkToDeath(sDeathNotifier); sMediaPlayerService = interface_cast<IMediaPlayerService>(binder); } return
sMediaPlayerService; }

其中defaultServiceManager()過程在上一篇文章獲取ServiceManager已講過,返回BpServiceManager。

在請求獲取名為”media.player”的服務過程中,採用不斷迴圈獲取的方法。由於MediaPlayerService服務可能還沒向ServiceManager註冊完成或者尚未啟動完成等情況,故則binder返回為NULL,休眠0.5s後繼續請求,直到獲取服務為止。

2.2 BpSM.getService

[-> IServiceManager.cpp ::BpServiceManager]

virtual sp<IBinder> getService(const String16& name) const
    {
        unsigned n;
        for (n = 0; n < 5; n++){
            sp<IBinder> svc = checkService(name); //【見2.3】
            if (svc != NULL) return svc;
            sleep(1);
        }
        return NULL;
    }

通過BpServiceManager來獲取MediaPlayer服務:檢索服務是否存在,當服務存在則返回相應的服務,當服務不存在則休眠1s再繼續檢索服務。該迴圈進行5次。為什麼是迴圈5次呢,這估計跟Android的ANR時間為5s相關。如果每次都無法獲取服務,迴圈5次,每次迴圈休眠1s,忽略checkService()的時間,差不多就是5s的時間

2.3 BpSM.checkService

[-> IServiceManager.cpp ::BpServiceManager]

virtual sp<IBinder> checkService( const String16& name) const
{
    Parcel data, reply;
    //寫入RPC頭
    data.writeInterfaceToken(IServiceManager::getInterfaceDescriptor());
    //寫入服務名
    data.writeString16(name);
    remote()->transact(CHECK_SERVICE_TRANSACTION, data, &reply); //【見2.4】
    return reply.readStrongBinder(); //【見小節2.9】
}

檢索指定服務是否存在, 其中remote()為BpBinder。

2.4 BpBinder::transact

[-> BpBinder.cpp]

status_t BpBinder::transact(
    uint32_t code, const Parcel& data, Parcel* reply, uint32_t flags)
{
    if (mAlive) {
        //【見流程2.5】
        status_t status = IPCThreadState::self()->transact(
            mHandle, code, data, reply, flags);
        if (status == DEAD_OBJECT) mAlive = 0;
        return status;
    }
    return DEAD_OBJECT;
}

Binder代理類呼叫transact()方法,真正工作還是交給IPCThreadState來進行transact工作,

2.4.1 IPCThreadState::self

[-> IPCThreadState.cpp]

IPCThreadState* IPCThreadState::self()
{
    if (gHaveTLS) {
restart:
        const pthread_key_t k = gTLS;
        IPCThreadState* st = (IPCThreadState*)pthread_getspecific(k);
        if (st) return st;
        return new IPCThreadState;  //初始IPCThreadState 【見小節2.4.2】
    }

    if (gShutdown) return NULL;
    pthread_mutex_lock(&gTLSMutex);
    if (!gHaveTLS) { //首次進入gHaveTLS為false
        if (pthread_key_create(&gTLS, threadDestructor) != 0) { //建立執行緒的TLS
            pthread_mutex_unlock(&gTLSMutex);
            return NULL;
        }
        gHaveTLS = true;
    }
    pthread_mutex_unlock(&gTLSMutex);
    goto restart;
}

TLS是指Thread local storage(執行緒本地儲存空間),每個執行緒都擁有自己的TLS,並且是私有空間,執行緒之間不會共享。通過pthread_getspecific/pthread_setspecific函式可以獲取/設定這些空間中的內容。從執行緒本地儲存空間中獲得儲存在其中的IPCThreadState物件。

2.4.2 IPCThreadState初始化

[-> IPCThreadState.cpp]

IPCThreadState::IPCThreadState()
    : mProcess(ProcessState::self()),
      mMyThreadId(gettid()),
      mStrictModePolicy(0),
      mLastTransactionBinderFlags(0)
{
    pthread_setspecific(gTLS, this);
    clearCaller();
    mIn.setDataCapacity(256);
    mOut.setDataCapacity(256);
}

每個執行緒都有一個IPCThreadState,每個IPCThreadState中都有一個mIn、一個mOut。成員變數mProcess儲存了ProcessState變數(每個程序只有一個)。

  • mIn 用來接收來自Binder裝置的資料,預設大小為256位元組;
  • mOut用來儲存發往Binder裝置的資料,預設大小為256位元組。

2.5 IPC::transact

[-> IPCThreadState.cpp]

status_t IPCThreadState::transact(int32_t handle,
                                  uint32_t code, const Parcel& data,
                                  Parcel* reply, uint32_t flags)
{
    status_t err = data.errorCheck(); //資料錯誤檢查
    flags |= TF_ACCEPT_FDS;
    ....
    if (err == NO_ERROR) {
         // 傳輸資料 【見流程2.6】
        err = writeTransactionData(BC_TRANSACTION, flags, handle, code, data, NULL);
    }

    if (err != NO_ERROR) {
        if (reply) reply->setError(err);
        return (mLastError = err);
    }

    if ((flags & TF_ONE_WAY) == 0) { //flags=0進入該分支
        if (reply) {
            //等待響應 【見流程2.7】
            err = waitForResponse(reply);
        } else {
            Parcel fakeReply;
            err = waitForResponse(&fakeReply);
        }

    } else {
        //不需要響應訊息的binder則進入該分支
        err = waitForResponse(NULL, NULL);
    }
    return err;
}

2.6 IPC.writeTransactionData

[-> IPCThreadState.cpp]

status_t IPCThreadState::writeTransactionData(int32_t cmd, uint32_t binderFlags,
    int32_t handle, uint32_t code, const Parcel& data, status_t* statusBuffer)
{
    binder_transaction_data tr;
    tr.target.ptr = 0;
    tr.target.handle = handle; // handle = 0
    tr.code = code;            // code = CHECK_SERVICE_TRANSACTION
    tr.flags = binderFlags;    // binderFlags = 0
    tr.cookie = 0;
    tr.sender_pid = 0;
    tr.sender_euid = 0;

    // data為記錄Media服務資訊的Parcel物件
    const status_t err = data.errorCheck();
    if (err == NO_ERROR) {
        tr.data_size = data.ipcDataSize();  // mDataSize
        tr.data.ptr.buffer = data.ipcData(); //mData
        tr.offsets_size = data.ipcObjectsCount()*sizeof(binder_size_t); //mObjectsSize
        tr.data.ptr.offsets = data.ipcObjects(); //mObjects
    } else if (statusBuffer) {
        ...
    } else {
        return (mLastError = err);
    }

    mOut.writeInt32(cmd);         //cmd = BC_TRANSACTION
    mOut.write(&tr, sizeof(tr));  //寫入binder_transaction_data資料
    return NO_ERROR;
}

其中handle的值用來標識目的端,註冊服務過程的目的端為service manager,此處handle=0所對應的是binder_context_mgr_node物件,正是service manager所對應的binder實體物件。binder_transaction_data結構體是binder驅動通訊的資料結構,該過程最終是把Binder請求碼BC_TRANSACTION和binder_transaction_data結構體寫入到mOut

2.7 IPC.waitForResponse

[-> IPCThreadState.cpp]

status_t IPCThreadState::waitForResponse(Parcel *reply, status_t *acquireResult)
{
    int32_t cmd;
    int32_t err;

    while (1) {
        if ((err=talkWithDriver()) < NO_ERROR) break; // 【見流程2.8】
        err = mIn.errorCheck();
        if (err < NO_ERROR) break;
        if (mIn.dataAvail() == 0) continue;

        cmd = mIn.readInt32();
        switch (cmd) {
            case BR_TRANSACTION_COMPLETE: ...
            case BR_DEAD_REPLY: ...
            case BR_FAILED_REPLY: ...
            case BR_ACQUIRE_RESULT: ...
            case BR_REPLY:
            {
              binder_transaction_data tr;
              err = mIn.read(&tr, sizeof(tr));
              if (reply) {
                  if ((tr.flags & TF_STATUS_CODE) == 0) {
                      reply->ipcSetDataReference(
                          reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),
                          tr.data_size,
                          reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),
                          tr.offsets_size/sizeof(binder_size_t),
                          freeBuffer, this);
                  } else {
                      ...
                  }
              }
            }
            goto finish;

            default:
                err = executeCommand(cmd);
                if (err != NO_ERROR) goto finish;
                break;
        }
    }
    ...
    return err;
}

2.8 IPC.talkWithDriver

[-> IPCThreadState.cpp]

status_t IPCThreadState::talkWithDriver(bool doReceive)
{
    ...
    binder_write_read bwr;
    const bool needRead = mIn.dataPosition() >= mIn.dataSize();
    const size_t outAvail = (!doReceive || needRead) ? mOut.dataSize() : 0;

    bwr.write_size = outAvail;
    bwr.write_buffer = (uintptr_t)mOut.data();

    if (doReceive && needRead) {
        //接收資料緩衝區資訊的填充。如果以後收到資料,就直接填在mIn中了。
        bwr.read_size = mIn.dataCapacity();
        bwr.read_buffer = (uintptr_t)mIn.data();
    } else {
        bwr.read_size = 0;
        bwr.read_buffer = 0;
    }
    //當讀緩衝和寫緩衝都為空,則直接返回
    if ((bwr.write_size == 0) && (bwr.read_size == 0)) return NO_ERROR;

    bwr.write_consumed = 0;
    bwr.read_consumed = 0;
    status_t err;
    do {
        //通過ioctl不停的讀寫操作,跟Binder Driver進行通訊【2.8.1】
        if (ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr) >= 0)
            err = NO_ERROR;
        ...
    } while (err == -EINTR); //當被中斷,則繼續執行
    ...
    return err;
}

binder_write_read結構體用來與Binder裝置交換資料的結構, 通過ioctl與mDriverFD通訊,是真正與Binder驅動進行資料讀寫互動的過程。 先向service manager程序傳送查詢服務的請求(BR_TRANSACTION),見Binder系列3—啟動ServiceManager。當service manager程序收到該命令後,會執行do_find_service() 查詢服務所對應的handle,然後再binder_send_reply()應答 發起者,傳送BC_REPLY協議,然後呼叫binder_transaction(),再向服務請求者的Todo佇列 插入事務。

接下來,再看看binder_transaction過程。

2.8.1 binder_transaction

static void binder_transaction(struct binder_proc *proc,
               struct binder_thread *thread,
               struct binder_transaction_data *tr, int reply){
    //根據各種判定,獲取以下資訊:
    struct binder_thread *target_thread; //目標執行緒
    struct binder_proc *target_proc;    //目標程序
    struct binder_node *target_node;    //目標binder節點
    struct list_head *target_list;      //目標TODO佇列
    wait_queue_head_t *target_wait;     //目標等待佇列
    ...

    //分配兩個結構體記憶體
    struct binder_transaction *t = kzalloc(sizeof(*t), GFP_KERNEL);
    struct binder_work *tcomplete = kzalloc(sizeof(*tcomplete), GFP_KERNEL);
    //從target_proc分配一塊buffer
    t->buffer = binder_alloc_buf(target_proc, tr->data_size,

    for (; offp < off_end; offp++) {
        switch (fp->type) {
        case BINDER_TYPE_BINDER: ...
        case BINDER_TYPE_WEAK_BINDER: ...

        case BINDER_TYPE_HANDLE:
        case BINDER_TYPE_WEAK_HANDLE: {
          struct binder_ref *ref = binder_get_ref(proc, fp->handle,
                fp->type == BINDER_TYPE_HANDLE);
          ...
          //此時執行在servicemanager程序,故ref->node是指向服務所在程序的binder實體,
          //而target_proc為請求服務所在的程序,此時並不相等。
          if (ref->node->proc == target_proc) {
            if (fp->type == BINDER_TYPE_HANDLE)
              fp->type = BINDER_TYPE_BINDER;
            else
              fp->type = BINDER_TYPE_WEAK_BINDER;
            fp->binder = ref->node->ptr;
            fp->cookie = ref->node->cookie; //BBinder服務的地址
            binder_inc_node(ref->node, fp->type == BINDER_TYPE_BINDER, 0, NULL);

          } else {
            struct binder_ref *new_ref;
            //請求服務所在程序並非服務所在程序,則為請求服務所在程序建立binder_ref
            new_ref = binder_get_ref_for_node(target_proc, ref->node);
            fp->binder = 0;
            fp->handle = new_ref->desc; //重新賦予handle值
            fp->cookie = 0;
            binder_inc_ref(new_ref, fp->type == BINDER_TYPE_HANDLE, NULL);
          }
        } break;

        case BINDER_TYPE_FD: ...
        }
    }
    //分別target_list和當前執行緒TODO佇列插入事務
    t->work.type = BINDER_WORK_TRANSACTION;
    list_add_tail(&t->work.entry, target_list);
    tcomplete->type = BINDER_WORK_TRANSACTION_COMPLETE;
    list_add_tail(&tcomplete->entry, &thread->todo);
    if (target_wait)
        wake_up_interruptible(target_wait);
    return;
}

這個過程非常重要,分兩種情況來說:

  1. 當請求服務的程序與服務屬於不同程序,則為請求服務所在程序建立binder_ref物件,指向服務程序中的binder_node;
  2. 當請求服務的程序與服務屬於同一程序,則不再建立新物件,只是引用計數加1,並且修改type為BINDER_TYPE_BINDER或BINDER_TYPE_WEAK_BINDER。

2.8.2 binder_thread_read

binder_thread_read(...){
    ...
    //當執行緒todo佇列有資料則執行往下執行;當執行緒todo佇列沒有資料,則進入休眠等待狀態
    ret = wait_event_freezable(thread->wait, binder_has_thread_work(thread));
    ...
    while (1) {
        uint32_t cmd;
        struct binder_transaction_data tr;
        struct binder_work *w;
        struct binder_transaction *t = NULL;
        //先從執行緒todo佇列獲取事務資料
        if (!list_empty(&thread->todo)) {
            w = list_first_entry(&thread->todo, struct binder_work, entry);
        // 執行緒todo佇列沒有資料, 則從程序todo對獲取事務資料
        } else if (!list_empty(&proc->todo) && wait_for_proc_work) {
            ...
        }
        switch (w->type) {
            case BINDER_WORK_TRANSACTION:
                //獲取transaction資料
                t = container_of(w, struct binder_transaction, work);
                break;

            case : ...  
        }

        //只有BINDER_WORK_TRANSACTION命令才能繼續往下執行
        if (!t) continue;

        if (t->buffer->target_node) {
            ...
        } else {
            tr.target.ptr = NULL;
            tr.cookie = NULL;
            cmd = BR_REPLY; //設定命令為BR_REPLY
        }
        tr.code = t->code;
        tr.flags = t->flags;
        tr.sender_euid = t->sender_euid;

        if (t->from) {
            struct task_struct *sender = t->from->proc->tsk;
            //當非oneway的情況下,將呼叫者程序的pid儲存到sender_pid
            tr.sender_pid = task_tgid_nr_ns(sender, current->nsproxy->pid_ns);
        } else {
            ...
        }

        tr.data_size = t->buffer->data_size;
        tr.offsets_size = t->buffer->offsets_size;
        tr.data.ptr.buffer = (void *)t->buffer->data +
                    proc->user_buffer_offset;
        tr.data.ptr.offsets = tr.data.ptr.buffer +
                    ALIGN(t->buffer->data_size,
                        sizeof(void *));

        //將cmd和資料寫回使用者空間
        put_user(cmd, (uint32_t __user *)ptr);
        ptr += sizeof(uint32_t);
        copy_to_user(ptr, &tr, sizeof(tr));
        ptr += sizeof(tr);

        list_del(&t->work.entry);
        t->buffer->allow_user_free = 1;
        if (cmd == BR_TRANSACTION && !(t->flags & TF_ONE_WAY)) {
            ...
        } else {
            t->buffer->transaction = NULL;
            kfree(t); //通訊完成則執行釋放
        }
        break;
    }
done:
    *consumed = ptr - buffer;
    if (proc->requested_threads + proc->ready_threads == 0 &&
        proc->requested_threads_started < proc->max_threads &&
        (thread->looper & (BINDER_LOOPER_STATE_REGISTERED |
         BINDER_LOOPER_STATE_ENTERED))) {
        proc->requested_threads++;
        // 生成BR_SPAWN_LOOPER命令,用於建立新的執行緒
        put_user(BR_SPAWN_LOOPER, (uint32_t __user *)buffer);
    }
    return 0;
}

2.9 readStrongBinder

[-> Parcel.cpp]

sp<IBinder> Parcel::readStrongBinder() const
{
    sp<IBinder> val;
    //【見小節2.9.1】
    unflatten_binder(ProcessState::self(), *this, &val);
    return val;
}

2.9.1 unflatten_binder

[-> Parcel.cpp]

            
           

相關推薦

Binder系列6獲取服務(getService)

一、 獲取服務在Native層的服務註冊,我們選擇以media為例來展開講解,先來看看media的類關係圖。1.1 類圖點選檢視大圖圖解:藍色: 代表獲取MediaPlayerService服務相關的類;綠色: 代表Binder架構中與Binder驅動通訊過程中的最為核心的兩

Docker Swarm系列——6.Swarm服務面板

在這篇文章中,大家將會了解如何通過docker CLI命令或docker-compose.yml檔案的形式部署一個簡單的網頁視覺化面板,不用登陸每臺伺服器就可以方便地檢視整個Swarm叢集、服務、容器等的執行情況。 1. 建立服務 通過前面幾篇文章地介紹,我們

Binder系列5—註冊服務(addService)

framework/native/libs/binder/ - Binder.cpp - BpBinder.cpp - IPCThreadState.cpp - ProcessState.cpp - IServiceManager.cpp - IInt

C#開發BIMFACE系列6 服務端API之獲取檔案資訊

在《C#開發BIMFACE系列4 服務端API之源上傳檔案》、《C#開發BIMFACE系列5 服務端API之檔案直傳》兩篇文章中詳細介紹瞭如何將本地檔案上傳到BIMFACE伺服器及BIMFACE後臺的分散式儲存系統中。檔案上傳成功後,BIMFACE的服務會返回與該檔案相關的資訊,如下圖:  開

Azure手把手系列6:存儲服務介紹

雲計算 雲平臺 在使用Azure的過程中,在大多數情況下我們都會使用到存儲服務,對於虛擬機來說就是我們的磁盤存儲。Azure對於存儲來說是劃分的非常全面和細致的,在使用各種存儲服務之前我們需要創建存儲帳戶,然後即可將數據傳入/傳出該存儲帳戶中的特定服務。 ? ?首先,我們來看下Azure提供了什麽類型

系列6-springCloud微服務-config配置中心

config配置中心分為服務端和客戶端,服務端根據檔案儲存位置分為三種設定方式:git\githupSVN本地儲存其中git\githup,SVN的設定方式基本一樣。此處僅記錄git的設定,githup與git的設定完全一樣。1.config Server基於git步驟1:本

Oracle Apex 有用筆記系列 6 - 可編輯交互報告 Editable Interactive Report

gin where 表格 報告 查詢 查詢語句 item 提醒 lis 據筆者所知。Apex 4.x 是沒有提供可編輯交互報告組件的。這就須要我們手動實現。事實上這也並非非常復雜,僅僅須要簡單幾步。 1. 依據向導建立一個interactive report。查詢語句能

問題解決系列: 後臺服務流量控制- 控制訪問別的服務的速度

發送 template 個人 exce 保護 rms 這一 ole 每分鐘 互聯網的後臺提倡大系統小做,微服務化。所以後臺服務之間相互依賴,我依賴別人的,別人也依賴我的,這很正常。但是後臺服務講穩定性。只有一切可控,才能談穩定性。為了不沖垮下遊的服務,我們有兩種做法:一種是

Redis系列--6、Redis Java連接操作

redis java連接操作安裝要在Java程序中使用使用操作Redis,需要確保有Redis的Java驅動程序和Java設置在機器上。可以檢查看Java教程-學習如何在機器上安裝Java。現在,讓我們來看看如何設置Redis的Java驅動程序。需要下載jedis.jar。請一定要下載它的最新版本。需要包括j

PHP獲取服務器環境信息

虛擬主機 6.2 執行 主機名 true 字符 fastcgi port 服務器環境 PHP獲取服務器環境信息 PHP的php_uname() 函數 和$_SERVER(服務器和執行環境信息) echo ‘服務器版本和虛擬主機名的字符串‘.$_SERVER[‘SERVER

獲取服務器時間ajax

min ava ise 獲取 years subst log esp hour $.ajax({ type:"OPTIONS", url:"/", complete:function(x){ // alert(x.getResponseHea

php獲取服務器信息常用方法(零碎知識記憶)

附件 sof 常用方法 exe 上傳 系統 文檔 file 信息 突然整理下零碎小知識.......加深下印象: $info = array( ‘操作系統‘=>PHP_OS, ‘運行環境‘=>$_SERVER["

獲取服務器IP,客戶端IP

進程 獲取 () base accep roc eight 可能 php_sapi 客戶端IP相關的變量 1. $_SERVER[‘REMOTE_ADDR‘]; 客戶端IP,有可能是用戶的IP,也有可能是代理的IP。 2. $_SERVER[‘HTTP_CLIENT_IP

20170825L08-05老男孩linux實戰運維培訓-Lamp系列之-Apache服務生產實戰應用指南02

apache這一節說Apache的安裝目錄文件具體介紹了一些重要文件的配置tree -L 1 /usr/local/apache[[email protected] extra]# tree -L 1 /usr/local/apache/usr/local/apache├── apache ->

20170830L08-06老男孩linux實戰運維培訓-Lamp系列之-Apache服務生產實戰應用指南03

apache還是說的apache的設置這一次說的是虛擬主機主要配置文件httpd.confhttpd-vhhsots.confhttpd.conf主要控制目錄的訪問httpd-vhosts.conf控制域名的轉換,要別名,日誌的路徑對於實驗中的訪問主機中要設置 hosts文件<Directory "/v

6.FTP服務

ftp1、概述(1)FTP(File transfer protocol)文件傳輸協議常見的FTPLinux:VSFTP(Very Secure FTP)、ProFTPD(Daemon 守護進程)Windows:Serv-U(2)特點:安全、高速、穩定(3)端口21:傳指令20:傳數據2、服務安裝(1)服務端

SpringCloud系列研究---Eureka服務發現

url next register default spring one static demo auto :創建項目工程 新建project 這裏選擇gradle 直接next 繼續next 最後點擊finish 二:創建Eureka服務中心 選擇第一步中創

Centos 6.5 服務器下面配置郵件客戶端 發送報警郵件

postfix crontab 郵件報警 監控以Centos 系統為例,確保服務器可以正常連接外網Centos 6.5 下面默認 安裝 postfix查看rpm -qa | grep postfixpostfix-2.6.6-2.2.el6_1.x86_64配置郵件客戶端set [email pr

獲取服務端https證書

certificate最近開發一個需求,涉及獲取服務端https證書。一般進行https調用我們都不太關心底層細節,直接使用WebClient或者HttpWebRequest來發送請求,這兩種方法都無法獲取證書信息,需要用到ServicePoint,這個類用於提供HTTP連接的管理。寫個Demo,拿新浪首頁試

Openstack 安裝部署指南翻譯系列 之 Horizon服務安裝(Dashboad)

openstack 翻譯 horizon安裝1.1.1.1. Horizon服務安裝(Dashboad)本節介紹如何在控制器節點上安裝和配置儀表板。儀表板所需的唯一核心服務是身份服務。您可以使用儀表板與其他服務(如鏡像服務,計算和網絡)結合使用。您還可以在具有獨立服務(如對象存儲)的環境中使用儀表板。註意:本