代碼的印象派:寫點好代碼吧

ygw3 9年前發布 | 36K 次閱讀 代碼

原文出處: Dennis Gao 的博客(@東之言)  


最近有一位獵頭顧問打電話詢問是否有換工作的意向,對推薦的公司和職位的描述為:”我們這里有一家非常關注軟件質量的公司,在尋找一位不僅能完成有挑戰的軟件開發任務,并且還對代碼質量有非常高追求的軟件工程師。”。

很難得獵頭顧問會以這樣的切入點來推薦職位,而不是諸如 “我們是互聯網公司”,”我們是著名互聯網公司”,”我們可以提供業內領先的薪資”,”我們創業的方向是行業的趨勢”,”我們提供創業公司期權”,”我們提供人體工程座椅”,”我們公司漂亮妹子多” 等等。

誰會為了把椅子或者每天能看公司漂亮的前臺妹子而跳槽呢?將這些描述可以概括成一點,就是 “我們給的錢多”。誠然,好的薪水是可以招募到杰出的軟件工程師的。然而,優秀的軟件工程師通常已經得到了較好的薪水,所以如果不是給出足夠的量,不一定會為五斗米而折腰。大多數的軟件工程師更看重的是技術興趣,所以諸如 “來我們這做 Openstack 吧”,”我們需要用 Go 實現 Docker 相關組件的人才” 看起來更有吸引力。

而軟件質量,代碼風格,則是另一個吸引優秀工程師的方向。追求卓越軟件質量的工程師,通常有著自己的軟件設計與實現思路,并在不斷的實踐中錘煉出自己的編程風格和代碼品味。這一類工程師,其實無關使用什么語言、做什么產品,他們會始終保持自己的品味,追求軟件實現的卓越,進而產出高質量的軟件。更重要的是,優秀的工程師希望與更多優秀的工程師合作,并更愿意工作在崇尚代碼質量的氛圍中。

一般條件下,對軟件質量的要求通常與軟件生命周期的長短相關。按照軟件生命周期的長短,我們可以將軟件公司分為兩類:

  • 快公司:創業公司,互聯網公司。推崇快速開發,快速試錯。軟件生命周期較短,代碼質量相對要求不高。
  • 慢公司:傳統行業軟件公司,產品公司。推崇穩定可靠的軟件設計。軟件生命周期較長,代碼質量相對要求較高。

軟件生命周期的長短,通常也決定了實現軟件所使用的編程語言。比如,快公司通常會使用 PHP/Python/Ruby 等動態類型語言,而慢公司通常會選擇 C/C++/C#/Java 等靜態類型語言。

當獵頭顧問還沒有說出公司的名字之前,我們也可以大體猜測出該公司所屬的行業方向或軟件產品類型。比如,該公司可能來自傳統的金融、電信、醫療、石油、ERP 行業等,這些行業中有 IBM、Huawei、GE、Schlumberger、SAP 等等世界 500 強巨頭更重視軟件質量。當然,通常推崇軟件質量的公司中,大概率條件下碰到的會是外企,即使是中小外企,也會對軟件質量有相對較高的要求。對于產品類型,越是接近 Mission Critical 的產品形態則對軟件質量的要求越高。各種軟件中間件的軟件質量要求也相對較高,比如內存數據庫、Message Queue 等。或者如果區分 Framework 和 Application,則 Framework 的軟件質量顯然要求更高。

每一家較成熟的軟件公司,都會設計自己的軟件編碼規范,增強軟件工程師的協同效應,相互之間可以讀懂對方的代碼。但實際操作中,又有多少人會執著遵守呢?編碼規范并不能保證產生出好代碼,那代碼編寫的好壞又如何具體衡量呢?筆者經歷過的公司中,多半都是以軟件發布后的 Bug 數量來衡量軟件的質量的,這種統計形式簡單粗暴,優點就是可以量化,缺點就是很難評判出軟件代碼編寫的優雅程度。我聽過一則笑話,說軟件質量也不能做的太好,軟件一定要有 Bug,這樣客戶才會買我們的服務,而我們就是靠后期賣服務賺錢的。好吧,情何以堪~~

好了,說了這么多,好像文不切題,這篇文章不是叫《代碼的印象派》嗎?和上面說的這些有什么關系呀?

