[C++ 小東西] 簡易的書籍管理程式

必須先說這個東西的由來。鑒於以前大部分應用程式都是用 C# 或是用 Python 寫,最近突然想試試看用 C++ 比較不同之處,並且從管理系統著手(以前有陣子蠻常寫這個的)。俗話說的好:不要重複製造輪子。畢竟我的目的是圖形介面和資料庫的練習,所以就隨意找了管理系統相關的簡單程式為基底下去改。

結果呢,參考的程式完全不能用 🙂 邏輯整個漏洞一堆且錯誤百出,竟然是某些網站推薦的範例檔??將近兩到三個小時我幾乎是忙著大改裡面的東西,弄完之後短時間內沒啥熱情再去做原本想做的東西了,改完後甚至是全新的樣子XDDD 不過過程中依然複習到蠻多東西的,會看見很多寫 Python 時不需考慮的基本概念,但在 C++ 這邊會格外重要,莫名重新學了很多東西,也算是因禍得福。


程式流程和功能

一個東西的管理程式,大部分除了前台操作界面外,最重要的就是資料庫。資料庫以廣義來講有相當多的形式,除了大家熟悉的非關聯或關聯式資料庫,像是 MySQL、MSSQL、Oralcle、MongoDB 等等,還有依不同需求發展相當多類型的資料庫,而最傳統的當然就是 Excel 和 CSV 檔啦!(沒錯,因為被別人專案雷到的關係,所以只能先維持使用最可愛的方式XDD)

情境是一個簡單的小系統,以 CSV 檔為資料庫,每本書都有書名、出版日期、作者和價錢這四個資訊。使用者有兩類:管理者和顧客。顧客只能看到目前的書庫內容,管理者可以更動書庫,包括新增書籍、刪除書籍、變更書籍資訊以及查看書庫。

data.csv 為檔案作為簡單的資料庫,裡面有所有的書籍資料,一般在資料庫內會需要設定資料型態,即使不在資料庫內,統一的資料型態後續也會比較好維護。不過因為這次訴求是改好別人的鳥程式,所以越簡單越好,沒有限定格式。

以程式來說以上資訊將分別儲存在變數 titlepublish_dateauthorprice,沒有限定格式。

細項實作

選單

原先是想要設計顧客和管理員不一樣的密碼進入後,有不一樣的選單。但待會會講到關於這個程式遇到的流程問題點,結論就是放棄這種模式,改成管理者模式登入,有更改檔案的權限;而使用顧客模式登入,則只能讀取書籍清單。

選單部份會有兩大部分:剛進入選擇登入權限的選單(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::appios::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"):指定全畫面色彩

我的圖片很明顯是使用後者,是用十六進位表示法,第一個字元代表背景,第二個字元代表字的顏色,可以參考以下表格玩玩看:

除了單純的更改背景和文字,還看到更神奇的操作,以後來研究看看。

讓我知道你在想什麼!