แค่อยากใช้ openAPI Spec Generator

Problem Solving
Published 2024-12-16
POST

วันนี้เราจะพาไปเรียนรู้วิธีรับมือกับปัญหาที่ไม่ค่อยพบเจอในการทำงานทั่วไป โดยจะค่อยๆหาทางออกไปทีละขั้น เริ่มจากวิธีแก้ปัญหาพื้นฐานทั่วไป ไปจนถึงแนวทางที่ออกนอกกรอบ ซึ่งบางครั้งอาจมีทางเลือกไม่มากนัก เพราะความเป็นจริงแล้ว โลกของเรามักซับซ้อนกว่าที่คาดคิด แต่ไม่ว่าจะด้วยเหตุผลใด เราไปหาทางแก้ไขมันกัน

หากอ่านไป แล้วสังเกตว่าชื่อหัวข้อไม่ค่อยถูกกล่าวถึง ก็ไม่ต้องแปลกใจ เพราะจุดสำคัญของบทความนี้ไม่ได้อยู่ที่ชื่อหัวข้อ แต่อยู่ที่แนวคิดในการแก้ปัญหาที่จะนำเสนอ

ปัญหา

เวลาทำงานกับโค๊ดออกแบบมาไม่ดี เช่นโค๊ดที่ไม่ได้เกี่ยวข้องกันถูกผูกเข้าด้วยกัน ทำให้การนำโค้ดกลับมาใช้ซ้ำทำได้ยาก

js | file-a.js
1
// Assumed to be setting up something important but unrelated
2
console.log(injectedVariable)
บรรทัดนี้ ถึงแม้ไม่ได้ต้องการใช้งาน ก็ถูกรันเมื่อมีการ require/import
3
4
const obj = {
5
field: 'interesting value'
6
}
ค่าที่อยากนำไปใช้ที่อื่น
7
8
module.exports = obj

เวลาจะนำ file-a.js มาใช้ที่ไฟล์อื่น ก็ต้อง setup ค่าต่างๆ ก่อนจะนำค่าเหล่านั้นมาใช้การได้ เช่น

js | file-b.js
1
global.injectedVariable = "Injected Utils, Helpers, ..."
setup เพื่อให้เรียก file-a.js ได้
2
3
const obj = require('./file-a.js')
4
console.log(obj.field)

ปัญหาที่เราจะเจอ อย่างเช่นเขียน test ให้ file-a.js แล้วไม่ได้ setup เพราะไม่ได้มีส่วนเกี่ยวข้องกับ test

js | file-a.test.js
1
const obj = require('./file-a.js')
ReferenceError: injectedVariable is not defined
2
3
test('correct value', () => {
4
expect(obj.field).toBe('interesting value')
5
})

เมื่อรัน file-a.test.js ก็จะเจอ error เพราะเราไม่ได้ทำการ setup injectedVariable ก่อน ทั้งๆ ที่ค่าจาก obj.field ที่เราต้องการ ไม่ได้ต้องพึ่งพาอะไรจาก injectedVariable

ทางออก

ถ้าหากเจอเหตุการณ์อย่างนี้ บางคนอาจจะเริ่ม เอ๊ะ แล้วว่าโค๊ดนี้ ไม่ได้มีการทำ Separation of Concerns (SoC) ที่เหมาะสม โดยโค้ดที่ไม่มีความสัมพันธ์กันถูกบังคับให้ทำงานร่วมกัน แทนที่จะแยกการทำงานออกจากกันอย่างเป็นอิสระ

Refactor

ถ้าหากเรา refactor โค๊ดได้ สิ่งที่เราแก้ อาจจะเป็น

js | file-a.js
1
+
const setup = () => {
2
+
// Assumed to be setting up something important but unrelated
3
console.log(injectedVariable)
4
+
}
นำมาครอบใส่ function จะได้เลือกเวลาที่ถูกรันได้
5
6
const obj = {
7
field: 'interesting value'
8
}
9
10
module.exports = { setup, obj }
export ให้ไฟล์ที่จะใช้งานเป็นคนเลือกเวลาที่จะรันแทน
js | file-b.js
1
global.injectedVariable = "Injected Utils, Helpers, ..."
2
3
const { setup, obj } = require('./file-a.js')
4
+
setup()
หากไฟล์ไหนต้องการจะรันก็ค่อยเรียกเอา
5
console.log(obj.field)
js | file-a.test.js
1
const { obj } = require('./file-a.js')
ไฟล์ไหนไม่ได้ใช้ก็ไม่จำเป็นต้องเรียก setup
2
3
test('correct value', () => {
4
expect(obj.field).toBe('interesting value')
5
})