關系就在于,軟件質量與代碼編寫的優雅程度息息相關。而是否能編寫出優雅的代碼與軟件工程師的個人風格和品味息息相關。

在軟件工程領域,通常生命周期長的軟件會有著更高的軟件質量需求,描述軟件質量的內容可以參考下面兩篇文章。

  • Quality 是什么?
  • 軟件質量模型

在各種軟件質量模型的描述中,都包含著軟件可維護性(Maintainability)這一屬性。而越是生命周期長的軟件,對其軟件可維護性的要求越高。而提高軟件可維護性的根本方式就是編寫可閱讀的代碼,讓其他人理解代碼的時間最小化。代碼生來就是為人閱讀的,只是順便在機器上執行以完成功能。

在漫長的軟件生命周期中,我們有很多機會去修改軟件代碼,比如發現了新的 Bug、增加新的功能、改進已有功能等。修改代碼的第一步當然就是閱讀代碼,以了解當前的設計和思路。如果代碼都讀不懂的話,何談修改呢?還有,大概率條件下,修復自己實現模塊的 Bug 的人通常就是你自己,如果時隔幾個月后自己都讀不懂自己編寫的代碼,會是什么感受呢?

所以,如何編寫出易讀的代碼就成了問題的關鍵。而能否編寫出易讀代碼,則直接取決于軟件工程師自己的的編程風格和代碼品味。

在《孫子兵法》中有云:”上兵伐謀,其次伐交,其次伐兵,其下攻城。攻城之法,為不得已。” 對應到軟件領域,軟件架構師可以通過出色的系統分析來構建可演進的軟件架構,講究謀略;而軟件工程師則通過良好的設計和編程風格來完成攻城任務,講究方法。

Paul Graham 的《黑客與畫家》中描述了黑客與畫家的共同點,就是他們都是創作者,并且都在努力創作優秀的作品。畫家創作的作品就是畫,內嵌著自己的風格和品味。軟件工程師的作品就是軟件和代碼,如果可以的話,你可以將代碼打印成卷,出版成書,只是,閱讀的人會向你那樣幸福嗎?

畫家的作品都會保留下來,如果你把一個畫家的作品按照時間順序排列,就會發現每幅畫所用的技巧,都是建立在上一幅作品學到的東西之上。某幅作品如果特別出眾,你往往能在更早期的作品中找到類似的版本。軟件工程師也是通過實踐來學些編程,并且所進行的工作也是具有原創性的,通常不會有他人的完美的成果可以依靠,如果有的話我們為什么還要再造輪子呢?

創作者的另一個學習途徑是通過范例。對于畫家而言,臨摹大師的作品一直是傳統美術教育的一部分,因為臨摹迫使你仔細觀察一幅畫是如何完成的。軟件工程師也可以通過學習優秀的程序源碼來學會編程,不是看其執行結果,而是看源碼實現思路和風格。優秀的軟件一定都是在軟件工程師對軟件美的不懈追求中實現的,現如今有眾多優秀的開源軟件存在,如果你查看優秀軟件的內部,就會發現,即使在那些不被人知的部分,也同樣被優美的實現著。

所以說,代碼是有畫面感的,看一段代碼就可以了解一個軟件工程師的風格,進而塑造出該工程師在你心目中的印象。工作中,我們每天都在閱讀同事們的代碼,進而對不同的同事產生不同的印象,對各種不同印象的感受也在不斷影響著自身風格的塑造。代碼的印象派,說的就是,你想讓你的同事對你產生何種印象呢?

筆者不能自詡為我就是那類有著良好的編程風格,并且代碼品味高雅的軟件工程師,只能說,我還在向這個目標努力著。風格和品味不是一朝一夕就能養成的,世間存在多少種風格我們也無法列舉,而說某種風格比另一種風格要好也會陷入無意的爭辯。況且,軟件工程師多少都會有點自戀情節,在沒有見到更好的代碼之前,始終都會感覺自己寫出的代碼就是好代碼,并且有時不管你說什么,咱就是這個味兒!

