實現一個簡單的Database3(譯文)

来源:https://www.cnblogs.com/greatsql/archive/2022/09/29/16741096.html
-Advertisement-
Play Games

前文回顧 實現一個簡單的Database1(譯文) 實現一個簡單的Database2(譯文) 實現一個簡單的Database3(譯文) 譯註:cstsck在github維護了一個簡單的、類似sqlite的資料庫實現,通過這個簡單的項目,可以很好的理解資料庫是如何運行的。本文是第三篇,主要是實現資料庫 ...


前文回顧

實現一個簡單的Database3(譯文)

譯註:cstsck在github維護了一個簡單的、類似sqlite的資料庫實現,通過這個簡單的項目,可以很好的理解資料庫是如何運行的。本文是第三篇,主要是實現資料庫的實現記憶體中的數據結構並存儲數據

Part 3 在記憶體中,只追加的單表資料庫

我們從一個小型的,有許多限制的資料庫開始。現在資料庫將:

  • 支持兩個操作:插入一行並列印所有行
  • 數據駐留在記憶體中(沒有持久化到磁碟)
  • 支持單個、硬編碼的表

我們的硬編碼表將用來存儲用戶數據,看起來就行下麵展示的這樣:

column type
id integer
username varchar(32)
email varchar(255)

這是一個簡單的方案,但是它將讓我們的資料庫能夠支持不同的數據類型和不同大小的文本數據類型。插入語句現在看起來像下麵這樣:

    insert 1 cstack [email protected]

這意味我們需要升級prepare_statement()函數來解析參數:

