必須先說這個東西的由來。鑒於以前大部分應用程式都是用 C# 或是用 Python 寫,最近突然想試試看用 C++ 比較不同之處,並且從管理系統著手(以前有陣子蠻常寫這個的)。俗話說的好:不要重複製造輪子。畢竟我的目的是圖形介面和資料庫的練習,所以就隨意找了管理系統相關的簡單程式為基底下去改。
結果呢,參考的程式完全不能用 🙂 邏輯整個漏洞一堆且錯誤百出,竟然是某些網站推薦的範例檔??將近兩到三個小時我幾乎是忙著大改裡面的東西,弄完之後短時間內沒啥熱情再去做原本想做的東西了,改完後甚至是全新的樣子XDDD 不過過程中依然複習到蠻多東西的,會看見很多寫 Python 時不需考慮的基本概念,但在 C++ 這邊會格外重要,莫名重新學了很多東西,也算是因禍得福。
程式流程和功能
一個東西的管理程式,大部分除了前台操作界面外,最重要的就是資料庫。資料庫以廣義來講有相當多的形式,除了大家熟悉的非關聯或關聯式資料庫,像是 MySQL、MSSQL、Oralcle、MongoDB 等等,還有依不同需求發展相當多類型的資料庫,而最傳統的當然就是 Excel 和 CSV 檔啦!(沒錯,因為被別人專案雷到的關係,所以只能先維持使用最可愛的方式XDD)
情境是一個簡單的小系統,以 CSV 檔為資料庫,每本書都有書名、出版日期、作者和價錢這四個資訊。使用者有兩類:管理者和顧客。顧客只能看到目前的書庫內容,管理者可以更動書庫,包括新增書籍、刪除書籍、變更書籍資訊以及查看書庫。
以 data.csv
為檔案作為簡單的資料庫,裡面有所有的書籍資料,一般在資料庫內會需要設定資料型態,即使不在資料庫內,統一的資料型態後續也會比較好維護。不過因為這次訴求是改好別人的鳥程式,所以越簡單越好,沒有限定格式。
以程式來說以上資訊將分別儲存在變數 title
、publish_date
、author
和 price
,沒有限定格式。
細項實作
選單
原先是想要設計顧客和管理員不一樣的密碼進入後,有不一樣的選單。但待會會講到關於這個程式遇到的流程問題點,結論就是放棄這種模式,改成管理者模式登入,有更改檔案的權限;而使用顧客模式登入,則只能讀取書籍清單。
選單部份會有兩大部分:剛進入選擇登入權限的選單(mainMenu
),以及選擇要做哪個動作的選單(adminOption
)。
mainMenu
選單就很簡單,分為管理員、顧客,還有一個離開程式。選到管理員,則進入 adminOption
選單,顧客就僅能查看書籍列表(待會會提到的 viewData
)。
而 adminOption
則會先驗證密碼,這邊密碼也很簡單,只是判斷密碼字串有沒有符合,沒有加密、也不能改變密碼XDD 另外若有登入成功,則在 isAdmin
紀錄登入狀態,這樣每次回到這張選單就不用一直輸入密碼。管理員的權限則有增加書籍 addData
、刪除書籍 deleteData
、更新書籍資料 viewData
以及查看書籍列表的權限 viewData
。
void mainMenu(){ if (isAdmin){ system("cls"); } int number; isAdmin = false; cout << "Login As: " << endl; cout << "1. Admin " << endl; cout << "2. Customer" << endl; cout << "3. Exit" << endl; cout << "Enter the number: " << endl; cin >> number; switch (number){ case 1: adminOption(); break; case 2: isAdmin = false; viewData(); break; case 3: exit(0); break; default: break; } }
void adminOption(){ int option; string password; if (!isAdmin){ cout << "Please enter the password. Type \"q\" to turn back to Main Menu.: " << endl; cin >> password; if (password == "q"){ mainMenu(); } while (password != "admin"){ cout << "Invalid password!" << endl; cout << "Try again. Type \"q\" to turn back to Main Menu.: " << endl; cin >> password; if (password == "q"){ mainMenu(); } } } isAdmin = true; cout << "\n" << endl; cout << "Choose one action: " << endl; cout << "1. Add Data" << endl; cout << "2. Delete Data" << endl; cout << "3. Update Data" << endl; cout << "4. View Table" << endl; cout << "5. Main Menu " << endl; cout << "6. Exit" << endl; cout << "Enter a number: " << endl; do { cin >> option; switch (option){ case 1: addData(); break; case 2: deleteData(); break; case 3: updateData(); break; case 4: viewData(); break; case 5: mainMenu(); break; case 6: system("cls"); cout << "Exit the system." << endl; exit(0); break; default: cout << "That's invalid number." << endl; cout << "1. Add Data" << endl; cout << "2. Delete Data" << endl; cout << "3. Update Data" << endl; cout << "4. View Table" << endl; cout << "5. Main Menu " << endl; cout << "6. Exit" << endl; cout << "Enter a number: " << endl; break; } } while (option != 6); }
查看書籍列表
這個功能只需要讀取,不用更動檔案,較為簡單所以通常會先寫。
這邊算是再次複習了 setw()
這個好東西,可以預設寬度並靠右對齊,長度不到的就以空白填滿,久久不用還真的忘記了。
void viewData(){ system("cls"); fstream file("data.csv"); string title, publish_date, author, price; if (!file){ cout << "Data not found." << endl; exit(0); } cout << "----------------------------------------" << "Books" << "----------------------------------------" << endl; cout << setw(30) << "title" << " |" << setw(15) << "publish date" << " |" << setw(20) << "author" << " |" << setw(10) << "price" << endl; cout << "-------------------------------------------------------------------------------------" << endl ; while(!file.eof()){ getline(file, title, ','); getline(file, publish_date, ','); getline(file, author, ','); getline(file, price, '\n'); cout << setw(30) << title << " |" << setw(15) << publish_date << " |" << setw(20) << author << " |" << setw(10) << price << endl; } file.close(); cout << "-------------------------------------------------------------------------------------" << endl ; if (isAdmin){ adminOption(); } else { mainMenu(); } }
增加書籍
接著要寫增加書籍檔案的功能。增加檔案主要會是放入最後一筆資料列後方(當然如果有指定要插入中間哪一個資料列又是其他狀況了),相對來說也算是蠻容易的,排在讀取書籍資料的功能後面寫。
void addData(){ system("cls"); fstream file("data.csv", ios::out | ios::app); ifstream ifile("data.csv"); string title, publishDate, author, price, isAdd; string line; if (!(ifile.peek() == EOF)){ file << "\n"; } ifile.close(); //input cout << "Title: " << endl; cin.get(); getline(cin, title); cout << "Publish Date: " << endl; getline(cin, publishDate); cout << "Author: " << endl; getline(cin, author); cout << "Price: " << endl; getline(cin, price); //add data into the file. file << title << ","; file << publishDate << ","; file << author << ","; file << price; file.close(); cout << "Need to add more data? (y/n): " << endl; cin >> isAdd; if (isAdd == "y"){ addData(); } else if (isAdd == "n"){ adminOption(); } else { cout << "Invalid option. Need to add more data? (y/n): " << endl; } }
刪除書籍
通常資料庫會有唯一的 id 或是 index 去抓資料,不過因為我們目前不考慮唯一或重複狀況,所以只要有符合同名的書籍都會被刪除。
這裡開始利用 vector
讓資料的讀取和寫入能夠更順利。流程如下圖,基本上以不動到原檔的方式,另建一個檔案叫做 data_v1.csv
,持續讀取 data.csv
如果不是要刪除的書本,就寫入 data_v1.csv
;如果是要刪除的書本,直接跳過繼續讀取下一列資料(達成刪除的意義)。最後刪除 data.csv
,並將 data_v1.csv
檔名改為 data.csv
。
void deleteData(){ system("cls"); fstream fin("data.csv", ios::in); fstream fout("data_v1.csv", ios::out); bool isDeleteData = false; bool isFirstLine = true; string line, content, titleBook; vector<string> info; cout << "Enter the title of the book you want to delete: " << endl; cin.get(); getline(cin, titleBook); while (!fin.eof()){ info.clear(); getline(fin, line); stringstream ss1(line); while (getline(ss1, content, ',')){ info.push_back(content); } int infoSize = info.size(); if (info[0] != titleBook){ if (!isFirstLine){ fout << "\n"; } isFirstLine = false; for (int i = 0; i < infoSize - 1; i++){ fout << info[i] << ","; } fout << info[infoSize - 1]; } else { isDeleteData = true; } } if (isDeleteData){ cout << "The book is deleted." << endl; fin.close(); fout.close(); remove("data.csv"); rename("data_v1.csv","data.csv"); } else{ cout << "The book not found." << endl; fin.close(); fout.close(); remove("data_v1.csv"); } adminOption(); }
更新書籍資料
更新的動作會比較麻煩一點點,除了基本的讀取寫入之外,還牽涉到指定資料改寫,並且不能動到其他資料。寫 SQL 語言送出前也需要再三檢查 where
後面的條件是不是夠嚴謹,深怕改到不該改的資料。不過這個程式簡單多了,資料只有四欄、書名符合即更改,沒什麼需要顧慮的。
這裡程式碼僅一部份,和刪除書籍的功能類似,也是利用暫存檔 data_v1.csv
以及 vector<string>
。首先輸入書名,並詢問要更改的項目(數字標號)。遇到同書名的就更動該指定項目(陣列中的某一項),其他就是照常堆資料,執行完後讓 data_v1.csv
取代原檔。
while (!fin.eof()){ info.clear(); getline(fin, line); stringstream ss1(line); while (getline(ss1, content, ',')){ info.push_back(content); } int infoSize = info.size(); if (info[0] != titleBook){ if (!isFirstLine){ fout << "\n"; } isFirstLine = false; for (int i = 0; i < infoSize - 1; i++){ fout << info[i] << ","; } fout << info[infoSize - 1]; } else { cout << "Enter which one you want to update" << endl; cout << "(1) title/(2) publish date/(3) author/(4) price/(5) Exit: " << endl; cin >> updateOpt; while (updateOpt != 1 && updateOpt != 2 && updateOpt != 3 && updateOpt != 4 && updateOpt != 5){ cout << "Invalid Option." << endl; cout << "Enter which one you want to update" << endl; cout << "(1) title/(2) publish date/(3) author/(4) price/(5) Exit: " << endl; cin >> updateOpt; } if (updateOpt == 5){ system("cls"); adminOption(); } cout << "Enter information: " << endl; cin.get(); getline(cin, newContent); if (!isFirstLine){ fout << "\n"; } isFirstLine = false; isUpdateData = true; stringstream ss2; ss2 << newContent; info[updateOpt - 1] = ss2.str(); for (int i = 0; i < infoSize - 1; i++){ fout << info[i] << ","; } fout << info[infoSize - 1]; } }
這部分還能改成若有兩本以上同名書籍,就再進一步詢問使用者要改哪一筆,這功能會需要類似篩選的步驟並對使用者顯示資料。
遇到問題點
程式流程大亂
原本要用的程式很愛用 system("cls")
、do-while 迴圈以及判斷 eof()
的部份,這些原本是很小也很簡單的功能,但被用得很可怕。因為邏輯流程相當亂,所以有時候不該清的畫面被清除掉了。另外有些地方可以不用 do-while 迴圈,用簡單的判斷就好,反而用迴圈條件寫不好會有無限迴圈的狀況。另外原程式不斷使用 while
配上 eof()
以及 break
,其實前者算是很平常的用法,但 eof()
又在迴圈內 double check 相當沒有必要,導致不該結束的流程被打斷。
另外流程本身也相當有問題。一開始的首頁選單和管理者選單以及後續的動作選單,照理來說應該是很簡單的安排,卻常常會有動作操作完後直接跳回首頁選單,或是跳至其他頁面等其他類似的問題。
然而這個卻是某幾個網站推薦的範例程式,可見我改到最後有多生氣。(鑒於最後整個架構大改,所以就沒有附註來源了)
刪除檔案會多刪除一列
從這個功能的操作來看,若最後結果是多刪除一列,表示我中途暫存的 data_v1.csv
應該是有一筆未讀取到。至於這個讀取問題,就是上面有提到過的不需判斷是否到檔案結尾的地方多了判斷,導致直接跳過,少執行一個資料列。
空白處皆被 setfill()
內的字元填滿
這個部份源自於對 setfill()
功能不熟悉XD 這個功能的用法是這樣:cout << setfill('*') << setw(10) << "test1"<< endl;
output:*****test1
也就是預留空白的部份用 *
填滿,原先以為是 setw()
前才有作用,但沒有想到以下的程式碼會呈現預期以外的結果:cout << setfill('*') << setw(10) << "test1"<< endl;
cout << setw(10) << "test2"<< endl;
output:*****test1
*****test2
test2 之前我並沒有設定填空字元。原來用了一次後,所有的填空字元都會變成上一次使用的字元。所以若下次變換填空的字元時(即使是預設的空白),要記得再設定一次 setfill()
:cout << setfill('*') << setw(10) << "test1"<< endl;
cout << setw(10) << "test2"<< endl;
cout << setfill(' ') << setw(10) << "test3"<< endl;
cout << setw(10) << "test4"<< endl;
output:*****test1
*****test2
test3
test4
幾乎是字元處理問題
其實大部分遇到的問題都可以歸納為字元處理的問題。主要是換行的 \n
,這裡先前已經有提過。另外就是空白字元。因為我們的書名通常一定會有空格,但程式讀取到空白會當作後面的字串是下一筆資料,為了應付這個狀況,把原本資料輸入的 cin
都改為 string 的 getline()
,才能讀取完整的字串,不過所幸語言是 C++,可以用 string,這個問題算是比 C 容易解決。
對於檔案的操作
CSV 檔本身已經算是相當簡單的檔案了,沒有想到這裡我也會遇上問題,問題點在於對於每列資料結尾的處理,以及空白檔增加資料的處理。
在增加書籍資料的功能中,我複習了關於 fstream file("data.csv", ios::out | ios::app)
這種形式的功能,簡單整理如下:ios::in
允許讀取,如果没有檔案,ios::app
和 ios::ate
都會失敗,ios::out
允許寫入,如果沒有檔案就建立ios::app
如果有檔案,在尾端新增(app 是 append 的意思)ios::ate
如果有檔案,則清空檔案。|
是 or 的意思,也可以套在上述的屬性,例如 ios::out | ios::app
表示允許寫入,如果沒有檔案則建立,有檔案就在尾端新增資料。
增加書籍資料的功能中,原先是新增一列後加入一個換行符號 \n
,這樣下次新增資料可以直接接續著檔案結尾增加,但是容易出現不必要的空白資料列。因此最後決定改寫成:判斷是否為第一列(靠讀取檔案迴圈的次數是否為第一次),若非第一列資料列則前方加入 \n
再填入資料。(針對 data_v1.csv
)
而這種處理方法會有另外一種情況,因為 addData()
函式是直接對檔案 data.csv
進行修改,無法靠迴圈次數判定,所以原本的 data.csv
若是空的,寫入時會多出一筆空白資料在第一列。因此這邊會改用 peek()
偷看一下檔案是否空的,若不是空的再增加 \n
。
其實正常來說每個功能都可以都改寫成用 peek()
判斷,統一做法也比較好維護,但我真的改到心好累,懶得改了QQ
後記
當初碰到這個鳥專案還蠻氣的,畢竟當初就是想省時間結果反而被拖住XDD 但也複習了一些已經漸漸遺忘的資料處理眉眉角角。這個程式為了之後能夠方便更改,所以極簡化各種項目,防呆的部份也沒有考慮到太完善,有些 input 還是會崩潰,甚至密碼是固定的 admin
而且輸入過程會整個裸奔沒有加密等等,總而言之目前都是預設使用者很正常,不會亂到酒吧點炒飯的好寶寶。待之後有比較成熟的作品會再把這些項目考慮進去的。除了上述的部份外,資料部份的格式規定也很值得再修改,以及重複資料(同名書本)的部份要如何處理。
啊目前這個版本已經改到連他媽都不認得了,不過流程部份因為是在蠻荒唐的邏輯下盡量改的,應該會有一點痕跡在,搞不好認得出是哪個所謂 project 改的XD 希望若真的有人也被同個 project 騙的話,能幫上一些忙。
噢對了,我這次還學到了超級微不足道的功能:更改命令視窗顏色XDDDD 雖然沒什麼必要但心情很好。
setColor()
:指定局部色彩system("color 0a")
:指定全畫面色彩
我的圖片很明顯是使用後者,是用十六進位表示法,第一個字元代表背景,第二個字元代表字的顏色,可以參考以下表格玩玩看:
除了單純的更改背景和文字,還看到更神奇的操作,以後來研究看看。