個人介紹:韓偉,1999年大學實習期加入初創期的網易,成為第30号員工,8年間從程式員開始,曆任項目經理、産品總監。2007年後創業4年,開發過視訊直播社群,及多款頁遊産品。2011年後就職于騰訊遊戲研發部公共技術中心架構規劃組,專注于通用遊戲技術底層的研發。
假設我們希望開發一套通用型的軟體架構,這個架構允許使用者自定義大量不同的情況下的回調函數(方法),用來實作豐富多彩的業務邏輯功能,例如一個遊戲腳本引擎,那麼,其中一個實作方式,就是使用觀察者模式,以事件的方式來驅動整個架構。使用者通過定義各個事件的響應函數,來組織和實作業務邏輯。而架構也提供了自定義事件及其響應函數的入口。
在一些實作代碼中,我們可能會發現有大量的“注冊事件”的代碼,或者是使用一個巨大的switch…case…對事件函數進行分發調用。譬如我們想做一個伺服器端的基本程序架構,這個架構讓使用者隻需要填寫一些回調函數,就能成為一個穩定持續運作的背景服務程序。其中一個部分,就是需要定義程式啟動事件,以便使用者自定義程式啟動要做的事情。那麼我們可以定義一個”Init”的字元串來代表這個事件,在一個事件響應函數的回調哈希表裡面,記錄上”Init”pfunInit()。又或者是用一個常量宏INIT=12來表示此事件,在程式的主循環處,利用switch…case…來檢查代表每個事件的類型編碼,如果發現是和INIT宏相等的,就調用case INIT下面的代碼(往往是一個單獨的函數,如pfunINit())。
維護長長的“注冊事件”代碼和長長的switch…case…都一樣的讓人昏昏欲睡,同時容易讓人錯漏百出。這些代碼往往還帶有大量的“常量”,因為用來作為回調函數的key的資料,往往都是一些自定義的常量。這些常量的同步維護,也往往讓人筋疲力盡。這些長長的代碼清單,經常還都需要由多個開發者一起來使用,自然就很容易發生你錯改了我的,我覆寫了你的這一類問題。這些問題非常的“低級”,但是要找起來卻一點都不容易。
遊戲的按鍵控制代碼/JS:

難道我們的架構代碼中,就一定會充斥着長長的字元串常量,或者整數常量嗎?答案是否定的,因為很多程式設計語言,都提供能反射的功能。在編譯型語言如C/C++裡面,也可以利用代碼生成技術,模拟出類似反射的能力。
要想知道什麼是反射,我們可以先來看一個觀察者模式的例子。假設我們在編寫一個GUI的程式:在一個窗體上安放了一個按鈕,此按鈕的名字叫“ButtonA”,當這個按鈕按下的時候,我們希望有一個我們自己寫的函數被調用。根據觀察者模式的設計,這個按鈕被使用者按下後,程式底層應該能監測到這個事情,然後在程序内部産生一個“事件”,這個“事件”對象往往會帶有這個資訊:被按下的按鈕名字。如果我們用以前的注冊事件的方法來編碼,我們必須要在按鈕被按下之前,比如程式初始化的時候,就向觀察者對象注冊這樣一個回調函數:
<code>RegisterEvent(“ButtonA”, ONCLICK, myOnClick) —— ButtonA</code>被按下的事件—<code>myOnClick()</code>。
這裡的函數<code>myOnClick()</code>就是我們想處理ButtonA被按下的事件的響應函數。
但是,我們可以用另外一個更省事的方法來解決:我們把myOnClick()函數的名字改成<code>ButtonA_OnClick()</code>,然後觀察者在發生“ButtonA”被按下的事件後,自動去找有沒有叫“<code>ButtonA_OnClick</code>”這個名字的函數,如果找到的話,就調用這個函數。——顯然這種做法無需預先手工去注冊回調函數,而是僅僅根據函數名字的約定,簡單的來決定要調用什麼函數。一般來說,我們認為程式運作的過程中,這些函數名字、類名字、屬性名字都不起什麼重要的作用,以至于我們還會用一些“混淆器”軟體來處理源代碼,把這些自定義的名字都弄的亂七八糟,也不影響程式的運作。然而,如果我們使用反射的技術,程式就可以在運作時,實時的用一些常量,來檢索并且獲得源代碼中,函數、類、屬性名字所對應的實體,并且還能調用這些東西。
在Java裡通過字元串類名反射建構一個對象:
反射這種功能,在編譯型的C語言程式中,幾乎是不可使用的,因為C語言源代碼中的名字“常量”,都被分離成“符号表”,然後在連結的過程中從二進制可執行程式中去掉了。雖然動态連結庫會保留部分類似反射的能力,但是也僅僅限于動态連結庫的接口函數。在 C++ 中,由于編譯器支援 RTTI (運作時類型檢測),我們可以通過 typeof()操 作符獲得任何一個對象的類型資訊,但我們還是不能實施用一個常量在運作時直接調用一個函數或對象的操作。不過,如果我們使用 IDL (接口定義語言)來用程式生成 C++ 的源代碼,倒是可以把對象構造器函數、成員函數等等的名字常量,作為一個 Map 的 key 存放起來,對應把這些函數作為 value 放入 Map ,這樣實作類似反射的功能(前提是要反射的對象都需要用 IDL 來描述,否則就要自己手工寫一堆注冊名字—函數的代碼)。
如果我們使用基于虛拟機的語言,比如 C# 或者 JAVA ,又或者腳本語言,如 python , Lua , JavaScript 這些,都非常适合使用反射功能。由于虛拟機在運作時是能完全掌控所有代碼的“符号表”,是以使用語言系統提供的一些 API ,就能很友善的通過任何一個字元串常量,查找這個常量對應(在源代碼中)的類、方法、成員屬性等等。
在我們懂得反射的用法後,我們就可以發現,源代碼不再是“資料結構+算法”這麼簡單的東西。我們可以利用源代碼作為資料本身的載體。一個最簡單的例子,就是XML的解析:我們可以定義一個和 XML 檔案對應的類,這個類的成員屬性的名字,和需要解析的 XML 檔案結構中的字段名一緻。當我們在解析對應的 XML 文檔的時候,就可以通過XML内容中的字段名,找到對應類成員屬性對象,然後把 XML 字段值指派進去。而這個過程中,隻要我們按照XML文檔的結構來定義類,就能很友善的把 XML 文檔内的資料,指派到一個類對象裡面,這對于編寫冗長的解析、指派代碼來說,能介紹不少的代碼篇幅。這種做法也許不是非常高效,因為反射查找本身需要額外的 CPU 消耗,但是,如果解析 XML 這個步驟不是“關鍵路徑”,這點性能損失對比大段的類似代碼,還是很值得的。
反射用于配置的另外一個功能,是把類名、方法名放在配置檔案裡面,作為程式功能的配置項。以前我們如果想要利用配置檔案,來定制一個程式的行為,必須要在源代碼中編寫一段 switch…case,來把行為函數和配置檔案中的配置值對應起來。這對于頻繁修改、增加這些可配置行為的架構來說,是一個非常難以維護的工作。但是,如果我們利用反射,就可以直接在配置檔案中寫入對應行為的類名或方法名,這樣架構就可以通過這些常量名字,在運作時找到程序空間中對應的類、對象、方法,進而直接調用他們以生效。這方面最常見的場景,有 Tomcat 這一類 web 容器,它們往往把一個個對應不同 URL 處理的 servlet 對象的類名,寫入到配置檔案中。或者如 Spring 架構,把互相依賴的各個對象的類名,都用配置檔案管理起來,在運作時根據這樣的配置檔案,實時的反射出對應的類和對象,建立按配置要求的對象關系來。
Spring通過XML來配置對象的關系:
從代碼維護的角度來看,類、成員、方法的名字,被程式以外的一些“配置檔案”所管理和知道,是有一定風險的。因為我們常常不把配置檔案看成是源代碼那麼重要的東西,錯漏也沒有編譯器或者 IDE 協助,是以一些難以調試的 BUG 往往是從這些位置産生的。不過作為一種大大節省架構代碼的技術,還是受到廣泛歡迎。而上文所說的問題,現在漸漸由另外一種技術“中繼資料”(或者叫注解、特性),把配置檔案和源代碼合并起來,這樣就能大大改善上述的問題。
我們在編寫通信功能的程式時,傳統的思路是要定義協定,也就是定義協定頭部,協定包長度,協定包字段等等。在一個比較複雜的網絡服務程式中,這樣的協定很容易就有幾十上百個。維護代碼的程式員想要搞明白别人定義的如此衆多的協定,實際上是不太容易的。我們很容易想到,能不能使用對象模型來代替通信協定的定義呢?答案是可以的。但是,使用對象模型又有一個新的問題:對象是一個在運作時的記憶體結構,如何把對象中的資料,通過網絡接收和發送呢?最簡單的做法,就是使用memcpy(),Linux提供了這個功能強大的API,可以讓任何記憶體中的資料變成一段位元組數組,然後我們就能直接通過網絡發送了。
但是,如果我們的對象不是一個簡單的結構體(事實上簡單的結構體也有問題),而是一個對象,這個對象裡面可能存在指針類型的成員,這樣的拷貝就不可能顧及到這些指針指向的資料了。而且,如果收發兩端的程式,并不是同一種語言(作業系統、平台),這樣的記憶體結構資料可能毫無意義,比如把一個C++的對象記憶體直接拷貝給JAVA程式,肯定無法直接使用。是以,我們想要用對象結構來定義通信協定,我們需要一個把對象轉換成通用的位元組數組的方法,這就是“序列化/反序列化”的能力。在這裡我不打算說太多關于序列化的内容,我隻想說,當這些對象具備序列化能力後,就能成為通信資料的載體。
問題是,如果我們收到了一段對象序列化的資料,如何建構出對應資料的對象呢?答案就是使用反射,反射機能能從資料中獲得對象類的名字,然後通過這個名字構造出對象來,然後從資料中繼續獲得餘下成員的資料,一一複制到這個對象身上。由此看,隻要我們有反射功能,我們可以讓使用者,簡單的構造一個對象,然後整個把這個對象發送給網絡的另外一端,對方也能直接收到一個對象,這樣在編寫通信程式的時候,隻要按照業務需求定義對象即可。對于閱讀代碼的程式員來說,不用在腦子裝一根叫“編碼、解碼”的弦,隻要“無腦”的定義、處理對象即可。
在通信程式中,有種叫指令模式的設計模式非常常見,它脫胎于傳統的基于指令字的網絡處理方式:解析出指令字通過 switch…case 調用對應的處理函數。指令模式下的通信程式往往很簡單,就是定義一個類型,這個類型的成員屬性(通信協定)是可以随便定義的,隻要再定一個 Process() 方法即可——這個方法的内容,就是收到此類型對象,應該如何處理的容器。由于我們利用反射可以在網絡另外一段重建這個對象,是以我們也可以調用這個預定義的 Process() 方法,這個方法由于和協定對象類定義在一起,是以它是知道所有的成員定義的,這樣這個處理方法,就無需好像以前的程式那樣,費勁的通過強制類型轉換,來得到具體的資料内容。在指令模式的通信程式實作過程裡,反射是至關重要的一環,因為當我們收到一個資料包時,必須要從資料包中得到其對應的對象的類名,然後建立這個類所對應的對象。一旦這個對象建立後,我們可以調用其反序列化函數,讓對象的内容和資料包中一緻,最後調用其 Process() 方法,就大功告成了。這種設計,可以用不同的語言,定義同結構的類對象,用來在不同的語言平台程式之間通訊,而無需定義很複雜的協定定義規範。一些強大的對象資料工具,比如 Google Protocol Buffer 和 Apache Thrift ,直接可以用一個通用的 IDL 語言,生成各種語言的類定義源代碼,就更友善了。
Thrift、PB的自動序列化/反序列化的類型字段:
在我剛剛接觸 Delphi 這款 IDE 的時候,我驚歎于它那便利的功能:可以對任何一個控件對象進行圖形化的編輯。雖然我們可以用初始化的代碼,來對任何一個對象進行修改,但是直接在 IDE 界面修改這些屬性,還是非常友善的。甚至我會通過這些屬性界面,來猜測和學習一款控件的用法。像這類功能,往往背後就需要反射的力量(當然delphi可能不是使用反射,而是利用元件模版等技術實作)。當我們自己開發一個這樣的程式,我們必須要把一些對象、類的内部結構讀取出來,然後才能以另外的途徑展示出來。
delphi上用界面設定ADO資料庫控件的屬性:
在 JAVA 中,JavaBean 就是一個著名的利用反射來使用的“對象約定”:隻要你編寫的 JAVA 類型,其成員是類似<code>setXXX()</code>或者<code>getXXX()</code>的,很多架構都會自動識别和處理這些成員函數,進而實作諸如自動更新成員資料,自動關聯界面内容等功能。另外一個類似的例子是 JMX ,這個 JAVA 的通用監控标準接口,可以把你定義的類對象解析出來,成員屬性的值可以變成統計圖線、可修改的表格項,方法變成按鈕。在遊戲開發領域,反射還廣泛的用于,把圖形美術資源和程式代碼結合的目的:比如 Flash Builder 就可以通過反射,把一個 Flash 動畫對象,綁定到一個 MovieClip 類型上,進而獲得一個既具備美術效果,又能讓使用者自定義行為的對象。Unity3D 在綁定了 3D 的遊戲對象和腳本元件後,對于腳本中的 Start()/Update() 函數調用,也是通過反射進行的,這樣開發者就不必要把腳本的類型,死死的和某個基類綁定到一塊,而且這些反射調用的函數,還是可以有不同的傳回值(不同的函數原型),進而實作協程或者非協程的調用。
在flash編輯器裡,對一個動畫指定關聯的自定義類:
反射由于可以把源代碼中的資訊提取出來,和其他的資料結合,讓源代碼的能力大大的提升,是以在開發工具方面,具有非常重要的地位。我們不再需要通過寫代碼,一遍遍的把源代碼的資料和外部結構做對接,而是簡單的開發一個反射能力架構,就能讓我們實作某種源代碼的“約定”,進而實作各種豐富的快捷開發能力。
在反射的使用過程中,我們往往會發現,源代碼直接作為資料,還是會有一些問題。譬如我們的源代碼可能會根據一些非業務因數做修改,改名、改參數類型是在重構的時候非常常見的。是以我們往往還是離不開配置檔案,把源代碼裡的名字寫到配置裡面,然後架構再根據配置來運作。一個比較典型的例子就是Hibernate,這一款著名的ORM架構,能讓你的源代碼類型和資料庫、表結構關聯起來。按理說利用反射,我們可以直接建立一些和資料庫表、字段名字同名的對象,就能直接關聯了,但是我們的源代碼如果需要修改這些名字,再去改資料庫的内容,就顯得太麻煩了。是以我們要編寫很多配置檔案,來關聯什麼表對應什麼類,什麼字段對應哪個屬性……這些配置檔案往往和使用資料庫的表數量一樣多,任何的修改都還要記得對應這些配置的修改,我們被迫同時維護:資料庫結構、配置檔案、源代碼這三個東西。然而,如果我們的平台是支援“中繼資料”的話,問題就很好解決了。因為我們可以在源代碼裡面直接寫配置檔案項目。我們在源代碼的類名前面,用類似注釋的方式,标注這個類對應資料庫的哪個表;在屬性名前面,用注釋标注對應的字段、預設值等等。這樣我們隻需要維護兩個東西:資料庫結構、源代碼。這大大的減輕的項目的複雜程度。
我接觸的最早最著名的中繼資料,是用來同步修改API文檔的JavaDoc技術,這個技術讓更新文檔不再成為一個苦力活。由于可以在源代碼的注釋裡面編寫文檔,是以在修改代碼的同時也可以同時更新文檔。更重要的是,javadoc标記自然的把源代碼中的“名字表”和相關注釋自動對應起來了,要知道,這種對應如果人工來做,可是要費相當大的功夫。在javadoc的教育下,我對于java的注解、C#的attribute(特性)都覺得非常親切。以前那些需要登記大量類名、方法名的配置,統統都可以直接記錄在源代碼裡面了。而一些和美術資源關聯的用戶端代碼,也可以通過源代碼的特殊标記,連接配接上正确的圖形資源。
能讓這些源代碼裡面的“中繼資料”生效的重要技術,其實就是反射。由于我們的中繼資料處理程式,一般都需要和源代碼裡面的類、方法名字對應起來,是以都要使用反射的方法。而這種反射,又為我們任意增加“中繼資料”提供了強大的機制。
我們曾經相信:資料結構+算法=程式。但是從今天的軟體産業來看,固然還是有很多專事計算的軟體在被開發着,然而我們接觸到更多的軟體,都是所謂“資訊管理系統”類的軟體。這類軟體要處理的并非是複雜的計算任務,而是對各種各樣現實世界中的資訊,增删查改是這些資訊處理最通俗的描述。我們在處理這些資訊的時候,如果還是把程式的載體源代碼,僅僅看成是編譯過程中不可缺少的一環而已,那麼我們就必須額外處理大量的資料形式:資料庫、配置檔案、IDE配置……然而,在面向對象的風潮之下,源代碼完全可以作為一種“樹狀”的資料承載方式。面向對象定義的類、成員、方法,就是一個個現實世界中的實體映像,他們所包含的結構和常量,往往直接可以成為系統中的資料源頭。在MUD文字遊戲中,幾乎整個遊戲世界,都是以源代碼常量的形式編寫的,這不但沒有成為維護的難題,反而讓真個遊戲的開發變得更輕松,因為程式員還是最習慣于面對源代碼去工作。
反射這種特性,能把源代碼中的所有資料,包括“名字元号表”,都提供給開發者去使用,讓軟體開發過程,從單純的算法實作過程,變成一個綜合的資訊管理的過程。這個做法看起來似乎不夠專業,但是在程式設計已經不算“高科技”的年代,這種技術能幫助大量的開發者,以某種“約定”的方式去編寫源代碼,進而自動獲得架構的強大支援。——制造這種允許“約定”方式運作源代碼的架構,正式新的架構應該擁有的特點,因為人類的創造時間,不應該被浪費在大量的重複而類似的工作之上啊!