【總】Java序列化

如果你只知道實現(xiàn) Serializable 接口的對象馅闽,可以序列化為本地文件落剪。那你最好再閱讀該篇文章餐屎,文章對序列化進(jìn)行了更深一步的討論输玷,用實際的例子代碼講述了序列化的高級認(rèn)識队丝,包括父類序列化的問題靡馁、靜態(tài)變量問題、transient 關(guān)鍵字的影響机久、序列化 ID 問題臭墨。

將 Java 對象序列化為二進(jìn)制文件的 Java 序列化技術(shù)是 Java 系列技術(shù)中一個較為重要的技術(shù)點,在大部分情況下膘盖,開發(fā)人員只需要了解被序列化的類需要實現(xiàn) Serializable 接口胧弛,使用 ObjectInputStream 和 ObjectOutputStream 進(jìn)行對象的讀寫。然而在有些情況下侠畔,光知道這些還遠(yuǎn)遠(yuǎn)不夠

文章結(jié)構(gòu)

  • 基本概念
  • 用處
  • 序列化 ID 的問題
  • 靜態(tài)變量序列化
  • 父類的序列化與 Transient 關(guān)鍵字
  • 對敏感字段加密
  • 序列化存儲規(guī)則
  • serialVersionUID
  • 序列化前后對象的關(guān)系
  • 問題

概念

Serialization(序列化)是一種將對象以一連串的字節(jié)描述的過程结缚;
反序列化deserialization是一種將這些字節(jié)重建成一個對象的過程。

使用場景

a)當(dāng)你想把的內(nèi)存中的對象保存到一個文件中或者數(shù)據(jù)庫中時候软棺;
b)當(dāng)你想用套接字在網(wǎng)絡(luò)上傳送對象的時候掺冠;
c)當(dāng)你想通過RMI傳輸對象的時候;

序列化 ID 的問題

情境:兩個客戶端 A 和 B 試圖通過網(wǎng)絡(luò)傳遞對象數(shù)據(jù)码党,A 端將對象 C 序列化為二進(jìn)制數(shù)據(jù)再傳給 B,B 反序列化得到 C斥黑。

問題:C 對象的全類路徑假設(shè)為 com.inout.Test揖盘,在 A 和 B 端都有這么一個類文件,功能代碼完全一致锌奴。也都實現(xiàn)了 Serializable 接口兽狭,但是反序列化時總是提示不成功。

解決:虛擬機(jī)是否允許反序列化鹿蜀,不僅取決于類路徑和功能代碼是否一致箕慧,一個非常重要的點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。雖然兩個類的功能代碼完全一致茴恰,但是序列化 ID 不同颠焦,他們無法相互序列化和反序列化。

private static final long serialVersionUID = 1L; 

序列化 ID 在 Eclipse 下提供了兩種生成策略往枣,一個是固定的 1L伐庭,一個是隨機(jī)生成一個不重復(fù)的 long 類型數(shù)據(jù)(實際上是使用 JDK 工具生成),在這里有一個建議分冈,如果沒有特殊需求圾另,就是用默認(rèn)的 1L 就可以,這樣可以確保代碼一致時反序列化成功雕沉。

那么隨機(jī)生成的序列化 ID 有什么作用呢集乔,有些時候,通過改變序列化 ID 可以用來限制某些用戶的使用坡椒。

使用案例
讀者應(yīng)該聽過 Fa?ade 模式扰路,它是為應(yīng)用程序提供統(tǒng)一的訪問接口尤溜,案例程序中的 Client 客戶端使用了該模式,案例程序結(jié)構(gòu)圖如圖 :

Fa?ade 模式
  • Client 端通過 Fa?ade Object 才可以與業(yè)務(wù)邏輯對象進(jìn)行交互
  • 客戶端的 Fa?ade Object 不能直接由 Client 生成幼衰,而是需要 Server 端生成靴跛,然后序列化后通過網(wǎng)絡(luò)將二進(jìn)制對象數(shù)據(jù)傳給 Client,Client 負(fù)責(zé)反序列化得到 Fa?ade 對象
  • 該模式可以使得 Client 端程序的使用需要服務(wù)器端的許可渡嚣,同時 Client 端和服務(wù)器端的 Fa?ade Object 類需要保持一致
  • 當(dāng)服務(wù)器端想要進(jìn)行版本更新時梢睛,只要將服務(wù)器端的 Fa?ade Object 類的序列化 ID 再次生成,當(dāng) Client 端反序列化 Fa?ade Object 就會失敗识椰,也就是強(qiáng)制 Client 端從服務(wù)器端獲取最新程序绝葡。