我個人總結了幾點關于優雅代碼風格的描述:

  • 代碼簡單:不隱藏設計者的意圖,抽象干凈利落,控制語句直截了當。
  • 接口清晰:類型接口表現力直白,字面表達含義,API 相互呼應以增強可測試性。
  • 依賴項少:依賴關系越少越好,依賴少證明內聚程度高,低耦合利于自動測試,便于重構。
  • 沒有重復:重復代碼意味著某些概念或想法沒有在代碼中良好的體現,及時重構消除重復。
  • 戰術分層:代碼分層清晰,隔離明確,減少間接依賴,劃清名空間,理清目錄。
  • 性能最優:局部代碼性能調至最優,減少后期因性能問題修改代碼的機會。
  • 自動測試:測試與產品代碼同等重要,自動測試覆蓋 80% 的代碼,剩余 20% 選擇性測試。

下面,我會列舉一些我在工作中遇到的不同的編程風格,用切身的體會來感悟代碼的風格和品味。當然,吐槽為主,因為據說在 Code Review 時記錄說 “我擦” 的數量就可以衡量代碼的好壞。

 

變量

關于變量,很遺憾,不得不提變量的命名。時至今日,在 Code Review 中仍然可以看到下面這樣的代碼。

public class Job
  {
    private DateTime StartTime;
    private DateTime mStartTime;
    private DateTime m_StartTime;
    private DateTime _StartTime;
    public DateTime endTime;
    private Command pCommand;
    private long schId;
  }

有各種奇葩的前綴出現,有時同一個人的命名居然也不統一。雖然,眼睛和大腦在重復的觀察變量名后會自動學習以忽略前綴,并不會太影響閱讀。實際上,使用前綴的目的主要是為了在局部代碼中區分全局變量和局部變量。使用類似于 C# 這樣的高級語言,我們已經不再需要為變量添加前綴了,可以利用 this 關鍵字來區分。如果非要添加的話,建議使用 “_” 單下劃線前綴,促進大腦更快速的忽略。

public class Job
  {
    private DateTime _startTime; // use _ as prefix
    private DateTime endTime; // no prefix

    public DateTime StartTime
    {
      get { return _startTime; }
      set { _startTime = value; }
    }

    public DateTime EndTime
    {
      get { return endTime; }
      set { endTime = value; }
    }

    public long ScheduleId { get; private set; } // or no field needed
  }

將 Field 標記為 public 應該是沒有分清 Field 與 Property 的作用,進而推測對面向對象編程中的封裝概念理解也不會有多好。

使用 “p” 前綴的顯然有 C/C++ 編程情節,想描述這個變量是一個指針,好吧,這種寫法在 C# 中只能稱為不倫不類。

使用縮寫,這里的 “sch” 其實是想代表 “schedule”,但在沒有上下文的條件下誰能想的出來呢?我個人是絕對不推薦使用縮寫的,除非是普世的理解性存在,例如 “obj”, “ctx”, “pwd”, “ex”, “args” 這樣非常常見的縮寫。

使用拼音和有拼寫錯誤的單詞作為變量名會直接拉低工程師的檔次。使用合適單詞描述可以直接提高代碼的質量,比如通常 “Begin”, “End” 會成對兒出現,上面的示例代碼中涉及到了時間,”StartTime” 和 “BeginTime” 是同義詞,所以我們參考了 Outlook Calendar 中的默認術語,也就是 “StartTime” 和 “EndTime”,也就是找范例。

在局部變量的使用中,我認為有一種使用方式是值得推薦的,那就是 “解釋性變量”。當今的編程風格中流行使用 Fluent API,這樣會產生類似于下面這樣的代碼。

if(DateTimeOffset.UtcNow >= 
        period
          .RecurrenceRange.StartDate.ConvertTime(period.TimeZone)
          .Add(schedule.Period.StartTime.ConvertTime(period.TimeZone).TimeOfDay))
      {
        // do something
      }

這一串 “.” 看著好帥氣,但我是理解不了這是要比較什么。可以簡單重構為解釋性變量。

var firstOccurrenceStartTime = 
        period
          .RecurrenceRange.StartDate.ConvertTime(period.TimeZone)
          .Add(schedule.Period.StartTime.ConvertTime(period.TimeZone).TimeOfDay);

      if(DateTimeOffset.UtcNow >= firstOccurrenceStartTime)
      {
        // do something
      }

 

構造函數