เพียงเท่านี้ โค๊ดเราก็แยกหน้าที่การทำงานเป็นระเบียบขึ้น และรันไฟล์ Test ได้แล้ว

ใครคิดว่าจะเขียนให้โค๊ดนี้ดีกว่าได้อีก ลองเก็บไปเป็นการบ้านดูนะ เพราะนี้ไม่ใช่เป้าหมายของเราในวันนี้

Refactor ไม่ใช่ทางเลือก

อยากพามาคิดในอีกแง่หนึ่ง ถ้าหาก refactor ไม่ใช่ทางเลือกที่เรามี อาจจะเพราะว่า

  • มีโค๊ดแบบ file-a.js เป็น 100 ไฟล์
  • file-b.js เป็นโครงสร้างหลักของโค๊ดเรา ที่เป็นพื้นฐานของ หลาย 100 ไฟล์
  • แทบไม่มีการเขียน test
  • โค๊ดเก่า ไม่มีผู้รู้ของโค๊ดส่วนนั้น
  • หัวหน้าไม่ให้แก้ ด้วยเหตุผลบางประการ ¯\_(ツ)_/¯

จู่ๆ เราจะไป refactor เพื่อให้ใช้ค่า obj.field ได้ เราก็ไม่รู้ว่า ผลกระทบจะมีมากน้อยเพียงใด โค๊ดตรงไหนจะพัง หรือแม้แต่ ใครจะมาช่วยเราแก้เป็นร้อยๆ ไฟล์ ใครจะมา Approve PR เรา

ใครมาเจอจุดนี้ ก็จะพบกับปัญหาไข่กับไก่ อยาก refactor ก่อน แต่ test ไม่ครอบคลุม แต่ถ้าอยากเขียน test เพิ่มก่อน ก็ต้อง refactor ให้ได้ก่อน

Generated by DALL·E 3. Prompt "A realistically rendered image displaying the philosophical conundrum known as the 'chicken and egg' problem."

ดังนั้นเรามาย้อนดูกันก่อน โจทย์ของเราในตอนนี้ คือ อยากดึงค่า obj.field ออกมา ดังนั้น นอกจากวิธีที่เราจะ import มาเหมือนเขียนโปรแกรมปกติ เราจะทำทางไหนได้อีกบ้าง

Regex

ถ้าเรามีโค๊ดที่ทำเหมือนคนได้ คือ เปิด VS code มา แล้วก็ cmd+f หา "field" แล้ว ครอบค่าใน "" ที่ตามหลัง : แล้ว cmd+c ได้ก็ดีสินะ 🤔

แล้วทำไมถึงไม่ได้ละ เรามี Regex ลองเขียนโค๊ดไวๆได้