靜態(tài)變量序列化

情境:查看清單 2 的代碼。

//清單 2 
public class Test implements Serializable {

    private static final long serialVersionUID = 1L;

    public static int staticVar = 5;

    public static void main(String[] args) {
        try {
            //初始時staticVar為5
            ObjectOutputStream out = new ObjectOutputStream(
                    new FileOutputStream("result.obj"));
            out.writeObject(new Test());
            out.close();

            //序列化后修改為10
            Test.staticVar = 10;

            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
                    "result.obj"));
            Test t = (Test) oin.readObject();
            oin.close();
            
            //再讀取腹鹉,通過t.staticVar打印新的值
            System.out.println(t.staticVar);
            
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
  • 清單 2 中的 main 方法藏畅,將對象序列化后,修改靜態(tài)變量的數(shù)值功咒,再將序列化對象讀取出來愉阎,然后通過讀取出來的對象獲得靜態(tài)變量的數(shù)值并打印出來。依照清單 2力奋,這個 System.out.println(t.staticVar) 語句輸出的是 10 還是 5 呢榜旦?

  • 最后的輸出是 10,對于無法理解的讀者認(rèn)為景殷,打印的 staticVar 是從讀取的對象里獲得的溅呢,應(yīng)該是保存時的狀態(tài)才對。之所以打印 10 的原因在于序列化時猿挚,并不保存靜態(tài)變量咐旧,這其實比較容易理解,序列化保存的是對象的狀態(tài)绩蜻,靜態(tài)變量屬于類的狀態(tài)铣墨,因此 序列化并不保存靜態(tài)變量。

父類的序列化與 Transient 關(guān)鍵字

情境:一個子類實現(xiàn)了 Serializable 接口办绝,它的父類都沒有實現(xiàn) Serializable 接口踏兜,序列化該子類對象,然后反序列化后輸出父類定義的某變量的數(shù)值八秃,該變量數(shù)值與序列化時的數(shù)值不同碱妆。
解決:要想將父類對象也序列化,就需要讓父類也實現(xiàn)Serializable 接口昔驱。如果父類不實現(xiàn)的話的疹尾,就需要有默認(rèn)的無參的構(gòu)造函數(shù)。
在父類沒有實現(xiàn) Serializable 接口時,虛擬機(jī)是不會序列化父對象的纳本,而一個 Java 對象的構(gòu)造必須先有父對象窍蓝,才有子對象,反序列化也不例外繁成。所以反序列化時吓笙,為了構(gòu)造父對象,只能調(diào)用父類的無參構(gòu)造函數(shù)作為默認(rèn)的父對象巾腕。因此當(dāng)我們?nèi)?父對象的變量值時面睛,它的值是調(diào)用父類無參構(gòu)造函數(shù)后的值。如果你考慮到這種序列化的情況尊搬,在父類無參構(gòu)造函數(shù)中對變量進(jìn)行初始化叁鉴,否則的話,父類變量值都 是默認(rèn)聲明的值佛寿,如 int 型的默認(rèn)是 0幌墓,string 型的默認(rèn)是 null。

Transient關(guān)鍵字的作用是控制變量的序列化冀泻,在變量聲明前加上該關(guān)鍵字常侣,可以阻止該變量被序列化到文件中,在被反序列化后弹渔,transient 變量的值被設(shè)為初始值胳施,如 int 型的是 0,對象型的是 null捞附。

使用案例

我們熟悉使用 Transient 關(guān)鍵字可以使得字段不被序列化,那么還有別的方法嗎您没?

根據(jù)父類對象序列化的規(guī)則鸟召,我們可以將不需要被序列化的字段抽取出來放到父類中,子類實現(xiàn) Serializable 接口氨鹏,父類不實現(xiàn)欧募,根據(jù)父類序列化規(guī)則,父類的字段數(shù)據(jù)將不被序列化仆抵,形成類圖如圖

