教你从头写游戏服务器框架
作者:韓偉
前言
大概已經有差不多一年沒寫技術文章了,原因是今年投入了一些具體游戲項目的開發。這些新的游戲項目,比較接近獨立游戲的開發方式。我覺得公司的“祖傳”服務器框架技術不太適合,所以從頭寫了一個游戲服務器端的框架,以便獲得更好的開發效率和靈活性。現在項目將近上線,有時間就想總結一下,這樣一個游戲服務器框架的設計和實現過程。
這個框架的基本運行環境是 Linux ,采用 C++ 編寫。為了能在各種環境上運行和使用,所以采用了 gcc 4.8 這個“古老”的編譯器,以 C99 規范開發。
需求
由于“越通用的代碼,就是越沒用的代碼”,所以在設計之初,我就認為應該使用分層的模式來構建整個系統。按照游戲服務器的一般需求劃分,最基本的可以分為兩層:
我希望能有一個基本完整的“底層基礎功能”的框架,可以被復用于多個不同的游戲。由于目標是開發一個 適合獨立游戲開發 的游戲服務器框架。所以最基本的需求分析為:
功能性需求
非功能性需求
一旦需求明確下來,基本的層級結構也可以設計了:
| 邏輯層 | 實現更具體的業務邏輯 | 能調用所有下層代碼,但應主要依賴接口層 |
| 實現層 | 對各種具體的通信協議、存儲設備等功能的實現 | 滿足下層的接口層來做實現,禁止同層間互相調用 |
| 接口層 | 定義了各模塊的基本使用方式,用以隔離具體的實現和設計,從而提供互相替換的能力 | 本層之間代碼可以互相調用,但禁止調用上層代碼 |
| 工具層 | 提供通用的 C++ 工具庫功能,如 log/json/ini/日期時間/字符串處理 等等 | 不應該調用其他層代碼,也不應該調用同層其他模塊 |
| 第三方庫 | 提供諸如 redis/tcaplus 或者其他現成功能,其地位和“工具層”一樣 | 不應該調用其他層代碼,甚至不應該修改其源碼 |
最后,整體的架構模塊類似:
| 功能實現 | TcpUdpKcpTlvLine | JsonHandlerObjectProcessor | SessionLocalCacheRedisMapRamMapZooKeeperMap | FileDataStoreRedisDataStroe |
| 接口定義 | TransferProtocol | ServerClientProcessor | DataMapSerializable | DataStore |
| 工具類庫 | ConfigLOGJSONCoroutine |
通信模塊
對于通信模塊來說,需要有靈活的可替換協議的能力,就必須按一定的層次進行進一步的劃分。對于游戲來說,最底層的通信協議,一般會使用 TCP 和 UDP 這兩種,在服務器之間,也會使用消息隊列中間件一類通信軟件。框架必須要有能同事支持這幾通信協議的能力。故此設計了一個層次為: Transport
在協議層面,最基本的需求有“分包”“分發”“對象序列化”等幾種需求。如果要支持“請求-響應”模式,還需要在協議中帶上“序列號”的數據,以便對應“請求”和“響應”。另外,游戲通常都是一種“會話”式的應用,也就是一系列的請求,會被視為一次“會話”,這就需要協眾需要有類似 Session ID 這種數據。為了滿足這些需求,設計一個層次為: Protocol
擁有了以上兩個層次,是可以完成最基本的協議層能力了。但是,我們往往希望業務數據的協議包,能自動化的成為編程中的 對象,所以在處理消息體這里,需要一個可選的額外層次,用來把字節數組,轉換成對象。所以我設計了一個特別的處理器:ObjectProcessor ,去規范通信模塊中對象序列化、反序列化的接口。
| data | Transport | 通信 | buffer |
| buffer | Protocol | 分包 | Message |
| Message | Processor | 分發 | object |
| object | 處理模塊 | 處理 | 業務邏輯 |
Transport
此層次是為了統一各種不同的底層傳輸協議而設置的,最基本應該支持 TCP 和 UDP 這兩種協議。對于通信協議的抽象,其實在很多底層庫也做的非常好了,比如 Linux 的 socket 庫,其讀寫 API 甚至可以和文件的讀寫通用。C# 的 Socket 庫在 TCP 和 UDP 之間,其 api 也幾乎是完全一樣的。但是由于作用游戲服務器,很多適合還會接入一些特別的“接入層”,比如一些代理服務器,或者一些消息中間件,這些 API 可是五花八門的。另外,在 html5 游戲(比如微信小游戲)和一些頁游領域,還有用 HTTP 服務器作為游戲服務器的傳統(如使用 WebSocket 協議),這樣就需要一個完全不同的傳輸層了。
服務器傳輸層在異步模型下的基本使用序列,就是:
根據上面三個特點,可以歸納出一個基本的接口:
class Transport { public: /*** 初始化Transport對象,輸入Config對象配置最大連接數等參數,可以是一個新建的Config對象。*/ virtual int Init(Config* config) = 0;/*** 檢查是否有數據可以讀取,返回可讀的事件數。后續代碼應該根據此返回值循環調用Read()提取數據。* 參數fds用于返回出現事件的所有fd列表,len表示這個列表的最大長度。如果可用事件大于這個數字,并不影響后續可以Read()的次數。* fds的內容,如果出現負數,表示有一個新的終端等待接入。*/virtual int Peek(int* fds, int len) = 0;/*** 讀取網絡管道中的數據。數據放在輸出參數 peer 的緩沖區中。* @param peer 參數是產生事件的通信對端對象。* @return 返回值為可讀數據的長度,如果是 0 表示沒有數據可以讀,返回 -1 表示連接需要被關閉。*/virtual int Read( Peer* peer) = 0;/*** 寫入數據,output_buf, buf_len為想要寫入的數據緩沖區,output_peer為目標隊端,* 返回值表示成功寫入了的數據長度。-1表示寫入出錯。*/virtual int Write(const char* output_buf, int buf_len, const Peer& output_peer) = 0;/*** 關閉一個對端的連接*/virtual void ClosePeer(const Peer& peer) = 0;/*** 關閉Transport對象。*/virtual void Close() = 0;}在上面的定義中,可以看到需要有一個 Peer 類型。這個類型是為了代表通信的客戶端(對端)對象。在一般的 Linux 系統中,一般我們用 fd (File Description)來代表。但是因為在框架中,我們還需要為每個客戶端建立接收數據的緩存區,以及記錄通信地址等功能,所以在 fd 的基礎上封裝了一個這樣的類型。這樣也有利于把 UDP 通信以不同客戶端的模型,進行封裝。
///@brief 此類型負責存放連接過來的客戶端信息和數據緩沖區 class Peer { public: int buf_size_; ///< 緩沖區長度char* const buffer_;///< 緩沖區起始地址int produced_pos_; ///< 填入了數據的長度int consumed_pos_; ///< 消耗了數據的長度int GetFd() const;void SetFd(int fd); /// 獲得本地地址const struct sockaddr_in& GetLocalAddr() const;void SetLocalAddr(const struct sockaddr_in& localAddr); /// 獲得遠程地址const struct sockaddr_in& GetRemoteAddr() const;void SetRemoteAddr(const struct sockaddr_in& remoteAddr);private:int fd_; ///< 收發數據用的fdstruct sockaddr_in remote_addr_; ///< 對端地址struct sockaddr_in local_addr_; ///< 本端地址 };游戲使用 UDP 協議的特點:一般來說 UDP 是無連接的,但是對于游戲來說,是肯定需要有明確的客戶端的,所以就不能簡單用一個 UDP socket 的fd 來代表客戶端,這就造成了上層的代碼無法簡單在 UDP 和 TCP 之間保持一致。因此這里使用 Peer 這個抽象層,正好可以接近這個問題。這也可以用于那些使用某種消息隊列中間件的情況,因為可能這些中間件,也是多路復用一個 fd 的,甚至可能就不是通過使用 fd 的 API 來開發的。
對于上面的 Transport 定義,對于 TCP 的實現者來說,是非常容易能完成的。但是對于 UDP 的實現者來說,則需要考慮如何寵妃利用 Peer ,特別是 Peer.fd_ 這個數據。我在實現的時候,使用了一套虛擬的 fd 機制,通過一個客戶端的 IPv4 地址到 int 的對應 Map ,來對上層提供區分客戶端的功能。在 Linux 上,這些 IO 都可以使用 epoll 庫來實現,在 Peek() 函數中讀取 IO 事件,在 Read()/Write() 填上 socket 的調用就可以了。
另外,為了實現服務器之間的通信,還需要設計和 Tansport 對應的一個類型:Connector 。這個抽象基類,用于以客戶端模型對服務器發起請求。其設計和 Transport 大同小異。除了 Linux 環境下的 Connecotr ,我還實現了在 C# 下的代碼,以便用 Unity 開發的客戶端可以方便的使用。由于 .NET 本身就支持異步模型,所以其實現也不費太多功夫。
/*** @brief 客戶端使用的連接器類,代表傳輸協議,如 TCP 或 UDP*/ class Connector {public: virtual ~Connector() {} /*** @brief 初始化建立連接等* @param config 需要的配置* @return 0 為成功*/virtual int Init(Config* config) = 0;/*** @brief 關閉*/virtual void Close() = 0;/*** @brief 讀取是否有網絡數據到來* 讀取有無數據到來,返回值為可讀事件的數量,通常為1* 如果為0表示沒有數據可以讀取。* 如果返回 -1 表示出現網絡錯誤,需要關閉此連接。* 如果返回 -2 表示此連接成功連上對端。* @return 網絡數據的情況*/virtual int Peek() = 0;/*** @brief 讀取網絡數 * 讀取連接里面的數據,返回讀取到的字節數,如果返回0表示沒有數據,* 如果buffer_length是0, 也會返回0,* @return 返回-1表示連接需要關閉(各種出錯也返回0)*/virtual int Read(char* ouput_buffer, int buffer_length) = 0;/*** @brief 把input_buffer里的數據寫入網絡連接,返回寫入的字節數。* @return 如果返回-1表示寫入出錯,需要關閉此連接。*/virtual int Write(const char* input_buffer, int buffer_length) = 0;protected:Connector(){} };Protocol
對于通信“協議”來說,其實包含了許許多多的含義。在眾多的需求中,我所定義的這個協議層,只希望完成四個最基本的能力:
除了以上三個功能,實際上希望在協議層處理的能力,還有很多,最典型的就是對象序列化的功能,還有壓縮、加密功能等等。我之所以沒有把對象序列化的能力放在 Protocol 中,原因是對象序列化中的“對象”本身是一個業務邏輯關聯性非常強的概念。在 C++ 中,并沒有完整的“對象”模型,也缺乏原生的反射支持,所以無法很簡單的把代碼層次通過“對象”這個抽象概念劃分開來。但是我也設計了一個 ObjectProcessor ,把對象序列化的支持,以更上層的形式結合到框架中。這個 Processor 是可以自定義對象序列化的方法,這樣開發者就可以自己選擇任何“編碼、解碼”的能力,而不需要依靠底層的支持。
至于壓縮和加密這一類功能,確實是可以放在 Protocol 層中實現,甚至可以作為一個抽象層次加入 Protocol ,可能只有一個 Protocol 層不足以支持這么豐富的功能,需要好像 Apache Mina 這樣,設計一個“調用鏈”的模型。但是為了簡單起見,我覺得在具體需要用到的地方,再額外添加 Protocol 的實現類就好,比如添加一個“帶壓縮功能的 TLV Protocol 類型”之類的。
消息本身被抽象成一個叫 Message 的類型,它擁有“服務名字”“會話ID”兩個消息頭字段,用以完成“分發”和“會話保持”功能。而消息體則被放在一個字節數組中,并記錄下字節數組的長度。
enum MessageType {TypeError, ///< 錯誤的協議TypeRequest, ///< 請求類型,從客戶端發往服務器TypeResponse, ///< 響應類型,服務器收到請求后返回TypeNotice ///< 通知類型,服務器主動通知客戶端 };///@brief 通信消息體的基類 ///基本上是一個 char[] 緩沖區 struct Message { public:static int MAX_MAESSAGE_LENGTH;static int MAX_HEADER_LENGTH;MessageType type; ///< 此消息體的類型(MessageType)信息virtual ~Message(); virtual Message& operator=(const Message& right);/*** @brief 把數據拷貝進此包體緩沖區*/void SetData(const char* input_ptr, int input_length);///@brief 獲得數據指針inline char* GetData() const{return data_;}///@brief 獲得數據長度inline int GetDataLen() const{return data_len_;}char* GetHeader() const;int GetHeaderLen() const;protected:Message();Message(const Message& message);private:char* data_; // 包體內容緩沖區int data_len_; // 包體長度};根據之前設計的“請求響應”和“通知”兩種通信模式,需要設計出三種消息類型繼承于 Message,他們是:
- Request 請求包
- Response 響應包
- Notice 通知包
Request 和 Response 兩個類,都有記錄序列號的 seq_id 字段,但 Notice 沒有。Protocol 類就是負責把一段 buffer 字節數組,轉換成 Message 的子類對象。所以需要針對三種 Message 的子類型都實現對應的 Encode() / Decode() 方法。
class Protocol {public:virtual ~Protocol() {}/*** @brief 把請求消息編碼成二進制數據* 編碼,把msg編碼到buf里面,返回寫入了多長的數據,如果超過了 len,則返回-1表示錯誤。* 如果返回 0 ,表示不需要編碼,框架會直接從 msg 的緩沖區讀取數據發送。* @param buf 目標數據緩沖區* @param offset 目標偏移量* @param len 目標數據長度* @param msg 輸入消息對象* @return 編碼完成所用的字節數,如果 < 0 表示出錯*/virtual int Encode(char* buf, int offset, int len, const Request& msg) = 0;/*** 編碼,把msg編碼到buf里面,返回寫入了多長的數據,如果超過了 len,則返回-1表示錯誤。* 如果返回 0 ,表示不需要編碼,框架會直接從 msg 的緩沖區讀取數據發送。* @param buf 目標數據緩沖區* @param offset 目標偏移量* @param len 目標數據長度* @param msg 輸入消息對象* @return 編碼完成所用的字節數,如果 < 0 表示出錯*/virtual int Encode(char* buf, int offset, int len, const Response& msg) = 0;/*** 編碼,把msg編碼到buf里面,返回寫入了多長的數據,如果超過了 len,則返回-1表示錯誤。* 如果返回 0 ,表示不需要編碼,框架會直接從 msg 的緩沖區讀取數據發送。* @param buf 目標數據緩沖區* @param offset 目標偏移量* @param len 目標數據長度* @param msg 輸入消息對象* @return 編碼完成所用的字節數,如果 < 0 表示出錯*/virtual int Encode(char* buf, int offset, int len, const Notice& msg) = 0;/*** 開始編碼,會返回即將解碼出來的消息類型,以便使用者構造合適的對象。* 實際操作是在進行“分包”操作。* @param buf 輸入緩沖區* @param offset 輸入偏移量* @param len 緩沖區長度* @param msg_type 輸出參數,表示下一個消息的類型,只在返回值 > 0 的情況下有效,否則都是 TypeError* @return 如果返回0表示分包未完成,需要繼續分包。如果返回-1表示協議包頭解析出錯。其他返回值表示這個消息包占用的長度。*/virtual int DecodeBegin(const char* buf, int offset, int len,MessageType* msg_type) = 0;/*** 解碼,把之前DecodeBegin()的buf數據解碼成具體消息對象。* @param request 輸出參數,解碼對象會寫入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Request* request) = 0;/*** 解碼,把之前DecodeBegin()的buf數據解碼成具體消息對象。* @param request 輸出參數,解碼對象會寫入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Response* response) = 0;/*** 解碼,把之前DecodeBegin()的buf數據解碼成具體消息對象。* @param request 輸出參數,解碼對象會寫入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Notice* notice) = 0;protected:Protocol() {}};這里有一點需要注意,由于 C++ 沒有內存垃圾搜集和反射的能力,在解釋數據的時候,并不能一步就把一個 char[] 轉換成某個子類對象,而必須分成兩步處理。
對于 Protocol 的具體實現子類,我首先實現了一個 LineProtocol ,是一個非常不嚴謹的,基于文本ASCII編碼的,用空格分隔字段,用回車分包的協議。用來測試這個框架是否可行。因為這樣可以直接通過 telnet 工具,來測試協議的編解碼。然后我按照 TLV (Type Length Value)的方法設計了一個二進制的協議。大概的定義如下:
協議分包: [消息類型:int:2] [消息長度:int:4] [消息內容:bytes:消息長度]
消息類型取值:
- 0x00 Error
- 0x01 Request
- 0x02 Response
- 0x03 Notice
| Request | 服務名 | 字段:int:2[字符串內容:chars:消息長度] |
| 序列號 | 字段:int:2 | |
| 會話ID | 字段:int:2 | |
| 消息體 | 字段:int:2[字符串內容:chars:消息長度] | |
| Response | 服務名 | 字段:int:2[字符串內容:chars:消息長度] |
| 序列號 | 字段:int:2 | |
| 會話ID | 字段:int:2 | |
| 消息體 | 字段:int:2[字符串內容:chars:消息長度] | |
| Notice | 服務名 | 字段:int:2[字符串內容:chars:消息長度] |
| 消息體 | 字段:int:2[字符串內容:chars:消息長度] |
一個名為 TlvProtocol 的類型完成對這個協議的實現。
Processor
處理器層是我設計用來對接具體業務邏輯的抽象層,它主要通過輸入參數 Request 和 Peer 來獲得客戶端的輸入數據,然后通過 Server 類的 Reply()/Inform() 來返回 Response 和 Notice 消息。實際上 Transport 和 Protocol 的子類們,都屬于 net 模塊,而各種 Processor 和 Server/Client 這些功能類型,屬于另外一個 processor 模塊。這樣設計的原因,是希望所有 processor 模塊的代碼單向的依賴 net 模塊的代碼,但反過來不成立。
Processor 基類非常簡單,就是一個處理函數回調函數入口 Process():
///@brief 處理器基類,提供業務邏輯回調接口class Processor {public:Processor();virtual ~Processor();/*** 初始化一個處理器,參數server為業務邏輯提供了基本的能力接口。*/virtual int Init(Server* server, Config* config = NULL);/*** 處理請求-響應類型包實現此方法,返回值是0表示成功,否則會被記錄在錯誤日志中。* 參數peer表示發來請求的對端情況。其中 Server 對象的指針,可以用來調用 Reply(),* Inform() 等方法。如果是監聽多個服務器,server 參數則會是不同的對象。*/virtual int Process(const Request& request, const Peer& peer,Server* server);/*** 關閉清理處理器所占用的資源*/virtual int Close(); };設計完 Transport/Protocol/Processor 三個通信處理層次后,就需要一個組合這三個層次的代碼,那就是 Server 類。這個類在 Init() 的時候,需要上面三個類型的子類作為參數,以組合成不同功能的服務器,如:
TlvProtocol tlv_protocol; // Type Length Value 格式分包協議,需要和客戶端一致 TcpTransport tcp_transport; // 使用 TCP 的通信協議,默認監聽 0.0.0.0:6666 EchoProcessor echo_processor; // 業務邏輯處理器 Server server; // DenOS 的網絡服務器主對象 server.Init(&tcp_transport, &tlv_protocol, &echo_processor); // 組裝一個游戲服務器對象:TLV 編碼、TCP 通信和回音服務Server 類型還需要一個 Update() 函數,讓用戶進程的“主循環”不停的調用,用來驅動整個程序的運行。這個 Update() 函數的內容非常明確:
另外,Server 還需要處理一些額外的功能,比如維護一個會話緩存池(Session),提供發送 Response 和 Notice 消息的接口。當這些工作都完成后,整套系統已經可以用來作為一個比較“通用”的網絡消息服務器框架存在了。剩下的就是添加各種 Transport/Protocol/Processor 子類的工作。
class Server {public:Server();virtual ~Server();/*** 初始化服務器,需要選擇組裝你的通信協議鏈*/int Init(Transport* transport, Protocol* protocol, Processor* processor, Config* config = NULL);/*** 阻塞方法,進入主循環。*/void Start();/*** 需要循環調用驅動的方法。如果返回值是0表示空閑。其他返回值表示處理過的任務數。*/virtual int Update();void ClosePeer(Peer* peer, bool is_clear = false); //關閉當個連接,is_clear 表示是否最終整體清理/*** 關閉服務器*/void Close();/*** 對某個客戶端發送通知消息,* 參數peer代表要通知的對端。*/int Inform(const Notice& notice, const Peer& peer);/*** 對某個 Session ID 對應的客戶端發送通知消息,返回 0 表示可以發送,其他值為發送失敗。* 此接口能支持斷線重連,只要客戶端已經成功連接,并使用舊的 Session ID,同樣有效。*/int Inform(const Notice& notice, const std::string& session_id);/*** 對某個客戶端發來的Request發回回應消息。* 參數response的成員seqid必須正確填寫,才能正確回應。* 返回0成功,其它值(-1)表示失敗。*/int Reply(Response* response, const Peer& peer);/*** 對某個 Session ID 對應的客戶端發送回應消息。* 參數 response 的 seqid 成員系統會自動填寫會話中記錄的數值。* 此接口能支持斷線重連,只要客戶端已經成功連接,并使用舊的 Session ID,同樣有效。* 返回0成功,其它值(-1)表示失敗。*/int Reply(Response* response, const std::string& session_id);/*** 會話功能*/Session* GetSession(const std::string& session_id = "", bool use_this_id = false);Session* GetSessionByNumId(int session_id = 0);bool IsExist(const std::string& session_id);};有了 Server 類型,肯定也需要有 Client 類型。而 Client 類型的設計和 Server 類似,但就不是使用 Transport 接口作為傳輸層,而是 Connector 接口。不過 Protocol 的抽象層是完全重用的。Client 并不需要 Processor 這種形式的回調,而是直接傳入接受數據消息就發起回調的接口對象 ClientCallback。
class ClientCallback {public:ClientCallback() {}virtual ~ClientCallback() {// Do nothing}/*** 當連接建立成功時回調此方法。* @return 返回 -1 表示不接受這個連接,需要關閉掉此連接。*/virtual int OnConnected() {return 0;}/*** 當網絡連接被關閉的時候,調用此方法*/virtual void OnDisconnected() { // Do nothing}/*** 收到響應,或者請求超時,此方法會被調用。* @param response 從服務器發來的回應* @return 如果返回非0值,服務器會打印一行錯誤日志。*/virtual int Callback(const Response& response) {return 0;}/*** 當請求發生錯誤,比如超時的時候,返回這個錯誤* @param err_code 錯誤碼*/virtual void OnError(int err_code){WARN_LOG("The request is timeout, err_code: %d", err_code);}/*** 收到通知消息時,此方法會被調用*/virtual int Callback(const Notice& notice) {return 0;}/*** 返回此對象是否應該被刪除。此方法會被在 Callback() 調用前調用。* @return 如果返回 true,則會調用 delete 此對象的指針。*/virtual bool ShouldBeRemoved() {return false;} };class Client : public Updateable {public:Client(); virtual ~Client();/*** 連接服務器* @param connector 傳輸協議,如 TCP, UDP ...* @param protocol 分包協議,如 TLV, Line, TDR ...* @param notice_callback 收到通知后觸發的回調對象,如果傳輸協議有“連接概念”(如TCP/TCONND),建立、關閉連接時也會調用。* @param config 配置文件對象,將讀取以下配置項目:MAX_TRANSACTIONS_OF_CLIENT 客戶端最大并發連接數; BUFFER_LENGTH_OF_CLIENT客戶端收包緩存;CLIENT_RESPONSE_TIMEOUT 客戶端響應等待超時時間。* @return 返回 0 表示成功,其他表示失敗*/int Init(Connector* connector, Protocol* protocol,ClientCallback* notice_callback = NULL, Config* config = NULL);/*** callback 參數可以為 NULL,表示不需要回應,只是單純的發包即可。*/virtual int SendRequest(Request* request, ClientCallback* callback = NULL);/*** 返回值表示有多少數據需要處理,返回-1為出錯,需要關閉連接。返回0表示沒有數據需要處理。*/virtual int Update();virtual void OnExit();void Close();Connector* connector() ;ClientCallback* notice_callback() ;Protocol* protocol() ; };至此,客戶端和服務器端基本設計完成,可以直接通過編寫測試代碼,來檢查是否運行正常。
此文已由騰訊云+社區在各渠道發布,一切權利歸作者所有
獲取更多新鮮技術干貨,可以關注我們騰訊云技術社區-云加社區官方號及知乎機構號
總結
以上是生活随笔為你收集整理的教你从头写游戏服务器框架的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: OA总结
- 下一篇: POJ2774 Long Long Me