Node.js Dependencies injection ช่วยในการ Optimize backend ได้อย่างไร
ไม่นานมานี้ ผมกำลังทำการ Optimize ระบบส่งอีเมล์ตัวนึงของ Taskworld อยู่ และมีท่าที่อยากแบ่งปัน
ที่ Taskworld ทุกเช้าเราจะส่งเมล์ทักทายตอนเช้าที่เรียกกันว่า Digest email สรุปว่าวันที่ผ่านมาเกิดอะไรขึ้นบ้างใน Task ที่แต่ละคนเกี่ยวข้อง
วิธีการสร้างเมล์คร่าวๆ ก็เป็นดังนี้
- เลือก User ที่อยู่ในช่วงเช้า ตาม Timezone ที่แตกต่างกัน
- หา Task ทั้งหมดที่เกี่ยวข้องกับ User คนนั้น
- หา Comment ทั้งหมดที่เกี่ยวข้องกับ User คนดังกล่าว
- ถ้า Comment เหล่านั้นมี Mention ถึงใคร (เช่น @chris) ให้เอาชื่อปัจจุบันของคนนั้นมาแสดง
- จากข้อมูลทั้งหมด สร้าง Email
หน้าตาของอีเมล์คร่าวๆ ก็จะประมาณนี้
Hello chris, here the news this morning
The task ‘Save the world’ has been recently comment as ‘@Chris Well done’
The task ‘Shut down Taskworld’ has been recently comment as ‘Please stop’
ก็ฟังดูง่ายตรงไปตรงมานะ
โค้ดเวอร์ชั่นแรกก็จะมีหน้าตาคร่าวๆ ประมาณนี้
const { findTasksByUser } = require('Task-Model')
const { findCommentByTaskId } = require('Comment-Model')
const { findUserbyId } = require('User-Model')async function resolveMention (comment) {
if (comment.hasMention) {
for (const mentionUserId of comment.mentionUsers) {
const relatedUser = await findUserbyId(mentionUserId)
comment.body = str.replace(MentionRegExp, relatedUser.fullName)
}
}
return comment// *** Let's assume we have "MentionRegExp" somewhere ***
}async function createDigestEmailContent (user) {
let line = generateEmailHead()
const tasks = await findTasksByUser(user._id)
for (const task of tasks.filter(c => isThisMorning(c)) {
const comments = await findCommentByTaskId(task._id)
const resolvedComment = await Promise.map(comments, comment => resolveMention(comment))
line += generateEmailLine(task, comments)
}
line += generateEmailFooter()
return line+
}async function sendDigestEmail () {
const allUsers = await UserModel.findUserByTimeZone('morning')
for (const user of allUsers) {
const content = await createDigestEmailContent(user)
sendEmail(user.email, 'Taskworld morning digest', content)
}
}schedule.run(sendDigestEmail, 30)
(เกริ่นนำว่า ผม Highlight Database operation ไว้ให้เห็นชัดๆ นะครับว่าตรงไหนช้า และเราใช้ MongoDB + MongooseJs เป็นฐานข้อมูล)
โอเค พอรันโค้ดนี้ทุกๆ 30 นาที พบว่ามันช้าและกิน Resource เยอะมาก เนื่องจากมีความซ้ำซ้อนสูง มีปัญหาดังนี้
- บาง Task นั้นอาจเกี่ยวข้องกับผู้ใช้หลายคน แต่เราวน Loop หา Task ใหม่ทุกครั้งสำหรับผู้ใช้แต่ละคน
- หลายๆ Comment ก็เกี่ยวข้องกับ User คนเดียวกัน แต่เราก็ Loop ถามฐานข้อมูลหา User ที่เกี่ยวข้องหลายครั้ง
ทั้งหมดนี้มันจะมี Query ที่ยิงเข้าฐานข้อมูลซ้ำไปซ้ำมาเยอะมาก จนเป็นภาระของ MongoDB ที่เราสามารถลดได้ ดังนั้น เรามาลดกันเหอะ
Options #1: Rethink algorithm in Batch processing
ทางเลือกแรกคือเราเขียนอัลกอริธึมใหม่ให้คิดทุกอย่างเป็นก้อน แทนที่จะคิดทีละคน
โอเค ถ้าเกิดว่าเราควรจะหาสิ่งที่เกี่ยวข้องทีเดียวจะได้ไม่ช้า งั้นเริ่มง่ายๆ จากการหา Tasks ที่เกี่ยวข้องทั้งหมดที่ส่งในรอบนี้ทีเดียวใน Query เดียวดีกว่า
หน้าตาโค้ดใหม่ก็ควรจะเป็นประมาณนี้
async function sendDigestEmail () {
const allUsers = await UserModel.findUserByTimeZone('morning')
const contents = await createDigestEmailContent(allUsers)
....
}
เอ๊ะ มาถึงจุดนี้ ผมก็นึกได้ว่าสุดท้ายเราได้ allUsers ซึ่งเป็น User ทั้งหมด (Array) กับ contents ซึ่งเป็นเนื้อหาทั้งหมด (Array) มาแล้ว แต่เราต้องส่งอีเมล์ให้ User ที่แตกต่างกันแต่ละคนนี้นา ดังนั้น เราก็ต้องมีข้อมูลอะไรบางอย่างที่บอกได้ว่าในแต่ละ content เป็นของ User คนไหน
อืม ในที่นี้คนเดียวที่รู้ว่าเนื้อหาอันไหนคู่กับ User คนใด ก็คือ function createDigestEmailContent อ่ะนะ มันก็ต้อง “แอบ” ใส่ข้อมูลการ Mapping ไว้ให้สินะ
ก็จะเป็นประมาณนี้
async function createDigestEmailContents (allUsers) {
// =================
// here we need to get contents
// =================
return contents.map(content => {
const userId = content.userId
const relatedUser = _.find(allUsers, user => user._id === userId)
return Object.assign(content, { user: relatedUser })
})
}async function sendDigestEmail () {
const allUsers = await UserModel.findUserByTimeZone('morning')
const contents = await createDigestEmailContent(allUsers)
await Promise.all(contents.map(content => sendEmail(content.user.email, 'Taskworld morning digest', content)))
}
ส่วนที่ทำตัวหนาไว้คือส่วนที่แอบใส่ว่า User ไหนคู่กับ Content array อะไร
ทีนี้พอคิดไปคิดมาในส่วนที่ “Here we need to get contents” ถ้าอยาก Optimize query จริงๆ เราก็ต้องพยายามทำแบบนี้
- หาทุก Tasks ที่เกี่ยวข้องใน Query เดียว
- หาทุก Comment ที่เกี่ยวข้องใน Query เดียว
- หา Users ที่เกี่ยวข้องกับ Mention ใน Query เดียว
เอาล่ะ แต่เราก็เดาได้ว่าพอเราทำทุกอย่างในลักษณะ Batch single query เราก็ต้องมีการ Map กลับมาเพื่อให้รู้ว่า Tasks ที่ทำ Query เดียว เกี่ยวข้องกับ User คนไหน
หน้าตาโค้ดใหม่เอาแค่ข้อ 1 ก็จะประมาณนี้
async function createDigestEmailContents (allUsers) {
let line = generateEmailHead()
const tasks = await findTasksByUserIds(allUsers.map(u => u._id))// MAP BACK
const mappedTasks = tasks.map(task => {
const relatedUser = _.find(allUsers, user => user._id === task.userId)
return Object.assign(task, { user: relatedUser })
})// Do the same for comment, mention and then create email lines
}
จะเห็นว่าตอนนี้เรามี 2 บรรทัดที่เป็นเนื้อหา กับ 3 บรรทัดที่ทำหน้าที่ Map กลับ (ตัวหนา)
เอาล่ะ ลองเทียบกับของเก่าดู
// Old code
async function createDigestEmailContent (user) {
let line = generateEmailHead()
const tasks = await findTasksByUser(user._id)
for (const task of tasks.filter(c => isThisMorning(c)) {
const comments = await findCommentsByTaskId(task._id)
const resolvedComment = await Promise.map(comments, comment => resolveMention(comment))
line += generateEmailLine(task, comments)
}
line += generateEmailFooter()
return line+
}// ================================================================// New code
async function createDigestEmailContents (allUsers) {
let line = generateEmailHead()
const tasks = await findTasksByUserIds(allUsers.map(u => u._id))// === MAP BACK ===const mappedTasks = tasks.map(task => {
const relatedUser = _.find(allUsers, user => user._id === task.userId)
return Object.assign(task, { user: relatedUser })
})// =================// *** Now, do this for every entity.........
}
โค้ดของเก่านั้นตรงไปตรงมาและง่ายมาก ในขณะที่ตอนนี้จะเห็นว่าโค้ดชุดใหม่ถึงแม้จะ Optimize กว่า แต่เกินครึ่งของเนื้อหา Function มีไว้เพียงเพื่อ Map งาน Batch กลับมาที่เดิมเท่านั้น ทั้งๆ ที่มันชื่อ createDigestEmailContent แต่เกินครึ่งของเนื้อหา Code มันดันทำแค่การ Map ของไปๆ มาๆ
โค้ดแบบนี้จะดูแลรักษายากนะ ลองคิดดูว่าโค้ดชุดไหนก็ตามที่ 60% ของบรรทัดแม่งไม่ได้เกี่ยวข้องกับสิ่งที่มันอยากทำเลย มันเป็นโค้ดที่ใครมาอ่านก็คงมองบนแล้วสงสัยว่า “เอ็งจะพยายามทำอะไรฟะ” ยากต่อการทำความเข้าใจเป็นอย่างมาก
โจทย์ตอนนี้คือ ผมอยากได้โค้ดที่อ่านง่ายเหมือนของเก่า ชัดเจนมากๆ ว่า DigestEmail ของแต่ละคนสร้างมายังไง แต่ก็ยังประมวลผลได้รวดเร็วอยู่ เลยจะมาเสนอท่าที่สามารถทำแบบนั้นได้
Option #2: Dependencies Injection
ถ้าย้อนกลับไปอ่านโค้ดชุดแรก จะเห็นว่าส่วนที่ช้ามันมีแค่ส่วนที่ค้นหาฐานข้อมูล ซึ่งเรายิงเข้าฐานข้อมูลจริงๆ ผ่านการ Require มาจาก Mongoose model
ดังนั้นคำถามนึงที่ผมตั้งกับตัวเองคือ “จะเป็นไปได้มั้ยที่เราจะปรับเฉพาะตรงนี้ให้เร็วขึ้น โดยที่ไม่ต้องแก้โค้ด”
คำตอบคือได้ ผ่านการใช้ Dependencies injection หรือการใส่ฟังก์ชั่นไปให้เลย
async function createDigestEmailContents (allUsers, deps) {
const {
findTasksByUser,
findCommentByTaskId,
findUserById
} = deps
// .... Continue
}
จะเห็นว่าจากตอนนี้ แทนที่เราจะ Require มาจาก Model โดยตรง เราส่ง function findTasksByUser เข้ามาแทน
ถึงจุดนี้ ข้อดีมากๆ เลยคือ เราจะยังรันเทสผ่านอยู่ ถ้าเราส่ง dependencies findTasksByUser, findComment …. เป็นตัวเดิมที่ผ่านโมเดล โอเค ดีมาก Refactor โดยไม่พังเทสเลยแม้แต่นิดเดียว ฟินจริงๆ
ถัดมาเราสร้าง findTasksByUser ชุดใหม่เข้าไป ที่มันฉลาดกว่าเดิมทำงานแค่ 1 Query ไม่ว่าจะเรียกกี่ครั้ง ยัดเข้าไปแทน findTasksByUser ชุดเดิมได้ ซึ่งผมจะเรียกมันว่า betterDataLayer ที่ฉลาดกว่าเดิม ยัดเข้าไป
ขอโชว์ตัวอย่างแค่ findTasksByUser ดังนี้
async function createDataLayerForDigestEmail (users) {
const tasks = await findTasksByUserIds(users.map(u => u._id))const userMapTask = tasks.reduce((acc, task) => {
const relatedUser = _.find(allUsers, user => user._id === task.userId)
acc[relatedUser._id] = task
return acc
}, { })
return {
findTasksByUser: (id) => userMapTask[id],
}
}async function sendDigestEmail () {
const allUsers = await UserModel.findUserByTimeZone('morning')
const betterDatalayer = await createDataLayerForDigestEmail(allUsers)
const contents = await createDigestEmailContent(allUsers, betterDatalayer)
await Promise.all(contents.map(c => sendEmail(c.user.email, 'Taskworld morning digest', content)))
}async function createDigestEmailContent (users, deps) {
// ==== No mapping here anymore, just straight-forward code
// ==== but we use data fetcher from betterDataLayer}
betterDataLayer ทำการ Query ครั้งเดียวมาเก็บไว้ใน Memory แล้วก็ถ้าต้องการ findTasksByUser เราสามารถทำได้บน HashMap บน Memory ซึ่งเป็นการค้นหาของที่เร็วที่สุดเท่าที่จะเร็วได้แล้ว
พอทำแบบนี้ เราสามารถแยก Concern ออกจากกันได้
- createDigestEmailContent จะเป็นฟังก์ชั่นอ่านง่ายๆ เหมือนเดิม ทุกสิ่งทุกอย่างในฟังก์ชั่นนั้นจะเป็นการพยายามสร้าง Email เพื่อส่งล้วนๆ ไม่มี Line ไหนเลยที่ต้องใส่เข้าไปเพื่อ Optimization เข้ามาปน (ต่างกับ Option#1 มาก ที่เกิน 60% ของบรรทัดเกิดขึ้นเพื่อ Optimize)
- ส่วนของการ Optimize เราแยกไปไว้ที่ createDataLayer แทน
ที่ฟินที่สุดเลยคือ
Test ไม่พังเลยแม้แต่วินาทีเดียว!!!!!
ข้อเสียจริงๆ ก็มีบ้างตรงที่เราต้องดูแล createDataLayer ให้มั่นใจว่ามันมีข้อมูลมากพอที่จะสร้าง Email นะ ถ้า Requirement ในการสร้าง Email เปลี่ยน เราต้องเปลี่ยนทั้งวิธีการ Fetch ใน createDataLayer ด้วย และเปลี่ยนวิธีเขียนเมล์ใน createDigestEmailContent ด้วย
ซึ่งเมื่อก่อนก็ต้องเปลี่ยนทั้ง Fetcher และการสร้างอีเมล์อยู่แล้ว แต่สมัยก่อนมันรวมๆ ปนๆ ที่เดียวกัน ตอนนี้ต้องมีสติหน่อยว่ามันไม่ได้รวมๆ ปนๆ กันแล้วนะ แต่ก็ไม่น่ายากอะไรขนาดนั้น
บทสรุป
จริงๆ ปัญหาเรื่องการ Fetch data และการจัดการกับฐานข้อมูลแบบเป็น Batch เป็นปัญหาทั่วไปที่เจออยู่แล้วถ้าทำงาน Backend ผมเลยคิดว่าหัวข้อนี้น่าสนใจมาก เพราะเมื่อก่อนผมก็จะเขียนแบบ Option#1 นี่แหละ คือ รวมส่วนที่ทำเพื่อให้ Performance ดีขึ้น กับโค้ดที่ประมวลข้อมูลเข้าด้วยกันเลย
แล้วทุกทีที่ทำโค้ดก็จะอ่านยากเสมอ จนพึ่งนึกได้ว่ามันมีท่าหลังอยู่ด้วยที่ทำให้ function ที่ใช้ในการ Process ข้อมูลจำนวนมาก โค้ดไม่เละเกินไป แต่กลับกัน ก็ยังมี Performance ที่ดีอยู่
ก็เลยขอแบ่งปันท่าที่คิดได้ใหม่และเห็นว่าเวิร์ค ให้ปรับใช้งานตามเหมาะสมครับ