對敏感字段加密

情境:服務(wù)器端給客戶端發(fā)送序列化對象數(shù)據(jù)跟继,對象中有一些數(shù)據(jù)是敏感的,比如密碼字符串等镣丑,希望對該密碼字段在序列化時舔糖,進(jìn)行加密,而客戶端如果擁有解密的密鑰莺匠,只有在客戶端進(jìn)行反序列化時金吗,才可以對密碼進(jìn)行讀取,這樣可以一定程度保證序列化對象的數(shù)據(jù)安全。

解決:在序列化過程中摇庙,虛擬機(jī)會試圖調(diào)用對象類里的writeObject 和 readObject 方法旱物,進(jìn)行用戶自定義的序列化和反序列化卫袒,如果沒有這樣的方法宵呛,則默認(rèn)調(diào)用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。用戶自定義的 writeObject 和 readObject 方法可以允許用戶控制序列化的過程夕凝,比如可以在序列化的過程中動態(tài)改變序列化的數(shù)值宝穗。基于這個原理迹冤,可以在實際應(yīng)用中得到使用讽营,用于敏感字段的加密工作, 清單 3 展示了這個過程泡徙。

//清單3
private static final long serialVersionUID = 1L;

    private String password = "pass";

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    private void writeObject(ObjectOutputStream out) {
        try {
            PutField putFields = out.putFields();
            System.out.println("原密碼:" + password);
            password = "encryption";//模擬加密
            putFields.put("password", password);
            System.out.println("加密后的密碼" + password);
            out.writeFields();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void readObject(ObjectInputStream in) {
        try {
            GetField readFields = in.readFields();
            Object object = readFields.get("password", "");
            System.out.println("要解密的字符串:" + object.toString());
            password = "pass";//模擬解密,需要獲得本地的密鑰
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {
        try {
            ObjectOutputStream out = new ObjectOutputStream(
                    new FileOutputStream("result.obj"));
            out.writeObject(new Test());
            out.close();

            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
                    "result.obj"));
            Test t = (Test) oin.readObject();
            System.out.println("解密后的字符串:" + t.getPassword());
            oin.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

使用案例

RMI 技術(shù)是完全基于 Java 序列化技術(shù)的橱鹏,服務(wù)器端接口調(diào)用所需要的參數(shù)對象來至于客戶端,它們通過網(wǎng)絡(luò)相互傳輸堪藐。這就涉及 RMI 的安全傳輸?shù)膯栴}莉兰。一些敏感的字段,如用戶名密碼(用戶登錄時需要對密碼進(jìn)行傳輸)礁竞,我們希望對其進(jìn)行加密糖荒,這時,就可以采用本節(jié)介紹的方法在客戶端對密 碼進(jìn)行加密模捂,服務(wù)器端進(jìn)行解密捶朵,確保數(shù)據(jù)傳輸?shù)陌踩浴?/p>

序列化存儲規(guī)則

情境:問題代碼如清單 3 所示,兩個對象寫入一個文件狂男。

//清單3
ObjectOutputStream out = new ObjectOutputStream(
                    new FileOutputStream("result.obj"));
    Test test = new Test();
    //試圖將對象兩次寫入文件
    out.writeObject(test);
    out.flush();
    System.out.println(new File("result.obj").length());
    out.writeObject(test);
    out.close();
    System.out.println(new File("result.obj").length());

    ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
            "result.obj"));
    //從文件依次讀出兩個文件
    Test t1 = (Test) oin.readObject();
    Test t2 = (Test) oin.readObject();
    oin.close();
            
    //判斷兩個引用是否指向同一個對象
    System.out.println(t1 == t2);

清單 3 中對同一對象兩次寫入文件综看,打印出寫入一次對象后的存儲大小和寫入兩次后的存儲大小,然后從文件中反序列化出兩個對象岖食,比較這兩個對象是否為同一對象红碑。一 般的思維是,兩次寫入對象泡垃,文件大小會變?yōu)閮杀兜拇笮∥錾海葱蛄谢瘯r,由于從文件讀取蔑穴,生成了兩個對象忠寻,判斷相等時應(yīng)該是輸入 false 才對,但是最后結(jié)果輸出如圖 所示存和。

