[Go] Parse CSV/Excel columns ด้วย named constant และ map แทนที่จะใช้ index โดยตรง

ช่วงนี้มี usecase ที่ต้อง parse ข้อมูลจาก Excel แต่ว่าบ้างครั้งลำดับของ column ก็มีเปลี่ยนไป มีเพิ่ม มีลดกันบ้าง เลยได้ลองคิดท่าใหม่เพื่อให้โค้ดในการ parse ไม่ต้องแก้เยอะเพราะลำดับของ column เปลี่ยน ซึ่งก็คือใช้ constant และ map มาช่วยนั่นเอง

สมมติเรามีข้อมูลใน CSV/Excel แบบนี้

Name Address BirthDay
John Doe Bangkok 20/02/2000
Jane Doe New York 12/03/2001
Jame Doe London 01/02/2002

ผมจะละโค้ดตอนอ่านไฟล์ไปนะ สมมติว่าเราอ่านไฟล์มาแล้วเก็บไว้ใน slice ของ slice ของ string แบบนี้ และทำการตัด header row ออกแล้วด้วยนะเหลือแต่ data rows

var rows [][]string = loadData()

แล้วเราจะ parse เพื่อให้เก็บใน slice ของ User แบบนี้

type User struct {
        Name string
        Address string
        BirthDay string
}

func parseData(rows [][]string) []*User{
        var users []*User

        for _, row := range rows {
                var user User
                user.Name = row[0]
                user.Address = row[1]
                user.BirthDay = row[2]

                users = append(users, &user)
        }

        return users
}

จะเห็นแบบ version แรกนี้เราใช้ index ตรงๆอยู่ เราทำให้ดีกว่านี้ได้โดยใช้ constant ช่วยแบบนี้

type UserParsingIndex int

const (
        UserNameColumn UserParsingIndex = iota
        UserAddressColumn
        UserBirthdayColumn
)

จะเห็นว่าเราใช้ iota ช่วยเพื่อไล่เลขจาก 0 และ define type ใหม่สำหรับ parsing index โดยเฉพาะ หลังจากนั้นปรับโค้ดเป็นแบบนี้

func parseData(rows [][]string) []*User{
        var users []*User

        for _, row := range rows {
                var user User
                user.Name = row[UserNameColumn]
                user.Address = row[UserAddressColumn]
                user.BirthDay = row[UserBirthdayColumn]

                users = append(users, &user)
        }

        return users
}

แบบนี้เราสามารถปรับ ลำดับโค้ดจากตรง define constant ได้เลยเช่นสลับ Address กับ BirthDay เราก็แค่ปรับตรง const แบบนี้

const (
        UserNameColumn UserParsingIndex = iota
        UserBirthdayColumn
        UserAddressColumn
)

โจทย์ต่อไปถ้าช่วงเปลี่ยนผ่านเช่นต้องการแยก column name เป็น FirstName กับ LastName ล่ะ แต่ว่า Struct ยังเก็บเป็น Name เหมือนเดิม จะทำยังไงให้รองรับได้ทั้งสองแบบ

ดังนั้นแทนที่จะใช้ constant โดยตรง เราสามารถใช้ constant และ map เข้าช่วย แทนที่จะใช้ constant แทน index เราใช้ constant เพื่อ lookup index อีกทีใน map ได้แบบนี้

type UserColumnParse struct {
        row []string
        columnIndexMap map[UserParsingIndex]int
}

func (p *UserColumnParse) get(column UserParsingIndex) (string, bool) {
        if index, ok := columnIndexMap[column]; ok {
                return p.row[index], true
        }

        return "", false
}

สร้าง type ใหม่ที่รับ row และ columnIndexMap ที่เป็น map ระหว่าง column constant ไปหา int ที่เป็น index จริงๆ เสร็จแล้วสร้าง method get เพื่อรับ column แล้วไป lookup หา index ที่แท้จริง ถ้ามี ก็เอาไป get จาก row แล้วตอบกับไปพร้อม true เพื่อบอกว่ามีค่านี้อยู่

ทีนี้การสร้าง columnIndexMap ถ้ามาสร้างเองทีละค่าก็จะยุ่งยาก เลยสร้างฟังก์ชันช่วยในการสร้างแบบนี้

func makeColumnIndexMap(columns ...UserParsingIndex) map[UserParsingIndex]int {
        m := map[UserParsingIndex]int{}
	for index, column := range columns {
		m[column] = index
	}

	return m
}

จะเห็นว่าฟังก์ชันรับเป็น variadic parameter ซึ่งทำให้เราจัดลำดับของ column ตามลำดับ parameter ได้เลย ถ้าเปลี่ยนก็แค่สร้างเป็นลำดับใหม่

ทีนี้มาดูโค้ดทั้งหมดหลังจากใชั้ constant และ map ช่วยกัน

const (
        UserNameColumn UserParsingIndex = iota
        UserFirstNameColumn
        UserLastNameColumn
        UserBirthdayColumn
        UserAddressColumn
)

type UserColumnParse struct {
        row []string
        columnIndexMap map[UserParsingIndex]int
}

func (p *UserColumnParse) get(column UserParsingIndex) (string, bool) {
        if index, ok := columnIndexMap[column]; ok {
                return p.row[index], true
        }

        return "", false
}

func parseData(rows [][]string, columnIndexMap map[UserParsingIndex]int) []*User{
        var users []*User

        for _, row := range rows {
                columnParse := UserColumnParse{
                        row: row,
                        columnIndexMap: columnIndexMap,
                }

                var user User
                if name, ok := columnParse.get(UserNameColumn); ok {
                        user.Name = name
                }
                if firstName, ok := columnParse.get(UserFirstNameColumn); ok {
                        user.Name = firstName
                }
                if lastName, ok := columnParse.get(UserLastNameColumn); ok {
                        user.Name += " " + lastName
                }

                if address, ok := columnParse.get(UserAddressColumn); ok {
                        user.Address = address
                }
                if birthDay, ok := columnParse.get(UserBirthdayColumn); ok {
                        user.BirthDay = birthDay
                }

                users = append(users, &user)
        }

        return users
}

// ตอนเรียกใช้ parseData โดยใช้ mapping แบบเดิม
parseData(rows, makeColumnIndexMap(
        UserNameColumn,
        UserAddressColumn,
        UserBirthdayColumn
))

// ตอนเรียกใช้ parseData โดยใช้ mapping แบบใหม่
parseData(rows, makeColumnIndexMap(
        UserFirstNameColumn,
        UserLastNameColumn
        UserAddressColumn,
        UserBirthdayColumn
))

จะเห็นว่าตรงประกาศ constant จะไม่ได้มีผลกับลำดับของ column อีกต่อไปแล้ว จะเป็นแค่การประกาศชื่อ column ที่เป็นไปได้แทน แล้วเราค่อยไป mapping ชื่อกับ index จริงๆอีกทีผ่านฟังก์ชัน makeColumnIndexMap โดยเรียงลำดับตาม parameter ที่ส่งให้ฟังก์ชันนั่นเอง