Avalist

ใช้ MediatR ทำ Response Caching

Caching น่าจะเป็นสิ่งที่จำเป็นหลักของการออกแบบโปรแกรมสมัยใหม่ เพื่อรองรับกับการจำนวน request ที่เพิ่มขึ้นอย่างรวดเร็ว ในบทความนี้เราจะมาลองทำ caching โดยใช้ MediatR กัน
ใช้ MediatR ทำ Response Caching

การทำ Caching เป็นเทคนิคที่สำคัญในการพัฒนาแอปพลิเคชันที่มีประสิทธิภาพ เนื่องจากช่วยให้การเข้าถึงข้อมูลเป็นไปอย่างรวดเร็วและลดภาระบนระบบหลัก โดยที่ระบบที่ต้องทำการโหลดข้อมูลจาก database หรือ external service จะสามารถเรียกใช้ข้อมูลที่เคยโหลดไว้แล้ว แทนที่จะต้องทำการโหลดข้อมูลใหม่ทุกครั้ง แต่วิธีในการทำ caching ก็มีอยู่หลายวิธีขึ้นอยู่กับตัวโปรแกรมหรือเทคโนโลยีที่เราใช้ และมีการทำ caching ในระดับต่าง ๆ ตั้งแต่ caching ในระดับ application จนถึง caching ในระดับ database หรือ service ภายนอก

ก่อนอื่นมาทำความรู้จัก Distributed Cache กันก่อน

จริงๆ แล้วใน ASP.NET Core รองรับการทำ caching อยู่หลายแบบทั้งที่เรียกว่าเป็น in-memory cache หรือ distributed cache โดยที่ in-memory cache จะเป็นการเก็บข้อมูลในหน่วยความจำของเครื่องเซิร์ฟเวอร์ ในขณะที่ distributed cache จะเป็นการเก็บข้อมูลในหน่วยความจำของเครื่องเซิร์ฟเวอร์หลายเครื่องหรือในระดับ service อื่นๆ ที่เป็นที่นิยมที่สุดก็คงหนีไม่พ้น Redis หรือ Memcached สาเหตุที่ทำไมถึงต้องใช้ Distributed Cache ทั้งๆ ที่เราสามารถเก็บข้อมูลลงในหน่วยความจำของเครื่องได้อยู่แล้ว เนื่องจากว่า application สมัยใหม่ๆ ไม่ได้ถูกสร้างให้รันอยู่บนเครื่องๆ เดียว แต่ระบบส่วนใหญ่มีการ scale และ deploy อยู่บน cloud หรือ container ที่สามารถขยาย application ให้รันได้หลายๆ instance พร้อมๆ กันเพื่อรับโหลดของผู้ใช้งานจำนวนมากๆ การที่เก็บข้อมูลไว้ที่เครื่องไดเครื่องหนึ่งอาจจะทำให้ข้อมูลในแต่ละเครื่องไม่ตรงกันและทำให้ผู้ใช้งานได้รับข้อมูลที่ไม่ถูกต้อง

ข้อดีข้อเสียของการใช้ Distributed Cache

ข้อดี

  1. Scalability สามารถเก็บข้อมูลในหน่วยความจำของเครื่องหลายเครื่องหรือในระดับ service อื่นๆ ที่สามารถขยายขนาดได้
  2. Data Consistency ข้อมูลที่เก็บใน distributed cache ทุกระบบที่เรียกจะได้ข้อมูลเดียวกัน
  3. Persistence ข้อมูลที่เก็บใน distributed cache สามารถเก็บไว้ได้นานเมื่อเทียบกับ in-memory cache ถ้าเกิดเรามีการ restart ตัวแอปพลิเคชันข้อมูลใน distributed cache ก็ยังคงอยู่
  4. High Availability การทำ distributed cache ทำให้ระบบของเรามีความเสถียรมากขึ้น

