Model wywołań zwrotnych JavaScriptu był jedynym mechanizmem nieblokującego wejścia/wyjścia, gdy Node.js pojawił się w 2009 roku. Zanim Promises pojawił się w ES6, a async/await w ES2017, większość baz kodu produkcyjnego miała już lata wdrożonej logiki opartej na wywołaniach zwrotnych: zagnieżdżone procedury obsługi błędów, współdzielony stan zmienny wątkowany przez domknięcia, logikę ponawiania prób osadzoną w anonimowych funkcjach na trzech poziomach. Składnia się zmieniła. Kod roboczy nie. Dziś większość zespołów inżynierskich dziedziczy dokładnie tę sytuację: bazę kodu, w której nowe i stare wzorce współistnieją, gdzie zespół chce migrować, ale system nie może się zatrzymać na czas.
Bezpieczna migracja bazy kodu asynchronicznego
SMART TS XL i identyfikuje ryzyko migracji w całej bazie kodu zanim zmienisz choćby jedną linię.
Przeglądaj terazDobra wiadomość jest taka, że migracja nie wymaga przepisywania kodu. Wywołania zwrotne, obietnice i async/await są kompatybilne w ściśle określonych granicach. Node.js jest dostarczany util.promisify Właśnie po to, by wypełnić lukę między wywołaniami zwrotnymi typu error-first a funkcjami zwracającymi obietnice. Warstwy opakowujące pozwalają na współistnienie starego i nowego kodu podczas transformacji. Migracja przyrostowa, moduł po module, utrzymuje produkcję w ruchu, podczas gdy baza kodu posuwa się naprzód. Wyzwaniem nie jest sama transformacja, ale jej systematyczne przeprowadzanie: zrozumienie, które wywołania zwrotne można bezpiecznie przekonwertować w pierwszej kolejności, które antywzorce spowodują błędy, jeśli zostaną naiwnie przepisane, oraz jak sprawdzić, czy każda przekonwertowana funkcja zachowuje się identycznie jak ta, którą zastąpiła.
Zrozumienie modelu wywołań zwrotnych i jego awarii na dużą skalę
Konwencja wywołania zwrotnego w Node.js jest prosta: funkcje wykonujące pracę asynchroniczną przyjmują wywołanie zwrotne jako ostatni argument. Wywołanie zwrotne otrzymuje błąd jako pierwszy argument, a wynik jako drugi. Każda standardowa funkcja biblioteczna w Node.js stosuje się do tej konwencji: fs.readFile, http.get, child_process.execi setki innych.
javascript
// Standard Node.js error-first callback pattern
const fs = require('fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read config:', err);
return;
}
const config = JSON.parse(data);
console.log('Config loaded:', config);
});
Ten wzorzec jest prosty w przypadku pojedynczej operacji. Zawodzi jednak, gdy operacje muszą być łączone łańcuchowo, ponieważ każda kolejna operacja musi być inicjowana w poprzednim wywołaniu zwrotnym. Trzyetapowa sekwencja odczytu, transformacji i zapisu generuje trzy zagnieżdżone poziomy wywołania zwrotnego. Pięcioetapowa sekwencja generuje pięć. Tę strukturę programiści nazywają „piekło wywołań zwrotnych” lub „piramidą zagłady” i nie jest to tylko problem estetyczny. Głęboko zagnieżdżone wywołania zwrotne powodują niespójność obsługi błędów (każdy poziom musi niezależnie sprawdzać swój argument błędu), utrudniają wnioskowanie o kolejności wykonywania i czynią refaktoryzację niebezpieczną, ponieważ przepływ danych jest niejawny, a nie jawny.
Bardziej krytycznym problemem dla systemów produkcyjnych jest brak natywnego mechanizmu koordynacji wywołań zwrotnych. Uruchomienie dwóch operacji asynchronicznych i oczekiwanie na ich zakończenie wymaga ręcznego śledzenia liczników. Uruchomienie sekwencji operacji na tablicy wymaga wzorców rekurencyjnych lub bibliotek zewnętrznych, takich jak… asyncŻadna z tych złożoności nie jest widoczna w sygnaturach funkcji: interfejs API oparty na wywołaniach zwrotnych i współbieżna orkiestracja zbudowana na nim wyglądają identycznie od zewnątrz, dopóki nie zawiodą.
Jak naprawdę wygląda piekło wywołań zwrotnych
Piramida wywołań zwrotnych w świecie rzeczywistym z usługi Node.js, która odczytuje dane użytkownika, weryfikuje uprawnienia i rejestruje dostęp:
javascript
// Three-level callback pyramid -- representative of real legacy code
function getUserReport(userId, callback) {
db.query('SELECT * FROM users WHERE id = ?', [userId], (err, user) => {
if (err) return callback(err);
if (!user) return callback(new Error('User not found'));
permissions.check(userId, 'read:reports', (err, allowed) => {
if (err) return callback(err);
if (!allowed) return callback(new Error('Permission denied'));
auditLog.write({ userId, action: 'read:reports' }, (err) => {
if (err) return callback(err);
// Finally, the actual work
callback(null, buildReport(user));
});
});
});
}
Obsługa błędów powtarza się na każdym poziomie. Wcięcia sprawiają, że przepływ danych jest wizualnie skomplikowany. Dodanie czwartego kroku wymaga kolejnego zagnieżdżonego poziomu. Testowanie tej funkcji wymaga zamaskowania wszystkich trzech zależności po kolei, a symulacja błędu na trzecim poziomie wymaga zamaskowania pierwszych dwóch, aby się powiodła. Każda z tych cech pogarsza się wraz z rozwojem łańcucha.
Używanie util.promisify do łączenia wywołań zwrotnych i obietnic
Wprowadzenie Node.js 8.0 util.promisify, która konwertuje każdą funkcję zgodną ze standardową konwencją wywołania zwrotnego „error-first” na funkcję zwracającą obietnicę (Promise). To właściwy punkt wyjścia dla każdej migracji async/await w bazie kodu Node.js.
javascript
const { promisify } = require('util');
const fs = require('fs');
// Convert Node.js built-ins
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
// Now usable with async/await
async function processConfig(path) {
const data = await readFile(path, 'utf8');
const config = JSON.parse(data);
config.lastLoaded = Date.now();
await writeFile(path, JSON.stringify(config, null, 2), 'utf8');
return config;
}
util.promisify Automatycznie obsługuje konwencję „najpierw błąd”: jeśli wywołanie zwrotne otrzyma pierwszy argument różny od null, zwrócona obietnica zostanie odrzucona z tym błędem. Jeśli wywołanie zwrotne otrzyma pierwszy argument o wartości null i wynik, obietnica zostanie rozwiązana z wynikiem. W przypadku zdecydowanej większości podstawowych interfejsów API Node.js i bibliotek zewnętrznych, które stosują się do tej konwencji, nie jest wymagane niestandardowe opakowanie.
Obiecujące niestandardowe funkcje wywołania zwrotnego
W przypadku funkcji niestandardowych, które stosują się do konwencji „najpierw błąd”, ale nie są wbudowanymi funkcjami Node.js, util.promisify działa identycznie:
javascript
const { promisify } = require('util');
// Your existing callback-based function
function fetchUserFromDB(userId, callback) {
db.query('SELECT * FROM users WHERE id = ?', [userId], (err, rows) => {
if (err) return callback(err);
callback(null, rows[0] || null);
});
}
// Promisified version -- no changes to the original function needed
const fetchUser = promisify(fetchUserFromDB);
// Use in async context
async function getUser(userId) {
const user = await fetchUser(userId);
if (!user) throw new Error(`User ${userId} not found`);
return user;
}
To podejście jest ważne: util.promisify Opakowuje oryginalną funkcję bez jej modyfikowania. Oryginalna wersja wywołania zwrotnego nadal działa. Funkcje wywołujące, które nie zostały jeszcze zmigrowane, nadal korzystają z wersji wywołania zwrotnego. Funkcje wywołujące, które zostały zmigrowane, korzystają z wersji obiecanej. To współistnienie umożliwia migrację przyrostową.
Obsługa niestandardowych podpisów wywołania zwrotnego
Niektóre starsze biblioteki przekazują wiele wartości wyników do swoich wywołań zwrotnych, co util.promisify rozwiązuje się tylko do pierwszej wartości. W takich przypadkach util.promisify.custom symbol umożliwia zdefiniowanie niestandardowej obietnicy:
javascript
const { promisify } = require('util');
// A function that passes two results to its callback
function parseData(input, callback) {
// callback(err, parsedData, metadata)
callback(null, { value: input.trim() }, { length: input.length });
}
// Custom promisification that returns both results
parseData[promisify.custom] = (input) => {
return new Promise((resolve, reject) => {
parseData(input, (err, data, meta) => {
if (err) reject(err);
else resolve({ data, meta });
});
});
};
const parseDataAsync = promisify(parseData);
const result = await parseDataAsync(' hello ');
// result === { data: { value: 'hello' }, meta: { length: 9 } }
Konwersja wywołań zwrotnych na obietnice: schemat krok po kroku
W przypadku kodu, którego nie można obsłużyć za pomocą util.promisify Bezpośrednio, ręczny wrapper Promise jest ścieżką migracji. Wzór jest spójny:
javascript
// Step 1: Original callback-based function
function checkPermission(userId, resource, callback) {
acl.check({ userId, resource }, (err, result) => {
if (err) return callback(err);
callback(null, result.allowed);
});
}
// Step 2: Promise wrapper (coexists with the original)
function checkPermissionAsync(userId, resource) {
return new Promise((resolve, reject) => {
checkPermission(userId, resource, (err, allowed) => {
if (err) reject(err);
else resolve(allowed);
});
});
}
// Step 3: async/await consumer
async function authorizeRequest(userId, resource) {
const allowed = await checkPermissionAsync(userId, resource);
if (!allowed) {
throw new Error(`${userId} does not have access to ${resource}`);
}
}
Trójstopniowa piramida wywołań zwrotnych z wcześniejszej wersji, napisana ponownie za pomocą async/await:
javascript
// After migration: same logic, linear structure
async function getUserReport(userId) {
const user = await db.queryAsync('SELECT * FROM users WHERE id = ?', [userId]);
if (!user) throw new Error('User not found');
const allowed = await permissions.checkAsync(userId, 'read:reports');
if (!allowed) throw new Error('Permission denied');
await auditLog.writeAsync({ userId, action: 'read:reports' });
return buildReport(user);
}
Obsługa błędów jest teraz obsługiwana przez pojedynczy try/catch w miejscu wywołania, a nie powtarzana na każdym poziomie. Wcięcie jest płaskie. Dodanie czwartego kroku wymaga jeszcze jednego. await linia. Testowanie wymaga osobnego mockingu każdej zależności, w dowolnej kolejności.
Równoległe wykonywanie z Promise.all i Promise.allSettled
Jednym z najczęstszych błędów przy migracji z wywołań zwrotnych do async/await jest sekwencyjne wykonywanie operacji, które mogłyby być wykonywane równolegle. Wywołania zwrotne sprawiły, że równoległe wykonywanie stało się na tyle skomplikowane, że wielu programistów domyślnie postawiło na sekwencyjne łańcuchy. Async/await sprawia, że paralelizm wygląda na sekwencyjny, co może zmylić programistów i skłonić ich do pisania kodu wolniejszego niż poprzednie wywołania zwrotne.
javascript
// WRONG: sequential execution -- each awaits the previous result
async function loadDashboardData(userId) {
const profile = await fetchProfile(userId); // 100ms
const orders = await fetchOrders(userId); // 150ms
const reviews = await fetchReviews(userId); // 80ms
return { profile, orders, reviews }; // Total: ~330ms
}
// RIGHT: parallel execution with Promise.all
async function loadDashboardData(userId) {
const [profile, orders, reviews] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
fetchReviews(userId),
]);
return { profile, orders, reviews }; // Total: ~150ms
}
Promise.all Odrzuca, jeśli jakakolwiek obietnica w tablicy zostanie odrzucona. Gdy niezależne operacje mogą zakończyć się niepowodzeniem bez wpływu na siebie nawzajem, a wywołujący musi wiedzieć o wszystkich awariach, Promise.allSettled jest właściwym narzędziem:
javascript
// Promise.allSettled: runs all, reports success or failure per operation
async function syncAllSources(userId) {
const results = await Promise.allSettled([
syncFromGitHub(userId),
syncFromJira(userId),
syncFromSlack(userId),
]);
const failed = results
.filter(r => r.status === 'rejected')
.map(r => r.reason.message);
if (failed.length > 0) {
console.warn('Some syncs failed:', failed);
}
return results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
}
Ten wzorzec nie ma naturalnego odpowiednika w kodzie opartym na wywołaniu zwrotnym bez ręcznego licznika lub biblioteki takiej jak async.parallel. Migracja do Promise.allSettled jest często jedną z najbardziej znaczących zmian w starszej bazie kodu asynchronicznego.
Unikanie antywzorca „oczekiwanie w pętli”
Błąd polegający na tym, że wykonywanie jest sekwencyjne zamiast równoległego, najczęściej pojawia się wewnątrz pętli:
javascript
// WRONG: sequential -- processes items one at a time
async function processOrders(orderIds) {
const results = [];
for (const id of orderIds) {
const result = await processOrder(id); // blocks until each completes
results.push(result);
}
return results;
}
// RIGHT: parallel -- all orders processed concurrently
async function processOrders(orderIds) {
return Promise.all(orderIds.map(id => processOrder(id)));
}
// RIGHT (with concurrency limit): parallel but bounded
const pLimit = require('p-limit');
const limit = pLimit(5); // max 5 concurrent
async function processOrders(orderIds) {
return Promise.all(
orderIds.map(id => limit(() => processOrder(id)))
);
}
Wzorzec oczekiwania w pętli jest jednym z najczęstszych regresji wprowadzanych podczas migracji kodu opartego na wywołaniach zwrotnych w trybie async/await, ponieważ wywołania zwrotne zmuszają programistów do wyraźnego myślenia o współbieżności, podczas gdy async/await ją zaciemnia.
Obsługa błędów w trybie Async/Await: zastępowanie propagacji błędów wywołania zwrotnego
Wywołania zwrotne propagują błędy zgodnie z konwencją: pierwszym argumentem każdego wywołania zwrotnego jest błąd lub wartość null. Działa to, ale wymaga od każdego wywołującego ręcznego sprawdzenia argumentu błędu. Async/await propaguje błędy poprzez mechanizm odrzucania obietnic, który integruje się z natywnym mechanizmem try/catch języka JavaScript.
javascript
// Callback error propagation: repeated at every level
function processPayment(orderId, callback) {
validateOrder(orderId, (err, order) => {
if (err) return callback(err); // propagate
chargeCard(order.amount, (err, charge) => {
if (err) return callback(err); // propagate again
updateInventory(orderId, (err) => {
if (err) return callback(err); // propagate again
callback(null, charge.id);
});
});
});
}
// Async/await: error propagation is automatic
async function processPayment(orderId) {
const order = await validateOrder(orderId); // throws on error
const charge = await chargeCard(order.amount); // throws on error
await updateInventory(orderId); // throws on error
return charge.id;
}
Kluczową kwestią podczas migracji jest zachowanie kontekstu błędów. Kod oparty na wywołaniach zwrotnych często przekazuje błędy, które zostały uzupełnione o informacje kontekstowe na każdym poziomie. Podczas migracji do trybu async/await należy upewnić się, że opakowywanie błędów zachowuje ten kontekst:
javascript
// Preserving error context during async migration
async function processPayment(orderId) {
let order;
try {
order = await validateOrder(orderId);
} catch (err) {
throw new Error(`Payment validation failed for order ${orderId}: ${err.message}`);
}
try {
const charge = await chargeCard(order.amount);
await updateInventory(orderId);
return charge.id;
} catch (err) {
// Attempt rollback, then rethrow with context
await refundCharge(order.amount).catch(console.error);
throw new Error(`Payment processing failed for order ${orderId}: ${err.message}`);
}
}
Nieobsłużone odrzucenia obietnic
Kod oparty na wywołaniach zwrotnych po cichu przechwytuje błędy, gdy argument błędu jest ignorowany. Async/await generuje nieobsłużone odrzucenia Promise, które w Node.js 15+ domyślnie powodują zakończenie procesu. Jest to zmiana powodująca przerwanie migracji: kod, który wcześniej nie wykrył błędu, teraz ulegnie awarii.
javascript
// This produces an unhandled rejection in Node.js 15+
async function riskyOperation() {
throw new Error('Something failed');
}
riskyOperation(); // Promise rejected, but rejection is not caught
// Fix: always await or chain .catch()
await riskyOperation(); // throws, caller handles it
riskyOperation().catch(console.error); // handles inline
Przeprowadź audyt wszystkich wywołań funkcji asynchronicznych typu „wystrzel i zapomnij” podczas migracji. Każde wywołanie funkcji asynchronicznej, której wartość zwracana nie jest oczekiwana ani powiązana łańcuchowo z .catch() jest potencjalną cichą awarią w świecie wywołań zwrotnych, ale nieobsłużonym odrzuceniem w świecie async/await.
Migracja wzorców EventEmitter do obietnic i asynchronicznych iteratorów
Emitery zdarzeń Node.js to forma wzorca wywołań zwrotnych, w którym dla nazwanych zdarzeń rejestrowanych jest wiele wywołań zwrotnych. Są one powszechne w strumieniach, połączeniach sieciowych i niestandardowych magistralach zdarzeń. Bezpośrednia migracja do async/await wymaga opakowania interfejsu API opartego na zdarzeniach.
javascript
const { EventEmitter } = require('events');
// Original EventEmitter-based pattern
function fetchDataLegacy(source) {
const emitter = new EventEmitter();
setTimeout(() => {
emitter.emit('data', { records: [1, 2, 3] });
emitter.emit('end');
}, 100);
return emitter;
}
// Usage: callback registration
const stream = fetchDataLegacy('api');
stream.on('data', chunk => console.log('received', chunk));
stream.on('error', err => console.error('error', err));
stream.on('end', () => console.log('done'));
Konwersja jednorazowego zdarzenia na obietnicę jest prosta:
javascript
// Converting a single-event completion to Promise
function waitForEvent(emitter, successEvent, errorEvent = 'error') {
return new Promise((resolve, reject) => {
emitter.once(successEvent, resolve);
emitter.once(errorEvent, reject);
});
}
async function fetchData(source) {
const emitter = fetchDataLegacy(source);
const data = await waitForEvent(emitter, 'data');
await waitForEvent(emitter, 'end');
return data;
}
W przypadku strumieni emitujących wiele zdarzeń danych Node.js zapewnia events.on który zwraca asynchroniczny iterator, umożliwiający wykorzystanie całego strumienia for await...of:
javascript
const { on } = require('events');
async function processStream(readable) {
for await (const chunk of on(readable, 'data')) {
await processChunk(chunk);
}
}
Odczytowalne strumienie Node.js są również bezpośrednio iterowalne jako obiekty iterowalne asynchronicznie od Node 10:
javascript
const fs = require('fs');
async function countLines(filePath) {
let lines = 0;
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
for await (const chunk of stream) {
lines += chunk.split('\n').length - 1;
}
return lines;
}
Wzorce migracji TypeScript Async/Await
Bazy kodu TypeScript wymagają dodatkowych rozważań podczas migracji do async/await. Typy zwracane muszą zostać zaktualizowane z sygnatur wywołań zwrotnych do Promise<T>, a kompilator wymusza, aby polecenie wait było używane wyłącznie wewnątrz funkcji asynchronicznych.
maszynopis
// Before: callback signature
function fetchUser(
id: string,
callback: (err: Error | null, user: User | null) => void
): void {
db.findOne({ id }, callback);
}
// After: async/await signature with proper return type
async function fetchUser(id: string): Promise<User> {
const user = await db.findOneAsync<User>({ id });
if (!user) throw new Error(`User ${id} not found`);
return user;
}
Ścisłe sprawdzanie wartości null w TypeScript współdziała z kodem asynchronicznym w sposób, który wychwytuje typowe błędy migracji. Jeśli funkcja zwrócona wcześniej User | null poprzez wywołanie zwrotne, a migracja zmienia je na Promise<User> (rzucając zamiast zwracać null), TypeScript wychwyci wywołujących, którzy sprawdzali null, ale nie muszą tego już robić, a także wywołujących, którzy nie sprawdzają błędów, które teraz muszą obsłużyć.
W przypadku starszego kodu TypeScript, który używa @types/node sygnatury wywołań zwrotnych, util.promisify jest w pełni typizowany i automatycznie wnioskuje poprawny typ zwracany Promise dla wbudowanych funkcji Node.js.
Strategia migracji przyrostowej dla systemów produkcyjnych
Nie da się zmigrować całego systemu produkcyjnego naraz. Podejście przyrostowe polega na konwersji jednego modułu na raz, jego walidacji, a następnie przejściu do kolejnego. Kluczem jest zachowanie wyraźnej granicy między kodem przekonwertowanym a nieprzekonwertowanym na każdym etapie.
Kolejność migracji powinna być zgodna z kierunkiem zależności: najpierw konwertuj najgłębsze zależności, a następnie przechodź w górę, w kierunku obiektów wywołujących. Dzięki temu w momencie konwersji funkcji wyższego poziomu jej zależności będą już zwracać obietnice, a warstwa opakowująca nie będzie już potrzebna.
javascript
// Stage 1: Promisify the data layer (deepest dependency)
const db = {
queryAsync: promisify(db.query.bind(db)),
insertAsync: promisify(db.insert.bind(db)),
};
// Stage 2: Convert the service layer (depends on db)
class UserService {
async getUser(id) {
return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
}
async createUser(data) {
return db.insertAsync('users', data);
}
}
// Stage 3: Convert the controller layer (depends on service)
// -- only after Stage 2 is validated and deployed
async function handleGetUser(req, res) {
try {
const user = await userService.getUser(req.params.id);
res.json(user);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
To etapowe podejście jest bezpośrednio wspierane przez narzędzia analityczne. Jak omówiono w analiza przepływu danych i sterowaniaZrozumienie, w jaki sposób dane przepływają przez warstwy asynchroniczne przed ich modyfikacją, jest warunkiem wstępnym bezpiecznego refaktoryzowania przyrostowego. Kierunek zależności określa, które moduły można bezpiecznie przekonwertować w pierwszej kolejności, a które muszą poczekać, aż ich zależności zostaną zmigrowane.
Warstwy opakowania zapewniające wsteczną kompatybilność
Podczas migracji niektórzy użytkownicy nadal będą oczekiwać interfejsów API opartych na wywołaniach zwrotnych. util.callbackify funkcja jest odwrotnością util.promisify:konwertuje funkcję asynchroniczną z powrotem do interfejsu wywołania zwrotnego obsługującego najpierw błąd:
javascript
const { callbackify } = require('util');
// New async implementation
async function fetchUserAsync(id) {
return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
}
// Backward-compatible callback version for unconverted callers
const fetchUser = callbackify(fetchUserAsync);
// Old callers continue to work unchanged
fetchUser(userId, (err, user) => {
if (err) return handleError(err);
render(user);
});
// New callers use the async version directly
const user = await fetchUserAsync(userId);
Ta dwukierunkowa zgodność oznacza, że konwersja nie jest wydarzeniem „flag day”. Poszczególne moduły można konwertować w dowolnym sprincie, bez konieczności jednoczesnej koordynacji z każdym wywołującym.
Typowe pułapki async/await podczas migracji
Brak oczekiwania na wywołania funkcji asynchronicznych
Najczęstszym błędem migracji jest wywołanie funkcji asynchronicznej bez oczekiwania na nią. Jest to niewidoczne dla środowiska wykonawczego JavaScript, chyba że funkcja odrzuci wywołanie, a odrzucenie stanie się nieobsłużonym odrzuceniem obietnicy, a nie zgłoszonym błędem.
javascript
// Bug: missing await -- function runs but result is a Promise, not the user
async function updateUserName(id, name) {
const user = fetchUser(id); // BUG: forgot await, user is a Promise object
user.name = name; // setting .name on a Promise, not a user
await saveUser(user); // saves the Promise object
}
// Fix
async function updateUserName(id, name) {
const user = await fetchUser(id);
user.name = name;
await saveUser(user);
}
TypeScript i ESLint no-floating-promises Reguła automatycznie wychwytuje ten wzorzec. Zdecydowanie zaleca się dodanie tej reguły lint podczas migracji.
Funkcje asynchroniczne w metodach tablicowych
Array.prototype.forEach nie oczekuje na asynchroniczne wywołania zwrotne. Powoduje to takie samo sekwencyjne, ale błędne zachowanie jak w przypadku funkcji „await-in-loop”, z tą różnicą, że kod wydaje się działać, wykonując jednocześnie wszystkie wywołania zwrotne bez oczekiwania na żadne z nich:
javascript
// Bug: forEach does not await async callbacks
async function processAll(ids) {
ids.forEach(async (id) => {
await processItem(id); // these run concurrently, forEach completes immediately
});
// function returns before any processItem completes
}
// Fix: use Promise.all with map
async function processAll(ids) {
await Promise.all(ids.map(id => processItem(id)));
}
Try/Catch nie wychwytuje błędów asynchronicznych poza Await
Blok try/catch wychwytuje błędy tylko z wyrażeń oczekujących. Odrzucenie wywołania funkcji asynchronicznej bez bloku „await” nie zostanie wykryte przez otaczający blok try/catch:
javascript
// Bug: the rejection from riskyOp() is not caught
async function run() {
try {
riskyOp(); // not awaited -- rejection escapes the try/catch
} catch (err) {
console.error(err); // never reached
}
}
// Fix: await inside the try block
async function run() {
try {
await riskyOp();
} catch (err) {
console.error(err);
}
}
Testowanie kodu asynchronicznego po migracji
Migrowane funkcje asynchroniczne wymagają asynchronicznych przypadków testowych. Nowoczesne frameworki testowe obsługują to natywnie.
javascript
// Jest async test patterns
describe('UserService', () => {
// Pattern 1: async/await in test
test('fetches user by id', async () => {
const user = await userService.getUser('user-123');
expect(user.id).toBe('user-123');
});
// Pattern 2: testing rejection
test('throws when user not found', async () => {
await expect(userService.getUser('nonexistent'))
.rejects.toThrow('User nonexistent not found');
});
// Pattern 3: parallel setup
beforeAll(async () => {
await db.connect();
await db.seed(testData);
});
afterAll(async () => {
await db.cleanup();
await db.disconnect();
});
});
Testowanie porównawcze jest skuteczne przy sprawdzaniu, czy przeniesiona funkcja generuje identyczne dane wyjściowe, jak poprzednia funkcja wywołania zwrotnego:
javascript
// Comparison test: callback version vs. async version must agree
test('async version matches callback version output', async () => {
const callbackResult = await promisify(fetchUserLegacy)('user-123');
const asyncResult = await fetchUserAsync('user-123');
expect(asyncResult).toEqual(callbackResult);
});
Ten wzorzec testowy jest szczególnie przydatny w okresie przejściowym, gdy obie wersje działają równolegle. Jak zbadano w kontekście analiza wpływu przed wprowadzeniem zmian w kodziesprawdzenie, czy przebudowana funkcja generuje identyczne wyniki jak jej poprzedniczka, stanowi oparte na dowodach potwierdzenie, że migracja jest poprawna przed usunięciem starej wersji.
W jaki sposób SMART TS XL Obsługuje bezpieczną migrację asynchroniczną na dużą skalę
W przypadku baz kodu przedsiębiorstwa, w których łańcuchy wywołań zwrotnych obejmują wiele plików, usług i zespołów, pierwszym wymogiem bezpiecznej migracji jest kompletna mapa istniejących zależności asynchronicznych: które funkcje wywołują które, jakie dane są między nimi przekazywane, które łańcuchy są ścieżkami krytycznymi dla głównych operacji aplikacji, a które są odizolowanymi narzędziami, które można migrować niezależnie.
SMART TS XL Konstruuje tę mapę zależności, analizując całą bazę kodu, a nie tylko poszczególne pliki. Rozwiązuje ona problem odwoływania się do siebie nawzajem przez granice modułów, identyfikuje, który współdzielony stan jest powiązany z domknięciami, i wizualizuje łańcuchy wykonywania, które muszą pozostać nienaruszone podczas migracji. Ta analiza strukturalna dostarcza danych wejściowych do podejścia do migracji etapowej opisanego w tym przewodniku: które moduły znajdują się na dole grafu zależności i mogą zostać przekonwertowane w pierwszej kolejności, a które muszą poczekać, aż ich zależności zostaną zmigrowane.
Platforma analiza wpływu Możliwość rozszerza to o ocenę zmian. Przed konwersją modułu opartego na wywołaniach zwrotnych w celu zwracania obietnic, analiza wpływu identyfikuje każdy inny moduł w bazie kodu, który wywołuje go za pomocą interfejsu wywołania zwrotnego. Te wywołujące moduły stanowią zakres migracji dla następnego etapu: muszą zostać przekonwertowane jednocześnie lub muszą zostać użyte w wrapperze zgodnym z poprzednimi wersjami za pomocą util.callbackify Należy je utrzymywać do momentu konwersji. Bez tego wyliczenia migracje przebiegają z nieznanym zakresem i powodują nieoczekiwane przerwanie, gdy niezidentyfikowani wywołujący napotykają obietnicę, w której oczekiwali wywołania zwrotnego.
W przypadku baz kodu, które łączą JavaScript z TypeScript lub odwołują się do usług zaplecza w innych językach, SMART TS XL'S analiza zależności międzyjęzykowych Zapewnia wgląd w pełną ścieżkę wykonania, a nie tylko w warstwę JavaScript. Łańcuch wywołań zwrotnych, który kończy się wywołaniem usługi zewnętrznej napisanej w Javie lub Pythonie, ma zależności, których narzędzia jednojęzyczne nie dostrzegają, a planowanie migracji, które ignoruje te zależności, jest niekompletne. wizualizacja zależności że SMART TS XL umożliwia uwidocznienie tych powiązań transgranicznych przed dokonaniem jakichkolwiek zmian w migracji.
Utrzymanie migracji: od wywołania zwrotnego do async/await w całej bazie kodu
Przejście z wywołań zwrotnych do async/await nie kończy się wraz z konwersją pierwszego modułu. Kończy się ono po usunięciu ostatniej warstwy wrappera i braku pozostałości w bazie kodu. callback Konwencja ta jest zawarta w jej podstawowej logice. Osiągnięcie tego celu wymaga dyscypliny w całym okresie migracji: nowy kod musi być pisany w trybie async/await, warstwy opakowujące muszą być traktowane jako tymczasowe, a reguły ESLint muszą wymuszać, aby funkcje w stylu wywołania zwrotnego nie były wprowadzane w konwertowanych modułach.
Praktycznymi wskaźnikami ukończonej migracji są: brak util.promisify wywołania w kodzie aplikacji (były potrzebne tylko w okresie przejściowym), nie (err, result) => wzorce w podstawowej logice biznesowej (zastąpiono je metodą try/catch w funkcjach asynchronicznych), brak ręcznych konstruktorów Promise, gdzie async/await wystarczyłoby i Promise.all wszędzie tam, gdzie wcześniej niezależne operacje były wykonywane sekwencyjnie.
Każdy z nich można zmierzyć za pomocą analizy statycznej, co oznacza, że postępy można śledzić i raportować obiektywnie, a nie szacować. W przypadku zespołów działających na dużą skalę, połączenie zautomatyzowanej analizy statycznej w celu znalezienia wzorców wywołań zwrotnych i analizy zależności w celu określenia zakresu każdego etapu migracji stanowi różnicę między migracją, która kończy się w określonym czasie, a migracją, która trwa w nieskończoność, ponieważ zakres nigdy nie był w pełni znany.