Developer Advocate
رفتن به کانال در Telegram
1 035
مشترکین
اطلاعاتی وجود ندارد24 ساعت
اطلاعاتی وجود ندارد7 روز
-130 روز
آرشیو پست ها
1 035
در دنیای React Server Components (RSC) امکان fetching داده مستقیماً روی سرور و استریم آن به سمت کلاینت، یکی از paradigm-shiftهای عملیاتی است که UX را دگرگون میکند. بیایید یک الگوی کاربردی و پیشرفته را برای این منظور بررسی کنیم:
فرض کنید یک لیست داده باید از API دریافت شده و با هر chunk جدید، بلافاصله رندر و نمایش داده شود (partial hydration). با بهرهگیری از قابلیت async/await و استریم سرور به شکل زیر پیش بروید:
// app/components/StreamingPosts.server.jsx
import { Suspense } from 'react';
// هر Post کد زیر را Fetch و Render میکند
async function fetchPostsChunked() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await res.json();
// تقسیم به chunk های کوچک برای نمایش تدریجی
const chunkSize = 10;
let chunks = [];
for (let i = 0; i < posts.length; i += chunkSize) {
chunks.push(posts.slice(i, i + chunkSize));
}
// Emulate delay for streaming
for (const chunk of chunks) {
// هر chunk ابتدا yield میشود، سپس ادامه داده
yield chunk;
await new Promise(r => setTimeout(r, 700)); // شبیهسازی latency
}
}
export default async function StreamingPosts() {
const chunks = fetchPostsChunked();
return (
<div>
{/*
هربار که یک chunk آماده شد، کامپوننت بهصورت incremental عرضه میشود.
این ویژگی توسط React Server Components و Suspense ممکن است.
*/}
<Suspense fallback={<p>در حال دریافت دادهها...</p>}>
{Array.fromAsync(chunks, async (chunk, idx) =>
<PostsChunk key={idx} posts={chunk} />
)}
</Suspense>
</div>
);
}
function PostsChunk({ posts }) {
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
🔹 با این معماری، هر chunk روی سرور fetch و فوراً به کلاینت استریم میشود، درنتیجه کاربر به جای انتظار برای کل payload، تجربه رندر تدریجی و پاسخگو خواهد داشت.
🧠 نکته: Array.fromAsync هنوز در بعضی bundlerها experimental است. اگر مشکل داشتید میتوانید از helper داخلی یا لایبری مثل streaming-ssr استفاده کنید، یا async generator را با stateful component و هوک ترکیب کنید.
این الگو بهترین بهره را از SSR، استریمینگ و تمایز Client/Server Components میگیرد و فراتر از صرفاً SSR سنتی عمل میکند. برای پروژههای مقیاسپذیر، این pattern را در ساخت featureهای interactivity-critical جدی بگیرید!
@DeveloperAdvocate 🥑1 035
🎯 معماری Idempotency در API — پیادهسازی Endpoint ایمن در ASP.NET Core
در پرداخت، سفارش، یا هر عملیات حیاتی، تضمین Idempotency یعنی هر درخواست تکراری با همان ورودی، همیشه همان خروجی را دارد. بیایید یک Idempotent Endpoint با توکن درخواست (Request ID) پیادهسازی کنیم که هم ساده باشد و هم مقیاسپذیر.
فرض: درخواستها یک هدر اختیاری
"Idempotency-Key" دارند. در Database یا Redis نتیجه هر درخواست بر اساس این کلید کش میشود.
⛓️ 1. Middleware برای استخراج و مدیریت Idempotency-Key:
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _cache;
public IdempotencyMiddleware(RequestDelegate next, IDistributedCache cache)
{
_next = next;
_cache = cache;
}
public async Task InvokeAsync(HttpContext context)
{
var key = context.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(key))
{
var cached = await _cache.GetStringAsync(key);
if (cached != null)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(cached);
return;
}
// نگهداری Out-Response برای کش شدن بعداً
var originalBody = context.Response.Body;
using var memStream = new MemoryStream();
context.Response.Body = memStream;
await _next(context);
// بررسی موفقیت و ذخیره پاسخ
if (context.Response.StatusCode == StatusCodes.Status200OK)
{
memStream.Position = 0;
var responseBody = new StreamReader(memStream).ReadToEnd();
await _cache.SetStringAsync(key, responseBody, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
memStream.Position = 0;
await memStream.CopyToAsync(originalBody);
}
context.Response.Body = originalBody;
return;
}
await _next(context);
}
}
⛓️ 2. ثبت Middleware و کانفیگ Redis:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379"; // آدرس Redis
});
app.UseMiddleware<IdempotencyMiddleware>();
✨ نکته مهم:
- فقط درخواستهایی که با موفقیت اجرا میشوند کش شوند (Status200).
- Idempotency-Key منحصربهفرد توصیه میشود (مثلاً GUID).
- اگر عملیات حالتمند دارید (مثلاً ثبت سفارش)، در Service layer سمت سرور با transaction تضمین شود که داده تکراری ثبت نشود.
این الگو برای API-first محصولات مالی و SaaS بسیار حیاتی است. سناریوهای پیچیدهتر مانند مدیریت Race Condition یا Status Codeهای مختلف، نیاز به Extension دارند.
#معماری #Idempotency #ASPNetCore #Redis #API #SolidPrinciples
@DeveloperAdvocate 🥑1 035
یکی از ابزارهایی که بهرهوری توسعهدهندگان .NET و JavaScript را به شکل چشمگیری افزایش میدهد، افزونهی VS Code به نام "GitLens" است. فراتر از Git Blame سنتی، GitLens جزئیات عمیقی مثل تاریخچهی خطی که روی آن ایستادهاید، آخرین commit، تفاوت فعلی با commit قبلی، و حتی اطلاعات وابستگیها را بهصورت Context-aware نمایش میدهد. اما نکتهی طلایی برای مهندسان حرفهای این است که میتوانید با ترکیب GitLens و قابلیت CodeLens خود .NET، traceability بسیار دقیقی از تغییرات کد و دلیل و زمان اعمال آنها داشته باشید.
فرض کنید یک سرویس حساس دارید و نیاز است بفهمید چرا متد زیر اخیراً تغییر کرده و چه کسی روی آن commit زده است. کافی است با فعالسازی CodeLens و نصب GitLens، هنگام باز کردن فایل، بالای متد توضیح موردنظر را همراه با لینک به commit و issue مرتبط مشاهده کنید:
public async Task<Result> Handle(Command request, CancellationToken cancellationToken)
{
// ... business logic
}
انتخاب این اطلاعات نه تنها باعث حفظ context هنگام دیباگ و کدنویسی میشود، بلکه ردیابی refactorها و رفع regression را به فرایندی سریع و self-serviced تبدیل میکند.
در پروژههای جاوااسکریپتی نیز GitLens عملاً "archeology" کد را ساده میکند. بهمحض حرکت روی هر خط، علت تغییر، شماره PR، و نام committer بهوضوح نمایش داده میشود — نیازی به "git log"های تکراری و خروج از محیط توسعه نیست.
پیشنهاد ویژه: تنظیم کنید GitLens تغییرات خطوط حساس (مثل فیلدهای امنیتی یا متدهای کلیدی) را با annotation متفاوت نمایش دهد. این کار به حفظ معماری clean و Stable API Contract کمک شایانی میکند.
@DotNetArchitects
@DeveloperAdvocate 🥑1 035
معماری سیستمها و CAP Theorem — چرا گاهی اوقات CP را به AP ترجیح میدهیم؟
The CAP Theorem بیان میکند که در یک سیستم توزیعشده، تنها میتوانید دو مورد از سه ویژگی Consistency (یکسانی دادهها از دید همه نودها)، Availability (همیشه پاسخگویی سرویس) و Partition tolerance (تابآوری در برابر قطعی شبکه) را تضمین کنید.
فرض کنید در حال توسعه یک سیستم بانکداری آنلاین هستید که شبیهسازی عملیات انتقال وجه میان حسابهای بانکی را انجام میدهد. در این سناریو، فرض بر این است که partition tolerance حتماً باید رعایت شود (هیچ گزینهای جزء تحمل قطع ارتباط نداریم)، اما باید بین Consistency و Availability یکی را انتخاب کنیم.
انتخاب CP:
در سیستم بانکی، Consistency بسیار حیاتی است—نباید دو برداشت همزمان باعث شود موجودی به عدد منفی تغییر کند یا اطلاعات ناهماهنگ میان نودها شکل بگیرد. فرض کنید یک درخواست انتقال وجه از طریق یک شعبه ارسال میشود، اما به دلیل قطعی شبکه، نود دوم بانک در دسترس نیست. اگر AP را انتخاب کنیم، هر دو شعبه فارغ از قطعی میتوانند به صورت مستقل تراکنش ثبت کنند (ولی دادهها ممکن است ناسازگار شوند). اما اگر CP را انتخاب کنیم، سرویس تا زمانی که ارتباط شبکه بین نودها برقرار نشود، پاسخدهی نمیکند (availability قربانی میشود تا از consistency دفاع شود).
تجربه معماری:
در داتنت با استفاده از Event Sourcing و CQRS این سناریو را شبیهسازی کنید. فرض کنید میخواهید در سرویس اعتبارسنجی برداشت وجه، بدون دریافت تایید از quorum اکثریت نودها اجازه برداشت ندهید (درست شبیه Raft یا Paxos).
نمونه ساده:
public async Task<bool> Withdraw(Guid accountId, decimal amount)
{
var quorum = await GetQuorumAsync();
var votes = 0;
foreach (var node in quorum)
{
if (await node.CanWithdrawAsync(accountId, amount))
votes++;
}
if (votes < quorum.Count / 2 + 1)
return false; // Consistency over Availability
await CommitWithdrawAsync(accountId, amount);
return true;
}
در سناریوی بالا، اگر ارتباط با بخشی از quorum قطع شود یا تاخیر بیفتد، عملیات برداشت لغو یا معلق میماند، اما هیچگاه دادههای نامتناقض تولید نمیشود.
نکته کلیدی:
در مواردی که یکسانی داده (Consistency) اولویت حیاتی دارد—مثلاً حساب بانکی، سیستم رزرو صندلی هواپیما، یا مدیریت موجودی انبار—انتخاب CP معماری قابل دفاع و منطقی به شمار میرود، حتی به قیمت کاهش availability. انتخاب درست به دامنه مسأله بستگی مستقیم دارد.
@DeveloperAdvocate 🥑1 035
🎯 تستپذیری پیشرفته منطق وابسته به زمان با TimeProvider در .NET
تقریباً همهی اپلیکیشنها در بخشی از منطق خود به زمان سیستم وابستهاند: بررسی سررسید، انقضا، ثبت زمان یا محاسبه مدت زمان سپریشده. مشکلِ دیرینه اینجاست: هنگام Unit Test، نمیتوانیم به راحتی خودمان را «در آینده» یا «در گذشته» قرار دهیم. این وابستگی به
DateTime.UtcNow یا DateTime.Now تست کردن را به کابوس تبدیل میکند.
### 🔍 راهحل مدرن: TimeProvider
از .NET 8 به بعد، کلاس جدیدی به نام TimeProvider معرفی شده که وابستگی مستقیم به زمان سیستم را حذف و Mock کردن زمان را بسیار ساده میکند.
### ✅ مثال کاربردی
منطق کلاس زیر باید بررسی کند آیا سفارش منقضی شده است یا خیر:
public class Order
{
private readonly DateTime _createdAt;
private readonly TimeSpan _expiry;
private readonly TimeProvider _timeProvider;
public Order(DateTime createdAt, TimeSpan expiry, TimeProvider timeProvider)
{
_createdAt = createdAt;
_expiry = expiry;
_timeProvider = timeProvider;
}
public bool IsExpired()
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
return now - _createdAt > _expiry;
}
}
#### 👇 در Production:
var order = new Order(DateTime.UtcNow, TimeSpan.FromDays(2), TimeProvider.System);
#### 🧪 در Unit Test (Mock زمان):
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(new DateTime(2024, 7, 1));
var order = new Order(
createdAt: new DateTime(2024, 6, 29),
expiry: TimeSpan.FromDays(2),
timeProvider: fakeTime);
Assert.True(order.IsExpired());
#### نکات حرفهای
- تمام لایههای وابسته به زمان را با DI به TimeProvider وابسته کنید نه DateTime.UtcNow.
- در تستها میتوانید با FakeTimeProvider، زمان را Freeze، Fast-Forward یا حتی به عقب ببرید.
- تست وضعیتهای مرزی (Boundary Cases) به طرز چشمگیری سادهتر شدهاند.
🧠 حرفهای فکر کنید:
وقتی بخشهای سیستمتان از تقویم و ساعت سیستم مستقل شوند، نه تنها تستپذیری وحشیانهای خواهید داشت، بلکه قابلیت سناریوسازی تکرارپذیر (Deterministic Scenarios) در Integration Test و Simulation را هم به آسانی به دست میآورید.
#dotnet #architecture #testing #bestpractice
@DeveloperAdvocate 🥑1 035
اگر از دنیای backend مثل .NET یا Java وارد اکوسیستم JavaScript (مثلاً Node.js) شدهاید، درک دقیق Event Loop کلید اصلی مواجهه با رفتار async جاوااسکریپت است. تفاوت بنیادین اینجاست: جاوااسکریپت، برخلاف مدل threading سنتی، یک thread دارد که به کمک Event Loop و queueها به شکل غیرمستقیم و غیرهمزمان کد را اجرا میکند.
مکانیزم به این صورت است:
۱. همه کدها ابتدا وارد "Call Stack" میشوند.
۲. اگر یک عملیات async (مثل خواندن فایل یا query به DB) رخ دهد، این درخواست به موتور C++ (مانند libuv در Node.js) واگذار شده و سپس callback آن به Event Queue میرود.
۳. Event Loop به صورت مداوم چک میکند که آیا Call Stack خالی است؛ اگر بله، یک task از Event Queue برداشته و اجرا میکند.
یک تفاوت مهم با backend این است که هیچوقت چندین thread به صورت همزمان در JS کد شما را اجرا نمیکنند؛ بلکه Event Loop است که به وظایف معلق یکییکی سرویس میدهد. بنابراین race conditionهای کلاسیک کمتر پیش میآیند، اما امکان starvation یا block شدن آن thread اصلی وجود دارد (مثلاً با یک حلقه سنگین sync).
برای فهم عمیقتر، این pseudo-code را ببینید:
// معادل C# (با ThreadPool)
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("async"));
// تفاوت: C# چند thread موازی دارد، اما JS یک Event Loop (یک thread + queues) دارد.
در جاوااسکریپت همان عملیات:
setTimeout(() => console.log("async"), 0);
console.log("sync");
// خروجی:
// sync
// async
اینجا حتی با setTimeout(..., 0)، callback بعد از خاتمه کد sync وارد صف میشود.
# جمعبندی معمارانه
اگر در Node.js کدهای backend مینویسید، بلوکه شدن Event Loop مساوی است با عدم پاسخگویی کل اپلیکیشن. پس همیشه از عملیات سنگین sync یا حلقههای بلاککننده پرهیز کنید و async را به سبک جاوااسکریپت فرا بگیرید؛ اکوسیستم شما دیگر multi-threaded به سبک .NET نیست—یک Event Loop دارید و همین!
@DeveloperAdvocate 🥑1 035
🟢 TypeScript: از قدرت Type Inference نهایت استفاده را ببرید
در TypeScript نوشتن typeها میتواند verbose و تکراری شود. اما Type Inference قوی TypeScript، بهترین متحد شما برای کدهای robust و maintainable است—بهخصوص هنگام کار با ساختارهای پیچیده یا فراخوانی APIهای generics.
برای نمونه، فرض کنید یک تابع دریافت داده از API دارید که خود تابع generic است:
async function fetchData<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json();
}
هنگام تعریف داده، میتوانید یک type دقیق بسازید:
type User = {
id: number;
username: string;
permissions: string[];
};
و حالا هنگام فراخوانی تابع، explicit type را پاس بدهید تا بقیهی کد در تمام زنجیره براساس این type پیش بروند:
const user = await fetchData<User>('/api/user/me');
user.permissions.includes('SYS_ADMIN'); // TypeScript دقیقاً میداند permissions چیست
نکته حرفهای: در توابع ترکیبی (Compositional Functions)، تا میتوانید اجازه دهید TypeScript خودش نوع را infer کند تا از تکرار و خطاهای انسانی جلوگیری شود. مثلاً هنگام استفاده با RxJS یا ترکیب map/filter/reduce، حتماً توجه کنید خروجی هر عملیات، نوع صحیح را منتقل میکند—و اگر نیاز بود، با یک type assertion دقیق این تضمین را کاملتر کنید.
استفاده هوشمندانه از type inference در کنار تصحیح explicit type annotation فقط برای APIها و لایههای انتزاعی باعث میشود همزمان هم کد conciseness و هم Type-Safety را داشته باشید. این موضوع، خوانایی و maintainability را طی زمان به شکل چشمگیری افزایش میدهد.
@DeveloperAdvocate 🥑1 035
آیا هنوز برای پیادهسازی الگوی Producer/Consumer در .NET روی کلاسهای قدیمی مثل BlockingCollection تکیه میکنید؟ وقت آن رسیده که با Channelها در
System.Threading.Channels آشنا شوید؛ ابزاری مدرن، سریع و thread-safe برای سناریوهای concurrent messaging.
Channelها بدون lock و با کمترین contention ممکن دادهها را میان producer و consumerها منتقل میکنند—و بسیار مناسب بارهای بالا و سناریوهای async هستند.
در ادامه یک نمونه minimal و idiomatic از پیادهسازی Producer/Consumer فولفانکشنال با Channel را میبینید:
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
public static class ChannelDemo
{
public static async Task RunAsync()
{
// کانالی با ظرفیت ۱۰۰ که دادهها را buffer میکند
var channel = Channel.CreateBounded<int>(100);
// Producer
var producer = Task.Run(async () =>
{
for (int i = 0; i < 500; i++)
{
await channel.Writer.WriteAsync(i);
}
channel.Writer.Complete(); // پایان ارسال داده
});
// Multiple Consumers (scalable)
var consumers = new Task[3];
for (int c = 0; c < consumers.Length; c++)
{
consumers[c] = Task.Run(async () =>
{
await foreach (var item in channel.Reader.ReadAllAsync())
{
// عملیات روی داده
Process(item);
}
});
}
await producer;
await Task.WhenAll(consumers);
}
static void Process(int item)
{
// پردازش داده (مثلاً ذخیره در دیتابیس یا ...)
Console.WriteLine($"Consumed: {item}");
}
}
// فراخوانی:
await ChannelDemo.RunAsync();
قابل توجه:
- Channel.CreateBounded نسبت به unbounded کنترل بهتری روی memory ارائه میدهد و backpressure ایجاد میکند.
- channel.Writer.Complete()، نقشه خروج تمیز مصرفکنندهها را فراهم میکند تا بعد از پایان داده، gracefully خارج شوند.
- ReadAllAsync مصرفکنندههای async را به سادگی پیادهسازی میکند.
- میتوانید تعداد consumerها را بسته به نیاز scale کنید—کافیست حلقهی consumers را افزایش دهید.
Channelها ابزار عالی برای parallel pipelineها، پردازش رویداد، یا هرجای دیگری هستند که backpressure و throughput اهمیت دارد.
#dotnet #channels #concurrency #پارالل
@DeveloperAdvocate 🥑1 035
برنامهنویسی ناهمگام در #NetCore. فراتر از async/await — | IAsyncEnumerable و ValueTask |
اگر در دنیای #CSharp با async/await به خوبی آشنا هستید، زمان آن فرارسیده که به مرحله بعدی بروید. دو مفهوم کلیدی وجود دارد که میتواند عملکرد سرویسهای شما را در چندین سطح بهینه کند:
1️⃣ IAsyncEnumerable<T>
وقتی با استریم دادهٔ بزرگ، پایگاه داده، یا سرویسهای ریموت کار میکنید که توان ارائه بخش بخش دیتا را دارند، استفاده از Collectionهای معمول یا حتی Task<IReadOnlyList<T>> باعث تجمع کل داده در حافظه میشود.
اینجاست که
IAsyncEnumerable<T> وارد میشود.
مثال کوتاه:
public async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(100); // شبیهسازی عملیات طولانی
yield return i;
}
}
// مصرف کننده:
await foreach (var n in GetNumbersAsync())
{
Console.WriteLine(n);
}
با این روش، دادهها به صورت Lazy و ناهمگام پردازش میشوند؛ هم زمان پاسخدهی را کم میکند، هم فشار روی Memory را کاهش میدهد.
۲️⃣ ValueTask و جایگزینی با Task
وقتی یک API ناهمگام اغلب با دادهٔ Cash شده یا بسیار سریع پاسخ میدهد، Task<T> هزینهٔ بیشتری نسبت به ValueTask<T> دارد.
ValueTask<T> ساختاری است که حتی میتواند بدون Heap-allocation، عملیات را انجام دهد.
مثال ملموس:
public ValueTask<string> GetCachedUserNameAsync(int userId)
{
if (_cache.TryGetValue(userId, out var name))
return new ValueTask<string>(name); // عملیات Sync بدون Heap
return new ValueTask<string>(GetUserNameFromDbAsync(userId)); // عملیات Async
}
نکته حرفهای:
استفاده از ValueTask زمانی مقرون به صرفه است که callهای sync و async را ترکیب میکنید. اما بازگشت ValueTask بسیار پیچیدهتر از Task است؛ پس به درستی dispose و await کردن آن دقت کنید.
🦾 با مسلح شدن به این ابزارها، مدلهای دادهتان را هم منعطفتر و هم سریعتر کنید. تجربه شما از استفاده همزمان این دو ابزار چی بوده؟
@DeveloperAdvocate 🥑1 035
🔎 عملکرد Reflection در .NET و جایگزینهای سریعتر
یکی از جذابیتهای داتنت، قدرت Reflection است؛ ابزاری بیرقیب برای اکتشاف و کار با متادادهها، اما با قیمتی سنگین: هزینهی سنگین اجرا.
⚠️ چالش عملکردی
Reflection ذاتاً کند است، زیرا نیازمند عملیات زمان اجرا بر روی متادیتای اسمبلی و JIT میباشد. مثلا فراخوانی یک متد با reflection تا ۲۰ برابر کندتر از فراخوانی مستقیم است:
var method = typeof(MyService).GetMethod("DoWork");
method.Invoke(obj, null); // Slow!
در مسیر اصلی (hot path) برنامه، یا جایی که حجم زیادی data و فراخوانی داریم (مثلاً ORMها، سریالایزرها)، استفادههای بیمحابا از Reflection معمولا نشانهای از anti-pattern است.
🔥 جایگزینهای سریع: Auto-Generated Delegates & Expression Trees
برای عبور از چالش، میتوان از Delegateهای تولیدشده در زمان اجرا بهره برد که تقریباً با call مستقیم رقابت میکنند:
var method = typeof(MyService).GetMethod("DoWork");
var del = (Action<MyService>)Delegate.CreateDelegate(typeof(Action<MyService>), method);
del(obj); // Much faster
یا با ساختن Expression Tree:
var method = typeof(MyService).GetMethod("DoWork");
var param = Expression.Parameter(typeof(MyService), "instance");
var call = Expression.Call(param, method);
var lambda = Expression.Lambda<Action<MyService>>(call, param).Compile();
lambda(obj); // مشابه Delegate، با انعطافپذیری بیشتر
در اکثر سورسهای مدرن (مانند Newtonsoft.Json، Dapper و غیره)، این تکنیک کاملاً پیادهسازی شده تا cost reflection به حداقل برسد.
✨ گزینههای عصر جدید
در NET 5+، کتابخانههایی مانند System.Reflection.Metadata و source generators روشهای جدیدتری برای سرعت گرفتن در متادیتا را معرفی نمودهاند. ابزارهایی مانند Roslyn-based Source Generators کد reflection را در زمان کامپایل تولید میکنند و عملاً overhead را حذف میکنند.
---
🎯 نتیجه عملی:
اگر در هستهی سرویس خود نیاز به Reflection دارید، برای performance-critical stuff، همیشه delegate caching یا expression compilation را مدنظر بگیرید و از استفادهی سادهی reflection پرهیز کنید—و اگر مدل داده منسجم و تغییرناپذیر دارید، source-generatorها و compile-time meta-programming نقطهی طلایی معماری شما خواهند بود.
@DeveloperAdvocate 🥑1 035
🔐 Dependency Confusion Attack: تهدیدی جدی برای امنیت پکیجهای نرمافزاری
حمله Dependency Confusion زمانی رخ میدهد که نرمافزار شما به پکیجهایی با نام مشابه در یک ریپازیتوری عمومی (مانند NuGet) و خصوصی وابستگی دارد. اگر در پروژه وابستگی به
My.Internal.Package وجود دارد و یک مهاجم پکیجی با همین نام اما ورژن بالاتر را روی NuGet ثبت کند، ابزار مدیریت پکیج (مثل NuGet CLI یا dotnet restore) ممکن است به جای نسخه داخلی، نسخه مخرب را از ریپازیتوری عمومی فراخوانی کند.
🎯 راهکارهای عملی برای محافظت در برابر این حمله:
1. اولویتدهی صریح به فید خصوصی: ترتیب فیدها در فایلهای پیکربندی (nuget.config) اهمیت دارد. همیشه ابتدا فیدهای داخلی و امن قرار بگیرند.
<configuration>
<packageSources>
<add key="PrivateFeed" value="https://nuget.yourorg.local/v3/index.json" />
<add key="NuGet official" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
2. بستن فیدهای عمومی برای نامهای حساس: با Private Feeds پکیجهای سازمانی را تنها بر روی ریپازیتوری اختصاصی نگه دارید. میتوانید از گزینه trustedSigners نیز استفاده کنید تا تنها پکیجهای با امضای معتبر Load شوند.
3. Disable fallback به فید عمومی: با تنظیم allowFallbackOnPackageNotFound=false مطمئن شوید زمانی که پکیج در فید اختصاصی یافت نشد، سراغ فید عمومی نمیرود.
<packageSourceMapping>
<packageSource key="PrivateFeed">
<package pattern="My.Internal.*" />
</packageSource>
</packageSourceMapping>
4. نسخهبندی هوشمند و naming convention سختگیرانه: پکیجهای داخلی را با نام یکتا و اغلب با prefix اختصاصی منتشر کنید (مثلاً Company.Product.Component). از ورژنبندی داخلی بالاتر از هرگونه احتمال انتشار در فید عمومی استفاده کنید.
5. تهیه لیست سفید (Allowlist) و Audit دورهای: بررسی و whitelist کردن منابع پکیجها مانع افزودن غیرعمدی فیدهای ناامن خواهد شد.
6. کنترل و مانیتورینگ لاگ Restore: هر بار که فاز Restore اجرا میشود، لاگ مسیر و منشأ پکیجها را تحلیل کنید.
Dependency Confusion یادآور اهمیت supply chain security در DevOps است. با معماری صحیح فیدها و رعایت اصول مدیریت امن بستههای نرمافزاری، این تهدید را تا حد زیادی حذف خواهید کرد.
@DeveloperAdvocate 🥑1 035
پایگاههای داده گرافی (مانند Neo4j) ابزارهایی تخصصی برای ذخیرهسازی و جستوجوی دادههایی هستند که ماهیتاً به شکل شبکهای و ارتباطی در هم تنیدهاند. بر خلاف RDBMSها که داده را در جداول و سطرها مدل میکنند، گراف دیتابیس Nodes (گره) و Edges (یال) را بهصورت first-class citizen پشتیبانی میکند و بهینهسازی شده برای کوئریهای مبتنی بر ارتباطات پیچیده است.
یک مثال مسئله عالی برای گراف دیتابیسها، ساخت سیستم توصیهگر (Recommendation Engine) برای یک شبکه اجتماعی بزرگ است – مثلاً پیدا کردن دوستان جدید یا پیشنهاد ارتباطات حرفهای در یک پلتفرم مشابه LinkedIn.
فرض کنید میخواهیم همه کاربرانی را که نهایتاً با دو یال (دوستی مشترک) به یک کاربر متصل هستند پیدا کنیم (Friend-of-a-friend recommendations). این عیناً یک traversal ساده روی گراف است که در SQL معمولاً به کوئریهای بازگشتی پیچیده، سنگین و غیربهینه ختم میشود.
در Neo4j و با زبان Cypher، چنین کوئریای بسیار ساده و سریع نوشته میشود:
MATCH (me:User {id: 'U123'})-[:FRIEND]->(:User)-[:FRIEND]->(foaf:User)
WHERE NOT (me)-[:FRIEND]->(foaf)
AND me <> foaf
RETURN DISTINCT foaf
LIMIT 10
حالا اگر بخواهید دقیقاً همین منطق را با C# پیاده کنید (مثلاً با نئوجی در .NET، با استفاده از Neo4j.Driver)، میتوانید چنین الگوی کوئری را ارسال کنید:
var query = @"
MATCH (me:User {id: $userId})-[:FRIEND]->(:User)-[:FRIEND]->(foaf:User)
WHERE NOT (me)-[:FRIEND]->(foaf)
AND me <> foaf
RETURN DISTINCT foaf
LIMIT 10
";
var session = driver.AsyncSession();
var result = await session.RunAsync(query, new { userId = "U123" });
await foreach (var record in result)
{
var friend = record["foaf"].As<INode>();
// Process friend recommendation
}
اگر به دنبال مدیریت دادههایی هستید که روابط گرهها شدیداً اهمیت دارند — مانند شبکههای اجتماعی، سامانههای پیشنهادگر، مسیریابی (Routing)، یا تحلیل تقلب (Fraud Detection)، بدون شک گراف دیتابیسها نقطه قوت معماری مدرن شما خواهند بود. این ابزارها را جدیتر دنبال کنید؛ به زودی بخش مهمی از معماریهای داده پیچیده خواهند بود.
@DeveloperAdvocate 🥑1 035
🎯 مدیریت Eventual Consistency در UI: جزئیات، چالشها و UX مناسب
در معماریهای مدرن (CQRS, Microservices, Event Sourcing)، اغلب با eventual consistency بهجای strong consistency مواجهیم. این یعنی: عملیات موفقیتآمیز سمت کلاینت بهصورت ناهمزمان در سیستم ثبت و با کمی تأخیر منعکس میشود. اما از نگاه کاربر، تاخیر بدون بازخورد واضح یک ایراد UX جدی است.
⏳ Pattern پیشنهادی: Optimistic UI با Feedback
۱. Optimistic Update: داده را بلافاصله در UI تغییر دهید. فرض کنید عملیات موفق است، اما وضعیت "انتظار تایید نهایی" را شفاف نمایش دهید.
۲. Track Operation State: هر تغییر یک Id یکتا (correlationId) بگیرید؛ با track کردن وضعیت نهایی از طریق polling یا دریافت event، UI را sync نگه دارید.
۳. Feedback شفاف: اگر عملیات نهایی موفق/ناموفق بود، پیام مناسب به کاربر بدهید و در صورت نیاز داده را Rollback کنید.
💡 نمونه کد ViewModel با Track و Feedback در WPF:
public class OrderViewModel : INotifyPropertyChanged
{
private readonly IOrderService _orderService;
public ObservableCollection<OrderDto> Orders { get; }
public ObservableCollection<Guid> PendingOrders { get; } = new();
public async Task DeleteOrderAsync(OrderDto order)
{
var correlationId = Guid.NewGuid();
PendingOrders.Add(correlationId);
Orders.Remove(order); // Optimistic Update
try
{
await _orderService.DeleteOrderAsync(order.Id, correlationId);
//سپس Poll یا Listen برای تایید نهایی (مثلا از SignalR):
var isDeleted = await WaitForConfirmationAsync(order.Id, correlationId);
if (!isDeleted)
{
Orders.Add(order); // Rollback
ShowFeedback($"حذف سفارش {order.Id} با شکست مواجه شد.");
}
}
catch
{
Orders.Add(order); // Rollback
ShowFeedback($"حذف سفارش {order.Id} با خطا مواجه شد.");
}
finally
{
PendingOrders.Remove(correlationId);
}
}
}
✔️ با Binding وضعیت PendingOrders میتوان در UI (مثلاً با یک Spinner کنار آیتم حذفشده)، شفافیت ارائه داد که نتیجه نهایی هنوز قطعی نیست.
⚡️ نکته مهم:
در سیستمهایی که اسکال کمهایی دارند، ارتباط با Backend را به صورت event-driven (مثلاً با SignalR یا WebSocket) پیادهسازی کنید تا latency و polling اضافی کاهش یابد. این رویکرد تجربه کاربری نرم و قابل اعتمادتری رقم میزند.
#معماری #Consistency #CQRS #UX #dotnet
@DeveloperAdvocate 🥑1 035
🟠 WebAssembly (WASM) چیست و چرا برای داتنت مهم است؟
WebAssembly یک استاندارد باینری سطح پایین است که به مرورگر اجازه میدهد کدهای کامپایلشده (مثلاً C#, Rust, C++) را تقریباً با سرعت نیتیو اجرا کند—بدون وابستگی به جاواسکریپت. WASM خروجی کامپایلر است، نه زبانی برای کدنویسی مستقیم؛ و توسط همه مرورگرهای مدرن پشتیبانی میشود.
برای اکوسیستم داتنت، WASM یک گام انقلابی است: پروژههایی مانند Blazor WebAssembly امکان اجرای برنامههای کامل C# (حتی با داتنت رانتایم) را در مرورگر بدون هیچ پلاگین اضافه میدهند. یعنی: شما میتوانید UI، منطق بیزینس، حتی مدلهای ML را با همان ابزارهای قوی داتنت بنویسید و مستقیماً در سمت کلاینت اجرا کنید.
مثلاً در Blazor WASM، حتی میتوانید کتابخانههای Shared یا سرویسهایی مانند Dependency Injection را روی مرورگر داشته باشید:
public class WeatherForecastService
{
public async Task<Forecast[]> GetForecastAsync()
{
// این متد میتواند حتی در مرورگر و بدون سرور اجرا شود
using var http = new HttpClient();
var result = await http.GetFromJsonAsync<Forecast[]>("sample-data/weather.json");
return result ?? [];
}
}
🔻 فرصتها:
- اجرای کامپوننتهای داتنت روی کلاینت با سرعت بالا و امنیت سندباکسشده
- استفاده مجدد از کد Backend و frontend
- اجرای کتابخانههای third-party داتنت (حتی بخشی از ML.NET) روی مرورگر
- حذف کامل نیاز به جاواسکریپت برای برنامههای پیچیده
✳️ اگر دنبال تجربه Full-stack C# و آیندهٔ برنامهنویسی وب هستید، WASM را جدی بگیرید!
@DeveloperAdvocate 🥑1 035
در .NET مدرن، استفاده هوشمندانه از
Span<T> راهی برای پردازش سریع و بدون تخصیص حافظه اضافی بر روی دادههاست. بیایید تابعی بنویسیم که نویسههای عددی یک رشته را استخراج کند، بدون ایجاد آرایه جدید یا حتی اختصاص حافظه اضافی برای substring یا LINQ!
در این مثال، تنها یک بار buffer مورد استفاده قرار گرفته است که توسط کاربر تأمین میشود و هیچ نوع تخصیص حافظه اضافی برای پردازش رشته انجام نمیشود:
public static int ExtractDigits(ReadOnlySpan<char> input, Span<char> buffer)
{
int j = 0;
for (int i = 0; i < input.Length; i++)
{
if (char.IsDigit(input[i]))
{
if (j < buffer.Length)
buffer[j++] = input[i];
else
break; // جلوگیری از سرریز
}
}
return j;
}
// استفاده:
string input = "C# 8 introduced Span<T> in 2019.";
Span<char> digits = stackalloc char[input.Length];
int count = ExtractDigits(input, digits);
string onlyDigits = digits.Slice(0, count).ToString(); // "82019"
مهم:
- این رویکرد، هیچگونه تخصیص (Allocation) اضافه در Heap انجام نمیدهد، حتا در مقایسه با رویکردهای مبتنی بر LINQ یا string manipulation.
- میتوانید Span<T> را به راحتی روی stack یا بخش خاصی از آرایه قرار دهید.
- قابلیت استفاده امن و کارا در سناریوهای با حجم بالای پردازش یا در حلقههای tight.
🔗 اگر نیاز به پردازش رشتهها با کارایی بسیار بالا دارید (مثل Tokenization، validation یا parsing اولیه)، Span<T> دقیقاً همان چیزیست که باید به سراغش بروید!
@DeveloperAdvocate 🥑1 035
مدیریت اسرار در داتنت: تزریق امن Secrets از Azure Key Vault و HashiCorp Vault
در معماری مدرن، بهترین روش برای مدیریت secrets، حذف کامل آنها از فایلهای کانفیگ و تزریق مستقیم در زمان اجرا است. در این پست یک الگوی حرفهای را برای بارگذاری Secrets از Azure Key Vault و HashiCorp Vault به .NET نشان میدهیم، بدون اینکه حتی خطی از secret در دیسک بنشیند.
🔥 Azure Key Vault Integration (Native, Provider-Based)
برای اپلیکیشنهای ASP.NET Core، کافیست provider رسمی Azure را به chain پیکربندی اضافه کنید:
using Azure.Identity;
builder.Configuration.AddAzureKeyVault(
new Uri($"https://<VAULT_NAME>.vault.azure.net/"),
new DefaultAzureCredential()
);
انتخاب DefaultAzureCredential فوقالعاده است: در محیط تولید، از managed identity یا service principal و در توسعه، از VS یا Visual Studio Code account شما استفاده میکند—بدون ذخیره کلید.
✳️ حالا هر secret شبیه این قابل خواندن است:
string mySecret = builder.Configuration["MySecretKey"];
---
🔥 HashiCorp Vault Integration (Lib/SDK Injection)
اگر HashiCorp Vault را ترجیح میدهید، یک استراتژی ایدهآل abstraction با استفاده از IOptions و DI است:
۱. ابتدا قویترین کتابخانه یعنی VaultSharp را اضافه نمایید.
۲. سپس یک secrets provider کاملاً جداگانه بسازید:
public interface ISecretProvider
{
Task<string> GetSecretAsync(string key);
}
public class VaultSecretProvider : ISecretProvider
{
private readonly IVaultClient _vaultClient;
public VaultSecretProvider(IVaultClient vaultClient)
{
_vaultClient = vaultClient;
}
public async Task<string> GetSecretAsync(string key)
{
var result = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(key, mountPoint: "kv");
return result.Data.Data["value"].ToString();
}
}
۳. ثبت Provider با DI:
services.AddSingleton<IVaultClient>(sp =>
new VaultClient(new VaultClientSettings("https://your-vault:8200", new TokenAuthMethodInfo("{YOUR_TOKEN}")))
);
services.AddSingleton<ISecretProvider, VaultSecretProvider>();
✳️ استفاده در سرویس:
public class MyService
{
private readonly ISecretProvider _secrets;
public MyService(ISecretProvider secrets) => _secrets = secrets;
public async Task UseSecret()
{
var secretVal = await _secrets.GetSecretAsync("production/someKey");
// ... استفاده امن
}
}
---
✅ Best Practice: هیچ وقت secrets را نه در appsettings.json و نه در متغیر محیطی خام نگه ندارید. ورود secrets تنها از provider امن و در زمان اجرا انجام شود. با این تکنیک، کد شما Audit و Compliant و آماده برای هر نوع محیط ابری خواهد بود.
#DevOps #Security #Cloud #DotNet #BestPractices
@DeveloperAdvocate 🥑1 035
مدیریت حافظه و GC در .NET همیشه یکی از نقاط قوت این پلتفرم بوده، اما وقتی پای unmanaged resourceها به میان میآید، GC به تنهایی کافی نیست. در این سناریوها، پیادهسازی صحیح
IDisposable حیاتی میشود. اشتباه رایج این است که صرفاً متد Dispose() را اضافه کنیم و تمام. اما الگوی درست—الگوی Dispose — پیچیدگیهای خاص خود را دارد، به ویژه زمانی که کلاسهای شما هم unmanaged resource دارند و هم از inheritance استفاده میکنند.
الگوی پیشرفتهی Dispose:
- متد Dispose با یک پارامتر بولی (معمولاً disposing) را تعریف کنید.
- رفتار متد بر اساس مقدار این پارامتر (فراخوانی مستقیم یا GC) تغییر کند.
- از GC.SuppressFinalize(this) برای جلوگیری از Destructor redundancy استفاده کنید.
- از finalizer (~ClassName) تنها برای clean-up در صورت فراموشی Dispose استفاده کنید.
یک پیادهسازی اصولی:
public class ResourceHolder : IDisposable
{
private IntPtr unmanagedResource;
private Stream managedResource;
private bool disposed = false;
public ResourceHolder()
{
// alloc unmanaged resource
// init managed resource
}
~ResourceHolder()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed) return;
if (disposing)
{
// managed resource cleanup
managedResource?.Dispose();
}
// unmanaged resource cleanup
if (unmanagedResource != IntPtr.Zero)
{
// آزادسازی unmanaged
unmanagedResource = IntPtr.Zero;
}
disposed = true;
}
}
این الگو، امکان افزایش (inheritance) و جلوگیری از مشکلات سخت رفعشوندهی memory leak را فراهم میکند. حتی با وجود قدرت GC، دلیلی ندارد که در برخورد با unmanaged resources به اصول Dispose Pattern کملطفی کنیم. همیشه به “درستی” Dispose کنید؛ چون GC پادزهر همه چیز نیست.
@DeveloperAdvocate 🥑1 035
تفاوت Authentication و Authorization همیشه یکی از مباحث گیجکننده است که حتی مهندسان با تجربه هم گاهی به جای هم استفاده میکنند. بیایید با یک قیاس ساده شروع کنیم: فرض کنید به ساختمان شرکت میخواهید وارد شوید. Authentication مثل نشان دادن کارت شناسایی به نگهبان است تا اثبات کنید که واقعاً شما هستید. اما Authorization مثل این است که نگهبان پس از شناسایی، بررسی کند با توجه به سمتتان اجازه دسترسی به کدام اتاقها را دارید.
در زمینه فنی، Authentication اثبات هویت کاربر است (مثلاً با ورود نامکاربری/رمز عبور، یا JWT). پس از احراز هویت، Authorization تعیین میکند این کاربر خاص چه کارهایی اجازه دارد انجام دهد (دسترسی به API خاص یا منابع).
در .NET، این تفکیک فوقالعاده واضح و ماژولار پیادهسازی شده است:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* ... */ });
services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Administrator"));
});
در اینجا، AddAuthentication مشخص میکند سیستم باید چگونه کاربر را شناسایی کند. AddAuthorization تعیین میکند چه سطح دسترسیهایی تعریف شوند. مثلاً در Controller:
[Authorize(Policy = "AdminOnly")]
public IActionResult GetSecret()
{
// فقط ادمینها به این endpoint دسترسی دارند
}
خلاصه: Authentication میپرسد "چه کسی هستی؟"، Authorization میپرسد "به چه چیزهایی دسترسی داری؟". همیشه این جداسازی را در ذهنتان و طراحیتان حفظ کنید؛ این رویکرد هم امنیت معماری، و هم قابلیت توسعه سیستم را چندین برابر میکند.
@DeveloperAdvocate 🥑1 035
🎯 استراتژی ترکیب Micro-Frontends: نگاهی به Module Federation
در دنیای محصولات بزرگ با تیمهای مستقل، معماری micro-frontends راهکاری مناسب برای تقسیم UI بین تیمهاست. اما چالش اساسی: چگونه میتوان این ماژولها را کنار هم نشاند بدون آنکه وابستگی یا تکرار کد ایجاد شود؟
🎛 Module Federation (Webpack 5) — بازیگر کلیدی استراتژی ترکیب
با Module Federation، میتوانید هر micro-frontend را بهصورت جداگانه build و deploy کنید. هر تیم، میزبان (host) یا بارگذار (remote) میشود. اما ترکیب این ماژولها در زمان اجرا چگونه است؟ یک مثال پیشرفته:
// Startup.cs – Host App: راهاندازی reverse proxy برای بارگذاری runtime از چندین origin
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
spa.UseProxyToSpaDevelopmentServer("https://app1.local:3000");
spa.UseProxyToSpaDevelopmentServer("https://app2.local:4000");
});
و در سمت frontend (مثال React):
// app1/src/bootstrap.js
import('app2/MyWidget').then(({ default: MyWidget }) => {
ReactDOM.render(<MyWidget />, document.getElementById('remote-widget'));
});
📌 نکته کلیدی: قراداد اشتراک ماژولها. مثلا singleton کردن React در هر دو app:
// webpack.config.js (در هر اپ)
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.0.0' }
}
👨💻 تجربه عملی:
- از درزکردن نسخههای متفاوت libها جلوگیری کنید (foundation dependencies باید همیشه singleton باشند).
- Federation امکانی برای توسعه تدریجی فراهم میکند؛ migrate تدریجی front به تکنولوژی یا معماری جدید بدون توقف کل سیستم.
🧩 در پروژههای چند-تیمی، استراتژی ترکیب مبتنی بر Module Federation یک لایه decoupling واقعی میسازد. دقیق معماری کنید و قراردادهای اشتراک dependency و contract واسطها را مکتوب نگه دارید (shared types)! این الگو میتواند حتی بین پروژههای چند stack (مثلا React و Angular) هم جواب بدهد.
💡 شما چطور ترکیب micro-frontendها را میچینید؟
@DeveloperAdvocate 🥑1 035
تفاوت بازخوردهای سبک کدنویسی و بازخوردهای معماری در Code Review 🚦
در یک Pull Request حرفهای، تمایز بین بازخورد وابسته به "سبک" و بازخورد "معماری" حیاتی است و باید آگاهانه آنها را مدیریت کنید:
✅ بازخوردهای سبک (Style Feedback):
حیطهی این بازخوردها اغلب محدود به خوانایی، یکنواختی و نگهداری کد است—مثلاً:
- رعایت نامگذاری متغیرها یا متدها.
- جای خالیها، جایگذاری آکولادها، ترتیب پارامترها.
- حذف کدهای بلااستفاده یا بهینهسازی statementهای ساده.
مثال:
// Style feedback
if(flag==true){
DoSomething();}
پیشنهاد: رعایت فاصلهها و ترتیب آکولادها
---
✅ بازخوردهای معماری (Architectural Feedback):
این نوع بازخوردها ساختار کلی، الگوهای طراحی، جدایی مسئولیتها و تأثیر تصمیمگیریها در سطح کل سیستم را مورد خطاب قرار میدهند—مثلاً:
- وابستگی نامناسب ماژولها (tight coupling)
- نقض اصل SOLID یا DDD
- استفادهی غلط از DI، رویدادها یا فریمورکهای زیرساخت
- انتخاب اشتباه abstractionها یا نشتی مدل دامنه به بیرون
مثال:
// Architectural feedback
public class OrderController
{
public void PlaceOrder(string productId, int quantity)
{
var db = new SqlConnection("connStr");
db.Open();
// ...
}
}
اینجا کنترلر مستقیماً وابسته به دیتابیس است (نقض Dependency Inversion و الگوی Repository).
پیشنهاد: این وابستگی را تزریق کنید و منطق دیتا را به یک لایه Domain یا Service منتقل کنید:
public class OrderController
{
private readonly IOrderService _orderService;
public OrderController(IOrderService orderService)
{
_orderService = orderService;
}
public void PlaceOrder(string productId, int quantity)
{
_orderService.PlaceOrder(productId, quantity);
}
}
---
🔹 توجه:
بازخورد سبک را میتوان با ابزارهایی مثل linter یا قالبهای اتوماتیک حل کرد، اما اشکالات معماری نیازمند بحث تیمی و بازنگری عمیق هستند. تشخیص و جدا کردن این دو نوع بازخورد نشانهی یک مهندس حرفهای است!
@DeveloperAdvocate 🥑