NancyBlack ระบบแบบ Azure Mobile Service ของผมเอง (Part 1)

ช่วงนี้บริษัทเรามีงานเว็บเข้ามาค่อนข้างเยอะมาก แน่นอนว่า งานเว็บส่วนใหญ่ ก็มักจะเป็นงานการสร้างหน้าจอเพื่อให้ผู้ใช้ป้อนข้อมูลเข้าไป เก็บในฐานข้อมูล จากนั้นแสดงเป็นตาราง กดลิงค์จากตารางแล้วก็แสดงผลข้อมูลขึ้นมาให้แก้ไขหรือลบได้ ผมเลยเกิดความคิดที่ว่า ถ้าเราสามารถทำระบบแบบที่มันเก็บข้อมูลลงฐานข้อมูลแบบ Dynamic ได้เลย โดยไม่ต้องคอยสร้าง Table เอง สร้าง Type เอง และก็สร้าง Web API เอง เพื่อรองรับหน้าจอพวกนั้นก็คงจะดีมากเลย การทำงานจะคล้ายๆ กับ Azure Mobile Service คือผมสามารถสร้าง Table เปล่าๆ ขึ้นมา จากนั้นไม่ว่าผมจะส่งข้อมูล JSON ผ่าน POST ด้วยหน้าตาแบบไหนลงไปก็ตาม มันจะทำการสร้าง Column เพื่อเก็บข้อมูลนั้นให้เองโดยอัตโนมัติ

เทคโนโลยีและ Framework ที่จะใช้

ช่วงหลายๆ เดือนที่ผ่านมา ผมได้ศึกษาเรื่องเพิ่มเติม นอกจาก Windows Phone/Windows 8 เยอะมาก (รวมถึงการศึกษาเรื่อง Azure Mobile Service ด้วย จนอยากทำได้เองบ้าง) จนผมคิดว่า ผมน่าจะมีส่วนประกอบที่พร้อมจะสร้างขึ้นมาเป็นระบบในฝันนี้แล้วครับ ซึ่งก็มีหลักๆ อยู่สองตัวนี้ละครับ 

  • SisoDb (http://sisodb.org) เป็น Framework ในการเก็บข้อมูลแบบ Document-Oriented สำหรับ .NET ครับ ซึ่งตัว SisoDb จะทำการเก็บข้อมูลคลาสของเรา ลงไปในฐานข้อมูลให้ พร้อมทำ Index ให้อัตโนมัติ โดยไม่ต้อง Mapping ไม่ต้องมี Attribute ไม่ต้องมีอะไรทั้งสิ้น!!! แต่ผลลัพทธ์ที่ได้ดูน่ากลัวอยู่นิดหนึ่ง เพราะว่าคนทำเขาใช้วิธีสร้างตารางใหญ่มาหลายๆ ตาราง เพื่อเก็บ Id, Key, Value สำหรับ Class ของเราเลย (เรียกว่า EAV Model http://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model ) แล้วก็มีอีกตารางหนึ่งที่เก็บค่าของคลาสเราไว้เป็น JSON ครับ
  • NancyFX (http://nancyfx.org/) มีอยู่ช่วงหนึ่งที่ผมพยายามจะเริ่มศึกษา ASP.NET MVC และ WebAPI แต่ก็พบว่า ผมไม่ค่อยชอบการทำตามกฏเกณฑ์ที่คนอื่นกำหนดเท่าไหร่ ว่าคลาสต้องลงท้ายด้วย Controller หรือว่า View ต้องอยู่ใน Folder ชื่อว่า “Views” ผมชอบชีวิตอิสระครับ เป็นมนุษย์ Agile สมบูรณ์แบบ ผมจึงลองหาทางเลือกอื่น ก็เลยพบกับ NancyFX นี้ ซึ่งจุดเด่นของมันก็คือ ผมจะเอาอะไรวางตรงไหนก็ได้ เดี๋ยวตัว Engine มันจะหาให้ผมเอง และผมจะกำหนด URL แบบไหนอย่างไรก็ได้ ไม่จำเป็นต้องอยู่ใต้ “/api” (ซึ่งผมก็ทราบว่า มันสามารถเปลี่ยนได้ แต่ว่าต้องเขียนโค๊ดเปลี่ยนเอา) โดยเจ้าของโปรเจคเขาเรียกว่า “Super-Duper-Happy-Path” ซึ่งพอผมใช้แล้ว ก็รู้สึกแบบนั้นจริงๆ ครับ

เอาละ ก่อนจะเพ้อฝันไปมากกว่านี้ ผมควรจะเริ่มจาการพิสูจน์ก่อนว่า ผมสามารถทำให้ SisoDb ทำการ Map คลาสแบบ Dynamic ได้ ถ้าเราลองดูตัวอย่างของ Siso จะพบว่า วิธีการใช้ Siso นั้นง่ายมาก (Copy จากหน้าเว็บของ SisoDb มาเลย) ดังนี้ครับ

//Model
public class Customer
{
    public Guid Id { get; set; }
    public int CustomerNo { get; set; }
    public string Name { get; set; }
}

//Setup database
var db = "myConnectionStringOrName".CreateSql20012Db();
db.EnsureNewDatabase();

//Use the database
db.UseOnceTo().Insert(myCustomer);

var r = db.UseOnceTo().Query(c => Name == "Daniel").SingleOrDefault();

แต่ว่า ถ้าตามที่ผมต้องการก็คือ คลาส “Customer” นี้ จะไม่มีอยู่ จริงๆ คือมันไม่มีคลาสอะไรที่จะเป็น Entity อยู่เลยต่างหาก แต่ว่าคลาสที่เป็น Entity ของผม จะถูกสร้างขึ้นมาขณะที่ระบบกำลังทำงานอยู่ ขึ้นอยู่กับว่าตอนนั้น ผมส่งข้อมูลอะไรมา เอ๊ะ!!! มันทำได้ด้วยเหรอแบบนี้?!?

CodeDom และ CodeDomCompiler

หนึ่งในสิ่งที่ผมชอบมากของ .NET คือ การที่เราสามารถ Generate Code ตอน Runtime ได้ครับ ซึ่งก่อนหน้านี้ เราจะต้องใช้ Reflection.Emit เพื่อสร้าง IL กันทีละตัวๆ เลย โชคดีที่ใน .NET เวอร์ชั่นน่าจะประมาณ 3.0 นะครับ จำไม่ได้แน่ชัด มี Framework ตัวใหม่ ที่ชื่อ CodeDom กับ CodeDom Compiler มาให้เรา ผมไม่แน่ใจว่าตัวนี้ คือตัวเดียวกับ “Compiler-As-A-Service” ที่ชื่อว่า Roslyn หรือเปล่านะครับ แต่ว่าเราสามารถใช้ CodeDom Compiler นี้ทำการ Compile โค๊ด C# ขณะที่โปรแกรมกำลังรันอยู่ได้ และ Assembly ที่ได้จากการคอมไพล์ จะสามารถใช้งานในโปรแกรมเราได้ทันทีครับ

สำหรับแนวทางในการคอมไฟล์ด้วย CodeDom ทำได้สองทางครับ

  • โดยใช้ CodeDom วิธีนี้เราจะต้องทำการสร้าง Instance ของคลาสต่างๆ ขึ้นมา ในรูปของ CodeDom ครับ คล้ายๆ การสร้าง XDocument, XElement มาผูกกันให้มันออกมาเป็นคลาส เป็น Namespace คล้ายกับ AST (Abstract Syntax Tree) หรือ “ผลลัพธ์” ที่ได้จาก Parser ครับ พอเราร้อยเรียงวัตถุได้แล้ว ค่อยส่งให้ CodeDom ไปทำการ Compile ออกมาเป็น Assembly อีกทีหนึ่ง
  • โดยใช้ Source Code วิธีนี้เราก็จะทำการสร้าง Source Code ภาษาที่เราต้องการแบบบ้านๆ ขึ้นมาเลยใน string แล้วก็ส่ง string ไปให้ CodeDom Compiler ทำการ Compile ออกมาเป็น Assembly ครับ

หลังจากทดลองดูแล้ว ก็พบว่า การใช้ Source Code เอา ถึงแม้จะดูเป็นวิธีที่หน่อมแน้มและสปาเก็ตตี้กว่า แต่ผมพบว่า เราสามารถทำได้ง่ายกว่า และแก้ไขโค๊ดได้ง่ายกว่าเยอะครับ แต่เพื่อลดความหน่อมแน้มลง เราจะไม่ใช้การสร้าง Source Code ด้วย StringBuilder หรือ StringFormat แบบบ้านๆ แต่เราจะใช้ Razor ช่วยเราครับ โชคดีมากเลยที่มีคนเห็นประโยชน์ของ Razor มากกว่าการใช้เป็นแค่ View Engine สำหรับ ASP.NET MVC ดังนั้น โดยทำเป็น NuGet ไว้ให้พร้อมแล้วด้วย ชื่อว่า RazorEngine ครับ วิธีใช้ก็แสนจะง่าย บรรทัดเดียวจบ ดังนั้น ผมจึงสร้าง Template นี้ขึ้นมาครับ

var template = @"

using System;

@foreach( var type in @Model.Types )
{
    <text>public class</text> @type.Name
    {  
    <text>{</text>
                foreach( var property in @type.Properties )
                {
                    <text>public</text> @property.Type <text> </text> @property.Name <text>{ get; set; }</text>
                }
    <text>}</text>
    }
}";

และจากนั้น ผมก็สร้างคลาส DataType และ DataProperty ขึ้นมา และจำลองคลาสง่ายๆ ขึ้นมา 1 คลาส เพื่อทดสอบว่า เราสามารถ Generate Code ของคลาสนี้ขึ้นมาได้หรือไม่

List properties = new List() {
                
    new DataProperty(){ Type = "int", Name = "Id"},
    new DataProperty(){ Type = "string", Name = "Property1"},
    new DataProperty(){ Type = "int", Name = "Property2"},
};

List types = new List() {

    new DataType(){ Name = "Test1", Properties = properties }
};


var model = new {
    Types = types
};

string code = Razor.Parse(template, model );

และนี่คือผลลัพธ์ที่ได้ จากการ Generate Code ของคลาส Test1 ขึ้นมาครับ อาจจะดูเบี้ยวๆ หน่อย แต่ว่าสามารถใช้งานได้

using System;

    public class Test1    {
                    public int   Id { get; set; }
                    public string   Property1 { get; set; }
                    public int   Property2 { get; set; }
    }

ทีนี้ เรามี Code แล้ว ก็เหลือแค่การ Compile ให้ได้ออกมาเป็น Assembly เท่านั้นเองครับ ซึ่งการ Compile Source Code ด้วย CodeDom นั้น ก็มีโค๊ดสั้นๆ เท่านี้ครับ

var provider = new CSharpCodeProvider();
var result = provider.CompileAssemblyFromSource(new CompilerParameters( new string[] { "System.dll" })
{
    GenerateExecutable = false,
    GenerateInMemory = true,
    OutputAssembly = "DynamicType"
}, code);

ก็คือ ผมสั่งให้ CodeDom Compiler ทำการ Compile โดย Reference ไปยัง System.dll และให้สร้าง Assembly ชื่อว่า “DynamicType” ใน Memory และไม่ต้องทำเป็น .exe ครับ (จะได้ผลลัพธ์เป็น Class Library ซึ่งก็คือไฟล์ .dll) โดยตัว Assembly นี้ จะถูกโหลดเข้ามาใน AppDomain ของเราเองโดยอัตโนมัติครับ ที่เหลือก็คือว่า ผมจะสามารถใช้ SiSoDb ทำการ Map คลาส ที่ถูกสร้างขึ้นมาแบบ Dynamic นี้ ได้หรือไม่ ผมจึงเขียนโค๊ดทดสอบตามนี้ครับ

var conn = "Data Source=MyData.sdf;Persist Security Info=False";
var db = conn.CreateSqlCe4Db().CreateIfNotExists();

var output = result.CompiledAssembly.CreateInstance("Test1");
db.UseOnceTo().Insert(output.GetType(), output);

แล้วก็พบว่า SisoDb สามารถทำการ Map คลาสที่สร้างขึ้นใหม่นี้ ได้อย่างสวยงามครับ (ในแบบของเขานะ) และผมได้ทดลองเพิ่ม Property เข้าไป ก็พบว่า SiSoDb เข้าใจด้วยว่า เป็นการเพิ่ม Property และทำการใส่ข้อมูลลงไป พร้อมทั้ง Map เพิ่มได้อย่างถูกต้องครับ

image9

image10

ทำการ Map JSON เป็นคลาสใหม่

เมื่อเราพบแล้วว่า เราสามารถ Generate คลาสใหม่ขึ้นมาได้ โดยการใช้ CodeDom Compiler ผสมกับการสร้าง Source Code ด้วย Razor คราวนี้ก็เหลือแค่การครวจสอบโครงสร้างของ JSON ที่เราได้รับเข้ามา และทำการสร้างคลาสตามโครงสร้างครับ

โชคดีมาก (อีกแล้ว!) ที่ NewtonSoft.Json นั้น เขาได้ทำการออกแบบมาอย่างดีให้เราสามารถดูโครงสร้างของ JSON ได้ครับ ซึ่ง NewtonSoft.Json นั้น เวลาที่เราทำการ Deserialize JSON ที่ไม่ระบุชนิดของ Type ปลายทาง เราจะได้ Type พิเศษ ที่ NewtonSoft.Json สร้างขึ้นมา โดยใน Type เหล่านี้ จะมีรายละเอียดเกี่ยวกับตัว JSON ที่อ่านได้ค่อนข้างที่จะครบถ้วน ซึ่งเราเองต้องการแค่ ชื่อ และก็ชนิดของ Property ต่างๆ ใน Object เท่านั้นเอง

image16

สำหรับการ Map เราก็ไม่ทำอะไรซับซ้อนครับ เพียงแค่แปลี่ยน Property ที่ชื่อ Type ซึ่งเป็น Enum ให้เป็นชื่อคลาสใน .NET ที่ตรงกัน และใช้ชื่อตาม Property ที่ได้รับมาครับ

Func typeMapper = (jt) =>
{
                
    switch (jt)
    {
        case JTokenType.TimeSpan:
        case JTokenType.Uri:
        case JTokenType.Boolean:
        case JTokenType.Guid:
        case JTokenType.String:
            return "System." + jt.ToString();
        case JTokenType.Bytes:
            return "byte[]";
        case JTokenType.Date:
            return "DateTime";
        case JTokenType.Float:
            return "double";
        case JTokenType.Integer:
            return "int";
        default:
            throw new NotImplementedException("support for " + jt + " is not implemented."); 
    }
};

var sourceObject = JsonConvert.DeserializeObject(json) as JObject;
var testType = new DataType();
testType.Name = "Test2";
testType.Properties = from KeyValuePair property in sourceObject
                        select new DataProperty()
                        {
                            Name = property.Key,
                            Type = typeMapper( property.Value.Type )

                        };
code = Razor.Parse(template, testType);

เอาล่ะเท่านี้เราก็สามารถ Generate คลาสตอน Runtime ได้ ก็เหลือแค่ทดสอบดูว่า NewtonSoft.Json จะสามารถอ่าน JSON ให้ออกมาเป็นคลาสที่เราสร้างขึ้นแบบ Dynamic นี้ได้ไหม แน่นอนว่า ก็ต้องทำได้สิครับ ดังนี้ครับ

var test2Type = result.CompiledAssembly.GetType("Test2");
var readJsonAsDynamicType = JsonConvert.DeserializeObject(json, test2Type);

บรรทัดแรก คือผมใช้ Reflection ในการหา Type (คลาส) ที่เราสร้างขึ้นออกมา แล้วจึงบอกให้ NewtonSoft.Json ทำการ Deserialize ออกมาเป็น Type นั้น ซึ่งก็ได้ผลลัพธ์ออกมาตามที่เราต้องการครับ

image19

ถัดมา ก็คือการใช้ SisoDb ทำการบันทึกข้อมูลลงไป ด้วยคำสั่งเดิม เพียงแต่เปลี่ยน Parameter ให้เป็น Type และ Instance ที่เราอ่านจาก JSON แทน ผลที่ได้ก็คือ SiSoDb ก็สามารถบันทึกค่าได้อย่างไม่มีปัญหา (มีนิดหน่อย ตรงที่ว่า SiSoDb ต้องการ Property ที่ชื่อ Id ชนิดเป็น Int หรือ Guid ซึ่งผมลองใช้ id เป็นตัวเล็กแบบ JavaScript ปรากฏว่าไม่ได้ครับ ต้องเป็น Id เท่านั้น)

image22

สรุปว่า ในทางเทคนิคแล้ว สามารถทำได้ครับ ถ้าอย่างนั้น เรามาเริ่มสร้าง API ทางฝั่ง Server ด้วย NancyFX กันดีกว่า

NancyFX Backend

ในการใช้งาน NancyFX เราสามารถทำได้หลายวิธีมากครับ กระทั่งเราสามารถ Host Nancy เองก็ได้ โดยไม่ต้องใช้ IIS แต่เพื่อความง่าย ผมจะใช้ Nancy ผ่าน ASP.NET ครับ เราจะได้สามารถใส่ไฟล์ HTML ต่างๆ ลงไปได้ด้วย โดยไม่ต้อง Map Path ให้วุ่นว่าย

image26

แล้วจากนั้น เราก็ทำการ Add Nuget “Nancy.Hosting.Asp.net” เข้ามาครับ Package นี้จะดึง Dependency ของ Nancy ที่เราต้องการใช้มาให้ครับครับ และที่ขาดไม่ได้ ก็คือ Nancy.Viewengines.Razor สำหรับหน้า Frontend โดยใช้ Razor, NewtonSoft.Json และ SisoDb.SqlCe4 (หรือถ้าอยากจะเซฟลง SQL Server ก็เลือกใช้ตัวที่ต้องการได้ครับ)

image33

การเขียนโค๊ด Nancy เราจะทำใน Class ซึ่งสืบทอดจาก NancyModule ครับ  โดยเรากำหนด Path ที่เราจะรองรับ ไว้ใน Constructor กันดื้อๆ แบบนี้ โดย Path ใน Module จะตรงกัน หรือไม่ตรงกันเลยสักตัวก็ได้ และถ้าจะให้ Advance กว่านั้น จะเขียน Regular Expression ไว้ใน Path ก็ได้ครับ ตัว Nancy จะเทียบว่า Path ที่ถูกเรียกนั้น ตรงกับ Path ใน Module ไหนบ้าง ใช่แล้วครับ เราจะมีกี่ Module ก็ได้ ใน Module จะมีกี่ Path ก็ได้ เขียนเอาเองตามใจชอบได้ครับ

using Nancy;
using Nancy.ModelBinding;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace NantCom.NancyBlack.Modules
{
    public class DataModule : NancyModule
    {
        public DataModule()
        {
            Post["/data/{entityName}"] = this.SaveData;
        }

        private dynamic SaveData(dynamic arg)
        {
            var entityName = arg.entityName;

            return 200;
        }
    }
}

จากตัวอย่างที่ผมเขียนนี้ ก็คือ ถ้าผม Post มาที่ /data/Customer ฟังก์ชั่น SaveData จะถูกเรียกให้ทำงาน โดยคำว่า Customer จะถูกส่งมาใน arg.entityName ครับ ตอนนี้ผมยังไม่ทำอะไร แค่ return 200 ไปเฉยๆ หมายความว่า ให้ NancyFx ทำการคืนค่า Response ว่างๆ ไป โดยที่มี HTTP Status Code รหัส 200 แปลว่า ทุกอย่าง OK เท่านั้นเองครับ (จริงๆ ควรจะใช้ 204 แปลว่า OK แต่ไม่มีอะไรจะต้องอ่าน) และเมื่อเอาโค๊ดที่เราได้ทดสอบไปเมื่อกี้มาประกอบกัน เราก็จะได้ Backend ที่สามารถเซฟ JSON ที่ส่งมา เข้าฐานข้อมูลได้แบบอัตโนมัติเป็นที่เรียบร้อย

image36

และถ้าเราอยากจะเปลี่ยน Entity ก็เพียงแค่เปลี่ยน URL และเราก็จะได้ Entity ใหม่อัตโนมัติ

image39

สรุปว่า เราได้ระบบหลังบ้านที่ทำงานคล้ายๆ กับ Azure Mobile Service กันแล้ว เหลือแค่เพิ่มเติม Method GET/DELETE ก็จะทำงานได้อย่างสมบูรณ์แบบ

สำหรับ Source Code ของการทดลองนี้ ผมเปิดเป็น Open Source เอาไว้ให้ ที่ http://nancyblack.codeplex.com/ นะครับ เชิญไปทดสอบดาวน์โหลดกันไปใช้ดูได้ (แต่กำลังคิดอยู่ว่า จะใช้ GitHub แทนดีมั๊ย :) )