قدرت پردازش دادههای ناهمگام را با ترکیب توابع کمکی Async Iterator در جاوااسکریپت آزاد کنید. یاد بگیرید چگونه عملیات را بر روی استریمهای ناهمگام زنجیرهای کنید تا کدی کارآمد و زیبا داشته باشید.
ترکیب توابع کمکی Async Iterator در جاوااسکریپت: زنجیرهسازی استریمهای ناهمگام
برنامهنویسی ناهمگام (Asynchronous) یکی از پایههای اصلی توسعه مدرن جاوااسکریپت است، بهویژه هنگام کار با عملیات ورودی/خروجی، درخواستهای شبکه و استریمهای داده زنده. Async iterators و async iterables که در ECMAScript 2018 معرفی شدند، مکانیزم قدرتمندی برای مدیریت دنبالههای داده ناهمگام فراهم میکنند. این مقاله به مفهوم ترکیب توابع کمکی Async Iterator میپردازد و نشان میدهد چگونه میتوان عملیات را بر روی استریمهای ناهمگام زنجیرهای کرد تا به کدی تمیزتر، کارآمدتر و با قابلیت نگهداری بالا دست یافت.
درک Async Iterators و Async Iterables
قبل از اینکه به مبحث ترکیب بپردازیم، اجازه دهید اصول اولیه را روشن کنیم:
- Async Iterable: یک شیء که متد `Symbol.asyncIterator` را در خود دارد و یک async iterator برمیگرداند. این شیء نمایانگر دنبالهای از داده است که میتوان به صورت ناهمگام روی آن پیمایش کرد.
- Async Iterator: یک شیء که متد `next()` را تعریف میکند. این متد یک promise برمیگرداند که به یک شیء با دو ویژگی resolve میشود: `value` (آیتم بعدی در دنباله) و `done` (یک مقدار boolean که نشان میدهد آیا دنباله به پایان رسیده است یا خیر).
در اصل، یک async iterable منبع دادههای ناهمگام است و یک async iterator مکانیزمی برای دسترسی به این دادهها به صورت تکه تکه است. یک مثال واقعی را در نظر بگیرید: دریافت داده از یک API endpoint صفحهبندی شده. هر صفحه نمایانگر بخشی از داده است که به صورت ناهمگام در دسترس قرار میگیرد.
در اینجا یک مثال ساده از یک async iterable وجود دارد که دنبالهای از اعداد را تولید میکند:
async function* generateNumbers(max) {
for (let i = 0; i <= max; i++) {
await new Promise(resolve => setTimeout(resolve, 50)); // Simulate asynchronous delay
yield i;
}
}
const numberStream = generateNumbers(5);
(async () => {
for await (const number of numberStream) {
console.log(number); // Output: 0, 1, 2, 3, 4, 5 (with delays)
}
})();
در این مثال، `generateNumbers` یک تابع async generator است که یک async iterable ایجاد میکند. حلقه `for await...of` دادهها را از استریم به صورت ناهمگام مصرف میکند.
نیاز به ترکیب توابع کمکی Async Iterator
اغلب، شما نیاز دارید که چندین عملیات را روی یک استریم ناهمگام انجام دهید، مانند فیلتر کردن، نگاشت (mapping) و کاهش (reducing). به طور سنتی، ممکن است برای دستیابی به این هدف، حلقههای تو در تو یا توابع ناهمگام پیچیده بنویسید. با این حال، این کار میتواند منجر به کدی طولانی، سختخوان و با نگهداری دشوار شود.
ترکیب توابع کمکی Async Iterator یک رویکرد زیباتر و تابعیتر ارائه میدهد. این رویکرد به شما امکان میدهد عملیات را به هم زنجیر کنید و یک خط لوله (pipeline) ایجاد کنید که دادهها را به روشی متوالی و اعلانی (declarative) پردازش میکند. این امر باعث ترویج استفاده مجدد از کد، بهبود خوانایی و سادهسازی تست میشود.
فرض کنید میخواهید یک استریم از پروفایلهای کاربران را از یک API دریافت کنید، سپس کاربران فعال را فیلتر کرده و در نهایت آدرسهای ایمیل آنها را استخراج کنید. بدون ترکیب توابع کمکی، این کار میتوانست به یک آشفتگی تو در تو و پر از callback تبدیل شود.
ساخت توابع کمکی Async Iterator
یک تابع کمکی Async Iterator، تابعی است که یک async iterable را به عنوان ورودی میگیرد و یک async iterable جدید برمیگرداند که یک تبدیل یا عملیات خاص را روی استریم اصلی اعمال میکند. این توابع کمکی به گونهای طراحی شدهاند که قابل ترکیب باشند و به شما امکان میدهند آنها را به هم زنجیر کنید تا خطوط لوله پردازش داده پیچیدهای ایجاد کنید.
بیایید چند تابع کمکی رایج را تعریف کنیم:
1. تابع کمکی `map`
تابع کمکی `map` یک تابع تبدیل را روی هر عنصر در استریم ناهمگام اعمال کرده و مقدار تبدیل شده را yield میکند.
async function* map(iterable, transform) {
for await (const item of iterable) {
yield await transform(item);
}
}
مثال: تبدیل یک استریم از اعداد به توان دوم آنها.
async function* generateNumbers(max) {
for (let i = 0; i <= max; i++) {
await new Promise(resolve => setTimeout(resolve, 50));
yield i;
}
}
const numberStream = generateNumbers(5);
const squareStream = map(numberStream, async (number) => number * number);
(async () => {
for await (const square of squareStream) {
console.log(square); // Output: 0, 1, 4, 9, 16, 25 (with delays)
}
})();
2. تابع کمکی `filter`
تابع کمکی `filter` عناصر را از استریم ناهمگام بر اساس یک تابع predicate فیلتر میکند.
async function* filter(iterable, predicate) {
for await (const item of iterable) {
if (await predicate(item)) {
yield item;
}
}
}
مثال: فیلتر کردن اعداد زوج از یک استریم.
async function* generateNumbers(max) {
for (let i = 0; i <= max; i++) {
await new Promise(resolve => setTimeout(resolve, 50));
yield i;
}
}
const numberStream = generateNumbers(5);
const evenNumberStream = filter(numberStream, async (number) => number % 2 === 0);
(async () => {
for await (const evenNumber of evenNumberStream) {
console.log(evenNumber); // Output: 0, 2, 4 (with delays)
}
})();
3. تابع کمکی `take`
تابع کمکی `take` تعداد مشخصی از عناصر را از ابتدای استریم ناهمگام میگیرد.
async function* take(iterable, count) {
let i = 0;
for await (const item of iterable) {
if (i >= count) {
return;
}
yield item;
i++;
}
}
مثال: گرفتن ۳ عدد اول از یک استریم.
async function* generateNumbers(max) {
for (let i = 0; i <= max; i++) {
await new Promise(resolve => setTimeout(resolve, 50));
yield i;
}
}
const numberStream = generateNumbers(5);
const firstThreeNumbers = take(numberStream, 3);
(async () => {
for await (const number of firstThreeNumbers) {
console.log(number); // Output: 0, 1, 2 (with delays)
}
})();
4. تابع کمکی `toArray`
تابع کمکی `toArray` کل استریم ناهمگام را مصرف کرده و آرایهای حاوی تمام عناصر را برمیگرداند.
async function toArray(iterable) {
const result = [];
for await (const item of iterable) {
result.push(item);
}
return result;
}
مثال: تبدیل یک استریم از اعداد به یک آرایه.
async function* generateNumbers(max) {
for (let i = 0; i <= max; i++) {
await new Promise(resolve => setTimeout(resolve, 50));
yield i;
}
}
const numberStream = generateNumbers(5);
(async () => {
const numbersArray = await toArray(numberStream);
console.log(numbersArray); // Output: [0, 1, 2, 3, 4, 5]
})();
5. تابع کمکی `flatMap`
تابع کمکی `flatMap` یک تابع را روی هر عنصر اعمال کرده و سپس نتیجه را در یک استریم ناهمگام واحد مسطح میکند.
async function* flatMap(iterable, transform) {
for await (const item of iterable) {
const transformedIterable = await transform(item);
for await (const transformedItem of transformedIterable) {
yield transformedItem;
}
}
}
مثال: تبدیل یک استریم از رشتهها به یک استریم از کاراکترها.
async function* generateStrings() {
await new Promise(resolve => setTimeout(resolve, 50));
yield "hello";
await new Promise(resolve => setTimeout(resolve, 50));
yield "world";
}
const stringStream = generateStrings();
const charStream = flatMap(stringStream, async (str) => {
async function* stringToCharStream() {
for (let i = 0; i < str.length; i++) {
yield str[i];
}
}
return stringToCharStream();
});
(async () => {
for await (const char of charStream) {
console.log(char); // Output: h, e, l, l, o, w, o, r, l, d (with delays)
}
})();
ترکیب توابع کمکی Async Iterator
قدرت واقعی توابع کمکی Async Iterator از قابلیت ترکیبپذیری آنها ناشی میشود. شما میتوانید آنها را به هم زنجیر کنید تا خطوط لوله پردازش داده پیچیدهای ایجاد کنید. بیایید این موضوع را با یک مثال جامع نشان دهیم:
سناریو: دریافت دادههای کاربر از یک API صفحهبندی شده، فیلتر کردن کاربران فعال، استخراج آدرسهای ایمیل آنها، و گرفتن ۵ آدرس ایمیل اول.
async function* fetchUsers(apiUrl) {
let page = 1;
while (true) {
const response = await fetch(`${apiUrl}?page=${page}`);
const data = await response.json();
if (data.length === 0) {
return; // No more data
}
for (const user of data) {
yield user;
}
page++;
await new Promise(resolve => setTimeout(resolve, 200)); // Simulate API delay
}
}
// Sample API URL (replace with a real API endpoint)
const apiUrl = "https://example.com/api/users";
const userStream = fetchUsers(apiUrl);
const activeUserEmailStream = take(
map(
filter(
userStream,
async (user) => user.isActive
),
async (user) => user.email
),
5
);
(async () => {
const activeUserEmails = await toArray(activeUserEmailStream);
console.log(activeUserEmails); // Output: Array of the first 5 active user emails
})();
در این مثال، ما توابع کمکی `filter`، `map` و `take` را برای پردازش استریم دادههای کاربر زنجیرهای میکنیم. تابع `filter` فقط کاربران فعال را انتخاب میکند، تابع `map` آدرسهای ایمیل آنها را استخراج میکند و تابع `take` نتیجه را به ۵ ایمیل اول محدود میکند. به تو در تو بودن کد توجه کنید؛ این روش رایج است اما میتوان آن را با یک تابع کمکی، همانطور که در زیر مشاهده میشود، بهبود بخشید.
بهبود خوانایی با یک ابزار خط لوله (Pipeline)
در حالی که مثال بالا ترکیبپذیری را نشان میدهد، تو در تو بودن کد میتواند با خطوط لوله پیچیدهتر، دشوار شود. برای بهبود خوانایی، میتوانیم یک تابع کمکی `pipeline` ایجاد کنیم:
async function pipeline(iterable, ...operations) {
let result = iterable;
for (const operation of operations) {
result = operation(result);
}
return result;
}
اکنون، میتوانیم مثال قبلی را با استفاده از تابع `pipeline` بازنویسی کنیم:
async function* fetchUsers(apiUrl) {
let page = 1;
while (true) {
const response = await fetch(`${apiUrl}?page=${page}`);
const data = await response.json();
if (data.length === 0) {
return; // No more data
}
for (const user of data) {
yield user;
}
page++;
await new Promise(resolve => setTimeout(resolve, 200)); // Simulate API delay
}
}
// Sample API URL (replace with a real API endpoint)
const apiUrl = "https://example.com/api/users";
const userStream = fetchUsers(apiUrl);
const activeUserEmailStream = pipeline(
userStream,
(stream) => filter(stream, async (user) => user.isActive),
(stream) => map(stream, async (user) => user.email),
(stream) => take(stream, 5)
);
(async () => {
const activeUserEmails = await toArray(activeUserEmailStream);
console.log(activeUserEmails); // Output: Array of the first 5 active user emails
})();
این نسخه خواندن و درک آن بسیار آسانتر است. تابع `pipeline` عملیات را به صورت متوالی اعمال میکند و جریان داده را صریحتر میسازد.
مدیریت خطا
هنگام کار با عملیات ناهمگام، مدیریت خطا بسیار مهم است. شما میتوانید با قرار دادن دستورات `yield` در بلوکهای `try...catch`، مدیریت خطا را در توابع کمکی خود بگنجانید.
async function* map(iterable, transform) {
for await (const item of iterable) {
try {
yield await transform(item);
} catch (error) {
console.error("Error in map helper:", error);
// You can choose to re-throw the error, skip the item, or yield a default value.
// For example, to skip the item:
// continue;
}
}
}
به یاد داشته باشید که خطاها را بر اساس نیازهای برنامه خود به درستی مدیریت کنید. ممکن است بخواهید خطا را ثبت کنید، آیتم مشکلساز را نادیده بگیرید یا خط لوله را خاتمه دهید.
مزایای ترکیب توابع کمکی Async Iterator
- بهبود خوانایی: کد بیشتر اعلانی (declarative) و قابل فهم میشود.
- افزایش قابلیت استفاده مجدد: توابع کمکی را میتوان در بخشهای مختلف برنامه شما مجدداً استفاده کرد.
- سادهسازی تست: تست توابع کمکی به صورت مجزا آسانتر است.
- بهبود قابلیت نگهداری: تغییرات در یک تابع کمکی بر سایر بخشهای خط لوله تأثیر نمیگذارد (تا زمانی که قراردادهای ورودی/خروجی حفظ شوند).
- مدیریت خطای بهتر: مدیریت خطا میتواند در توابع کمکی متمرکز شود.
کاربردهای دنیای واقعی
ترکیب توابع کمکی Async Iterator در سناریوهای مختلفی ارزشمند است، از جمله:
- استریم داده: پردازش دادههای زنده از منابعی مانند شبکههای حسگر، فیدهای مالی یا استریمهای رسانههای اجتماعی.
- یکپارچهسازی API: دریافت و تبدیل دادهها از APIهای صفحهبندی شده یا چندین منبع داده. تصور کنید دادهها را از پلتفرمهای مختلف تجارت الکترونیک (آمازون، eBay، فروشگاه خودتان) جمعآوری کرده تا لیستهای محصولات یکپارچهای تولید کنید.
- پردازش فایل: خواندن و پردازش فایلهای بزرگ به صورت ناهمگام. به عنوان مثال، تجزیه یک فایل CSV بزرگ، فیلتر کردن ردیفها بر اساس معیارهای خاص (مثلاً فروشهای بالاتر از یک آستانه در ژاپن) و سپس تبدیل دادهها برای تجزیه و تحلیل.
- بهروزرسانی رابط کاربری: بهروزرسانی تدریجی عناصر UI با در دسترس قرار گرفتن دادهها. به عنوان مثال، نمایش نتایج جستجو همزمان با دریافت آنها از سرور راه دور، که حتی با اتصالات شبکه کند، تجربه کاربری روانتری را فراهم میکند.
- رویدادهای ارسالی از سرور (SSE): پردازش استریمهای SSE، فیلتر کردن رویدادها بر اساس نوع، و تبدیل دادهها برای نمایش یا پردازش بیشتر.
ملاحظات و بهترین شیوهها
- عملکرد: در حالی که توابع کمکی Async Iterator رویکردی تمیز و زیبا ارائه میدهند، به عملکرد توجه داشته باشید. هر تابع کمکی سربار اضافی ایجاد میکند، بنابراین از زنجیرهسازی بیش از حد خودداری کنید. در نظر بگیرید که آیا یک تابع واحد و پیچیدهتر ممکن است در سناریوهای خاص کارآمدتر باشد.
- استفاده از حافظه: هنگام کار با استریمهای بزرگ، از مصرف حافظه آگاه باشید. از بافر کردن مقادیر زیادی از داده در حافظه خودداری کنید. تابع کمکی `take` برای محدود کردن مقدار داده پردازش شده مفید است.
- مدیریت خطا: مدیریت خطای قوی را برای جلوگیری از خرابیهای غیرمنتظره یا خراب شدن دادهها پیادهسازی کنید.
- تست: تستهای واحد جامعی برای توابع کمکی خود بنویسید تا اطمینان حاصل کنید که مطابق انتظار رفتار میکنند.
- تغییرناپذیری (Immutability): با استریم داده به عنوان یک موجودیت تغییرناپذیر رفتار کنید. از تغییر دادههای اصلی در توابع کمکی خود خودداری کنید؛ به جای آن، اشیاء یا مقادیر جدیدی ایجاد کنید.
- TypeScript: استفاده از TypeScript میتواند به طور قابل توجهی ایمنی نوع (type safety) و قابلیت نگهداری کد Async Iterator Helper شما را بهبود بخشد. رابطهای واضحی برای ساختارهای داده خود تعریف کنید و از generics برای ایجاد توابع کمکی قابل استفاده مجدد استفاده کنید.
نتیجهگیری
ترکیب توابع کمکی Async Iterator در جاوااسکریپت روشی قدرتمند و زیبا برای پردازش استریمهای داده ناهمگام فراهم میکند. با زنجیرهای کردن عملیات به یکدیگر، میتوانید کدی تمیز، قابل استفاده مجدد و قابل نگهداری ایجاد کنید. در حالی که راهاندازی اولیه ممکن است پیچیده به نظر برسد، مزایای بهبود خوانایی، قابلیت تست و قابلیت نگهداری، آن را به یک سرمایهگذاری ارزشمند برای هر توسعهدهنده جاوااسکریپت که با دادههای ناهمگام کار میکند، تبدیل میکند.
قدرت async iterators را در آغوش بگیرید و سطح جدیدی از کارایی و زیبایی را در کد ناهمگام جاوااسکریپت خود باز کنید. با توابع کمکی مختلف آزمایش کنید و کشف کنید که چگونه میتوانند گردش کار پردازش داده شما را ساده کنند. به یاد داشته باشید که عملکرد و مصرف حافظه را در نظر بگیرید و همیشه مدیریت خطای قوی را در اولویت قرار دهید.