สวัสดีครับ
บล็อคนี้เป็นเรื่องยากสักหน่อย และผมจะใช้ภาษาของตัวเองเพื่ออธิบายการทำให้ระบบงานซอฟแวร์ที่เราสร้างขึ้นมีคุณสมบัติ resilient ก็คือ การรับประกันระบบซอฟแวร์ว่าจะทำงานชดเชย หรือ ฟื้นฟูให้ระบบกลับมาทำงานให้บริการเป็นปกติเหมือนเดิม หากเกิดความผิดพลาด (failures) และส่งผลให้เกิดความสูญเสีย (loss) ขึ้นแก่ธุรกิจ หรือ สิ่งอื่นๆที่เกี่ยวข้องกับระบบงานซอฟแวร์ของเราตามมา
ผมคิดว่าคุณสมบัติข้อนี้เป็นหัวใจหลักของระบบงานซอฟแวร์ ที่ทำให้เกิดคุณสมบัติ Responsive และ Elastic เกิดได้ ผมจึงเขียนบล็อคนี้ขึ้นมา
Resilient คือ การรับประกันความผิดพลาดใดๆจากการทำงานของระบบที่เกิดขึ้นในระหว่างการทำงานปกติ เมื่อระบบเกิดข้อผิดพลาด และเข้าแผนความคุ้มครองความผิดพลาด (policy) ที่กำหนดไว้แล้ว ก็จะมีการทำงานชดเชยตามแผน เพื่อให้กลับมาทำงานได้ตามปกติ และเกิดความสูญเสียน้อยที่สุด
Resilient ไม่ได้รับประกันว่าจะไม่เกิดข้อผิดพลาด แต่รับประกันว่า หากเกิดข้อผิดพลาดแล้วระบบต้องสามารถชดเชย หรือฟื้นฟูตนเอง ให้กลับมาทำงานได้ตามปกติ
การทำงานได้ตามปกติ หมายความว่า ระบบต้องทำงานโดยอยู่ในสถานะ ไม่มีข้อผิดพลาด
ข้อผิดพลาดนี้ ไม่ใช่แค่ error เท่านั้น แต่มีความหมายรวมถึง metric ที่วัดค่า health, availability, performance, KPI, SLA และอื่นๆที่ใช้วัดคุณภาพของระบบซอฟแวร์ที่เรากำหนด ด้วย
การทำงานได้ตามปกติ ระบบต้องทำงานให้บริการ อยู่ในคุณภาพที่เรากำหนดกันตั้งแต่เริ่มต้นสร้างระบบ โดยดูจาก metric ที่วัดค่าได้ เช่น จำนวนข้อผิดพลาดต้องเป็นศูนย์, การให้บริการโดยเฉลี่ยต้องไม่เกิน 16 วินาที, ทุก containers ต้องพร้อมบริการอยู่เสมอ เป็นต้น
กระบวนการหลักของ Resilient ประกอบด้วย
หนึ่ง แผนความคุ้มครองความผิดพลาด (Policy) และ สอง กระบวนการชดเชยความผิดพลาด (Compensate)
Policy ก็คือ แผนความคุ้มครองความผิดพลาด ที่มีการระบุเงื่อนไขไว้เพื่อจับข้อผิดพลาด ที่อาจจะเกิดขึ้นจากกระบวนการทำงานปกติ
ตัวอย่างที่จะทำเป็นตัวอย่างนี้ใช้ package Polly ติดตั้งได้โดยเขาไปที่ visual studio 2017 แล้วพิมพ์คำสั่ง ข้างล่างนี้ลงไปที่ package console manager
PM> install-package Polly
Polly จัดเตรียมเครื่องมือสำหรับแผนความคุ้มครองให้เลือกใช้ ดังนี้
Retry: แผนนี้จะ จับข้อผิดพลาดแล้วนับสะสมไว้ จากนั้นจะชดเชยข้อผิดพลาดในแต่ละครั้งไป ไม่เกินจำนวนครั้งที่กำหนด
ตัวอย่าง code Retry
public void SimpleRetry()
{
int countFoo = 0;
Action foo = () =>
{
++countFoo;
Console.WriteLine($”Act foo: {countFoo}”);
throw new NullReferenceException();
};
Action<int> bar = (retryCount) => Console.WriteLine($”Compensate: {retryCount}”);
Policy
.Handle<NullReferenceException>()
.Retry(3, (exception, retryCount) =>
{
bar(retryCount);
})
.ExecuteAndCapture(foo);
}
Output SimpleRetry
Act foo: 1
Compensate: 1
Act foo: 2
Compensate: 2
Act foo: 3
Compensate: 3
Act foo: 4
ดูจาก Output SimpleRetry Act foo ครั้ง 4 ไม่มีการ Compensate ให้ ตามแผนประกันข้อผิดพลาด retry ให้ 3 ครั้ง
Circuit-breaker: แผนประกันข้อผิดพลาดนี้จะคุ้มครองระบบไม่ให้ทำงานที่ผิดพลาดซ้ำๆหลายครั้ง พร้อมกับแก้ไขระบบให้กลับมาทำงานได้ตามปกติ มี 3 state คือ
- Closed: เป็นสถานะเริ่มต้น กรณีการทำงานเป็นปกติ
- Open: จะเป็นสถานะนี้ ก็ต่อเมื่อเกิดข้อผิดพลาดเกินจำนวนที่กำหนดไว้ หลักจากนั้น Circuit-breaker จะตั้งเวลาหยุดใหม่
- (durationOfBreak) พร้อมกับ หยุดทุกกระบวนการตามระยะเวลาที่กำหนดนี้ จนกระทั่งหมดเวลา ก็จะไปอยู่ในสถานะ Half-Open
- Half-Open: เมื่ออยู่ในสถานะนี้ Circuit-breaker จะเปิดให้ทำงานตามปกติได้ และเมื่อเรียกแล้วเกิดข้อผิดพลาดอีก ก็จะไปอยู่ในสถานะ Open อีกครั้ง วนไป แต่ถ้าเรียกกระบวนการแล้วผ่านได้ Circuit-breaker จะไปอยู่ที่สถานะ Closed ตามเดิม
ตัวอย่าง code Circuit breaker
public void SimpleCircuitBreaker()
{
int countFoo = 0;
Action foo = () =>
{
++countFoo;
Console.WriteLine($”Act foo: {countFoo} NullReferenceException”);
throw new NullReferenceException();
};
Action foo2 = () => Console.WriteLine($”Act foo2 completed”);
var breaker = Policy
.Handle<NullReferenceException>()
.CircuitBreaker(
exceptionsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(2),
onBreak: (exception, durationOfBreak) =>
{
Console.WriteLine($”onBreak”);
},
onHalfOpen: () =>
{
Console.WriteLine($”onHalfOpen”);
},
onReset: () =>
{
Console.WriteLine($”onReset”);
}
);
breaker.ExecuteAndCapture(foo); // 1
breaker.ExecuteAndCapture(foo); // 2
breaker.ExecuteAndCapture(foo); // 3 เปลี่ยนสถานนะ Open
// ในช่วง 2 วินาทีรี้ ทุกกระบวนการที่เรียกหลังจากนี้จะถูกหยุด
breaker.ExecuteAndCapture(foo); // 4 ถูกหยุด
breaker.ExecuteAndCapture(foo2); // 5 ถูกหยุด
// จำลองโดยการรอให้เวลาหมด 3 วินาที
Task.Delay(TimeSpan.FromSeconds(3)).Wait();
breaker.ExecuteAndCapture(foo); // ถูกเรียกอีกครั้ง แต่เกิดข้อผิดพลาด
// จำลองโดยการรอให้เวลาหมด 3 วินาที
Task.Delay(TimeSpan.FromSeconds(3)).Wait();
breaker.ExecuteAndCapture(foo2); // เรียก function ที่ถูกต้อง
}
Output SimpleCircuitBreaker
Act foo: 1 NullReferenceException
Act foo: 2 NullReferenceException
Act foo: 3 NullReferenceException
onBreak
onHalfOpen
Act foo: 4 NullReferenceException
onBreak
onHalfOpen
Act foo2 completed
onReset
เมื่อพิจารณาดูจะเห็นว่า Act foo เกิดข้อผิดพลาด NullReferenceException ทำงาน 3 ครั้ง แล้ว เข้าสถานะ Open คือ onBreak หลังจากนั้นแม้จะเรียก function foo ที่มี NullReferenceException หรือ foo2 ที่ไม่มี exception ก็ถูก break ไว้ในสถานะ Open
ต่อมาจึงจำลอง โดย delay เวลาไว้ 3 วินาที มากกว่าเวลาที่ CircuitBreaker มัน break คือ 2 วินาที จนมันไปอยู่ที่สถานะ Half-Open สังเกต output onHalfOpen แล้วก็เรียก foo ให้เกิดข้อผิดพลาด จะเห็นว่าเรียกเพียงครั้งเดียวก็เข้าไปสู่สถานะ Open อีกครั้ง การนับข้อผิดผลาดจะสะสมต่ออีก จนกระทั้ง หมดเวาล break กลับไปสู่ HalfOpen อีกครั้ง จำลองให้ delay เวลาไว้ 3 วินาทีเหมือนเดิม
ทีนี้เรียก foo2 เป็น function ที่ทำงานไม่มีข้อผิดพลาด CircuitBreaker จะกลับไปอยู่สถานะ Closed อีกครั้งพร้อมกับเริ่มนับข้อผิดพลาดใหม่
สำหรับ กระบวนการชดเชยข้อผิดพลาด สำหรับแผนแบบ CircuitBreaker สามารถทำได้ที่ onBreak และ onHalfOpen คือเมื่อ เข้า onBreak ให้พยายาม แก้ไขข้อผิดพลาดในช่วงเวลานั้น และเมื่อเวลา break หมดลง จะเข้ากระบวนการ onHalfOpen อีกครั้งให้ตรวจสอบว่าการแก้ไขนั้นผ่านหรือยัง ก่อนที่งานในกระบวนการปกติจะกลับเข้ามาทำงานได้อีกครั้ง
Timeout: จะประกันว่าการทำงานส่วนนี้ ต้องไม่รอนานเกินกว่าเวลา (timeout) ที่ระบุไว้แน่นอน
แผนแบบ Timeout มี 2 ผลประโยชน์ให้เลือกคือ Pessimistic และ Optimistic
- Optimistic Timeout: จะชดเชยก็ต่อเมื่อ เกิดข้อผิดพลาดในระหว่างการทำงานปกติ ที่เกินเวลา Timeout ไปแล้วเท่านั้น
- Pessimistic Timeout: จะชดเชยทันทีไม่ว่ากรณีใดๆเมื่อการทำงานปกติเกิด Timeout
ตัวอย่าง code Optimistic Timeout
public void SimpleOptimisticTimeout()
{
Action foo = () =>
{
Task.Delay(TimeSpan.FromSeconds(3)).Wait();
Console.WriteLine($”Throw ApplicationException”);
throw new ApplicationException();
Console.WriteLine($”Act foo completed”);
};
Action bar = () => Console.WriteLine(“Compensate”);
var timeoutPolicy = Policy.Timeout(
timeout: TimeSpan.FromSeconds(2),
timeoutStrategy: TimeoutStrategy.Optimistic,
onTimeout: (context, timespan, task, exception) =>
{
bar();
}
);
timeoutPolicy.ExecuteAndCapture(foo);
}
output ของ Optimistic Timeout
Throw ApplicationException
Compensate
จาก function foo ผมให้ delay งานไว้ 3 วินาที ให้มากกว่า Timeout Policy คือ 2 วินาที เมื่อ throw exception จากการทำงานปกติ หลังจากนี้จะเกิน timeout แผนความคุ้มครองก็จะชดเชยให้โดยเรียก function bar ลองปรับให้ delay งานไว้ต่ำกว่า 2 วินาทีดูครับ
ตัวอย่าง code Pessimistic Timeout
public void SimplePessimisticTimeout()
{
Action foo = () =>
{
Task.Delay(TimeSpan.FromSeconds(3)).Wait();
Console.WriteLine($”Act foo completed”);
};
Action bar = () => Console.WriteLine(“Compensate”);
var timeoutPolicy = Policy.Timeout(
timeout: TimeSpan.FromSeconds(2),
timeoutStrategy: TimeoutStrategy.Pessimistic,
onTimeout: (context, timespan, task, exception) =>
{
bar();
}
);
timeoutPolicy.ExecuteAndCapture(foo);
}
output ของ Pessimistic Timeout
Compensate
จาก function foo ผมให้ delay งานไว้ 3 วินาที ให้มากกว่า Timeout Policy คือ 2 วินาที จะเห็นว่า แผนนี้จะชดเชยให้ทันที โดยไม่รอให้การทำงานปกติจบลง ไม่ว่าในการทำงานจะเกิดข้อผิดพลาดใดๆก็ตาม แผนแบบ Pessimistic Timeout ก็จะชดเชยให้ครับ
Bulkhead Isolation: แผนนี้รับประกันว่าการใช้ทรัพยกรเพื่อกระบวนการทำงานปกติต้องไม่เกินขีดจำกัดที่กำหนดไว้
ตัวอย่าง code Bulkhead Isolation
public void SimpleBulkheadIsolation()
{
Action<Context> foo = (n) =>
{
Task.Delay(TimeSpan.FromSeconds(1)).Wait();
Console.WriteLine($”Act foo {n.OperationKey} completed”);
};
Action<string> bar = (corId) => Console.WriteLine($”Compensate for {corId}”);
var bulkhead = Policy
.Bulkhead(
maxParallelization: 1,
maxQueuingActions: 1,
onBulkheadRejected: (ct) =>
{
bar(ct.OperationKey.ToString());
});
var taksList = new List<Task>();
for (int i = 0; i < 5; i++)
{
var t = Task.Factory.StartNew(() =>
{
var corAppId = new Context(Guid.NewGuid().ToString());
bulkhead.ExecuteAndCapture(foo, corAppId);
});
taksList.Add(t);
}
Task.WhenAll(taksList).Wait();
}
output ของ Bulkhead Isolation
Compensate for 7b9b6042-0d36-49e3-ae1a-d6b365f6c9b8
Compensate for d73bd757-a645-4a91-b1c7-b78cec4f64e5
Compensate for c5b071d6-27b6-48d0-97ab-b8599ef1ec13
Act foo 84a336ae-64c9-4cf6-a94f-6a69757cca9c completed
Act foo c347f29f-c091-474c-a45c-5cb5cdc103a2 completed
อธิบายตัวอย่าง code แผนแบบ Bulkhead Isolation ตัวอย่างกำหนดแผนแบบ Bulkhead กำหนดค่าดังนี้
maxParallelization คือ ค่าจำกัดให้ทำงานพร้อมๆกัน ตัวอย่างทำได้ทีละ 1 งาน
maxQueuingActions คือ ค่าจำกัดให้งานรอในคิวได้ไม่เกินกำหนด ตัวอย่างรอทำได้ทีละ 1 งาน
เหตุการณ์ onBulkheadRejected จะเกิดขึ้นก็ต่อเมื่อ งานที่เข้ามาเกินค่า maxQueuingActions แล้ว ในตัวอย่างจะให้ชดเชยด้วย function bar ตัวอย่างให้เพิ่มงานวนไป 5 งาน เมื่อดู output จะเห็นว่า งานถูกชดเชยไป 3 งานและอีก 2 งานทำงานได้ตามปกติ
Cache: แผนนี้จะคืนค่าที่รู้อยู่แล้วกลับไปทันที ถ้าไม่รู้จะเก็บค่าไว้ระยะเวลาหนึ่ง ตามที่กำหนด คือเป็นแผนที่ช่วยชดเชยผลลัพย์ได้ชั่วคราว ถูกใช้ประกอบกับแผนความคุ้มครองอื่นๆ
Fallback: แผนนี้จะประกันว่ากระบวนการปกติต้องไม่เกิดข้อผิดพลาดใดๆ โดยจะชดเชยผลลัพย์ หรือ ทำกระบวนการให้ใหม่ แทนกระบวนการปกติ เมื่อเกิดข้อผิดพลาดขึ้นตามที่ระบุไว้ในแผน
ตัวอย่าง code Fallback
public void SimpleFallback()
{
Func<string, Product> foo = (name) =>
{
return new Product(name);
};
var fallback = Policy<Product>
.Handle<ArgumentNullException>()
.Fallback(() =>
{
return new Product(“Compensate product”);
});
var result = fallback.ExecuteAndCapture( () => foo(“”) );
Console.WriteLine($”Outcome: {result.Outcome}, Product name: {result.Result.Name}”);
}
public class Product
{
public Product(string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentNullException();
Name = name;
}
public string Name { get; set; }
}
output ของ Fallback
Outcome: Successful, Product name: Compensate product
อธิบายจาก code ตัวอย่าง
ผมสร้าง class Product และให้เกิดกข้อผิดพลาดถ้าใส่ชื่อค่าว่างเข้าไป กับ กำหนด function foo ให้เป็น กระบวนการทำงานปกติ คือทำหน้าที่แค่สร้าง Product ให้ และ กำหนด fallback policy กำหนดความคุ้มครองที่ข้อผิดพลาด ArgumentNullException ซึ่งจะสร้าง Product ตั้งคือใหม่เป็น Compensate product ให้ใหม่แทนข้อผิดพลาดจาก foo
ดู output จะเห็นว่า กระบวนการปกติตั้งใจใส่ชื่อว่าง เพื่อให้เกิดข้อผิดพลาด จะเห็นว่า Outcome นี้มีค่า Successful และ มีการชดเชย product ให้ใหม่เป็นชื่อ Compensate product
PolicyWrap: การรับประกันข้อผิดพลาดให้ระบบ หรือ Resilient จะต้องใช้ทุกแผนความคุ้มครองที่กล่าวมาทั้งหมดนั้น เพื่อให้ครอบคุมข้อผิดพลาดที่อาจจะเกิดขึ้น และ ชดเชย หรือ ทดแทน เพื่อฟื้นฟูระบบให้กลับมาทำงานเป็นปกติเหมือนเดิมได้
ตัวอย่าง code PolicyWrap
public void SimplePizzaResilient()
{
Func<string, Product> makePizza = (name) => new Product(name);
Action<Product> normalShipPizza = (product) =>
{
Task.Delay(TimeSpan.FromSeconds(3)).Wait();
Console.WriteLine($”Normal ship {product.Name} completed”);
};
Action<Product> specialShipPizza = (product) =>
{
Console.WriteLine($”Special ship {product.Name} completed”);
};
var makePizzaFallback = Policy<Product>
.Handle<TimeoutException>()
.Fallback(
fallbackAction: (ctx) =>
{
// make Compensate Pizza and special ship service
var bigPizza = makePizza(“Big Pizza by Compensate”);
specialShipPizza(bigPizza);
return bigPizza;
});
var serviceTimeout = Policy.Timeout<Product>(
timeout: TimeSpan.FromSeconds(2),
timeoutStrategy: TimeoutStrategy.Pessimistic,
onTimeout: (context, timespan, task, exception) =>
{
// send signal for produce compensate pizza
throw new TimeoutException();
}
);
var myPizzaPolicy = Policy.Wrap<Product>(makePizzaFallback, serviceTimeout);
myPizzaPolicy.ExecuteAndCapture(() =>
{
var mySinaturePizza = “Simple Pizza by #:P”;
var thePizza = makePizza(mySinaturePizza);
normalShipPizza(thePizza);
return thePizza;
});
}
อธิบาย code ตัวอย่างนี้เป็นการบริการพิซซ่า โดยการให้บริการครั้งหนึ่ง ประกอบด้วยกระบวนการปกติ คือ makePizza, normalShipPizza และ specialShipPizza โดย normalShipPizza ทำให้เกิด delay 3 วิทาที
class Product ดูที่ code ตัวอย่าง Fallback
ที่แผนความคุ้มครองการบริการพิซซ่า myPizzaPolicy คือการ wrap 2 แผนไว้ด้วยกัน ได้แก่ makePizzaFallback และ serviceTimeout โดยแผน makePizzaFallback จะทำการทำชดเชย โดยทำ pizza ขนาดใหญ่ makePizza พร้อมกับส่ง pizza แบบพิเศษ specialShipPizza
serviceTimeout ในตัวอย่างนี้ให้ง่าย จะส่ง signal โดย throw exception TimeoutException ออกไปเฉย เพื่อให้ makePizzaFallback ทำกระบวนการชดเชยให้
พอลอง run ดูก็จะเห็น output เป็นแบบนี้ครับ
Special ship Big Pizza by Compensate completed
แล้วลองแก้ไข delay ใน function normalShipPizza เป็น 1 วินาทีดู ก็จะ output ปกติออกมา ลอง run ดูครับ
ผมขอจบบล็อคไว้เพียวเท่านี้ก่อนครับ หวังว่าจะเป็นประโยชน์
ขอบคุณครับ
#:P