Thứ Sáu, 23 tháng 3, 2012

OOP - Bài 11: Doanh thu trong tháng


Thiết kế chương trình hướng đối tượng


    Center of Excellence


    Bài 11
    Doanh thu trong tháng



Cách nhanh nhất để làm được nhiều việc
là chỉ làm đúng một việc tại một thời điểm
-- Richard Cech


I. Bài toán


Doanh thu hàng ngày trong tháng của công ty được lưu trữ trong một tập tin văn bản (text file). Hãy phát triển chương trình tính tổng doanh thu tháng và doanh thu bình quân ngày. Sau đây là ví dụ về doanh thu tháng:

Hình 1

II. Giải pháp

Bước 1. Phát biểu lại yêu cầu bài toán

(1) Tính tổng doanh thu tháng (total sales)
  • Input: tên text file (file name)
  • Output: total sales
(2) Tính doanh thu bình quân ngày (average daily sales)
  • Input: file name
  • Output: average daily sales


Bước 2. Thiết kế sơ bộ


Tương tự như bài trước, ở đây ta phải làm việc với dữ liệu lưu trữ, nên ta tạo một class trong data tier, làm đại diện cho text file này.

Hình 2


Bước 3. Xây dựng test cases



No
fileName
sum?
averageDailySales?
1
April2012.txt theo Hình 1 (file hợp lệ, gồm nhiều dòng)
56437.42 (có thể sử dụng phần mền bảng tính để tính ra kết quả này)
1881.25 (làm tròn về hai chữ số sau dấu chấm thập phân từ kết quả 1881.247333...)
2
oneLine.txt (1 1458.32) (file hợp lệ, gồm một dòng)
1458.32
1458.32
3
File không tồn tại hoặc sai đường dẫn (nonexistence.txt)
Không có
Không có
4
empty.txt (file trống)
Không có
Không có
5
invalid1.txt (file không hợp lệ do chứa dòng trống)
Không có
Không có
6
invalid2.txt (file không hợp lệ do có dòng chứa không đủ hai giá trị)
Không có
Không có
7
invalid3.txt (file không hợp lệ do có dòng chứa giá trị không phải là số)
Không có
Không có



Bước 4. Phát triển test class


  • Project: MonthlySales
  • Package: data


4.1. Phát triển test method cho sum()


4.1.1. Trường hợp file hợp lệ, chứa một hay nhiều dòng


package data;

import java.io.FileNotFoundException;
import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class MonthlySalesFacadeTest {
    @Test
    public void sum() throws FileNotFoundException {
        assertEquals(56437.42, new MonthlySalesFacade("April2012.txt").sum(), 0.0);
        assertEquals(1458.32, new MonthlySalesFacade("oneLine.txt").sum(), 0.0);
    }
}

Sau khi đã có test method, ta tiến hành phát triển method chính. Ban đầu, cách làm tương tự như bài trước.

package data;

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class MonthlySalesFacade {
    private final String fileName;

    public MonthlySalesFacade(String fileName) {
        this.fileName = fileName;
    }
    
    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            double acc = 0.0;
            while (scanner.hasNext())
                ???
            return acc;
        }
    }
}

Tuy nhiên, cấu trúc file ở bài này khác bài trước ở chỗ: mỗi dòng (line) chứa hai giá trị, một số nguyên và một số thực. Vì vậy trước tiên ta cần đọc ra nguyên một dòng.

    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            double acc = 0.0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                ???
            }
            return acc;
        }
    }
Sau đó ta dùng thêm một Scanner object để thực hiện việc trích data từ line đó. Khi trích giá trị nguyên bằng nextInt(), ta không xử lý gì cả vì giá trị này không phục vụ cho việc tính tổng.

    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            double acc = 0.0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                Scanner lineScanner = new Scanner(line);
                lineScanner.nextInt();
                acc += lineScanner.nextDouble();
            }
            return acc;
        }
    }

Đến đây nếu thi hành test method, ta gặp tín hiệu đỏ, do khả năng biểu diễn và tính toán không thật chính xác trên số thực của computer. Ta điều chỉnh lại sai số cho phép như sau

    @Test
    public void sum() throws FileNotFoundException {
        assertEquals(56437.42, new MonthlySalesFacade("April2012.txt").sum(), 2E-11);
        assertEquals(1458.32, new MonthlySalesFacade("oneLine.txt").sum(), 0.0);
    }



4.1.2. Trường hợp file không tồn tại hoặc sai đường dẫn


Ta làm tương tự như bài trước.

    @Test(expected=FileNotFoundException.class)
    public void sumNonexistentFile() throws FileNotFoundException {
        new MonthlySalesFacade("nonExistence.txt").sum();
    }


4.1.3. Trường hợp file trống


Cách làm tương tự như bài trước.

    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            if (scanner.hasNext() == false)
                throw new NoSuchElementException(this.fileName + " is empty");
            double acc = 0.0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                Scanner lineScanner = new Scanner(line);
                lineScanner.nextInt();
                acc += lineScanner.nextDouble();
            }
            return acc;
        }
    }


4.1.4. Trường hợp file chứa dòng trống hoặc chứa dòng không đủ hai giá trị


Tương tự như trường hợp file trống, kết quả dự kiến sẽ là ngoại lệ NoSuchElementExceoption.

    @Test(expected=NoSuchElementException.class)
    public void sumEmptyFile() throws FileNotFoundException {
        new MonthlySalesFacade("empty.txt").sum();
    }
    
    @Test(expected=NoSuchElementException.class)
    public void sumFileWithBlankLine() throws FileNotFoundException {
        new MonthlySalesFacade("invalid1.txt").sum();
    }
    
    @Test(expected=NoSuchElementException.class)
    public void sumFileWithInsufficientData() throws FileNotFoundException {
        new MonthlySalesFacade("invalid2.txt").sum();
    }