if (strncmp(input_buffer->buffer, "insert", 6) == 0) {
      statement->type = STATEMENT_INSERT;
    +    int args_assigned = sscanf(
    +        input_buffer->buffer, "insert %d %s %s", &(statement->row_to_insert.id),
    +        statement->row_to_insert.username, statement->row_to_insert.email);
    +    if (args_assigned < 3) {
    +      return PREPARE_SYNTAX_ERROR;
    +    }
      return PREPARE_SUCCESS;
    }
    if (strcmp(input_buffer->buffer, "select") == 0) {

我們把這些解析出的的參數存儲到Statement對象中的一個新的數據結構Row中。

+#define COLUMN_USERNAME_SIZE 32
    +#define COLUMN_EMAIL_SIZE 255
    +typedef struct {
    +  uint32_t id;
    +  char username[COLUMN_USERNAME_SIZE];
    +  char email[COLUMN_EMAIL_SIZE];
    +} Row;
    +
     typedef struct {
       StatementType type;
    +  Row row_to_insert;  // only used by insert statement
     } Statement;

現在我們需要copy這些數據到其他一些代表table的數據結構中。SQLite為了支持快速查找、插入和刪除操作而使用B-tree。我們將從一些簡單的開始。像B-tree,它把行數據分組成頁(pages),但是為了替換把這些頁(pages)組織成一顆樹的這種方法,這裡我們把頁來組織成數組(array)。

這是我的計劃:

  • 存儲行數據到叫做頁(pages)的記憶體塊中
  • 每頁儘量多的去存儲適合他的大小的數據(在頁的大小範圍內,儘量多的存儲數據)
  • 在每頁中行數據將被序列化為緊湊表示(compact representation)
  • 頁只有在需要時候才會被分配
  • 保持固定大小的指針數組指向頁

首先我們定義一個緊湊表示的行(row):

 +#define size_of_attribute(Struct, Attribute) sizeof(((Struct*)0)->Attribute)
    +
    +const uint32_t ID_SIZE = size_of_attribute(Row, id);
    +const uint32_t USERNAME_SIZE = size_of_attribute(Row, username);
    +const uint32_t EMAIL_SIZE = size_of_attribute(Row, email);
    +const uint32_t ID_OFFSET = 0;
    +const uint32_t USERNAME_OFFSET = ID_OFFSET + ID_SIZE;
    +const uint32_t EMAIL_OFFSET = USERNAME_OFFSET + USERNAME_SIZE;
    +const uint32_t ROW_SIZE = ID_SIZE + USERNAME_SIZE + EMAIL_SIZE;

這意味著一個序列化的行的佈局看起來就像下麵這樣:

column size (bytes) offset
id 4 0
username 32 4
email 255 36
total 291

譯註:畫個圖來直觀的看一下這個行數據存儲格式

我們還需要編碼來轉換緊湊表示。(即把數據序列化與反序列化)

+void serialize_row(Row* source, void* destination) {
    +  memcpy(destination + ID_OFFSET, &(source->id), ID_SIZE);
    +  memcpy(destination + USERNAME_OFFSET, &(source->username), USERNAME_SIZE);
    +  memcpy(destination + EMAIL_OFFSET, &(source->email), EMAIL_SIZE);
    +}
    +
    +void deserialize_row(void* source, Row* destination) {
    +  memcpy(&(destination->id), source + ID_OFFSET, ID_SIZE);
    +  memcpy(&(destination->username), source + USERNAME_OFFSET, USERNAME_SIZE);
    +  memcpy(&(destination->email), source + EMAIL_OFFSET, EMAIL_SIZE);
    +}

接下來,實現一個表的結構指向存儲行的頁並跟蹤頁中有多少行:

+const uint32_t PAGE_SIZE = 4096;
    +#define TABLE_MAX_PAGES 100
    +const uint32_t ROWS_PER_PAGE = PAGE_SIZE / ROW_SIZE;
    +const uint32_t TABLE_MAX_ROWS = ROWS_PER_PAGE * TABLE_MAX_PAGES;
    +
    +typedef struct {
    +  uint32_t num_rows;
    +  void* pages[TABLE_MAX_PAGES];
    +} Table;

我把數據頁大小設定為4KB,因為它與大多數電腦架構的虛擬記憶體系統中使用數據頁大小相同。這意味著資料庫中的一個數據頁的大小和系統中的一頁大小正好相同。操作系統在把數據頁移入或者移出記憶體的時候會作為一個完整的單位來操作,而不會拆散他們。

在分配page時我設置了一個很隨意的限制,限制分配100個page。當切換到一個tree結構時,我們資料庫的最大限制就只是受到系統文件的大小限制了(儘管我仍然是限制在記憶體中一次可以有多少page可以保持)。

行不能超出page的邊界。由於page在記憶體中可能不會彼此相鄰,這個假設可以讓讀/寫行數據更簡單。

說到這一點,下麵是我們如何弄清楚在記憶體中去哪裡讀/寫特定行。

+void* row_slot(Table* table, uint32_t row_num) {
    +  uint32_t page_num = row_num / ROWS_PER_PAGE;
    +  void* page = table->pages[page_num];
    +  if (page == NULL) {
    +    // Allocate memory only when we try to access page
    +    page = table->pages[page_num] = malloc(PAGE_SIZE);
    +  }
    +  uint32_t row_offset = row_num % ROWS_PER_PAGE;
    +  uint32_t byte_offset = row_offset * ROW_SIZE;
    +  return page + byte_offset;
    +}

現在我們通過execute_statement()函數可以從表結構中讀/寫了。

-void execute_statement(Statement* statement) {
    +ExecuteResult execute_insert(Statement* statement, Table* table) {
    +  if (table->num_rows >= TABLE_MAX_ROWS) {
    +    return EXECUTE_TABLE_FULL;
    +  }
    +
    +  Row* row_to_insert = &(statement->row_to_insert);
    +
    +  serialize_row(row_to_insert, row_slot(table, table->num_rows));
    +  table->num_rows += 1;
    +
    +  return EXECUTE_SUCCESS;
    +}
    +
    +ExecuteResult execute_select(Statement* statement, Table* table) {
    +  Row row;
    +  for (uint32_t i = 0; i < table->num_rows; i++) {
    +    deserialize_row(row_slot(table, i), &row);
    +    print_row(&row);
    +  }
    +  return EXECUTE_SUCCESS;
    +}
    +
    +ExecuteResult execute_statement(Statement* statement, Table* table) {
       switch (statement->type) {
         case (STATEMENT_INSERT):
    -      printf("This is where we would do an insert.\n");
    -      break;
    +      return execute_insert(statement, table);
         case (STATEMENT_SELECT):
    -      printf("This is where we would do a select.\n");
    -      break;
    +      return execute_select(statement, table);
       }
     }

最後,我們需要初始化table,創建各自的記憶體釋放函數並且需要處理一些報錯情況:

 + Table* new_table() {
    +  Table* table = (Table*)malloc(sizeof(Table));
    +  table->num_rows = 0;
    +  for (uint32_t i = 0; i < TABLE_MAX_PAGES; i++) {
    +     table->pages[i] = NULL;
    +  }
    +  return table;
    +}
    +
    +void free_table(Table* table) {
    +    for (int i = 0; table->pages[i]; i++) {
    +    free(table->pages[i]);
    +    }
    +    free(table);
    +}

在主函數中調用table初始化,並處理報錯:

int main(int argc, char* argv[]) {
    +  Table* table = new_table();
      InputBuffer* input_buffer = new_input_buffer();
      while (true) {
        print_prompt();
    @@ -105,13 +203,22 @@ int main(int argc, char* argv[]) {
        switch (prepare_statement(input_buffer, &statement)) {
          case (PREPARE_SUCCESS):
            break;
    +      case (PREPARE_SYNTAX_ERROR):
    +        printf("Syntax error. Could not parse statement.\n");
    +        continue;
          case (PREPARE_UNRECOGNIZED_STATEMENT):
            printf("Unrecognized keyword at start of '%s'.\n",
                   input_buffer->buffer);
            continue;
        }
    
    -    execute_statement(&statement);
    -    printf("Executed.\n");
    +    switch (execute_statement(&statement, table)) {
    +      case (EXECUTE_SUCCESS):
    +        printf("Executed.\n");
    +        break;
    +      case (EXECUTE_TABLE_FULL):
    +        printf("Error: Table full.\n");
    +        break;
    +    }
      }
    }

做了這些修改後我們就能實際保存數據到資料庫了。

~ ./db
    db > insert 1 cstack [email protected]
    Executed.
    db > insert 2 bob [email protected]
    Executed.
    db > select
    (1, cstack, [email protected])
    (2, bob, [email protected])
    Executed.
    db > insert foo bar 1
    Syntax error. Could not parse statement.
    db > .exit
    ~

現在是寫一些測試的好時機,有幾個原因:

  • 我們計劃大幅度修改存儲表的數據結構,並且測試是可捕獲回歸
  • 還有一些邊界條件我們沒有手動測試(例如填滿一張表)

我們將在下一部分中解決這些問題。現在,看一下這一部分完整的區別(與上一部分對比,行開頭“+”為新增,“-”為刪除):

@@ -2,6 +2,7 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <stdint.h>

 typedef struct {
   char* buffer;
@@ -10,6 +11,105 @@ typedef struct {
 } InputBuffer;

+typedef enum { EXECUTE_SUCCESS, EXECUTE_TABLE_FULL } ExecuteResult;
+
+typedef enum {
+  META_COMMAND_SUCCESS,
+  META_COMMAND_UNRECOGNIZED_COMMAND
+} MetaCommandResult;
+
+typedef enum {
+  PREPARE_SUCCESS,
+  PREPARE_SYNTAX_ERROR,
+  PREPARE_UNRECOGNIZED_STATEMENT
+ } PrepareResult;
+
+typedef enum { STATEMENT_INSERT, STATEMENT_SELECT } StatementType;
+
+#define COLUMN_USERNAME_SIZE 32
+#define COLUMN_EMAIL_SIZE 255
+typedef struct {
+  uint32_t id;
+  char username[COLUMN_USERNAME_SIZE];
+  char email[COLUMN_EMAIL_SIZE];
+} Row;
+
+typedef struct {
+  StatementType type;
+  Row row_to_insert; //only used by insert statement
+} Statement;
+
+#define size_of_attribute(Struct, Attribute) sizeof(((Struct*)0)->Attribute)
+
+const uint32_t ID_SIZE = size_of_attribute(Row, id);
+const uint32_t USERNAME_SIZE = size_of_attribute(Row, username);
+const uint32_t EMAIL_SIZE = size_of_attribute(Row, email);
+const uint32_t ID_OFFSET = 0;
+const uint32_t USERNAME_OFFSET = ID_OFFSET + ID_SIZE;
+const uint32_t EMAIL_OFFSET = USERNAME_OFFSET + USERNAME_SIZE;
+const uint32_t ROW_SIZE = ID_SIZE + USERNAME_SIZE + EMAIL_SIZE;
+
+const uint32_t PAGE_SIZE = 4096;
+#define TABLE_MAX_PAGES 100
+const uint32_t ROWS_PER_PAGE = PAGE_SIZE / ROW_SIZE;
+const uint32_t TABLE_MAX_ROWS = ROWS_PER_PAGE * TABLE_MAX_PAGES;
+
+typedef struct {
+  uint32_t num_rows;
+  void* pages[TABLE_MAX_PAGES];
+} Table;
+
+void print_row(Row* row) {
+  printf("(%d, %s, %s)\n", row->id, row->username, row->email);
+}
+
+void serialize_row(Row* source, void* destination) {
+  memcpy(destination + ID_OFFSET, &(source->id), ID_SIZE);
+  memcpy(destination + USERNAME_OFFSET, &(source->username), USERNAME_SIZE);
+  memcpy(destination + EMAIL_OFFSET, &(source->email), EMAIL_SIZE);
+}
+
+void deserialize_row(void *source, Row* destination) {
+  memcpy(&(destination->id), source + ID_OFFSET, ID_SIZE);
+  memcpy(&(destination->username), source + USERNAME_OFFSET, USERNAME_SIZE);
+  memcpy(&(destination->email), source + EMAIL_OFFSET, EMAIL_SIZE);
+}
+
+void* row_slot(Table* table, uint32_t row_num) {
+  uint32_t page_num = row_num / ROWS_PER_PAGE;
+  void *page = table->pages[page_num];
+  if (page == NULL) {
+     // Allocate memory only when we try to access page
+     page = table->pages[page_num] = malloc(PAGE_SIZE);
+  }
+  uint32_t row_offset = row_num % ROWS_PER_PAGE;
+  uint32_t byte_offset = row_offset * ROW_SIZE;
+  return page + byte_offset;
+}
+
+Table* new_table() {
+  Table* table = (Table*)malloc(sizeof(Table));
+  table->num_rows = 0;
+  for (uint32_t i = 0; i < TABLE_MAX_PAGES; i++) {
+     table->pages[i] = NULL;
+  }
+  return table;
+}
+
+void free_table(Table* table) {
+  for (int i = 0; table->pages[i]; i++) {
+     free(table->pages[i]);
+  }
+  free(table);
+}
+
 InputBuffer* new_input_buffer() {
   InputBuffer* input_buffer = (InputBuffer*)malloc(sizeof(InputBuffer));
   input_buffer->buffer = NULL;
@@ -40,17 +140,105 @@ void close_input_buffer(InputBuffer* input_buffer) {
     free(input_buffer);
 }

+MetaCommandResult do_meta_command(InputBuffer* input_buffer, Table *table) {
+  if (strcmp(input_buffer->buffer, ".exit") == 0) {
+    close_input_buffer(input_buffer);
+    free_table(table);
+    exit(EXIT_SUCCESS);
+  } else {
+    return META_COMMAND_UNRECOGNIZED_COMMAND;
+  }
+}
+
+PrepareResult prepare_statement(InputBuffer* input_buffer,
+                                Statement* statement) {
+  if (strncmp(input_buffer->buffer, "insert", 6) == 0) {
+    statement->type = STATEMENT_INSERT;
+    int args_assigned = sscanf(
+    input_buffer->buffer, "insert %d %s %s", &(statement->row_to_insert.id),
+    statement->row_to_insert.username, statement->row_to_insert.email
+    );
+    if (args_assigned < 3) {
+    return PREPARE_SYNTAX_ERROR;
+    }
+    return PREPARE_SUCCESS;
+  }
+  if (strcmp(input_buffer->buffer, "select") == 0) {
+    statement->type = STATEMENT_SELECT;
+    return PREPARE_SUCCESS;
+  }
+
+  return PREPARE_UNRECOGNIZED_STATEMENT;
+}
+
+ExecuteResult execute_insert(Statement* statement, Table* table) {
+  if (table->num_rows >= TABLE_MAX_ROWS) {
+     return EXECUTE_TABLE_FULL;
+  }
+
+  Row* row_to_insert = &(statement->row_to_insert);
+
+  serialize_row(row_to_insert, row_slot(table, table->num_rows));
+  table->num_rows += 1;
+
+  return EXECUTE_SUCCESS;
+}
+
+ExecuteResult execute_select(Statement* statement, Table* table) {
+  Row row;
+  for (uint32_t i = 0; i < table->num_rows; i++) {
+     deserialize_row(row_slot(table, i), &row);
+     print_row(&row);
+  }
+  return EXECUTE_SUCCESS;
+}
+
+ExecuteResult execute_statement(Statement* statement, Table *table) {
+  switch (statement->type) {
+    case (STATEMENT_INSERT):
+           return execute_insert(statement, table);
+    case (STATEMENT_SELECT):
+    return execute_select(statement, table);
+  }
+}
+
 int main(int argc, char* argv[]) {
+  Table* table = new_table();
   InputBuffer* input_buffer = new_input_buffer();
   while (true) {
     print_prompt();
     read_input(input_buffer);

-    if (strcmp(input_buffer->buffer, ".exit") == 0) {
-      close_input_buffer(input_buffer);
-      exit(EXIT_SUCCESS);
-    } else {
-      printf("Unrecognized command '%s'.\n", input_buffer->buffer);
+    if (input_buffer->buffer[0] == '.') {
+      switch (do_meta_command(input_buffer, table)) {
+        case (META_COMMAND_SUCCESS):
+          continue;
+        case (META_COMMAND_UNRECOGNIZED_COMMAND):
+          printf("Unrecognized command '%s'\n", input_buffer->buffer);
+          continue;
+      }
+    }
+
+    Statement statement;
+    switch (prepare_statement(input_buffer, &statement)) {
+      case (PREPARE_SUCCESS):
+        break;
+      case (PREPARE_SYNTAX_ERROR):
+    printf("Syntax error. Could not parse statement.\n");
+    continue;
+      case (PREPARE_UNRECOGNIZED_STATEMENT):
+        printf("Unrecognized keyword at start of '%s'.\n",
+               input_buffer->buffer);
+        continue;
+    }
+
+    switch (execute_statement(&statement, table)) {
+    case (EXECUTE_SUCCESS):
+        printf("Executed.\n");
+        break;
+    case (EXECUTE_TABLE_FULL):
+        printf("Error: Table full.\n");
+        break;
     }
   }

Enjoy GreatSQL

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 序言 作為數據分析師,我們需要經常製作統計分析圖表。但是報表太多的時候往往需要花費我們大部分時間去製作報表。這耽誤了我們利用大量的時間去進行數據分析。但是作為數據分析師我們應該儘可能去挖掘表格圖表數據背後隱藏關聯信息,而不是簡單的統計表格製作圖表再發送報表。既然報表的工作不可免除,那我們應該如何利用 ...
  • 在現在這個信息爆炸的時代,要想高效的獲取數據,爬蟲是非常好用的。而用python做爬蟲也十分簡單方便,下麵通過一個簡單的小爬蟲程式來看一看寫爬蟲的基本過程: 首先是要用到的庫,因為是剛入門最簡單的程式,我們主要就用到下麵這兩: import requests //用於請求網頁 import re / ...
  • 前面的 YARP 文檔中,介紹了怎麼去代理 http,和如何根據功能變數名稱轉發,而在現在微服務的應用是越來越來多了,服務間的調用依靠 http 越來越不現實了,因為 http 多次握手的耗時越發的影響應用的響應時間;grpc 的出現,為解決這個問題提供了絕佳的解決方案,那 grpc 是什麼呢,我從網上摘抄 ...
  • tomcat上部署jenkins | 主機名稱 | IP地址 | 需要的應用服務 | 工具包 | | | | | | | node1 | 192.168.111.141 | tomcat、jenkins | git、maven | | node2 | 192.168.111.142 | tomcat ...
  • VMware安裝Win11+WSA子系統和使用教程 作者:Sna1lGo 時間:2022/9/29 下載相關文件: Win11鏡像:Download Windows 11 (microsoft.com) VMware:下載 VMware Workstation Pro - VMware Custom ...
  • 極限競速地平線2Horizon Chase 2 for mac是一款超級炫酷的賽車競速游戲,賽道可以說設計的非常專業,高低起伏加連續組合彎,對車子的操控有了很大的要求,非常具有挑戰性。另外游戲非常具有開放性,玩家們可以驅車自由馳騁,游戲的地形非常豐富,喜歡的朋友們還在等什麼?快來試試吧,這款游戲一定 ...
  • 我嘗試了Debian,Ubuntu,Kali Linux都不能啟動Windows。每次裝完,磁碟格式都會自動變成MBR。結果今天嘗試了安裝Fedora 36,居然輕輕鬆松就成功了。。。 ...
  • 一、原理總結 利用兩個寄存器R4和R5來存儲兩個數位管的顯示效果,R4是前一個數位管顯示所需,而R5是後一個數位管顯示所需,利用左移操作RLC來使之每一位被依次輸入到C中,然後將C輸入到LED中(當LED每位都有數據時,數位管才會顯示),利用停頓函數使數位管上數字停留一段時間。 二、程式分析 以下為 ...
一周排行
    -Advertisement-
    Play Games
  • 經常看到有群友調侃“為什麼搞Java的總在學習JVM調優?那是因為Java爛!我們.NET就不需要搞這些!”真的是這樣嗎?今天我就用一個案例來分析一下。 昨天,一位學生問了我一個問題:他建了一個預設的ASP.NET Core Web API的項目,也就是那個WeatherForecast的預設項目模 ...
  • 很多軟體工程師都認為MD5是一種加密演算法,然而這種觀點是不對的。作為一個 1992 年第一次被公開的演算法,到今天為止已經被髮現了一些致命的漏洞。本文討論MD5在密碼保存方面的一些問題。 ...
  • Maven可以使我們在構建項目時需要用到很多第三方類jar包,如下一些常用jar包 而maven的出現可以讓我們避免手動導入jar包出現的某些問題,它可以自動下載那須所需要的jar包 我們只需要在創建的maven項目自動生成的pom.xml中輸入如下代碼 <dependencies> <!--ser ...
  • 來源:https://developer.aliyun.com/article/694020 非同步調用幾乎是處理高併發Web應用性能問題的萬金油,那麼什麼是“非同步調用”? “非同步調用”對應的是“同步調用”,同步調用指程式按照定義順序依次執行,每一行程式都必須等待上一行程式執行完成之後才能執行;非同步調 ...
  • 1.面向對象 面向對象編程是在面向過程編程的基礎上發展來的,它比面向過程編程具有更強的靈活性和擴展性,所以可以先瞭解下什麼是面向過程編程: 面向過程編程的核心是過程,就是分析出實現需求所需要的步驟,通過函數一步一步實現這些步驟,接著依次調用即可,再簡單理解就是程式 從上到下一步步執行,從頭到尾的解決 ...
  • 10瓶毒藥其中只有一瓶有毒至少需要幾隻老鼠可以找到有毒的那瓶 身似浮雲,心如飛絮,氣若游絲。 用二分查找和二進位位運算的思想都可以把死亡的老鼠降到最低。 其中,二進位位運算就是每一隻老鼠代表一個二進位0或1,0就代表老鼠存活,1代表老鼠死亡;根據數學運算 23 = 8、24 = 16,那麼至少需要四 ...
  • 一、Kafka存在哪些方面的優勢 1. 多生產者 可以無縫地支持多個生產者,不管客戶端在使用單個主題還是多個主題。 2. 多消費者 支持多個消費者從一個單獨的消息流上讀取數據,而且消費者之間互不影響。 3. 基於磁碟的數據存儲 支持消費者非實時地讀取消息,由於消息被提交到磁碟,根據設置的規則進行保存 ...
  • 大家好,我是陶朱公Boy。 前言 上一篇文章《關於狀態機的技術選型,最後一個真心好》我跟大家聊了一下關於”狀態機“的話題。從眾多技術選型中我也推薦了一款阿裡開源的狀態機—“cola-statemachine”。 於是就有小伙伴私信我,自己項目也考慮引入這款狀態機,但網上資料實在太少,能不能系統的介紹 ...
  • 使用腳本自動跑實驗(Ubuntu),將實驗結果記錄在文件中,併在實驗結束之後將結果通過郵件發送到郵箱,最後在windows端自動解析成excel表格。 ...
  • 話說在前面,我不是小黑子~ 我是超級大黑子😏 表弟大周末的跑來我家,沒事幹天天騷擾我,搞得我都不能跟小姐姐好好聊天了,於是為了打發表弟,我決定用Python做一個小游戲來消耗一下他的精力,我思來想去,決定把他變成小黑子,於是做了一個坤坤打籃球的游戲,沒想到他還挺愛玩的~ 終於解放了,於是我把游戲寫 ...