很多工程師還沒有理解好構造函數的功效和使用方式,在選擇依賴注入方式時,更傾向于使用屬性依賴注入。個人認為,使用 “屬性依賴注入” 是懶惰的一種表現,其不僅打破了信息隱藏的封裝,而且還可以暴露了本不需要暴露的部分。使用構造函數進行依賴注入是最正確的方式,我們應該竭盡全力將代碼重構到這一點。

好的,你說的,我信了!并且,我也開始這么做了!絕對純凈的構造函數注入!

public class Schedule
  {
    public Schedule(long templateId, long seriesId,
      long promotionId, bool isOnceSche, DateTime startTime, DateTime endTime,
      List<TimeRange> blackOutList, ScheduleAddtionalConfig addtionalConfig,
      IDateTimeProvider tProvider, IScheduleMessageProxy proxy,
      IAppSetting appSetting, RevisionData revisionData)
    {
    }
  }

好吧,你贏了!構造函數居然有 12 個參數,距離史上最長的構造函數不遠了。

一般寫成這樣的代碼已經表示沒法看了,而且注定類的設計也不怎么樣,這要是遺留下來的 Legacy Code,不知道維護者心情幾何?

還有一類構造函數問題就是參數順序,這直接體現了軟件工程師最終他人的基本素養。因為構造函數生來就是為使用者準備的,而為使用者設計合理的參數順序是類設計者的基本職責。

public class Command
  {
    public Command(int argsA, int argsB)
    {
    }

    public Command(int argsC, int argsB, int argsA)
    {
    }
  }

上面這種反人類思維的參數順序,怎么描述呢?寫成下面這樣有多大難度?

public class Command
  {
    public Command(int argsA, int argsB)
    {
    }

    public Command(int argsA, int argsB, int argsC)
    {
    }
  }

 

屬性

蹩腳的屬性設計常常彰顯抽象對象類型的能力。以下面這個 Schedule 類為例,Schedule 業務上存在 Once 和 Recurring 兩種狀態。我們最初看到的類是這個樣子的。

public class Schedule
  {
    public Schedule(bool isOnceSchedule)
    {
      IsOnceSchedule = isOnceSchedule;
    }

    public bool IsOnceSchedule { get; set; }
  }

看來這是想通過構造函數直接注入指定狀態,但 IsOnceSchedule 屬性的 set 又是 public 的允許修改,不僅暴露了封裝,還沒有起到隱藏的效果!

那么,稍微改進下,試圖消滅 IsOnceSchedule 屬性,引入繼承機制。

public class Schedule
  {
  }

  public class OnceSchedule : Schedule
  {
  }

  public class RecurringSchedule : Schedule
  {
  }

實現上在 OnceSchedule 和 RecurringSchedule 中均封裝獨立的實現。如果非要通過父類抽象暴露 Recurring 狀態,可以在父類中通過屬性暴露只讀接口。

public class Schedule
  {
    public Schedule()
    {
      this.IsRecurring = false;
    }

    public bool IsRecurring { get; protected set; }
  }

  public class OnceSchedule : Schedule
  {
    public OnceSchedule()
      : base()
    {
      this.IsRecurring = false;
    }
  }

  public class RecurringSchedule : Schedule
  {
    public RecurringSchedule()
      : base()
    {
      this.IsRecurring = true;
    }
  }

 

函數

我們或許都知道,函數命名要動詞開頭,如需要可與名詞結合。而函數設計的要求是盡量只做一件事,這件事有時簡單有時困難。

簡單的可以像下面這種一句話代碼:

internal bool CheckDateTimeWeekendDay(DateTimeOffset dateTime)
    {
      return dateTime.DayOfWeek == DayOfWeek.Saturday 
        || dateTime.DayOfWeek == DayOfWeek.Sunday;
    }

復雜的見到幾百行的函數也不新奇。拆解長函數的方法有很多,這么不做贅述。這里推薦一種使用 C# 語法糖衍生出的函數設計方法。

上面的小函數其實是非常過程化的代碼,其是為類 DateTimeOffset 服務,我們可以使用 C# 中的擴展方法來優化這個小函數。

internal static class DateTimeOffsetExtensions
  {
    internal static bool IsWeekendDay(this DateTimeOffset dateTime)
    {
      return dateTime.DayOfWeek == DayOfWeek.Saturday
        || dateTime.DayOfWeek == DayOfWeek.Sunday;
    }
  }

這樣,我們就可以像下面這樣使用了,感覺會不會好一些?

if(DateTimeOffset.Now.IsWeekendDay())
{
     // do something
}

在設計函數時,我們時常猶豫的是,到底應該返回一個 null 值還是應該拋出一個異常呢?

答案就是,如果你總是期待函數返回一個值時,而值不存在則應該拋出異常;如果你期待函數可以返回一個不存在的值,則可以返回 null。總之,不要因為懶惰而使得應該設計拋出異常的函數最終返回了 null,不幸的是,這種懶惰經常出現。

正常的代碼是不需要 try..catch.. 的,異常就應該一拋到底直至應用程序崩潰,當然,這是開發階段。一拋到底有利于發現已有代碼路徑中的錯誤,畢竟異常在正常邏輯中是不應該產生的。我們要做的是,合理期待某調用可能會產生某類異常,則直接 catch 該特定異常,如 catch (System.IO.FileNotFoundException ex)。

實際上,遇到這種抉擇場景,我們可以在函數命名上下些功夫,以變相解決問題。

object FindObjectOrNull(string key);
     object FindObjectOrThrow(string key);
     object FindObjectOrCreate(string key, object dataNeededToCreateNewObject);
     object FindObjectOrDefault(string key, Object defaultReturnValue);

 

單元測試

在開始寫代碼的時候就開始考慮測試問題,有利于產生易于測試的代碼。幸運的是,對測試友好的設計會很自然的產生良好的代碼。

測試驅動開發(TDD)是一種編程風格,包含 TDD 三定律:

  1. 在編寫不能通過的單元測試前,不能編寫生產代碼;
  2. 只編寫剛好無法通過的單元測試,不能編譯不算通過;
  3. 只編寫剛好通過當前失敗測試的生產代碼;

我們顯然可以循規蹈矩的遵循上述 TDD 三定律風格編程,但 TDD 只是通過測試來保證代碼質量,驅動良好設計的一種風格,我們沒有必要完全強迫自己遵循上述定律,找到適合自己的過程可能效率更高,所以重點在于,要寫單元測試,通過寫代碼時思考測試這件事來幫助把代碼寫的更好。

測試代碼不是二等公民,它和生產代碼一樣重要。他需要被思考、被設計、被維護,并且要像生產代碼一樣保持優雅的風格。

單元測試測什么?

在單元測試中,可通過兩種方式來驗證代碼是否正確地工作。一種是基于結果狀態的測試,一種是基于交互行為的測試。這兩種方式在文章《單元測試的兩種方式》中有描述,這里就不再贅述。

單元測試的可讀性

在測試代碼中,可讀性仍然很重要。如果測試代碼的可讀性良好,使其更易于后期的維護和修改,不至于是測試代碼腐化以致被刪除。

下面是一些良好測試的關鍵點:

  • 測試越簡明越好,每個測試只關注一個點。
  • 如果測試運行失敗,則其應發出有幫助性的錯誤消息或提示。
  • 使用簡單明確的測試輸入條件。
  • 給測試用例取一個可描述的名字。

那么,具體什么樣的單元測試用例名稱,算是好名稱呢?這里推薦兩種:

  • 第一種:使用 Test_<ClassName>_<FunctionName>_<Situation> 風格;
  • 第二種:使用 Given_<State>_When_<Behavior>_Then_<SomethingHappen> 風格;

第二種實際上是 BDD 風格,其不僅可以應用于單元測試,在更高級的 Component Level 和 System Level 的測試中同樣有效。

實際上,單元測試用例代碼的內部實現也是有風格可遵循,常見的就是 Arrange-Act-Assert (AAA) 模式。

第三方組件代碼不便于測試

在文章《類依賴項的不透明性和透明性》中描述了依賴項對單元測試的影響,實踐中,我們碰到最多的是調用其他類庫的代碼而導致的不可測試性。

public class MyClass
  {
    private Job _job;

    public MyClass()
    {
      _job = new Job();
    }

    public void ExecuteJob()
    {
      _job.Execute();
    }
  }

  public sealed class Job
  {
    public void Execute()
    {
      // do something heavy
    }
  }

上面的代碼,如果寫一個 TestCase 的話,可能是下面這種情況。