4.1.5. Trường hợp file chứa giá trị không phải là số

Kết quả dự kiến sẽ là ngoại lệ InputMismatchExceoption.

    @Test(expected=InputMismatchException.class)
    public void sumFileWithNonNumberData() throws FileNotFoundException {
        new MonthlySalesFacade("invalid3.txt").sum();
    }


4.2. Phát triển test method cho averageDailySales()


public class MonthlySalesFacadeTest {
    @Test
    public void averageDailySales() {
        assertEquals(1881.25,
            new MonthlySalesFacade("April2012.txt").averageDailySales(), 0.003);
    }
    
    @Test
    public void sum() throws FileNotFoundException {
        assertEquals(56437.42, new MonthlySalesFacade("April2012.txt").sum(), 2E-11);
        assertEquals(1458.32, new MonthlySalesFacade("oneLine.txt").sum(), 0.0);
    }
    
    ...
}

Nhận thấy sự xuất hiện của code duplication trong đoạn code trên, ta tiến hành loại bỏ bằng cách đưa phần giống nhau ra ngoài để có thể dùng chung.

public class MonthlySalesFacadeTest {
    private MonthlySalesFacade aprilSales = new MonthlySalesFacade("April2012.txt");
    
    @Test
    public void averageDailySales() throws FileNotFoundException {
        assertEquals(1881.25, this.aprilSales.averageDailySales(), 0.003);
    }
    
    @Test
    public void sum() throws FileNotFoundException {
        assertEquals(56437.42, this.aprilSales.sum(), 2E-11);
        assertEquals(1458.32, new MonthlySalesFacade("oneLine.txt").sum(), 0.0);
    }
    
    ...
}
Đến đây ta bắt đầu phát triển averageDailySales() cho MonthlySalesFacade class. Doanh thu bình quân ngày sẽ bằng tổng doanh thu tháng chia cho tổng số ngày, ta viết

public class MonthlySalesFacade {
    private final String fileName;

    public MonthlySalesFacade(String fileName) {
        this.fileName = fileName;
    }
    
    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            if (scanner.hasNext() == false)
                throw new NoSuchElementException(this.fileName + " is empty");
            double acc = 0.0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                Scanner lineScanner = new Scanner(line);
                lineScanner.nextInt();
                acc += lineScanner.nextDouble();
            }
            return acc;
        }
    }
    
    public double averageDailySales() {
        return this.sum() / this.count();
    }
}

Rồi ta phát triển tiếp count() để đếm tổng số ngày, tức tổng số dòng chứa trong file. Tuy nhiên, mặc dù averageDailySales() ở trên chỉ chứa một dòng ngắn ngủi nhưng đó lại là method không hiệu quả về tốc độ thi hành. Đọc kỹ ta thấy method này đã phải thao tác trên file đến hai lần, một lần trong lệnh gọi sum() và một lần khác trong lệnh gọi count(). Biết rằng khi làm việc với bộ nhớ ngoài (external memory), chẳng hạn với files hoặc databases, tốc độ thi hành chương trình sẽ bị ảnh hưởng đáng kể. Vì vậy, ta cần hạn chế số lần truy cập external memory. Ta làm theo cách sau: khi đọc data từ file để tính trung bình, ta đồng thời làm hai việc: vừa tính tổng và vừa đếm số dòng. Ta đếm số dòng bằng cách ban đầu đặt count về 0, tức ban đầu chưa đếm được dòng nào trong file. Sau đó bên trong vòng lặp while, mỗi lần đọc được một dòng thì tăng count thêm 1 bằng toán tử ++.

public class MonthlySalesFacade {
    private final String fileName;

    public MonthlySalesFacade(String fileName) {
        this.fileName = fileName;
    }
    
    public double sum() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            if (scanner.hasNext() == false)
                throw new NoSuchElementException(this.fileName + " is empty");
            double acc = 0.0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                Scanner lineScanner = new Scanner(line);
                lineScanner.nextInt();
                acc += lineScanner.nextDouble();
            }
            return acc;
        }
    }
    
    public double averageDailySales() throws FileNotFoundException {
        try (Scanner scanner = new Scanner(new File(this.fileName))) {
            if (scanner.hasNext() == false)
                throw new NoSuchElementException(this.fileName + " is empty");
            double acc = 0.0;
            int count = 0;
            while (scanner.hasNext()) {
                String line = scanner.nextLine();
                Scanner lineScanner = new Scanner(line);
                lineScanner.nextInt();
                acc += lineScanner.nextDouble();
                count++;
            }
            return acc / count;
        }
    }
}

Cách làm trên khiến averageDailySales() trở nên khó hiểu, do nó phải giải quyết đồng thời hai việc. Bù lại, tốc độ thi hành được cải tiến. Nói chung, nếu không phải làm việc với external memory, ta nên tuân thủ nguyên tắc đơn thể (modularization) hay đơn nhiệm (single responsibility): Mỗi method chỉ nên làm một việc.


III. Tổng kết


Bài viết trình bày kỹ thuật xử lý dữ liệu có cấu trúc được lưu trong text files. Để cải thiện hiệu suất thi hành khi làm việc với external memory, chương trình đã vi phạm nguyên tắc đơn nhiệm.


IV. Tài liệu tham khảo

  1. Gaddis T. (2010) Starting Out with Java From Control Structures Through Objects (4th edition), Pearson Education, Boston. Chương 5.

Không có nhận xét nào:

Đăng nhận xét