js | extract-text-with-regex.js
1
const fs = require('fs')
2
3
const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })
อ่านไฟล์ file-a.js เสมือนเป็นข้อความธรรมดา
4
const regex = /field:\s*['"](.*?)['"]/;
ใช้ regex หารูปแบบคำที่ต้องการ
5
const match = input.match(regex);
6
const output = match[1]
7
8
console.log(output); // Output: interesting value
ได้ผลลัพธ์ที่ต้องการ

EZ? ถ้าโค๊ดเราไม่ได้ซับซ้อน หรือเขียนได้มาตรฐาน ก็คงถือเป็นวิธีที่รวดเร็ว และได้ผล แต่เมื่อไหร่ที่โค๊ดเราเขียนได้หลายรูปแบบเช่น

js
1
const obj = {
2
field:
3
'interesting value'
ขึ้นบรรทัดใหม่
4
}
js
1
const obj = {
2
field : 'interesting value'
เผลอใส่ space เกิน แล้วไม่ได้รัน prettier
3
}

โค๊ดเหล่านี้สามารถรันได้เหมือนโค๊ดดั้งเดิม แต่จะให้เขียน regex ให้ครอบคลุมทุกกรณี ก็คงไม่ใช่เรื่องง่าย หรือไม่คุ้มค่าในการใช้งานจริง เนื่องจาก regex ที่ครอบคลุมทุกกรณีอาจทำให้โค๊ดอ่านได้ยากเกินไป ฉะนั้นเราจะหยุดไว้ตรงนี้ก่อนสำหรับ regex

Abstract Syntax Tree (AST)

ถ้ารันโค๊ดเลยก็ไม่ได้ อ่านโค๊ดเป็นข้อความธรรมดา ก็ซับซ้อนเกินกว่าที่เวลาที่ใส่ลงไป เพราะเหมือนเรากำลังจะเขียน Parser เองจากแรกเริ่ม ดังนั้นเราพอจะมีทางตรงกลางตรงไหนบ้างให้สามารถรับมือกับมันได้

วันนี้เลยมานำเสนอ AST ตอนแรกอาจจะฟังดูน่ากลัว ซึ่งก็จริง แต่ถ้าเราเพียงเข้าใจแค่เล็กน้อยก็สามารถนำมาใช้งานได้แล้ว

ความเจ๋งของมันก็คือ จากเดิมข้อความที่เป็นพรืด เราสามารถจับโค๊ดได้เป็นก้อนๆ ที่เกี่ยวข้องกันตามหน้าที่การทำงาน โดยแต่ละก้อน ก็จะนับเป็น Node ตามประเภทของมัน เช่น โค๊ดที่รันฟังก์ชั่นแต่ไม่ได้นำผลลัพธ์ไปใส่ตัวแปร ก็จะเป็น Expression Statement ส่วนโค๊ดที่เราทำการประกาศตัวแปร ก็จะเป็น Variable Declaration เป็นต้น โดยที่เราไม่ต้องกังวลว่า จะมี ; หรือไม่มีลงท้ายแต่ละบรรทัด เราไม่ต้องเขียนโค๊ดเพื่อรองรับ syntax แต่ละแบบที่อาจให้ผลลัพธ์เหมือนกัน เราจะได้ไปโฟกัสกับหน้าที่ ที่มันทำแทน

อย่างเช่นในไฟล์ file-a.js เมื่อนำไปรันผ่าน parser จะสามารถแบ่งได้เป็น 3 ก้อนใหญ่ๆ

js | file-a.js
1
console.log(injectedVariable)
2
3
const obj = {
4
field: 'interesting value'
5
}
6
7
module.exports = obj
mermaid | AST
Program
ExpressionStatement
VariableDeclaration
ExpressionStatement

ต่อมา เราก็จะมีทางเลือกหลักๆ 2 ทางเลือก

  1. แกะค่า obj.field จากการไล่ทีละ node ใน object AST หากมาทางนี้ ก็คล้ายกับการทำ regex แต่เปลี่ยนมาเขียนเป็นภาษาโปรแกรมมิ่งแทน
  2. ตัดโค๊ดที่ไม่ใช้ออกไป แล้วเก็บส่วนที่เหลือ ไว้ใช้รันเป็นเหมือนโค๊ดปกติ

ทั้งสองทางนี้ ไม่ได้มีถูกผิด เพียงแต่เลือกใช้ตามความเหมาะสมกับสถานการณ์

สำหรับวันนี้ เราจะไปทางที่ 2 สิ่งที่จะทำคือ ลบโค๊ดที่ไม่ต้องการออก แล้วนำส่วนที่เหลือไปรัน

ภาพในหัวเราตอนนี้คือ อยากได้ผลลัพธ์เช่นนี้

js | modified-file-a.js
1
-
console.log(injectedVariable)
2
-
3
const obj = {
4
field: 'interesting value'
5
}
6
7
module.exports = obj
mermaid | AST
Program
VariableDeclaration
ExpressionStatement

เขียนโค๊ดให้ทำเช่นนั้นได้ เราจะทำตามขั้นตอนดังนี้

js | custom-extractor-script.js
1
const { parse } = require('acorn')
lib สำหรับแปลง code เป็น ast
2
const escodegen = require('escodegen')
lib สำหรับแปลง ast กลับเป็น code
3
const transformAST = requrie('./transformAST')
สมมุติเป็นฟังก์ชั่นที่มาจัดการ ast ให้ได้ผลลัพธ์ที่ต้องการ
4
const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })
อ่าน code เป็น string
5
const ast = parse(codeAsText)
แปลง code ได้ ast
6
const transformedAST = transformAST(ast)
แปลง ast ที่ตัดหรือดัดแปลงได้รูปแบบที่ต้องการ ในกรณีนี้ ลบโค๊ดบรรทัดที่ 1 ออก
7
const transformedCodeAsText = escodegen.generate(transformedAST)
แปลง ast ได้ code
8
const obj = eval(transformedCodeAsText)
รันโค๊ดที่ผ่านการดัดแปลง รันเหมือนเวลาใช้ require/import
9
console.log(obj.field)
PRINT: interesting value