ข้อเสีย

  1. Complexity การทำ distributed cache จะทำให้ระบบของเรามีความซับซ้อนมากขึ้น
  2. Cost การใช้ distributed cache อาจจะมีค่าใช้จ่ายเพิ่มขึ้น
  3. Latency การเข้าถึงข้อมูลใน distributed cache อาจจะช้ากว่า in-memory cache หรือ local cache
  4. Security การทำ distributed cache อาจจะทำให้ข้อมูลที่เก็บอยู่ใน cache ถูกเข้าถึงได้ง่ายขึ้น

ในบทความนี้เราจะมาเรียนรู้การทำ caching ในระดับ application โดยใช้ Distributed Cache ยอดนิยมอย่าง Redis ซึ่งเราจะใช้ Docker ในการัน

Redis คืออะไร

Redis เป็นโปรแกรมที่ใช้เก็บข้อมูลในรูปแบบ key-value อยู่ในตระกูลจำพวกเดียวกับ NoSQL แต่มันไม่ใช่ฐานข้อมูลเพราะว่าข้อมูลที่เก็บใน Redis จะถูกเก็บไว้ในหน่วยความจำของเครื่องเซิร์ฟเวอร์ ด้วยเหตุผลที่ว่ามันเก็บอยู่ในหน่วยความจำมันจึงได้เปรียบในเรื่องความเร็วในการเข้าถึงข้อมูล type ของ value ที่เก็บก็จะมีอยู่หลายแบบเช่น string, list, set, sorted set, hash และอื่นๆ อีกมากมาย ในบริบทของ Distributed Cache Redis เป็นตัวเลือกแรกๆ ที่ใช้สำหรับการใช้งานโหลดข้อมูลที่มีโหลดจำนวนมากๆ โดยเฉพาะ cache ข้อมูลที่เราโหลดมาได้จาก database หรือ external service เพื่อเพิ่มประสิทธิภาพการทำงานของโปรแกรมที่เราเขียน

ลองรัน Redis ด้วย Docker

เราจะใช้ Docker ในการรัน Redis โดยเราจะใช้คำสั่ง docker run เพื่อรัน Redis ในรูปแบบ container ซึ่งถ้าใครยังไม่มี Docker รันอยู่ที่เครื่องลองติตตั้งจากเว็บไซต์ Docker ก่อน

Step 1: รัน Redis ด้วย Docker

ใช้คำสั่งด้านล่างรัน redis/stack ซึ่งจะรวม Redis server กับ RedisInsight ซึ่งเป็นเครื่องมือในการดูข้อมูลใน Redis อยู่ภายใน image เดียว ซึ่งปกติเราจะใช้สำหรับทดสอบโปรแกรมบนเครื่องเราเองเท่านั้น

	   
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest

ส่วนถ้าใครมีลงพวก RedisInsight อยู่แล้วก็สามารถรันเฉพาะที่ Redis server อย่างเดียวได้

	   
docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest

Step 2: เข้าใช้งาน RedisInsight หรือ redis-cli

หลังจากที่รันเสร็จแล้วเราสามารถเข้าไปดูข้อมูลใน Redis ได้โดยเข้าไปที่ http://localhost:8001 ในเว็บเบราว์เซอร์

Redis Insight

หรือจะ access ผ่าน redis-cli โดยผ่าน docker

	   
docker exec -it redis-stack sh

	   
redis-cli

ตัวอย่างการ set key

	   
> Set mykey "Hello"
OK

ตัวอย่างการ get key

	   
> Get mykey
"Hello"

ตัวอย่างการ set key แบบมีการกำหนดเวลาในการ expire ใน 10 วินาที

	   
> SETEX mykey 10 "Hello"
OK

Redis Insight with key set example

มาเริ่มทำ Caching บน Redis ด้วย ASP.NET Core กัน

ตัวอย่างโค้ดในบทความนี้สามารถดูได้ใน

Go to Avalist’s GitHub repo
ตัวอย่างโปรแกรม
	   
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

สร้าง ICacheService interface

	   
public interface ICacheService
{
    Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken);

    Task SetAsync<T>(string key, T value, TimeSpan slidingExpiration, CancellationToken cancellationToken = default);

    Task RemoveAsync(string key, CancellationToken cancellationToken = default);
}

