BLOG

ลองเล่น C# Source Generators

สร้างโค๊ด พร้อมกับตอน Build!

สวัสดีครับ สำหรับโพสนี้ ผมมีอีกเรื่องที่น่าสนใจมากฝากกัน เป็น Feature ใหม่ของภาษา C# หรือจะเรียกว่าของ Roslyn ก็เรียกได้ไม่เต็มปากเท่าไหร่ เพราะว่ามันรองรับเฉพาะภาษา C# สำหรับฟีเจอร์ที่ว่านี้คือฟีเจอร์ที่ชื่อว่า Source Generator ครับ

โดยปกติแล้ว เราจะสามารถใช้ Reflection เพื่อใช้ในการตรวจสอบโครงสร้างของโค๊ดเราระหว่าง Run-Time และใช้ข้อมูลเหล่านี้ ในการพัฒนาโปรแกรม ในแบบที่แต่ก่อน สามารถทำได้ยากได้ เช่น

  • สร้างตัว Mapping เพื่อ Adapt/Proxy ออบเจ็กต์ชนิดหนึ่ง เป็นอีกชนิด
  • การทำ JSON Serialization ที่อ่าน JSON เป็น POCO (POCO - Plain Old C# Object)
  • ทำ Factory ที่หาคลาสที่ Implement Interface ที่เราต้องการจากใน Assembly โดยอัตโนมัติหรือระบุชื่อเป็น string ใน configuration file และสร้างออกมาเป็น Instance, หรือ
  • ทำระบบ Object Relational Mapping (ORM) ที่สามารถ Map คลาส C# ให้กลายเป็นตารางในฐานข้อมูล

เป็นต้น

แต่ด้วยระบบ Source Generator นี้ เราสามารถใช้ความสามารถของ Roslyn ในการวิเคราะห์โครงสร้างของ Assembly ที่เดิมเราทำด้วย Reflection นี้ ได้ตั้งแต่ตอน Compile เลย ขอยืมภาพจากบล็อก ที่แนะนำตัว C# Source Generators มาวางไว้ตรงนี้นะ ว่ามันทำงานตอนไหนอย่างไร

ระหว่างการศึกษาเรื่องนี้ ผมเลยได้สร้างโปรเจค SQLite-sg ขึ้นมาด้วย เป็น ORM ที่ทำงานแบบ Flat เลยนะ ไม่สนใจ Relationship มันจะทำการเซฟ/โหลด Class กับ Table ของ SQLite โดยใช้ Source Gen ในการสร้างโค๊ดเหมือนเขียนเอง แทนการใช้ Reflection ตอน Runtime ถ้าสนใจลองไปดูโค๊ดได้จากใน Github ครับ

ความแตกต่างจากการใช้ Code Generator แบบเดิม

จริงๆ แล้ว เราก็สามารถทำการสร้าง Tool ที่อ่าน Assembly (.dll/.exe ของ .NET) ด้วย Reflection จากนั้นก็สร้าง Source Code ขึ้นมาได้อยู่แล้วเช่นกัน แต่ว่า วิธีการใช้ Code Generator แบบนี้ จะมีข้อจำกัด คือ

  • การ Gen นั้น สามารถทำได้ หลังจากที่ Compile เสร็จแล้วเท่านั้น

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

    หนทางที่ทำได้ สำหรับกรณีของการ Serialization / ORM ก็คือ แยก Type ทั้งหมดที่อยากจะ Serialize หรือทำ Mapping ออกมาเป็นอีกโปรเจคหนึ่ง จากนั้น Compile โปรเจคนี้่ก่อน และใช้ Generate Code ใส่อีก Project หนึ่ง แล้ว Compile แต่ก็จะเกิดเป็นความยุ่งยากในการบริหารจัดการหลายโปรเจคขึ้นแทน
  • Visual Studio มองไม่เห็น Intellisense ของโค๊ดที่ Generate ออกมา หรือเห็นแบบ Delay ไป 1 ครั้ง

    เนื่องจากว่าการ Generate Source Code ออกมานั้น เป็นขั้นตอนที่เกิดหลังจากการ Compile ดังนั้น ตัว Visual Studio จะมองเห็น Source Code ต่อเมื่อเกิดการ Generate เสร็จแล้ว (คือต้อง Compile อีก Project หนึ่ง จากนั้นรันตัว Tool ในการ Generate Source Code ก่อน) ทำให้เราไม่สามารถมองเห็น IntelliSense ที่เป็นปัจจุบันได้ อาจจะทำให้โค๊ดผิด และก็สร้างความลำบากให้กับคนที่ติดกับระบบ IntelliSense นี้พอสมควร แต่ทั้งนี้ขึ้นอยู่กับโครงสร้างของโปรเจคด้วย
  • ในการ Generate Source Code มักจะต้องทำเป็น Custom Tool และจะต้องถูกติดตั้งต่างหาก  

    เนื่องจากส่วนมากแล้ว เราจะไม่สามารถใช้ T4 Template โดดๆ ได้ และมักจะต้องทำเป็น Custom Tool เพื่อช่วยในการ Generate Code เราก็จะต้องไล่ติดตั้งตัว Tool นี้ ให้กับเครื่อง Developer ทุกคน และก็เป็นข้อจำกัดอีกเล็กน้อยกว่า ถ้าจะให้ Custom Tool ทำการ Gen Source Code ได้ ก็จะต้องมีไฟล์ เพื่อให้ใช้ในการตั้งค่าให้ Custom Tool ทำงานด้วย ต่อให้ไม่มีความจำเป็นจะต้องใช้ไฟล์นั้นเลยก็ตาม เช่น กรณีที่ Source Code สามารถสร้างขึ้นมาเองได้โดยไม่ต้องมี Template เป็นต้น

แล้วก็มาถึง Source Generators (SourceGen)

ตัวระบบ Source Gen นั้น ออกมาเพื่อแก้ปัญหาสามตัวตรง ของ Custom Tools ตรงที่บอกมาแล้วเลยนั่นแหละ

  • ขั้นตอนการ Generate Source Code นั้น เป็นส่วนหนึ่งของขั้นตอนการ Build

    โดยตัว Roslyn (Compiler) จะทำการเรียกให้โค๊ดของเรา ที่ระบุไว้ว่าเป็น Source Gen นั้นทำงาน หลังจากที่ตัว Roslyn ทำการ Parse โค๊ดเราเสร็จแล้ว และได้ Syntax Tree + Semantics ออกมา และ Code ที่ถูก Generate ออกมา จะสามารถถูกเพิ่มเข้าไปเป็นส่วนหนึ่งของ Assembly ที่กำลังจะถูก Compile ในขั้นตอนสุดท้ายด้วยเลย
  • สามารถใช้ Intellisense ได้ แบบ Realtime

    เนื่องจากว่า Code ที่ถูก Gen มานั้น จะรวมอยู่ใน Assembly ที่ถูก Build ดังนั้น ตัวระบบ IntelliSense ของ Visual Studio ก็สามารถมองเห็นตัวโค๊ดที่ถูก Gen ได้ทันที เพราะตัว Visual Studio นั้น ก็จะต้องทำการ Parse/Build โค๊ดของเราไปด้วยตลอดเวลาอยู่แล้ว (เพื่อให้มันสามารถมองเห็นได้ว่า มีอะไรให้มันแสดงบ้าง)

    อย่างในภาพนี้ ตัวคลาส TimeSeriesTableMapping ไม่มีอยู่ในตอนแรก แต่พอผมได้ทำการเพิ่ม Attribute Table ซึ่งเป็นตัวกำหนดให้เกิดการใช้งาน Source Generator กับ Class TimeSeries นี้ และเป็นการสร้างคลาส TimeSeriesTableMapping ออกมา ตัว Visual Studio ก็สามารถมองเห็นมันได้ทันที

  • Tool ในการ Gen สามารถติดตั้งเป็น Nuget ได้

    ตัว Source Gen ใช้การ Reference โปรเจค ที่ต้องการจะใช้ Source Gen ไปหาตัว Tool ที่ใช้ในการ Gen ดังนั้นก็เลยสามารถทำตัว Tool นั้นเป็น Nuget และติดตั้งด้วย Nuget ได้ตามปกติ โดยไม่ต้องตั้งค่าอะไรเพิ่มเติม การทำงานเหมือนกับการติดตั้ง Library ธรรมดา

วิธีการสร้างโปรเจค Source Gen

สำหรับการใช้งาน Source Code จะต้องสร้างเป็น Project .NETStandrd2 "เท่านั้น" แบบ Class Library ยอมรับเลยว่า ตอนแรกไม่ได้อ่านให้ดี งมอยู่นานมากกว่าจะใช้งานได้ (ไปเลือกเป็น .NET 5) และ

(สำหรับโค๊ดในตัวอย่างนี้ มาจากโปรเจคที่ผมทดลองใช้ Source Gen ในการสร้าง Mapping สำหรับเซฟ/โหลดข้อมูลจากฐานข้อมูล SQLite ชื่อว่า SQLite-sg นะ)

จากนั้น เพิ่ม Nuget: Microsoft.CodeAnalysis.Common, Microsoft.CodeAnalysis.CSharp, Microsoft.CSharp เข้ามา และสร้างคลาสปกติ และทำการแปะ Attribute ว่า Generator และทำการ Implement Interface ISourceGenerator ตามที่กำหนด โดยทางทีมพัฒนาแนะนำให้มีโค๊ดสองส่วน คือ ส่วนที่เป็น Generator และส่วนที่เป็น Visitor 

โค๊ดที่เป็นส่วนของ Visitor นั้น เราจะบอกกับ Roslyn ในฟังก์ชั่น Initialize และ Roslyn จะเรียกใช้งานตอนที่ Roslyn ค่อยๆ ไล่ Parse โค๊ดของโปรเจคที่เราจะให้ตัว Source Gen ทำงานด้วย ทำให้เราไม่จำเป็นต้องเขียนโค๊ดเพื่อมานั่งเสาะหาเองว่า มี Class/Method/Type อะไรบ้างจะที่เป็นเป้าหมายในการทำ Source Gen ของเรา เช่น ถ้าเราจะเขียน Source Gen เพื่อ Generate Class ที่ทำการเซฟ Data Class เป็นตารางในฐานข้อมูล อาจจะสนใจเฉพาะ Class ที่มีการระบุ Attribute Table ไว้ เป็นต้น

  1. จะเห็นว่า โค๊ดเราจะมองเห็นเป้าหมายเป็น Syntax Node ไม่ใช่ Type แบบกรณีการทำ Reflection โค๊ดตรงส่วนนี้ ก็คือเราตรวจสอบว่า ใน Syntax Node ที่เรากำลังไปเยี่ยมนี้ มันเป็นการประกาศ Class ใช่หรือเปล่า และในภาษา C# 9.0 สามารถประกาศตัวแปรจาก is ต่อได้เลย
  2. จะเห็นว่า เราไล่ดูไปว่า ในโค๊ดที่เป็นการประกาศคลาสนี้ มี Attribute อะไรอยู่บ้าง  และถ้าชื่อของ Attribute คือ Table หรือ TableAttribute (ตอนที่เราใส่ Attribute นั้น จะใส่ว่า Table หรือ TableAttribute ก็ได้ Compiler จะถือว่าเป็น Attribute ที่ชื่อ TableAttribute เหมือนกัน) เราจะทำการเก็บ Reference ของ Class นี้ไว้ โดยในขั้นตอนนี้ ผมได้ทำการสร้าง SQLiteTableMappingParser ไปทีเดียวเลย

และในส่วนของการ Generate เราก็จะใช้ข้อมูลที่เรารวบรวมได้ ในขั้นตอน Visit นี้ มาใช้งาน เช่น เราเอาจจะเก็บ Reference ของ Class Syntax (โค๊ดที่ประกาศ Class) ทั้งหมดเอาไว้ใน List และในขั้นตอน Generate เราก็ไปไล่ดูตาม List และสร้าง Source Code ออกมา เป็นต้น ในตัวอย่างคือ ในฟังก์ชั่น Parse นั้น จะเป็นการอ่านข้อมูลจาก Source Code ของ Class ที่เป็นเป้าหมายในการ Generate ตัว Mapping ออกมาก่อน จากนั้น ใช้คำสั่ง context.AddSource เพื่อใส่โค๊ดที่ Generate เข้าไปในการ Compile ตัวโปรเจคเป้าหมาย จะเห็นว่าสามารถตั้งชื่อไฟล์ได้ด้วยนะ

ส่วนของโปรเจคที่จะนำตัว Source Generator ไปใช้งานนั้น ทำการ Add Reference ถึงตัวโปรเจคที่เป็น Source Generator เหมือนการ Add Reference ทั่วไป แต่เราจะต้องมีการแก้ไขตัวไฟล์โปรเจคสองส่วน คือ

  1. เปลี่ยน LangVersion เป็น Preview
  2. กำหนด Attribute OutputItemType ของ ProjectReference ว่าเป็น Analyzer และ ReferenceOutputAssembly เป็น false เพื่อบอกกับ Roslyn ว่า โปรเจคนี้ เป็นโปรเจคที่เราไม่ได้ต้องการจะเอาโค๊ดอะไรข้างในมาใช้งาน แต่ว่ามันเป็นโปรเจคที่จะมา Analyze โค๊ดของโปรเจคนี้ อีกทีหนึ่ง

เท่านี้ ก็จะสามารถใช้งานได้ (ผมนี้งมอยู่นานมาก อ่านหลายบล็อกเลย กว่าจะได้ เหอๆ)

ข้อจำกัดของ Source Gen

เนื่องจากว่า Source Gen นั้น เป็นการทำงานในขั้นตอนของการ Build และยังไม่ได้เป็น Assembly จริงๆ ออกมา จะมีความแตกต่างในเรื่องของการทำงาน และข้อจำกัดอยู่พอสมควร ดังนี้

  • การอ่านโครงสร้างและโค๊ดของโปรแกรม เป็นการอ่านในระดับ Syntax/Semantic

    สำหรับคนที่ไม่ได้เรียนเรื่อง Compiler มา ก็อาจจะไม่ค่อยคุ้นกับสองคำนี้เท่าไหร่ แต่สรุปก็คือ สิ่งที่เราสามารถใช้ในการอ่านโครงสร้างนั้น มันยังไม่อยู่ในรูปของ Type/Method เลย เพราะว่ามันยังไม่ได้ถูก Compile แต่จะเป็นการอ่านลักษณะการอ่าน Source Code แบบเหมือนอ่านไฟล์ Text เช่น เราไม่ได้มองเห็นเป็น class ที่ชื่อ MyData แต่เราจะมองเห็นเป็น "Keyword Class" ตามด้วย "Identifier ชื่อว่า MyData" แบบนี้แทน โค๊ดที่ใช้ในการทำความเข้าในโครงสร้างของโปรแกรมโดยอาศัย Reflection นั้น จะไม่สามารถใช้งานได้เลยกับการโมงเห็นโครงสร้างเป็น Syntax/Semantic แบบนี้ และต้องคิดใหม่ตามข้อจำกัดของ Syntax/Semantic ที่อยู่ใน Object Model ของ Roslyn อีกรอบ
     
  • ชื่อ Type นั้น อาจจะไม่ถูกต้อง และมีได้หลายชื่อ

    สำหรับ Type ที่เป็น Primitive ของ .NET เช่น Int32 นั้น สำหรับภาษา C# สามารถใช้ Alias ได้ว่า int ซึ่งการใช้ Reflection ในการตรวจสอบ Type ของตัวแปรที่ถูกประกาศ ไม่ว่าจะระบุว่า System.Int32, Int32 (โดยใช้ using System;) หรือ int ก็จะได้ Type เดียวกันออกมา

    แต่ในระดับ Syntax/Semantic นั้น ยังไม่ถึงขั้นตอนในการ Link Type เข้าด้วยกัน ตัว Compiler (จริงๆ คือ Parser) จะเข้าใจแค่ว่า มีการประกาศ Member และมีการระบุ Type เราจะอ่านออกมาได้ชื่อของ Type ที่คนเขียนโค๊ดระบุไว้ตรงๆ เช่น ถ้าคนเขียนโค๊ด ใส่ว่า uint การที่เราทำการตรวจสอบ Type ของ Member นี้ เทนที่จะได้ System.UInt32 เราก็จะได้คำว่า "uint" ที่เป็นเหมือน string ออกมาแทน ซึ่งเราจะต้องเข้าใจเองว่ามันคือ Type อะไร

    เวลาที่ Gen Code ก็อาจจะเกิดประเด็นได้เล็กน้อย โดยเฉพาะการที่โค๊ดที่เราจะอ่านโครงสร้างออกมา มีการเรียกใช้ Type ข้าม Namespace และการใช้ using เพื่อให้ไม่ต้องระบุชื่อ namespace นั้นตอนที่เรียกใช้ 

    สำหรับทางแก้ที่ตรงตัวมากๆ ก็แค่เอา Using ทั้งหมดของไฟล์ที่เราอ่าน ใส่ไปใน Source ที่เรา Gen ด้วย และก็ใช้ชื่อ Type ให้ตรงกันกับที่อยู่ใน Source Code ก็ใช้ได้แล้ว แต่การใช้ Using เพื่อ Rename ชื่อ Type ก็จะมีปัญหาอีกเช่นกัน คงต้องแก้กันเป็นกรณีๆ ไป
  • ตัวโค๊ด Attribute ไม่ได้ทำงาน ต้องใช้ Workaround ช่วย

    อันนี้ยังไม่ได้คอนเฟิร์ม แต่จากการทดลองใช้งานเอง ก็พบว่า ไม่สามารถใช้ Property ของ Attribute ตรงๆ ได้ คิดว่าน่าจะเป็นเพราะ Attribute นั้น มันก็ยังไม่ได้เริ่มทำงาน (มันไม่ได้ถูก ให้เป็น Instance ของ Class มองเห็นเป็นแค่โค๊ดเรียกใช้ Attribute ที่เป็นตัวอักษร) เราเลยไม่สามารถเอาค่า Property ของ Attribute ออกมาได้แบบตอนที่ใช้ Reflection

    ตรงนี้ สามารถ Workaround แก้ไขโดยการออกแบบ Attribute ใหม่ โดยกำหนดให้ค่าที่เราต้องการจะอ่าน อยู่ใน Contructor ของ Attribute ทั้งหมดและทำการเซ็ตค่า Property ใน Constructor แทน จากปกติที่เราจะสามารถใช้ Default Constructor (ไม่มี Parameter) แล้วเซ็ตค่า Attribute ต่างหากได้



    ตอนใช้งาน ผู้ที่เรียกใช้ Attribute จะต้องเขียนว่า:
    [MyAttribute(property1: "value")] คือ เรียกใช้ Constructor แบบที่ต้องระบุค่า Parameter
    แทนการเขียนว่า
    [MyAttribute(Property1="Value")] คือ เรียก Default Constructor แล้วเซ็ตค่า Property1

    นับว่าไม่ได้กระทบอะไรมากนัก เพราะก็เขียนแทบไม่ต่างจากเดิมเลย แต่เราสามารถอ่านออกมาได้ด้วย Semantic ของ Roslyn ได้โดยง่ายกว่ามาก

    ดังนั้นเป็นได้ว่า Attribute ที่เขียน Property เป็น Function ก็ควรใช้งานไม่ได้เช่นกัน เพราะว่ามันยังไม่ได้ทำงาน ลองค้นๆ ดูมีคนสอบถามเรื่องนี้อยู่บ้าง แต่ก็ยังไม่ได้คำตอบชัดเจนว่า ตกลง ตัว Roslyn มัน Run โค๊ดของ Attribute หรือยังตอนที่ Source Gen ทำงาน และจะเอาค่า Property ของ Attribute ออกมาได้อย่างไร
  • การ Debug ยังไม่สมบูรณ์ 

    ในการ Debug Source Gen นั้น จะต้องใช้การใส่โค๊ด Debugger.Launch เอง ในจุดที่ต้องการจะ Debug และถ้าไม่ได้เอาออก ทุกครั้งเราพิมพ์อะไรไป ต่อให้แค่ 1-2 ตัว ตัว Debugger ก็จะถูกเรียกให้ทำงานทันทีเหมือนกัน เพราะว่า Visual Studio นั้น ทำการ Build ตัวโปรเจคของเราอยู่เบื้องหลังตลอดเวลา ทำให้การพัฒนา Tool ในการทำ Source Gen ยังติดๆ ขัดๆ บ้างเล็กน้อย แต่ก็สามารถทำได้อยู่ และ Edit & Continue จะไม่สามารถใช้งานได้

    ในจุดนี้ มีผู้แนะนำให้ทำเป็นโปรเจคธรรมดาก่อน เพื่อใช้สำหรับ Debug โดยเฉพาะ แล้วค่อยเอาโค๊ดมาใส่ในโปรเจค Source Gen ก็เป็นวิธีที่ไม่เลวเหมือนกัน ส่วนตัวแล้วใช้ RoslynPad เพื่อทดสอบโค๊ดในส่วนที่เป็นการอ่านค่า Syntax แล้วถึงค่อย Copy มาใส่เหมือนกัน แล้ว Debug กับทั้งโซลูชั่นอีกรอบ ประมาณนี้

เดี๋ยวมาเล่าให้ฟังต่อ ถึง SQLite-sg

โพสต่อไป เดี๋ยวขอมาย้อนเล่าให้ฟังถึงประสบการณ์และเทคนิคต่างๆ ที่ผมใช้ในการแกะสปาเก็ตตี้ของ SQLITE-NET ให้ออกมาเป็น SQLITE-SG ได้ ติดตามอ่านกันนะ

BLOG

LEVEL51 คือใคร?

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

1317
Customers
0
THB 100,000 Builds
49
K
Average Build Price
0
K
Most Valuable Build

Our Government and Universities Customers:

Our Video Production, 3D Design, Software House Customers:

Landscape Design

Our Industrial and Construction Customers:

 

พิเศษเฉพาะคุณ - รับคูปองส่วนลด 2,000 บาท สำหรับการสั่งซื้อเครื่องกับเรา