เพียงเท่านี้ เราก็มี script ได้แบบ modified-file-a.js ที่จะเอาไปรันโดยไม่ไปแก้ไข หรือทำลาย โค๊ดดั้งเดิมได้แล้ว

ตัวอย่าง transformAST(ast) แบบเบื้องต้น

js | transformAST.js

ตัวอย่างการนำไปใช้

หากใครอ่านมาถึงตรงนี้ แล้วสงสัยว่า OpenAPI Spec Generator ไปอยู่ไหน เราเก็บไว้เป็นตัวอย่างที่ซับซ้อนขึ้นของปัญหาในวันนี้

แล้วทำไมโค๊ดนี้ถึงนำไปใช้งานที่อื่นได้ยาก เราไปดูกัน

js | router.js
1
-
const { controller } = application
application ถูก inject มา ทำให้ไม่สามารถใช้งานโดยไม่รู้ที่มาได้ แต่ไม่ได้จำเป็นสำหรับ Gen API Schema
2
const { z } = require('zod')
3
4
const getPet = {
5
method: 'GET',
6
path: '/pet/:petID',
7
validation: {
8
param: {
9
petID: z.string().uuid(),
10
}
11
},
ใช้สำหรับนำไป Gen API Schema
12
-
controller: controller.getPet
ไม่ได้ใช้ แต่ block การนำไป Gen API Schema
13
}
14
15
const addPet = {
16
method: 'POST',
17
path: '/pet',
18
validation: {
19
body: {
20
petID: z.string().uuid().optional(),
21
name: z.string().uuid(),
22
status: z.enum(["available", "pending", "sold"]).optional(),
23
}
24
},
ใช้สำหรับนำไป Gen API Schema
25
-
controller: controller.addPet
ไม่ได้ใช้ แต่ block การนำไป Gen API Schema
26
}
27
28
module.exports = [getPet, addPet]

จากในโค๊ดเราจะเห็นได้ว่า มีโค๊ดหลายบรรทัดที่ทำให้เราไม่สามารถ require/import ไฟล์นี้มาได้ง่าย

ถ้าอยากใช้ได้ เราก็ทำท่าเดียวกับก่อนหน้า นำ custom-extractor-script.js มาดัดแปลง โดยเฉพาะที่ฟังก์ชั่น transformAST(ast) ให้ลบ node ที่เราไม่ได้ใช้ออก

เมื่อทำสำเร็จ ผลลัพธ์ที่เราจะได้ ควรเหลือดังนี้

js | router.js
1
const { z } = require('zod')
2
3
const getPet = {
4
method: 'GET',
5
path: '/pet/:petID',
6
validation: {
7
param: {
8
petID: z.string().uuid(),
9
}
10
},
11
}
12
13
const addPet = {
14
method: 'POST',
15
path: '/pet',
16
validation: {
17
body: {
18
petID: z.string().uuid().optional(),
19
name: z.string().uuid(),
20
status: z.enum(["available", "pending", "sold"]).optional(),
21
}
22
},
23
}
24
25
module.exports = [getPet, addPet]

เมื่อเรารัน eval/import/require กับโค๊ดที่เราสร้างเมื่อซักครู่ เราก็สามารถเอาค่าเหล่านี้ไปสร้าง OpenAPI Specification ได้แล้ว เย่!

ปิดท้าย

บทความนี้อาจจะไม่ได้เป็นมิตรกับทุกคน แต่หวังว่าจะช่วยเปิดโลกและมุมมองใหม่ๆ ในการรับมือกับโค้ดในสถานการณ์ต่างๆ ไม่ว่าจะเป็นการมองโค้ดเป็นโค้ดธรรมดา หรือมองเป็นข้อความ หรือแม้กระทั้งมองเป็นโครงสร้าง node และ tree เพียงแค่เราไม่ปิดมุมมองของเรา และเปิดรับมุมมองใหม่ๆ ก็จะสามารถรับมือกับโจทย์ที่ยากได้อย่างไม่ยากเกินเอื้อมมือ

ไว้เจอกันไหมในบทความหน้า~