結(jié)果

我們看到锡溯,第二次寫入對象時文件只增加了 5 字節(jié)赶舆,并且兩個對象是相等的,這是為什么呢祭饭?

解答:Java 序列化機(jī)制為了節(jié)省磁盤空間芜茵,具有特定的存儲規(guī)則,當(dāng)寫入文件的為同一對象時倡蝙,并不會再將對象的內(nèi)容進(jìn)行存儲九串,而只是再次存儲一份引用,上面增加的 5 字節(jié)的存儲空間就是新增引用和一些控制信息的空間寺鸥。反序列化時猪钮,恢復(fù)引用關(guān)系,使得清單 3 中的 t1 和 t2 指向唯一的對象胆建,二者相等烤低,輸出 true。該存儲規(guī)則極大的節(jié)省了存儲空間笆载。

使用案例

//清單4
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
Test test = new Test();
test.i = 1;
out.writeObject(test);
out.flush();
test.i = 2;
out.writeObject(test);
out.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
                    "result.obj"));
Test t1 = (Test) oin.readObject();
Test t2 = (Test) oin.readObject();
System.out.println(t1.i);
System.out.println(t2.i);

清單 4 的目的是希望將 test 對象兩次保存到 result.obj 文件中扑馁,寫入一次以后修改對象屬性值再次保存第二次,然后從 result.obj 中再依次讀出兩個對象凉驻,輸出這兩個對象的 i 屬性值腻要。案例代碼的目的原本是希望一次性傳輸對象修改前后的狀態(tài)。

結(jié)果兩個輸出的都是 1涝登, 原因就是第一次寫入對象以后雄家,第二次再試圖寫的時候,虛擬機(jī)根據(jù)引用關(guān)系知道已經(jīng)有一個相同對象已經(jīng)寫入文件胀滚,因此只保存第二次寫的引用趟济,所以讀取時,都是第一次保存的對象咽笼。在使用一個文件多次 writeObject 需要特別注意這個問題顷编。

serialVersionUID

在Point類中有這樣一句話:
private static final long serialVersionUID = 2208L;
它是什么意思呢?

  • 顧名思義褐荷,serialVersionUID字段是“序列版本通用標(biāo)示號”勾效。它的訪問屬性可以是public嘹悼、private叛甫、protected,這都無所謂杨伙,但必須是靜態(tài)(static)其监、最終(final)的long型字段。它的定義格式為:
    任意訪問修飾符 static final long serialVersionUID = 某個long值;

  • 所以在例子中定義它的訪問屬性是private限匣,并給它賦了個隨意的long值2208抖苦。

  • serialVersionUID在類中不參與任何方法,也不應(yīng)和類的其他字段有任何關(guān)系,所以對于類本身沒有任何用處锌历。

  • 如果不提供serialVersionUID字段贮庞,發(fā)送者的Java編譯器會對可序列化類生成一個serialVersionUID值并發(fā)給接收者,它是根據(jù)類名究西、接口名窗慎、成員方法及屬性等生成的一個64位哈希值。接收者加載該對象的類時卤材,也會計算一個serialVersionUID值遮斥。如果這兩個值相等,則可以反序列化扇丛;如果不相等术吗,則會拋出InvalidClassException異常,反序列化會失敗帆精。

  • 所以即使編程者不提供這個字段较屿,系統(tǒng)也會自動計算serialVersionUID值的。我估計Sun公司這樣做实幕,是為了防止文件在網(wǎng)絡(luò)上被篡改吝镣。

  • 但是痒玩,發(fā)送者的Java編譯器和接受者的Java編譯器未必相同嗅虏,不同編譯器計算出來的serialVersionUID值可能不一樣,這樣在反序列化過程中可能會導(dǎo)致意外的InvalidClassException勋陪。

  • 所以為了保證serialVersionUID在不同的Java編譯器具有同一個值整吆,Java幫助文件強(qiáng)烈建議:可序列化的類最好聲明一個字段serialVersionUID拱撵。這就是在例子中增加它的原因。

序列化前后對象的關(guān)系

是 "=="還是equal表蝙? or 是淺復(fù)制還是深復(fù)制拴测?
答案:深復(fù)制,反序列化還原后的對象地址與原來的的地址不同
解釋:序列化前后對象的地址不同了府蛇,但是內(nèi)容是一樣的集索,而且對象中包含的引用也相同。換句話說汇跨,通過序列化操作,我們可以實現(xiàn)對任何可Serializable對象的”深度復(fù)制(deep copy)"——這意味著我們復(fù)制的是整個對象網(wǎng)务荆,而不僅僅是基本對象及其引用。對于同一流的對象穷遂,他們的地址是相同函匕,說明他們是同一個對象,但是與其他流的對象地址卻不相同蚪黑。也就說盅惜,只要將對象序列化到單一流中中剩,就可以恢復(fù)出與我們寫出時一樣的對象網(wǎng),而且只要在同一流中抒寂,對象都是同一個结啼。

問題

a)當(dāng)一個父類實現(xiàn)序列化,子類自動實現(xiàn)序列化屈芜,不需要顯式實現(xiàn)Serializable接口妆棒;
b)當(dāng)一個對象的實例變量引用其他對象,序列化該對象時也把引用對象進(jìn)行序列化沸伏;
c) static,transient后的變量不能被序列化糕珊;

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市毅糟,隨后出現(xiàn)的幾起案子红选,更是在濱河造成了極大的恐慌,老刑警劉巖姆另,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件喇肋,死亡現(xiàn)場離奇詭異,居然都是意外死亡迹辐,警方通過查閱死者的電腦和手機(jī)蝶防,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來明吩,“玉大人间学,你說我怎么就攤上這事∮±螅” “怎么了低葫?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長仍律。 經(jīng)常有香客問我嘿悬,道長,這世上最難降的妖魔是什么水泉? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任善涨,我火速辦了婚禮,結(jié)果婚禮上草则,老公的妹妹穿的比我還像新娘钢拧。我一直安慰自己,他們只是感情好畔师,可當(dāng)我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布娶靡。 她就那樣靜靜地躺著牧牢,像睡著了一般看锉。 火紅的嫁衣襯著肌膚如雪姿锭。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天伯铣,我揣著相機(jī)與錄音呻此,去河邊找鬼。 笑死腔寡,一個胖子當(dāng)著我的面吹牛焚鲜,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播放前,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼忿磅,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了凭语?” 一聲冷哼從身側(cè)響起葱她,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎似扔,沒想到半個月后吨些,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡炒辉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年豪墅,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片黔寇。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡偶器,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出缝裤,到底是詐尸還是另有隱情状囱,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布倘是,位于F島的核電站亭枷,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏搀崭。R本人自食惡果不足惜叨粘,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望瘤睹。 院中可真熱鬧升敲,春花似錦、人聲如沸轰传。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽获茬。三九已至港庄,卻和暖如春倔既,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背鹏氧。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工渤涌, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人把还。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓实蓬,卻偏偏與公主長得像,于是被迫代替她去往敵國和親吊履。 傳聞我的和親對象是個殘疾皇子安皱,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容

  • JAVA序列化機(jī)制的深入研究 對象序列化的最主要的用處就是在傳遞,和保存對象(object)的時候,保證對象的完整...
    時待吾閱讀 10,837評論 0 24
  • 一、 序列化和反序列化概念 Serialization(序列化)是一種將對象以一連串的字節(jié)描述的過程艇炎;反序列化de...
    步積閱讀 1,437評論 0 10
  • 引言 將 Java 對象序列化為二進(jìn)制文件的 Java 序列化技術(shù)是 Java 系列技術(shù)中一個較為重要的技術(shù)點练俐,在...
    xdoyf閱讀 530評論 0 0
  • 官方文檔理解 要使類的成員變量可以序列化和反序列化,必須實現(xiàn)Serializable接口冕臭。任何可序列化類的子類都是...
    獅_子歌歌閱讀 2,386評論 1 3
  • 序列化是什么 信息的傳遞腺晾、交換支撐整個互聯(lián)網(wǎng)產(chǎn)業(yè),那么信息的交流的過程中遵循著什么樣的標(biāo)準(zhǔn)辜贵。常見的網(wǎng)絡(luò)傳輸協(xié)議有 ...
    非典型程序員閱讀 2,278評論 0 5