สร้าง RedisCacheService class

	   
public class RedisCacheService(IDistributedCache cache) : ICacheService
{
    private static readonly JsonSerializerOptions serializerOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = true,
        AllowTrailingCommas = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken)
    {
        var value = await cache.GetAsync(key, cancellationToken);
        if (value is null) return default;
        return JsonSerializer.Deserialize<T>(value, serializerOptions);
    }

    public Task SetAsync<T>(string key, T value, TimeSpan slidingExpiration, CancellationToken cancellationToken = default)
    {
        var bytesValue = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, serializerOptions));
        return cache.SetAsync(
            key,
            bytesValue,
            new DistributedCacheEntryOptions
            {
                SlidingExpiration = slidingExpiration
            },
            cancellationToken);
    }

    public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
    {
        return cache.RemoveAsync(key, cancellationToken);
    }
}

เราสามารถเรียกใช้ RedisCacheService ผ่านการ register ที่ container ตอนที่แอพเรา start

	   
// register redis stack exchange service
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = configuration.GetConnectionString("RedisConnection");
    options.InstanceName = "ApiPractice";
});

// register caching service
services.AddScoped<ICacheService, RedisCacheService>();

Add redis connection string

	   
"ConnectionStrings": {
    "RedisConnection": "localhost:6379,ssl=False,abortConnect=False"
},

โดยทั่วไปเราสามารถเรียกใช้ ICacheService ได้ตรงๆ ผ่านตัว Dependency injection แต่ในบทความนี้จะแนะนำการเรียกใช้ ICacheService ผ่าน PipelineBehavior ของตัว MediatR ซึ่ง Pipeline Behavior จะทำหน้าที่คล้ายๆ กับ .NET Moddleware ที่ใช้สำหรับดักจับการทำงานก่อนหรือหลังของตัว request ทำให้เราสามารถที่จะแก้ไขหรือนำข้อมูลที่เราต้องการเข้าไปใน request หรือ response ได้

ในตัวอย่างเราจะลองสร้าง Behavior Pipeline เพื่อดักจับ request/response ของ API ที่เราสร้างขึ้นมาเพื่อที่จะเก็บ response ของเราลง Redis Caching อีกที ก่อนที่จะสร้าง Pipeline Behavior เราจะสร้าง interface เพื่อที่ไว้สำหรับระบุ Key ที่เราจะใช้ในการ cache และ expiration time

	   
public interface ICacheable
{
    string CacheKey { get; }

    TimeSpan SlidingExpiration { get; }
}

public interface ICacheable<TResponse> : IQuery<TResponse>, ICacheable
{
}

หลังจากนั้นก็สร้าง Pipeline Behavior ที่เราจะใช้ในการทำ caching โดยใช้ MediatR โดยเริ่มต้นเราจะสร้าง Pipeline โดยการเรียกใช้ ICacheService ที่เราสร้างไว้ก่อนหน้า ด้วยหลักการง่ายๆ คือจะทำการดักจับ request ที่มีการ implement interface ICacheable และทำการเก็บ response ลง Redis โดยใช้ key ที่ระบุจาก request และกำหนดเวลาที่จะหมดอายุของ cache ด้วย

	   
public sealed class CachingPipelineBehavior<TRequest, TResponse>(
    ICacheService cacheService,
    ILogger<CachingPipelineBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICacheable
    where TResponse : class
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        TResponse? cachedResult = await cacheService.GetAsync<TResponse>(request.CacheKey, cancellationToken);
        if (cachedResult is not null)
        {
            logger.LogInformation("Cache hit for {RequestName}", request.GetType().Name);
            return cachedResult;
        }

        logger.LogInformation("Cache miss for {RequestName}", request.GetType().Name);

        TResponse response = await next();
        if (response is not null)
        {
            await cacheService.SetAsync(
                request.CacheKey,
                response,
                request.SlidingExpiration,
                cancellationToken);
        }

        return response!; // Use the null-forgiving operator to suppress the warning
    }
}

