Last active
December 20, 2024 04:20
-
-
Save rkttu/67175fe8ef8d1bfb1972b909f5329bba to your computer and use it in GitHub Desktop.
Garnet Technical Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Query Kind="Statements"> | |
<NuGetReference>Microsoft.Extensions.Caching.StackExchangeRedis</NuGetReference> | |
<NuGetReference>Microsoft.Garnet</NuGetReference> | |
<Namespace>Garnet</Namespace> | |
<Namespace>Garnet.server</Namespace> | |
<Namespace>Garnet.server.Auth.Settings</Namespace> | |
<Namespace>Microsoft.AspNetCore.Builder</Namespace> | |
<Namespace>Microsoft.AspNetCore.Http</Namespace> | |
<Namespace>Microsoft.Extensions.DependencyInjection</Namespace> | |
<Namespace>StackExchange.Redis</Namespace> | |
<IncludeUncapsulator>false</IncludeUncapsulator> | |
<IncludeAspNet>true</IncludeAspNet> | |
<RuntimeVersion>9.0</RuntimeVersion> | |
</Query> | |
// Start Redis Server | |
var redisPassword = Guid.NewGuid().ToString("n"); | |
Console.Out.WriteLine($"Redis Password: {redisPassword}"); | |
var options = new GarnetServerOptions() | |
{ | |
Address = "127.0.0.1", | |
Port = 6379, | |
MemorySize = "128m", | |
AuthSettings = new PasswordAuthenticationSettings(redisPassword), | |
}; | |
using var server = new GarnetServer(options); | |
server.Start(); | |
// Start ASP.NET Core Server | |
var builder = WebApplication.CreateBuilder( | |
Environment.GetCommandLineArgs().Skip(1).ToArray()); | |
// Add Redis | |
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => | |
ConnectionMultiplexer.Connect($"{options.Address}:{options.Port},password={redisPassword}") | |
); | |
var app = builder.Build(); | |
// API Endpoints | |
app.MapPost("/send", async (ChatMessage message, IConnectionMultiplexer redis) => | |
{ | |
var db = redis.GetDatabase(); | |
await db.ListRightPushAsync("messages", message.ToString()); | |
return Results.Ok(); | |
}); | |
app.MapGet("/messages", async (IConnectionMultiplexer redis) => | |
{ | |
var db = redis.GetDatabase(); | |
var messages = await db.ListRangeAsync("messages"); | |
return Results.Ok(messages.Select(m => m.ToString())); | |
}); | |
// Serve HTML page dynamically | |
app.MapGet("/", () => Results.Content(""" | |
<!DOCTYPE html> | |
<html lang="ko"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>가넷으로 구동되는 채팅 애플리케이션</title> | |
<style type="text/css" media="all"> | |
body { font-family: Arial, sans-serif; } | |
#chat { max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ccc; } | |
#messages { list-style-type: none; padding: 0; } | |
#messages li { padding: 8px; border-bottom: 1px solid #eee; } | |
#sendMessage { display: flex; gap: 10px; margin-top: 10px; } | |
#messageInput { flex: 1; padding: 8px; } | |
#sendButton { padding: 8px; } | |
#changeNickname { padding: 8px; margin-top: 10px; } | |
.modal { | |
display: none; | |
position: fixed; | |
z-index: 1; | |
left: 0; | |
top: 0; | |
width: 100%; | |
height: 100%; | |
overflow: auto; | |
background-color: rgb(0,0,0); | |
background-color: rgba(0,0,0,0.4); | |
padding-top: 60px; | |
} | |
.modal-content { | |
background-color: #fefefe; | |
margin: 5% auto; | |
padding: 20px; | |
border: 1px solid #888; | |
width: 80%; | |
max-width: 300px; | |
} | |
.modal input[type="text"] { | |
width: 100%; | |
padding: 10px; | |
margin: 10px 0; | |
box-sizing: border-box; | |
} | |
.modal button { | |
padding: 10px; | |
width: 100%; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="chat"> | |
<ul id="messages"></ul> | |
<div id="sendMessage"> | |
<input type="text" id="messageInput" placeholder="메시지를 입력하세요..." /> | |
<button id="sendButton">보내기</button> | |
</div> | |
<button id="changeNickname">닉네임 변경</button> | |
</div> | |
<div id="nicknameModal" class="modal"> | |
<div class="modal-content"> | |
<h2>닉네임 설정</h2> | |
<input type="text" id="nicknameInput" placeholder="닉네임을 입력하세요..." /> | |
<button id="saveNicknameButton">저장</button> | |
</div> | |
</div> | |
<script type="text/javascript"> | |
const messagesList = document.getElementById('messages'); | |
const messageInput = document.getElementById('messageInput'); | |
const sendButton = document.getElementById('sendButton'); | |
const changeNicknameButton = document.getElementById('changeNickname'); | |
const nicknameModal = document.getElementById('nicknameModal'); | |
const nicknameInput = document.getElementById('nicknameInput'); | |
const saveNicknameButton = document.getElementById('saveNicknameButton'); | |
let nickname = ''; | |
function openNicknameModal() { | |
nicknameModal.style.display = 'block'; | |
} | |
function closeNicknameModal() { | |
nicknameModal.style.display = 'none'; | |
} | |
async function fetchMessages() { | |
try { | |
const response = await fetch('/messages'); | |
const messages = await response.json(); | |
messagesList.innerHTML = messages.map(msg => `<li>${msg}</li>`).join(''); | |
} catch (error) { | |
console.error('메시지 가져오기 오류: ', error); | |
} | |
} | |
async function sendMessage(nickname, message) { | |
try { | |
await fetch('/send', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ nickname, text: message }) | |
}); | |
messageInput.value = ''; | |
fetchMessages(); // 메시지 전송 후 갱신 | |
} catch (error) { | |
console.error('메시지 전송 오류:', error); | |
} | |
} | |
saveNicknameButton.addEventListener('click', () => { | |
const newNickname = nicknameInput.value.trim(); | |
if (newNickname) { | |
nickname = newNickname; | |
closeNicknameModal(); | |
} | |
}); | |
changeNicknameButton.addEventListener('click', openNicknameModal); | |
sendButton.addEventListener('click', () => { | |
const message = messageInput.value.trim(); | |
if (nickname && message) { | |
sendMessage(nickname, message); | |
} | |
}); | |
// 닉네임이 설정되지 않은 경우 모달 열기 | |
if (!nickname) { | |
openNicknameModal(); | |
} | |
// 페이지 로드 시 메시지 가져오고, 자동 갱신 설정 | |
fetchMessages(); | |
setInterval(fetchMessages, 1000); // 1초마다 갱신 | |
// 모달이 닫혀 있으면 열기 | |
window.onclick = function(event) { | |
if (event.target == nicknameModal) { | |
closeNicknameModal(); | |
} | |
} | |
</script> | |
</body> | |
</html> | |
""", "text/html")); | |
await app.RunAsync(); | |
public sealed class ChatMessage | |
{ | |
public string Nickname { get; set; } = string.Empty; | |
public string Text { get; set; } = string.Empty; | |
public override string ToString() | |
=> $"{Nickname}: {Text}"; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Query Kind="Program"> | |
<NuGetReference>Microsoft.Extensions.Caching.StackExchangeRedis</NuGetReference> | |
<NuGetReference>Microsoft.Garnet</NuGetReference> | |
<Namespace>Garnet</Namespace> | |
<Namespace>Garnet.server</Namespace> | |
<Namespace>Garnet.server.Auth.Settings</Namespace> | |
<Namespace>Microsoft.AspNetCore.Builder</Namespace> | |
<Namespace>Microsoft.AspNetCore.Http</Namespace> | |
<Namespace>Microsoft.Extensions.DependencyInjection</Namespace> | |
<Namespace>Microsoft.Extensions.Hosting</Namespace> | |
<Namespace>System.Threading.Tasks</Namespace> | |
<Namespace>StackExchange.Redis</Namespace> | |
<IncludeUncapsulator>false</IncludeUncapsulator> | |
<IncludeAspNet>true</IncludeAspNet> | |
<RuntimeVersion>9.0</RuntimeVersion> | |
</Query> | |
public static class Program | |
{ | |
internal static readonly Lazy<Encoding> _utf8WithoutBomEncoding = | |
new Lazy<Encoding>(() => new UTF8Encoding(false), LazyThreadSafetyMode.None); | |
public static void Main(string[] args) | |
{ | |
var redisPassword = Guid.NewGuid().ToString("n").Dump(); | |
Console.Out.WriteLine($"Redis Password: {redisPassword}"); | |
var options = new GarnetServerOptions() | |
{ | |
Address = "127.0.0.1", | |
Port = 6379, | |
MemorySize = "128m", | |
AuthSettings = new PasswordAuthenticationSettings(redisPassword), | |
}; | |
using var server = new GarnetServer(options); | |
server.Start(); | |
// Start ASP.NET Core Server | |
var builder = WebApplication.CreateBuilder( | |
Environment.GetCommandLineArgs().Skip(1).ToArray()); | |
builder.Services.AddDistributedMemoryCache(); | |
builder.Services.AddSession(options => | |
{ | |
options.IdleTimeout = TimeSpan.FromMinutes(30); | |
options.Cookie.HttpOnly = true; | |
options.Cookie.IsEssential = true; | |
}); | |
// Add Redis | |
builder.Services.AddSingleton<StackExchange.Redis.IConnectionMultiplexer>(sp => | |
ConnectionMultiplexer.Connect($"{options.Address}:{options.Port},password={redisPassword}") | |
); | |
builder.Services.AddSingleton<QueueService>(); | |
var app = builder.Build(); | |
if (app.Environment.IsDevelopment()) | |
{ | |
app.UseDeveloperExceptionPage(); | |
} | |
app.UseRouting(); | |
app.UseSession(); | |
app.UseWhen(context => context.Request.Path.StartsWithSegments("/resource"), appBuilder => | |
{ | |
appBuilder.UseMiddleware<QueueMiddleware>(); | |
}); | |
app.MapGet("/", async context => | |
{ | |
await context.Response.WriteAsync(HtmlPages.IndexPage(), _utf8WithoutBomEncoding.Value); | |
}); | |
app.MapGet("/queue", async context => | |
{ | |
var userId = context.Request.Query["userId"].ToString(); | |
var queueService = context.RequestServices.GetRequiredService<QueueService>(); | |
await queueService.EnqueueAsync(userId); | |
int position = await queueService.GetQueuePositionAsync(userId); | |
if (position > 1) | |
await QueueMiddleware.HandleQueueAsync(context, queueService, userId, position); | |
else | |
context.Response.Redirect("/resource"); | |
}); | |
app.MapGet("/resource", async context => | |
{ | |
await context.Response.WriteAsync(HtmlPages.ResourcePage(), _utf8WithoutBomEncoding.Value); | |
}); | |
// 시뮬레이션: 사용자를 대기열에 추가 | |
var queueService = app.Services.GetRequiredService<QueueService>(); | |
var count = 30; | |
for (int i = 0; i < 30; i++) | |
{ | |
var userId = Guid.NewGuid().ToString(); | |
queueService.EnqueueAsync(userId).Wait(); | |
} | |
Console.WriteLine($"Total {count} user(s) added to the queue."); | |
// 시뮬레이션: 대기열에서 주기적으로 사용자 처리 | |
Task.Run(async () => | |
{ | |
var queueService = app.Services.GetRequiredService<QueueService>(); | |
while (true) | |
{ | |
var userId = await queueService.DequeueAsync(); | |
if (userId != null) | |
Console.WriteLine($"User {userId} entered."); | |
await Task.Delay(1000); // 5초마다 사용자 처리 | |
} | |
}); | |
app.Run(); | |
} | |
} | |
public class QueueService | |
{ | |
private readonly IConnectionMultiplexer _redis; | |
private readonly IDatabase _db; | |
private const string QueueKey = "queue"; | |
public QueueService(IConnectionMultiplexer redis) | |
{ | |
_redis = redis; | |
_db = _redis.GetDatabase(); | |
} | |
public async Task EnqueueAsync(string userId) | |
{ | |
await _db.ListRightPushAsync(QueueKey, userId); | |
} | |
public async Task<string> DequeueAsync() | |
{ | |
return await _db.ListLeftPopAsync(QueueKey); | |
} | |
public async Task<int> GetQueuePositionAsync(string userId) | |
{ | |
var queue = await _db.ListRangeAsync(QueueKey); | |
for (int i = 0; i < queue.Length; i++) | |
{ | |
if (queue[i] == userId) | |
{ | |
return i + 1; | |
} | |
} | |
return -1; | |
} | |
public async Task<int> GetQueueLengthAsync() | |
{ | |
return (int)await _db.ListLengthAsync(QueueKey); | |
} | |
} | |
public class QueueMiddleware | |
{ | |
private readonly RequestDelegate _next; | |
private readonly QueueService _queueService; | |
private const int MaxActiveUsers = 5; // 최대 활성 사용자 수 | |
public QueueMiddleware(RequestDelegate next, QueueService queueService) | |
{ | |
_next = next; | |
_queueService = queueService; | |
} | |
public async Task InvokeAsync(HttpContext context) | |
{ | |
if (!context.Session.TryGetValue("UserId", out var userId)) | |
{ | |
userId = Guid.NewGuid().ToByteArray(); | |
context.Session.Set("UserId", userId); | |
await _queueService.EnqueueAsync(Convert.ToBase64String(userId)); | |
} | |
string userIdString = Convert.ToBase64String(userId); | |
int activeUsersCount = await _queueService.GetQueueLengthAsync(); | |
if (activeUsersCount >= MaxActiveUsers) | |
{ | |
int position = await _queueService.GetQueuePositionAsync(userIdString); | |
await HandleQueueAsync(context, _queueService, userIdString, position); | |
} | |
else | |
{ | |
await _next(context); | |
} | |
} | |
internal static async Task HandleQueueAsync(HttpContext context, QueueService queueService, string userId, int position) | |
{ | |
context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; | |
context.Response.Headers.Pragma = "no-cache"; | |
context.Response.Headers.Expires = "0"; | |
if (position > 1) | |
{ | |
int estimatedWaitTimeInSeconds = (position - 1) * 10; // 사용자당 처리 시간(초) | |
await context.Response.WriteAsync(HtmlPages.WaitingPage(position, estimatedWaitTimeInSeconds, userId), Program._utf8WithoutBomEncoding.Value); | |
position = await queueService.GetQueuePositionAsync(userId); | |
} | |
else | |
{ | |
await queueService.DequeueAsync(); | |
context.Response.Redirect("/resource"); | |
} | |
} | |
} | |
internal static class HtmlPages | |
{ | |
public static string IndexPage() | |
{ | |
return @" | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>대기열 서비스 샘플</title> | |
<meta charset='UTF-8' /> | |
</head> | |
<body> | |
<div class='text-center'> | |
<h1 class='display-4'>환영합니다.</h1> | |
<p>대기열 서비스 샘플 웹 사이트입니다.</p> | |
<a href='./resource'>사이트 접속하기</a> | |
</div> | |
</body> | |
</html>"; | |
} | |
public static string ResourcePage() | |
{ | |
return @" | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>안녕하세요.</title> | |
<meta charset='UTF-8' /> | |
</head> | |
<body> | |
<div class='text-center'> | |
<h1 class='display-4'>안녕하세요.</h1> | |
<iframe width='400' height='225' src='https://www.youtube.com/embed/jNQXAC9IVRw' title='Me at the zoo' frameborder='0' allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' referrerpolicy='strict-origin-when-cross-origin' allowfullscreen></iframe> | |
</div> | |
</body> | |
</html>"; | |
} | |
public static string WaitingPage(int position, int estimatedWaitTime, string userId) | |
{ | |
double estimatedWaitTimeInMinutes = Math.Ceiling((double)estimatedWaitTime / 60); | |
return $@" | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>대기실</title> | |
<meta charset='UTF-8' /> | |
<script> | |
setTimeout(function(){{ | |
document.getElementById('waiting_form').submit(); | |
}}, 5000); // 5초마다 새로 고침 | |
</script> | |
</head> | |
<body> | |
<div class='text-center'> | |
<h1 class='display-4'>대기실</h1> | |
<p>현재 대기열에서 귀하의 위치: <strong>{position}</strong></p> | |
<p>예상 대기 시간: <strong>{estimatedWaitTimeInMinutes}</strong> 분</p> | |
<p>잠시만 기다려 주십시오. 귀하의 순서가 되면 자동으로 이동합니다.</p> | |
<form type='get' action='./queue' id='waiting_form'> | |
<input type='hidden' name='userId' value='{userId}' /> | |
</form> | |
</div> | |
</body> | |
</html>"; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment