<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>SignalR on TECHFOR by Suriya Sonphu</title><link>http://suriyasonphu.com/tags/signalr/</link><description>Recent content in SignalR on TECHFOR by Suriya Sonphu</description><generator>Hugo -- gohugo.io</generator><language>th</language><lastBuildDate>Wed, 29 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="http://suriyasonphu.com/tags/signalr/index.xml" rel="self" type="application/rss+xml"/><item><title>สอง .NET Backends จะคุยกันอย่างไร? REST สำหรับ Request และ SignalR สำหรับ Realtime</title><link>http://suriyasonphu.com/post/dotnet-backend-communication-rest-signalr/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>http://suriyasonphu.com/post/dotnet-backend-communication-rest-signalr/</guid><description>&lt;img src="http://suriyasonphu.com/post/dotnet-backend-communication-rest-signalr/cover.png" alt="Featured image of post สอง .NET Backends จะคุยกันอย่างไร? REST สำหรับ Request และ SignalR สำหรับ Realtime" />&lt;p>เวลาเราเริ่มแยกระบบออกเป็น backend มากกว่าหนึ่งตัว สิ่งที่ตามมาทันทีคือ แล้วแต่ละระบบจะคุยกันอย่างไร?&lt;/p>
&lt;p>จริงๆ แล้วมี protocol ให้เลือกหลายแบบ ทั้ง REST, gRPC, WebSocket, SignalR รวมถึง Message broker แต่ก่อนจะเลือกใช้ตัวไหน เราต้องตอบให้ได้ก่อนว่า การสื่อสารครั้งนั้นต้องการคำตอบกลับมาทันทีหรือไม่ และข้อมูลที่ส่งออกไปยอมให้หายได้หรือเปล่า&lt;/p>
&lt;p>ในบทความนี้ผมจะยกตัวอย่างสองระบบ เพื่อให้เห็นภาพง่ายขึ้น:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Order API&lt;/strong> รับผิดชอบการสร้างและติดตามคำสั่งซื้อ&lt;/li>
&lt;li>&lt;strong>Inventory API&lt;/strong> เป็นเจ้าของข้อมูลสต็อกสินค้า&lt;/li>
&lt;/ul>
&lt;p>กรณีที่ Order API ต้องตรวจสอบว่าสินค้ามีพอหรือไม่ ระบบต้องส่งคำถามไปหา Inventory API และรอคำตอบกลับมา แบบนี้เรียกว่า &lt;strong>request-response&lt;/strong> ซึ่ง REST เหมาะกับงานลักษณะนี้&lt;/p>
&lt;p>แต่ถ้าจำนวนสินค้าเปลี่ยน แล้วเราต้องการให้ Inventory API แจ้ง Order API ทันที โดยไม่ต้องคอยยิง request ถามซ้ำทุกวินาที แบบนี้เราต้องการการสื่อสารแบบ &lt;strong>realtime&lt;/strong> ซึ่ง SignalR เข้ามาช่วยตรงนี้ได้&lt;/p>
&lt;p>อย่าเพิ่งคิดว่าเราต้องเลือก REST หรือ SignalR อย่างใดอย่างหนึ่ง เพราะในระบบจริงเราสามารถใช้ทั้งสองแบบร่วมกันได้ โดยให้แต่ละตัวทำงานที่ตัวเองถนัด&lt;/p>
&lt;h2 id="กอนเลอก-protocol-ลองดรปแบบการสอสารกอน">ก่อนเลือก Protocol ลองดูรูปแบบการสื่อสารก่อน
&lt;/h2>&lt;p>การสื่อสารระหว่าง backend สามารถแบ่งรูปแบบหลักๆ ได้ดังนี้&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>รูปแบบ&lt;/th>
&lt;th style="text-align: right">ผู้ส่งรอคำตอบหรือไม่&lt;/th>
&lt;th>ตัวอย่าง&lt;/th>
&lt;th>ทางเลือกที่พบบ่อย&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Request-response&lt;/td>
&lt;td style="text-align: right">รอ&lt;/td>
&lt;td>ตรวจสต็อก, อ่านข้อมูลลูกค้า, คำนวณราคา&lt;/td>
&lt;td>HTTP/REST, gRPC, GraphQL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Realtime push&lt;/td>
&lt;td style="text-align: right">ไม่ต้องถามซ้ำ&lt;/td>
&lt;td>แจ้ง stock changed, progress, live status&lt;/td>
&lt;td>SignalR, WebSocket, Server-Sent Events&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asynchronous messaging&lt;/td>
&lt;td style="text-align: right">ไม่รอและต้องส่งให้ถึง&lt;/td>
&lt;td>order created, payment completed&lt;/td>
&lt;td>RabbitMQ, Azure Service Bus, Kafka&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Streaming&lt;/td>
&lt;td style="text-align: right">รับข้อมูลต่อเนื่อง&lt;/td>
&lt;td>telemetry, log stream, market feed&lt;/td>
&lt;td>gRPC streaming, Kafka, WebSocket&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>จุดที่หลายคนอาจเข้าใจผิดคือคำว่า &lt;strong>realtime&lt;/strong> ไม่ได้แปลว่า &lt;strong>reliable&lt;/strong> เสมอไป SignalR ส่งข้อมูลไปยัง connection ที่กำลังเชื่อมต่ออยู่ได้รวดเร็วก็จริง แต่ถ้าผู้รับ offline อยู่ในเวลานั้น ข้อความไม่ได้ถูกเก็บไว้รอส่งให้อัตโนมัติ&lt;/p>
&lt;p>ดังนั้นถ้า event ทุกตัวต้องส่งถึงผู้รับอย่างน้อยหนึ่งครั้ง ควรพิจารณา Message broker เช่น RabbitMQ หรือ Azure Service Bus หรืออาจใช้ broker ร่วมกับ SignalR ก็ได้ (เดี๋ยวเราจะกลับมาที่เรื่องนี้อีกครั้ง)&lt;/p>
&lt;h2 id="มาดกนวาแตละ-protocol-เหมาะกบอะไรบาง">มาดูกันว่าแต่ละ Protocol เหมาะกับอะไรบ้าง
&lt;/h2>&lt;h3 id="httprest">HTTP/REST
&lt;/h3>&lt;p>REST เป็นแบบที่ Developer ส่วนใหญ่น่าจะคุ้นเคยกันดี โดยใช้ HTTP เป็นพื้นฐาน ข้อมูลมักอยู่ในรูป JSON และสื่อความหมายผ่าน resource, URL, HTTP method และ status code เช่น:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-http" data-lang="http">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#960050;background-color:#1e0010">GET /api/products/P-100/stock
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ข้อดีของ REST คือเข้าใจง่าย ทดสอบด้วยเครื่องมือทั่วไปได้ มี logging และ observability รองรับค่อนข้างดี รวมถึงทำงานข้ามภาษาและ platform ได้สะดวก ส่วนข้อแลกเปลี่ยนคือ JSON มีขนาดใหญ่กว่า binary protocol และ contract อาจไม่เข้มเท่า gRPC หากเราไม่ได้ใช้ OpenAPI หรือ generate client จาก schema&lt;/p>
&lt;p>โดยส่วนใหญ่ REST เหมาะกับ public API, CRUD, การ query ข้อมูล และ operation ที่ผู้เรียกต้องรู้ผลทันที&lt;/p>
&lt;h3 id="grpc">gRPC
&lt;/h3>&lt;p>gRPC ใช้ Protocol Buffers ในการกำหนด contract และโดยทั่วไปทำงานบน HTTP/2 ข้อมูลเป็น binary ทำให้ payload มีขนาดเล็กและเร็วกว่า JSON นอกจากนี้ยังรองรับทั้ง unary call, client streaming, server streaming และ bidirectional streaming&lt;/p>
&lt;p>ตัวนี้เหมาะกับ internal service-to-service communication ที่เราควบคุมได้ทั้งสองฝั่ง ต้องการ type-safe contract หรือมี request ปริมาณสูง แต่ข้อแลกเปลี่ยนคือเวลา debug เราจะเปิดดู payload ด้วยตาเปล่าไม่ง่ายเหมือน REST และการเชื่อมต่อจาก browser ก็มีข้อจำกัดมากกว่า&lt;/p>
&lt;h3 id="websocket">WebSocket
&lt;/h3>&lt;p>WebSocket จะเปิด connection สองทิศทางค้างไว้ ทำให้ทั้ง client และ server ส่งข้อมูลหาอีกฝ่ายได้ตลอดเวลา มี overhead ต่อ message ค่อนข้างต่ำ ฟังดูดี แต่สิ่งที่ตามมาคือเราต้องออกแบบ message format, routing, connection lifecycle, reconnect และ error handling เองมากขึ้น&lt;/p>
&lt;p>WebSocket จึงเหมาะกับกรณีที่เราต้องการ protocol เฉพาะ หรือต้องการควบคุมในระดับ transport อย่างละเอียดจริงๆ&lt;/p>
&lt;h3 id="signalr">SignalR
&lt;/h3>&lt;p>SignalR เป็น abstraction สำหรับ realtime communication ใน ASP.NET Core โดยมี Hub เป็นจุดรับส่งข้อความ เราสามารถส่งข้อมูลไปยัง connection รายตัว, user, group หรือทุก connection ได้ และ SignalR จะเลือก transport ที่เหมาะสมให้ เช่น ใช้ WebSocket เมื่อ environment รองรับ&lt;/p>
&lt;p>ข้อดีคือ integrate กับ dependency injection, authentication, authorization และ logging ของ ASP.NET Core ได้ดี มี client library สำหรับ .NET, JavaScript, Java และ Swift ในกรณีของเราที่เป็นสอง .NET backends ฝั่งรับ event สามารถใช้ &lt;code>Microsoft.AspNetCore.SignalR.Client&lt;/code> เชื่อมต่อได้โดยตรง&lt;/p>
&lt;p>SignalR เหมาะกับการ push notification และข้อมูลสด แต่ต้องย้ำอีกครั้งว่า SignalR ไม่ใช่ durable queue และ Hub มีอายุสั้น เราจึงไม่ควรเก็บ application state ไว้ใน instance ของ Hub&lt;/p>
&lt;h3 id="server-sent-events-หรอ-sse">Server-Sent Events หรือ SSE
&lt;/h3>&lt;p>SSE ส่ง event จาก server ไป client ทางเดียวผ่าน HTTP เหมาะกับ progress, notification หรือ feed ที่ client ไม่จำเป็นต้องส่งข้อมูลกลับมาทาง connection เดียวกัน ถ้าเป็น one-way push แบบง่ายๆ SSE ก็เป็นตัวเลือกที่น่าสนใจ แต่สำหรับระบบ .NET ที่ต้องสื่อสารสองทิศทาง SignalR มักจะใช้งานได้สะดวกกว่า&lt;/p>
&lt;h3 id="message-broker">Message broker
&lt;/h3>&lt;p>RabbitMQ, Azure Service Bus และ Kafka ไม่ได้เข้ามาแทน REST ในทุกกรณี แต่จะตอบโจทย์เมื่อผู้ส่งไม่ควรผูก availability ไว้กับผู้รับ หรือต้องการเก็บ message เอาไว้จนกว่าจะประมวลผลสำเร็จ&lt;/p>
&lt;p>ลองนึกภาพว่า Inventory API ต้องรับประกันว่า Order API จะได้รับ &lt;code>StockChanged&lt;/code> แม้ในเวลาที่ Order API ล่มอยู่ กรณีนี้ SignalR อย่างเดียวไม่พอ เราควร publish event ไปยัง broker แล้วให้ Order API กลับมา consume เมื่อพร้อมทำงาน ส่วน SignalR ยังสามารถใช้ push event ไปยัง dashboard หรือ connection ที่ online อยู่ได้ตามปกติ&lt;/p>
&lt;h2 id="ทาตวอยางในบทความน-rest-เปนคำตอบ-signalr-เปนสญญาณ">ท่าตัวอย่างในบทความนี้: REST เป็นคำตอบ SignalR เป็นสัญญาณ
&lt;/h2>&lt;p>&lt;img src="http://suriyasonphu.com/post/dotnet-backend-communication-rest-signalr/architecture-diagram.svg"
loading="lazy"
alt="แผนภาพการสื่อสารระหว่าง Order API และ Inventory API ด้วย REST และ SignalR"
>&lt;/p>
&lt;p>จากภาพผมแบ่งหน้าที่ของทั้งสอง protocol ไว้ดังนี้:&lt;/p>
&lt;ol>
&lt;li>Order API เรียก REST เพื่ออ่านสต็อกหรือสั่ง reserve สินค้า และรอผลลัพธ์ที่ชัดเจน&lt;/li>
&lt;li>Order API เปิด SignalR connection ค้างไว้เพื่อรับ &lt;code>StockChanged&lt;/code> ทันที&lt;/li>
&lt;li>ถ้า SignalR หลุด client จะ reconnect อัตโนมัติ&lt;/li>
&lt;li>หลัง reconnect ให้ Order API query snapshot ล่าสุดผ่าน REST อีกครั้ง เพราะ event ที่เกิดตอน connection หลุดอาจหายไป&lt;/li>
&lt;/ol>
&lt;p>จำง่ายๆ คือ &lt;strong>event บอกว่ามีบางอย่างเปลี่ยน แต่ REST ใช้ถามสถานะล่าสุดที่เชื่อถือได้&lt;/strong>&lt;/p>
&lt;h2 id="ลองสรางฝง-inventory-api">ลองสร้างฝั่ง Inventory API
&lt;/h2>&lt;p>ตัวอย่างนี้ผมเลือกใช้ Minimal API เพื่อให้เราเห็นเฉพาะส่วนสำคัญก่อน โดยไม่ต้องสนใจโครงสร้างอื่นมากจนเกินไป&lt;/p>
&lt;p>เริ่มจากสร้าง model และ strongly typed Hub contract:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> Microsoft.AspNetCore.SignalR;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">record&lt;/span> &lt;span style="color:#a6e22e">StockResponse&lt;/span>(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">string&lt;/span> ProductId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">int&lt;/span> AvailableQuantity,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DateTimeOffset UpdatedAt);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">record&lt;/span> &lt;span style="color:#a6e22e">UpdateStockRequest&lt;/span>(&lt;span style="color:#66d9ef">int&lt;/span> AvailableQuantity);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">record&lt;/span> &lt;span style="color:#a6e22e">StockChanged&lt;/span>(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">string&lt;/span> ProductId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">int&lt;/span> AvailableQuantity,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> DateTimeOffset OccurredAt);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">interface&lt;/span> &lt;span style="color:#a6e22e">IInventoryClient&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> Task StockChanged(StockChanged message);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">class&lt;/span> &lt;span style="color:#a6e22e">InventoryHub&lt;/span> : Hub&amp;lt;IInventoryClient&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>เหตุผลที่เลือกใช้ strongly typed Hub เพราะช่วยตรวจชื่อ method และชนิดข้อมูลได้ตั้งแต่ตอน compile แทนที่จะกระจาย magic string ไว้หลายจุดใน codebase (พิมพ์ชื่อผิดก็รู้ก่อนรัน)&lt;/p>
&lt;p>จากนั้นสร้าง REST endpoint และเปิด SignalR Hub:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> System.Collections.Concurrent;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> Microsoft.AspNetCore.SignalR;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">var&lt;/span> builder = WebApplication.CreateBuilder(args);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>builder.Services.AddSignalR();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">var&lt;/span> app = builder.Build();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">var&lt;/span> stocks = &lt;span style="color:#66d9ef">new&lt;/span> ConcurrentDictionary&amp;lt;&lt;span style="color:#66d9ef">string&lt;/span>, StockResponse&amp;gt;();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app.MapGet(&lt;span style="color:#e6db74">&amp;#34;/api/products/{productId}/stock&amp;#34;&lt;/span>, (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">string&lt;/span> productId) =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> stocks.TryGetValue(productId, &lt;span style="color:#66d9ef">out&lt;/span> &lt;span style="color:#66d9ef">var&lt;/span> stock)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ? Results.Ok(stock)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> : Results.NotFound();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app.MapPut(&lt;span style="color:#e6db74">&amp;#34;/api/products/{productId}/stock&amp;#34;&lt;/span>, &lt;span style="color:#66d9ef">async&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">string&lt;/span> productId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> UpdateStockRequest request,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> IHubContext&amp;lt;InventoryHub, IInventoryClient&amp;gt; hub,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken cancellationToken) =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> (request.AvailableQuantity &amp;lt; &lt;span style="color:#ae81ff">0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> Results.ValidationProblem(&lt;span style="color:#66d9ef">new&lt;/span> Dictionary&amp;lt;&lt;span style="color:#66d9ef">string&lt;/span>, &lt;span style="color:#66d9ef">string&lt;/span>[]&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#a6e22e"> [&amp;#34;availableQuantity&amp;#34;]&lt;/span> = [&lt;span style="color:#e6db74">&amp;#34;Quantity must be zero or greater.&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">var&lt;/span> now = DateTimeOffset.UtcNow;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">var&lt;/span> stock = &lt;span style="color:#66d9ef">new&lt;/span> StockResponse(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> productId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> request.AvailableQuantity,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> now);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stocks[productId] = stock;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> hub.Clients.All.StockChanged(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">new&lt;/span> StockChanged(productId, stock.AvailableQuantity, now));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> Results.Ok(stock);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app.MapHub&amp;lt;InventoryHub&amp;gt;(&lt;span style="color:#e6db74">&amp;#34;/hubs/inventory&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>app.Run();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>จุดที่อยากให้สังเกตคือ code ไม่ได้สร้าง &lt;code>InventoryHub&lt;/code> ขึ้นมาเอง แต่ใช้ &lt;code>IHubContext&lt;/code> เพื่อส่ง event จาก endpoint หรือ application service เนื่องจาก Hub เป็น transient object และไม่ควรถูกใช้เป็นที่เก็บ state&lt;/p>
&lt;p>เพื่อให้ตัวอย่างไม่ยาวเกินไป ผมเก็บข้อมูลไว้ใน memory ก่อน แต่ในระบบจริงเราควรเขียน database ให้สำเร็จแล้วจึง push event และยังต้องคิดถึงปัญหา dual write ด้วย เช่น database commit สำเร็จ แต่ process หยุดก่อนส่ง event แบบนี้ผู้รับจะไม่รู้เลยว่าข้อมูลเปลี่ยน หากเป็น event สำคัญควรใช้ &lt;strong>Transactional Outbox&lt;/strong> ร่วมกับ Message broker&lt;/p>
&lt;h2 id="ให-order-api-เรยกขอมลผาน-rest">ให้ Order API เรียกข้อมูลผ่าน REST
&lt;/h2>&lt;p>ฝั่ง Order API จะเรียก REST ไปหา Inventory API สิ่งหนึ่งที่ควรระวังคือไม่ควรสร้าง &lt;code>new HttpClient()&lt;/code> ใหม่ทุก request เพราะอาจเจอปัญหา socket/port exhaustion และการจัดการ DNS ที่ไม่เหมาะสม ในแอปที่ใช้ dependency injection เราสามารถใช้ &lt;code>IHttpClientFactory&lt;/code> หรือ typed client ได้&lt;/p>
&lt;p>เริ่มจากสร้าง client ที่ซ่อนรายละเอียด HTTP ไว้ใน class เดียว:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> System.Net;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> System.Net.Http.Json;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">class&lt;/span> &lt;span style="color:#a6e22e">InventoryClient&lt;/span>(HttpClient httpClient)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">async&lt;/span> Task&amp;lt;StockResponse?&amp;gt; GetStockAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">string&lt;/span> productId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken cancellationToken)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">using&lt;/span> var response = &lt;span style="color:#66d9ef">await&lt;/span> httpClient.GetAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">$&amp;#34;/api/products/{Uri.EscapeDataString(productId)}/stock&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> (response.StatusCode &lt;span style="color:#66d9ef">is&lt;/span> HttpStatusCode.NotFound)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#66d9ef">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response.EnsureSuccessStatusCode();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> response.Content.ReadFromJsonAsync&amp;lt;StockResponse&amp;gt;(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>แล้วลงทะเบียน typed client ใน &lt;code>Program.cs&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">var&lt;/span> builder = WebApplication.CreateBuilder(args);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>builder.Services.AddHttpClient&amp;lt;InventoryClient&amp;gt;(httpClient =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> httpClient.BaseAddress = &lt;span style="color:#66d9ef">new&lt;/span> Uri(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> builder.Configuration[&lt;span style="color:#e6db74">&amp;#34;Services:InventoryApi&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ?? &lt;span style="color:#66d9ef">throw&lt;/span> &lt;span style="color:#66d9ef">new&lt;/span> InvalidOperationException(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;Services:InventoryApi is not configured.&amp;#34;&lt;/span>));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> httpClient.Timeout = TimeSpan.FromSeconds(&lt;span style="color:#ae81ff">3&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>จากนั้นนำไปเรียกใช้จาก endpoint:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>app.MapPost(&lt;span style="color:#e6db74">&amp;#34;/api/orders&amp;#34;&lt;/span>, &lt;span style="color:#66d9ef">async&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CreateOrderRequest request,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> InventoryClient inventory,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken cancellationToken) =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">var&lt;/span> stock = &lt;span style="color:#66d9ef">await&lt;/span> inventory.GetStockAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> request.ProductId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> (stock &lt;span style="color:#66d9ef">is&lt;/span> &lt;span style="color:#66d9ef">null&lt;/span> ||
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> stock.AvailableQuantity &amp;lt; request.Quantity)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> Results.Conflict(&lt;span style="color:#66d9ef">new&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> code = &lt;span style="color:#e6db74">&amp;#34;INSUFFICIENT_STOCK&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> message = &lt;span style="color:#e6db74">&amp;#34;สินค้าไม่เพียงพอ&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#75715e">// Create the order and reserve stock here.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> Results.Accepted();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>เมื่อนำไปใช้บน production เราควรเพิ่ม resilience handler เพื่อรับมือ transient failure แต่อย่าเพิ่งใส่ retry ให้ทุกอย่าง เพราะ operation แต่ละแบบมีผลไม่เหมือนกัน:&lt;/p>
&lt;ul>
&lt;li>Retry &lt;code>GET&lt;/code> มักปลอดภัยเพราะเป็น idempotent operation&lt;/li>
&lt;li>ไม่ควร retry &lt;code>POST&lt;/code> ที่สร้างข้อมูลโดยอัตโนมัติ ถ้ายังไม่มี idempotency key&lt;/li>
&lt;li>กำหนด timeout เสมอ เพื่อไม่ให้ request ค้างจน thread และ connection ถูกใช้หมด&lt;/li>
&lt;li>แยกผลลัพธ์ business error เช่น stock ไม่พอ ออกจาก technical error เช่น timeout หรือ service unavailable&lt;/li>
&lt;li>ใช้ cancellation token ต่อเนื่องจาก incoming request ไปยัง outgoing request&lt;/li>
&lt;/ul>
&lt;h2 id="ตอ-realtime-ดวย-signalr">ต่อ Realtime ด้วย SignalR
&lt;/h2>&lt;p>ต่อไปเราจะให้ Order API รับ event แบบ realtime เริ่มจากติดตั้ง client package:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dotnet add package Microsoft.AspNetCore.SignalR.Client
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>แล้วสร้าง &lt;code>BackgroundService&lt;/code> ไว้ดูแล connection:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">using&lt;/span> Microsoft.AspNetCore.SignalR.Client;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">sealed&lt;/span> &lt;span style="color:#66d9ef">class&lt;/span> &lt;span style="color:#a6e22e">InventoryRealtimeWorker&lt;/span>(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> IConfiguration configuration,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> InventorySnapshotStore snapshotStore,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ILogger&amp;lt;InventoryRealtimeWorker&amp;gt; logger)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> : BackgroundService
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">private&lt;/span> HubConnection? _connection;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">protected&lt;/span> &lt;span style="color:#66d9ef">override&lt;/span> &lt;span style="color:#66d9ef">async&lt;/span> Task ExecuteAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken stoppingToken)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">var&lt;/span> inventoryUrl =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configuration[&lt;span style="color:#e6db74">&amp;#34;Services:InventoryApi&amp;#34;&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ?? &lt;span style="color:#66d9ef">throw&lt;/span> &lt;span style="color:#66d9ef">new&lt;/span> InvalidOperationException(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;Services:InventoryApi is not configured.&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _connection = &lt;span style="color:#66d9ef">new&lt;/span> HubConnectionBuilder()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .WithUrl(&lt;span style="color:#e6db74">$&amp;#34;{inventoryUrl.TrimEnd(&amp;#39;/&amp;#39;)}/hubs/inventory&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .WithAutomaticReconnect()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .Build();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _connection.On&amp;lt;StockChanged&amp;gt;(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> nameof(IInventoryClient.StockChanged),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> message =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> snapshotStore.Upsert(message);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> logger.LogInformation(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;Stock {ProductId} changed to {Quantity}&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> message.ProductId,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> message.AvailableQuantity);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> _connection.Reconnected += &lt;span style="color:#66d9ef">async&lt;/span> connectionId =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> logger.LogInformation(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;SignalR reconnected with connection {ConnectionId}&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> connectionId);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#75715e">// Re-sync current state through REST here. Events that happened&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#75715e">// while disconnected aren&amp;#39;t guaranteed to be replayed.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> snapshotStore.MarkForRefreshAsync(stoppingToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> StartWithRetryAsync(_connection, stoppingToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> Task.Delay(Timeout.Infinite, stoppingToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">private&lt;/span> &lt;span style="color:#66d9ef">static&lt;/span> &lt;span style="color:#66d9ef">async&lt;/span> Task StartWithRetryAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> HubConnection connection,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken cancellationToken)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">while&lt;/span> (!cancellationToken.IsCancellationRequested)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">try&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> connection.StartAsync(cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">catch&lt;/span> when (!cancellationToken.IsCancellationRequested)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> Task.Delay(TimeSpan.FromSeconds(&lt;span style="color:#ae81ff">5&lt;/span>), cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">public&lt;/span> &lt;span style="color:#66d9ef">override&lt;/span> &lt;span style="color:#66d9ef">async&lt;/span> Task StopAsync(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> CancellationToken cancellationToken)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> (_connection &lt;span style="color:#66d9ef">is&lt;/span> not &lt;span style="color:#66d9ef">null&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> _connection.DisposeAsync();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#66d9ef">base&lt;/span>.StopAsync(cancellationToken);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>สุดท้ายลงทะเบียน worker และ store:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>builder.Services.AddSingleton&amp;lt;InventorySnapshotStore&amp;gt;();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>builder.Services.AddHostedService&amp;lt;InventoryRealtimeWorker&amp;gt;();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>WithAutomaticReconnect()&lt;/code> ช่วย reconnect ในกรณีที่ connection เคยเชื่อมต่อสำเร็จแล้วหลุด แต่ตอนเริ่มเชื่อมต่อครั้งแรกเรายังต้องจัดการ retry เอง จึงมี &lt;code>StartWithRetryAsync&lt;/code> อยู่ในตัวอย่าง&lt;/p>
&lt;p>ข้อมูลใน memory snapshot เหมาะกับการแสดงผลหรือช่วยลดจำนวน query แต่ไม่ควรใช้เป็นข้อมูลสุดท้ายสำหรับการตัดสินใจสำคัญ เช่น การตัดสต็อกจริง เพราะข้อมูลอาจเก่าหรือมี event หลุดได้ ก่อนทำ transaction เราควรยืนยันสถานะกับ Inventory API อีกครั้งเสมอ&lt;/p>
&lt;h2 id="อยาลมเรอง-security">อย่าลืมเรื่อง Security
&lt;/h2>&lt;p>ถึงแม้สอง backends จะอยู่ใน private network ก็ไม่ได้หมายความว่าทุก request เชื่อถือได้โดยอัตโนมัติ อย่างน้อยระบบควรมี:&lt;/p>
&lt;ul>
&lt;li>HTTPS เพื่อเข้ารหัสข้อมูลระหว่างทาง&lt;/li>
&lt;li>OAuth 2.0 Client Credentials, workload identity หรือ mTLS เพื่อยืนยันตัวตนของ service&lt;/li>
&lt;li>Authorization policy จำกัดว่า service ใดเรียก endpoint หรือเชื่อม Hub ใดได้&lt;/li>
&lt;li>Secret และ certificate rotation โดยไม่ฝังค่าไว้ใน source code&lt;/li>
&lt;li>Rate limit และ payload validation ป้องกัน service ที่ผิดปกติทำให้ระบบอื่นล่มตาม&lt;/li>
&lt;/ul>
&lt;p>สำหรับ SignalR .NET client สามารถส่ง access token ผ่าน &lt;code>AccessTokenProvider&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-csharp" data-lang="csharp">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">var&lt;/span> connection = &lt;span style="color:#66d9ef">new&lt;/span> HubConnectionBuilder()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .WithUrl(hubUrl, options =&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> options.AccessTokenProvider = tokenProvider.GetAccessTokenAsync;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> })
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .WithAutomaticReconnect()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .Build();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ทั้ง Hub และ REST endpoint ควรใช้ authorization policy ที่สอดคล้องกัน แต่แยก scope ตามหน้าที่ เช่น &lt;code>inventory.read&lt;/code>, &lt;code>inventory.write&lt;/code> และ &lt;code>inventory.subscribe&lt;/code> เพื่อไม่ให้ service ได้สิทธิ์มากเกินความจำเป็น&lt;/p>
&lt;h2 id="กอนขน-production-ยงตองคดเรองอะไรอกบาง">ก่อนขึ้น Production ยังต้องคิดเรื่องอะไรอีกบ้าง
&lt;/h2>&lt;h3 id="contract-และ-versioning">Contract และ versioning
&lt;/h3>&lt;p>การ deploy สอง backends ไม่ได้เกิดพร้อมกันทุกครั้ง ดังนั้น contract ควร backward compatible:&lt;/p>
&lt;ul>
&lt;li>เพิ่ม field ใหม่แบบ optional&lt;/li>
&lt;li>อย่าเปลี่ยนความหมายของ field เดิม&lt;/li>
&lt;li>version REST endpoint เมื่อมี breaking change&lt;/li>
&lt;li>version event name หรือ payload เมื่อ consumer เก่ายังทำงานอยู่&lt;/li>
&lt;li>ใช้ OpenAPI หรือ shared contract package อย่างระมัดระวัง อย่าให้ package กลายเป็น coupling ที่บังคับให้ทุก service deploy พร้อมกัน&lt;/li>
&lt;/ul>
&lt;h3 id="observability">Observability
&lt;/h3>&lt;p>เวลาระบบมีปัญหา เราควรตามให้ได้ว่า request หรือ event เดินทางผ่าน service ใดมาบ้าง:&lt;/p>
&lt;ul>
&lt;li>ส่ง correlation ID หรือใช้ W3C Trace Context&lt;/li>
&lt;li>เก็บ latency, error rate, retry count และ reconnect count&lt;/li>
&lt;li>log business identifier เช่น &lt;code>OrderId&lt;/code> และ &lt;code>ProductId&lt;/code> แต่ไม่ log token หรือข้อมูลส่วนบุคคลเกินจำเป็น&lt;/li>
&lt;li>ตั้ง alert จากอาการที่ผู้ใช้ได้รับผลกระทบ ไม่ใช่ดูเพียง CPU&lt;/li>
&lt;/ul>
&lt;h3 id="scale-out">Scale-out
&lt;/h3>&lt;p>เมื่อ Inventory API มีหลาย instance ตัว connection ของ SignalR จะกระจายอยู่คนละเครื่อง การส่งผ่าน &lt;code>Clients.All&lt;/code> จาก instance หนึ่งจึงต้องมี scale-out solution เช่น Redis backplane หรือ managed SignalR service เพื่อให้ข้อความไปถึง connection ที่อยู่บน instance อื่นด้วย&lt;/p>
&lt;p>ส่วน REST ควรวางไว้หลัง load balancer และไม่ควรพึ่ง in-memory state ของ instance ใด instance หนึ่ง&lt;/p>
&lt;h3 id="failure-และ-consistency">Failure และ consistency
&lt;/h3>&lt;p>พอระบบแยกออกจากกันแล้ว เราจะไม่มี transaction เดียวที่ครอบทั้งสอง database จึงต้องตกลง behavior ให้ชัดว่า:&lt;/p>
&lt;ul>
&lt;li>ถ้า Inventory API timeout Order API จะ reject, retry หรือรับ order ไว้รอตรวจภายหลัง&lt;/li>
&lt;li>ถ้าตัดสต็อกสำเร็จแต่สร้าง order ไม่สำเร็จ จะชดเชยอย่างไร&lt;/li>
&lt;li>event ซ้ำจะถูกประมวลผลแบบ idempotent อย่างไร&lt;/li>
&lt;li>ข้อมูลยอมเก่าได้กี่วินาที&lt;/li>
&lt;li>ถ้า SignalR หลุดนานเท่าไรจึงถือว่า snapshot ใช้งานไม่ได้&lt;/li>
&lt;/ul>
&lt;p>คำถามพวกนี้อาจดูเยอะ แต่คำตอบมีผลต่อความถูกต้องของระบบมากกว่าการเลือก transport เสียอีก&lt;/p>
&lt;h2 id="สรปวาเลอกอะไรในสถานการณไหน">สรุปว่าเลือกอะไรในสถานการณ์ไหน
&lt;/h2>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ความต้องการ&lt;/th>
&lt;th>ตัวเลือกแรกที่ควรพิจารณา&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>CRUD หรือ query ที่ต้องตอบทันที&lt;/td>
&lt;td>REST&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal call ปริมาณสูงและต้องการ strict contract&lt;/td>
&lt;td>gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Push ข้อมูลสดให้ .NET backend หรือ UI ที่ online&lt;/td>
&lt;td>SignalR&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Push ทางเดียวผ่าน HTTP แบบเรียบง่าย&lt;/td>
&lt;td>SSE&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ทุก message ต้องถูกเก็บและส่งถึงแม้ consumer offline&lt;/td>
&lt;td>Message broker&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Event stream ปริมาณสูงและต้อง replay ย้อนหลัง&lt;/td>
&lt;td>Kafka หรือ streaming platform&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>สำหรับตัวอย่าง Order API และ Inventory API ผมจะเลือกใช้แบบนี้:&lt;/p>
&lt;ul>
&lt;li>ใช้ &lt;strong>REST&lt;/strong> สำหรับ &lt;code>GetStock&lt;/code>, &lt;code>ReserveStock&lt;/code> และ operation ที่ต้องรู้ผลสำเร็จหรือล้มเหลว&lt;/li>
&lt;li>ใช้ &lt;strong>SignalR&lt;/strong> สำหรับ &lt;code>StockChanged&lt;/code> เพื่อให้ผู้รับที่ online อัปเดตได้ทันที&lt;/li>
&lt;li>หลัง reconnect ให้ &lt;strong>sync ผ่าน REST&lt;/strong>&lt;/li>
&lt;li>ถ้า &lt;code>StockChanged&lt;/code> ห้ามสูญหาย ให้เพิ่ม &lt;strong>message broker และ Outbox&lt;/strong> ไม่ใช่พยายามทำให้ SignalR กลายเป็น queue&lt;/li>
&lt;/ul>
&lt;h2 id="โดยสวนตวคดวา">โดยส่วนตัวคิดว่า
&lt;/h2>&lt;p>การเลือก protocol ไม่ควรเริ่มจากคำถามว่าตัวไหนเร็วที่สุด หรือตัวไหนกำลังเป็นที่นิยม แต่ควรเริ่มจากข้อมูลของเราต้องการอะไร&lt;/p>
&lt;p>ถ้าเป็น operation ที่ต้องรู้ผลสำเร็จหรือล้มเหลวทันที REST เป็นจุดเริ่มต้นที่เข้าใจง่ายและเพียงพอสำหรับระบบส่วนใหญ่ ส่วน SignalR เหมาะกับการแจ้งว่ามีบางอย่างเปลี่ยน เพื่อให้ระบบที่ online อยู่ตอบสนองได้ทันที&lt;/p>
&lt;p>สิ่งสำคัญคือเราต้องแยก &lt;strong>คำตอบที่ต้องเชื่อถือ&lt;/strong> ออกจาก &lt;strong>สัญญาณที่ต้องรวดเร็ว&lt;/strong> ให้ได้ก่อน เมื่อแบ่งหน้าที่ชัดเจน ระบบจะเข้าใจง่าย ทดสอบง่าย และขยายต่อได้โดยไม่ผูกทุกอย่างไว้กับเทคโนโลยีเดียว (ไม่จำเป็นต้องเริ่มด้วย architecture ที่ซับซ้อนเกินความต้องการ)&lt;/p>
&lt;h2 id="อานเพมเตม">อ่านเพิ่มเติม
&lt;/h2>&lt;ul>
&lt;li>&lt;a class="link" href="https://learn.microsoft.com/aspnet/core/fundamentals/http-requests" target="_blank" rel="noopener"
>Make HTTP requests using IHttpClientFactory in ASP.NET Core&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines" target="_blank" rel="noopener"
>HttpClient guidelines for .NET&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://learn.microsoft.com/aspnet/core/signalr/introduction" target="_blank" rel="noopener"
>Overview of ASP.NET Core SignalR&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://learn.microsoft.com/aspnet/core/signalr/hubs" target="_blank" rel="noopener"
>Use hubs in ASP.NET Core SignalR&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://learn.microsoft.com/aspnet/core/signalr/dotnet-client" target="_blank" rel="noopener"
>ASP.NET Core SignalR .NET client&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>