ซึ่งตัว ICacheable เราจะใช้กับ Command Query ที่เราสร้างในบทความ ASP.NET Core สร้าง API ด้วย Command & CQRS pattern โดยการ implement ICacheable และระบุ key และเวลาที่หมดอายุของ cache จากตัวอย่างด้านล่างทุกๆ query ที่ทำการ implement ICacheable จะถูกให้ทำ Caching โดยอัตโนมัติ

	   
public record GetAllProductsQuery : IQuery<IReadOnlyList<ProductDto>>, ICacheable
{
    public string CacheKey => "ALL_PRODUCTS";

    public TimeSpan SlidingExpiration => TimeSpan.FromHours(1);
}

ก่อนที่จะเริ่มรันโปรแกรมอย่าลืมว่าเราต้องทำการ register Pipeline ที่เราสร้างขึ้นมา โดยการทำตามตัวอย่างด้านล่าง

	   
services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(applicationAssembly);
    config.AddOpenBehavior(typeof(CachingPipelineBehavior<,>));
});


ลองรันโปรแกรมกันดูเริ่มต้นในตัวอย่างโค้ดจะมี api ให้เรียกสองตัวคือ post products กับ get products ตัวที่เราทำ caching จะเป็น get products ซึ่งเราจะลองเรียก api หลายๅ ครั้งแล้วดูว่า response จะถูกเก็บ cache หรือไม่

Dotnet API Running

หลังจากที่เรียกไปหลายๆ ครั้งจะสังเกตว่า cache จะถูกสร้างตอนครั้งแรกที่เราเรียกตัว api Dotnet API Running

ถ้าเข้าไปดูใน RedisInsight ก็จะเห็นว่าข้อมูลถูกเก็บลง cache แล้วเรียบร้อย Dotnet API Running

สรุป

ในตัวอย่างทั้งหมดเป็นแค่วิธีการทำ Caching ในระดับ application โดยการประยุกต์ใช้กับ MediatR กับ Redis โค้ดที่เขียนอาจจะดูซับซ้อนแต่ว่าค่อนข้างเป็นระเบียบแล้วนำไปแก้ไขหรือปรับเปลี่ยนก็ง่ายมากๆ เผื่อเป็นทางเลือกให้กับ developer สาย .NET จะได้ลองใช้เทคนิคนี้ในการทำ Caching ในโปรเจคของตัวเองได้

ถ้าหากใครสนใจอยากดูวิธีการใช้ MediatR ลองดูได้ในบทความอื่นๆ ที่เขียนไว้ในนี้ได้นะครับ

  1. ใช้ MediatR ในการรับส่ง request/response ของตัว API “Dotnet API Command and Query pattern”
  2. ใช้ MediatR ในการทำ caching response ของตัว API “มาใช้ MediatR ทำ Response Caching”
  3. ใช้ MediatR ในการ log request/response ของตัว API “มาใช้ MediatR ทำ Logging”
  4. ใช้ MediatR ในการทำ Validation Handling “มาใช้ MediatR ทำ Validation handling”

อ่านบทความเพิ่มเติม

ใช้ MediatR ใน .NET Core ทำ Logging MediatR เป็นไลบรารีใน .NET Core ที่ใช้เพื่อจัดการการส่งข้อความหรือคำสั่งระหว่างระหว่าง component หลักการทำงานของ MediatR จะอยู่บนพื้นฐานของ Mediator Design Pattern โดยการประสานการทำงานระหว่างส่วนต่างๆ
1 min read
มาทำความรู้จัก MediatR ใน .NET Core MediatR เป็นไลบรารีใน .NET Core ที่ใช้เพื่อจัดการการส่งข้อความหรือคำสั่งระหว่างระหว่าง component หลักการทำงานของ MediatR จะอยู่บนพื้นฐานของ Mediator Design Pattern โดยการประสานการทำงานระหว่างส่วนต่างๆ
1 min read