[Test]
  public void Test_MyClass_ExecuteJob()
  {
    MyClass instance = new MyClass();
    instance.ExecuteJob();

    // what should we assert?
  }

這樣,調用了 instance.ExecuteJob() 導致了不知道如何驗證。同時,由于 Job 類使用了 sealed 關鍵字,并且沒有實現任何接口,所以也無法通過 mocking 庫來 mock。

解決辦法,增加中間層。

public class MyClass
  {
    private IJob _job;

    public MyClass(IJob job)
    {
      _job = job;
    }

    public void ExecuteJob()
    {
      _job.Execute();
    }
  }

  public class JobProxy : IJob
  {
    private Job _realJob;

    public JobProxy(Job job)
    {
      _realJob = job;
    }

    public void Execute()
    {
      _realJob.Execute();
    }
  }

  public interface IJob
  {
    void Execute();
  }

  // third-party Job Class
  public sealed class Job
  {
    public void Execute()
    {
      // do something heavy
    }
  }

這樣,我們在測試 MyClass 類時,就可以通過 IJob 接口注入 Mock 對象。這里選用的 Mocking Library 是 NSubstitute,參考《NSubstitute完全手冊索引》。

[Test]
  public void Test_MyClass_ExecuteJob()
  {
    IJob job = Substitute.For<IJob>();

    MyClass instance = new MyClass(job);
    instance.ExecuteJob();

    // assert
    job.Received(1).Execute();
  }

依賴時間的測試

還有一種較難測試的代碼是依賴于時間的代碼。比如,我們有一個依賴于時間的 Trigger 類,簡寫為這個樣子。

public class Trigger
  {
    public Trigger(DateTime triggeredTime)
    {
      this.TriggeredTime = triggeredTime;
    }

    public DateTime TriggeredTime { get; private set; }

    public bool TryExecute()
    {
      if (DateTime.Now >= TriggeredTime)
      {
        // do something
        return true;
      }

      return false;
    }
  }

測試時,我可能會挑一些特定時間進行測試,特定時間有可能在很遠的未來。

[Test]
  public void Test_Trigger_TryExecute_AfterTriggeredTime()
  {
    DateTime triggeredTimeInFuture = 
      new DateTime(2016, 2, 29, 8, 0, 0, DateTimeKind.Local);

    Trigger trigger = new Trigger(triggeredTimeInFuture);
    bool result = trigger.TryExecute();

    // assert
    Assert.IsTrue(result);
  }

好吧,這個 TestCase 應該是到 2016 年才能執行成功,顯然不是我們期待的。改進的辦法還是增加中間層,增加 IClock 接口用于提供時間。

public class Trigger
  {
    private IClock _clock;

    public Trigger(IClock clock, DateTime triggeredTime)
    {
      _clock = clock;
      this.TriggeredTime = triggeredTime;
    }

    public DateTime TriggeredTime { get; private set; }

    public bool TryExecute()
    {
      if (_clock.Now() >= TriggeredTime)
      {
        // do something
        return true;
      }

      return false;
    }
  }

  public interface IClock
  {
    Func<DateTimeOffset> UtcNow { get; }
    Func<DateTimeOffset> Now { get; }
  }

  public class Clock : IClock
  {
    public Func<DateTimeOffset> UtcNow { get { return () => DateTimeOffset.UtcNow; } }
    public Func<DateTimeOffset> Now { get { return () => DateTimeOffset.Now; } }
  }

這樣,我們就可以在 TestCase 代碼中使用 Mocking 類庫來替換 IClock 的實例,進而指定時間。

[Test]
  public void Test_Trigger_TryExecute_AfterTriggeredTime()
  {
    IClock clock = Substitute.For<IClock>();

    clock.Now
      .Returns<Func<DateTimeOffset>>(() =>
      {
        return DateTimeOffset.Parse(
          "2016-02-29T08:00:01.0000000", CultureInfo.CurrentCulture);
      });

    DateTime triggeredTimeInFuture =
      new DateTime(2016, 2, 29, 8, 0, 0, DateTimeKind.Local);

    Trigger trigger = new Trigger(clock, triggeredTimeInFuture);
    bool result = trigger.TryExecute();

    // assert
    Assert.IsTrue(result);
  }

 

 本文由用戶 ygw3 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!