Migration von bestehendem asynchronem Code zu Async/Await

Migration von bestehendem asynchronem Code zu Async/Await ohne Produktionsunterbrechung

Als Node.js 2009 erschien, war das Callback-Modell von JavaScript der einzige Mechanismus für nicht-blockierende Ein-/Ausgabe. Bis Promises in ES6 und async/await in ES2017 Einzug hielten, nutzten die meisten Produktionscodebasen bereits jahrelang Callback-basierte Logik: verschachtelte Fehlerbehandlungsroutinen, gemeinsam genutzter, veränderlicher Zustand, der über Closures verkettet war, und Wiederholungslogik, die in anonymen Funktionen bis in die dritte Ebene eingebettet war. Die Syntax änderte sich, der laufende Code jedoch nicht. Heute erben die meisten Entwicklerteams genau diese Situation: eine Codebasis, in der die neuen und die alten Muster nebeneinander existieren, in der das Team migrieren möchte, das System aber währenddessen nicht stillstehen kann.

Migrieren Sie Ihre asynchrone Codebasis sicher

SMART TS XL und identifiziert Migrationsrisiken in Ihrer gesamten Codebasis, bevor Sie auch nur eine Zeile ändern.

Jetzt entdecken

Die gute Nachricht ist, dass die Migration keine Neuentwicklung erfordert. Callbacks, Promises und async/await sind innerhalb klar definierter Grenzen interoperabel. Node.js wird mitgeliefert. util.promisify Genau um die Lücke zwischen fehlerbehandelnden Callbacks und Promise-rückgebenden Funktionen zu schließen, werden Wrapper-Schichten eingesetzt, die das gleichzeitige Nebeneinander von altem und neuem Code während der Übergangsphase ermöglichen. Die inkrementelle Migration, Modul für Modul, gewährleistet den laufenden Produktivbetrieb, während die Codebasis weiterentwickelt wird. Die Herausforderung liegt nicht in der Transformation selbst, sondern in ihrer systematischen Durchführung: Es gilt zu verstehen, welche Callbacks zuerst sicher konvertiert werden können, welche Anti-Patterns bei naiver Umschreibung zu Fehlern führen und wie sichergestellt wird, dass sich jede konvertierte Funktion identisch zu ihrer ursprünglichen verhält.

Das Callback-Modell verstehen und warum es bei großen Datenmengen versagt

Die Callback-Konvention in Node.js ist einfach: Funktionen, die asynchrone Aufgaben ausführen, erwarten einen Callback als letztes Argument. Der Callback erhält als erstes Argument einen Fehler und als zweites das Ergebnis. Jede Standardbibliotheksfunktion in Node.js folgt dieser Konvention. fs.readFile, http.get, child_process.execund Hunderte mehr.

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);
});

Dieses Muster ist für eine einzelne Operation unkompliziert. Es stößt jedoch an seine Grenzen, wenn Operationen verkettet werden müssen, da jede nachfolgende Operation innerhalb des vorherigen Callbacks gestartet werden muss. Eine dreistufige Sequenz aus Lesen, Transformieren und Schreiben erzeugt drei verschachtelte Callback-Ebenen. Eine fünfstufige Sequenz erzeugt fünf. Diese Struktur bezeichnen Entwickler als „Callback-Hölle“ oder „Pyramide des Grauens“, und sie ist nicht nur ein ästhetisches Problem. Tief verschachtelte Callbacks führen zu inkonsistenter Fehlerbehandlung (jede Ebene muss ihr Fehlerargument unabhängig prüfen), erschweren die Nachvollziehbarkeit der Ausführungsreihenfolge und machen Refactoring gefährlich, da der Datenfluss implizit statt explizit ist.

Das gravierendere Problem für Produktionssysteme besteht darin, dass Callbacks keinen nativen Mechanismus zur Koordination besitzen. Das Ausführen zweier asynchroner Operationen und das Warten auf deren Abschluss erfordert die manuelle Zählerverfolgung. Das Ausführen einer Operationssequenz auf einem Array erfordert rekursive Muster oder Drittanbieterbibliotheken wie … asyncNichts von dieser Komplexität ist in den Funktionssignaturen sichtbar: Eine Callback-basierte API und eine darauf aufbauende parallele Orchestrierung sehen von außen identisch aus – bis sie fehlschlagen.

Wie die Hölle der Rückrufe tatsächlich aussieht

Eine reale Callback-Pyramide eines Node.js-Dienstes, der Benutzerdaten liest, Berechtigungen validiert und den Zugriff protokolliert:

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));
      });
    });
  });
}

Die Fehlerbehandlung wiederholt sich auf jeder Ebene. Die Einrückung macht den Datenfluss optisch komplex. Ein vierter Schritt erfordert eine weitere verschachtelte Ebene. Um diese Funktion zu testen, müssen alle drei Abhängigkeiten nacheinander simuliert werden, und um einen Fehler auf der dritten Ebene zu simulieren, müssen die ersten beiden simuliert werden, damit dies erfolgreich ist. Jede dieser Eigenschaften verschlechtert sich mit zunehmender Komplexität der Kette.

Verwendung von util.promisify zur Überbrückung von Callbacks und Promises

Node.js 8.0 wurde eingeführt util.promisifyDadurch wird jede Funktion, die der Standardkonvention „Fehler zuerst“ folgt, in eine Funktion umgewandelt, die ein Promise zurückgibt. Dies ist der richtige Ausgangspunkt für jede Migration von async/await in einer Node.js-Codebasis.

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 Die Fehlerbehandlung erfolgt automatisch: Erhält die Callback-Funktion ein erstes Argument ungleich null, wird das zurückgegebene Promise mit diesem Fehler abgelehnt. Erhält die Callback-Funktion ein null-Argument und ein Ergebnis, wird das Promise mit dem Ergebnis aufgelöst. Für die überwiegende Mehrheit der Node.js-Kern-APIs und Drittanbieterbibliotheken, die dieser Konvention folgen, ist keine benutzerdefinierte Implementierung erforderlich.

Vielversprechende benutzerdefinierte Callback-Funktionen

Für benutzerdefinierte Funktionen, die der Konvention „Fehler zuerst“ folgen, aber keine in Node.js integrierten Funktionen sind, util.promisify Funktioniert identisch:

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;
}

Dieser Ansatz ist wichtig: util.promisify Die ursprüngliche Funktion wird umschlossen, ohne sie zu verändern. Die ursprüngliche Callback-Version funktioniert weiterhin. Aufrufer, die noch nicht migriert wurden, verwenden weiterhin die Callback-Version. Migrierte Aufrufer verwenden die Promise-Version. Diese Koexistenz ermöglicht die inkrementelle Migration.

Umgang mit nicht standardmäßigen Rückrufsignaturen

Einige ältere Bibliotheken übergeben mehrere Ergebniswerte an ihre Rückruffunktionen, util.promisify wird nur auf den ersten Wert aufgelöst. In diesen Fällen gilt: util.promisify.custom Das Symbol ermöglicht die Definition einer benutzerdefinierten Promisifizierung:

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 } }

Callbacks in Promises umwandeln: Die Schritt-für-Schritt-Anleitung

Für Code, der nicht verarbeitet werden kann von util.promisify Direkt ist der manuelle Promise-Wrapper der Migrationspfad. Das Muster ist konsistent:

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}`);
  }
}

Die dreistufige Callback-Pyramide von vorhin, neu geschrieben mit 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);
}

Die Fehlerbehandlung erfolgt nun durch einen einzigen try/catch-Block an der Aufrufstelle, anstatt auf jeder Ebene wiederholt zu werden. Die Einrückung ist flach. Das Hinzufügen eines vierten Schritts erfordert einen weiteren try/catch-Block. await Für die Tests muss jede Abhängigkeit unabhängig voneinander und in beliebiger Reihenfolge simuliert werden.

Parallele Ausführung mit Promise.all und Promise.allSettled

Einer der häufigsten Fehler beim Umstieg von Callbacks auf async/await ist die sequentielle Ausführung von Operationen, die parallel ausgeführt werden könnten. Callbacks machten die parallele Ausführung so komplex, dass viele Entwickler standardmäßig sequentielle Ketten verwendeten. Async/await lässt Parallelität sequentiell erscheinen, was Entwickler dazu verleiten kann, Code zu schreiben, der langsamer ist als sein Callback-Vorgänger.

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 Die Funktion wird abgelehnt, wenn ein Promise im Array fehlschlägt. Dies ist erforderlich, wenn unabhängige Operationen fehlschlagen können, ohne sich gegenseitig zu beeinflussen, und der Aufrufer über alle Fehler informiert werden muss. Promise.allSettled ist das richtige Werkzeug:

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);
}

Dieses Muster hat in Callback-basiertem Code ohne einen manuellen Zähler oder eine Bibliothek wie async.parallelMigration zu Promise.allSettled ist oft eine der wirkungsvollsten Änderungen in einer bestehenden asynchronen Codebasis.

Vermeidung des Anti-Patterns „await-in-loop“

Der Fehler, sequentiell statt parallel zu arbeiten, tritt am häufigsten innerhalb von Schleifen auf:

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)))
  );
}

Das Muster „await-in-loop“ ist einer der häufigsten Rückschritte, die bei der Migration von Callback-basiertem Code zu async/await entstehen, da Callbacks die Entwickler zwangen, explizit über Parallelität nachzudenken, während async/await diese verschleiert.

Fehlerbehandlung in Async/Await: Ersetzen der Callback-Fehlerweiterleitung

Callback-Funktionen leiten Fehler konventionsgemäß weiter: Das erste Argument jeder Callback-Funktion ist ein Fehler oder null. Dies funktioniert zwar, erfordert aber, dass jeder Aufrufer das Fehlerargument manuell prüft. Async/await leitet Fehler über den Promise-Ablehnungsmechanismus weiter, der mit dem nativen try/catch-Block von JavaScript integriert ist.

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;
}

Ein entscheidender Aspekt bei der Migration ist die Beibehaltung des Fehlerkontexts. Callback-basierter Code übergibt häufig Fehler, die auf jeder Ebene mit Kontextinformationen angereichert wurden. Stellen Sie bei der Migration zu async/await sicher, dass die Fehlerbehandlung diesen Kontext beibehält.

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}`);
  }
}

Nicht behandelte Ablehnungen von Versprechen

Callback-basierter Code unterdrückt Fehler stillschweigend, wenn das Fehlerargument ignoriert wird. Async/await führt zu unbehandelten Promise-Ablehnungen, die in Node.js 15+ standardmäßig zum Prozessabbruch führen. Dies stellt eine inkompatible Änderung bei der Migration dar: Code, der zuvor stillschweigend fehlschlug, stürzt nun ab.

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

Überprüfen Sie während der Migration alle asynchronen Funktionsaufrufe, die ohne vorheriges Abwarten ausgeführt werden. Jeder Aufruf einer asynchronen Funktion, deren Rückgabewert nicht erwartet oder verkettet wird, sollte überprüft werden. .catch() ist ein potenziell stiller Fehler in der Callback-Welt, aber ein unbehandelter Absturz in der async/await-Welt.

Migration von EventEmitter-Mustern zu Promises und asynchronen Iteratoren

Node.js EventEmitter sind eine Form des Callback-Musters, bei dem mehrere Callbacks für benannte Ereignisse registriert werden. Sie werden häufig in Streams, Netzwerkverbindungen und benutzerdefinierten Event-Bussen eingesetzt. Die direkte Migration zu async/await erfordert das Einbinden der ereignisbasierten API.

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'));

Die Umwandlung eines einmaligen Ereignisses in ein Promise ist unkompliziert:

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;
}

Für Datenströme, die mehrere Datenereignisse ausgeben, bietet Node.js Folgendes: events.on was einen asynchronen Iterator zurückgibt, der es ermöglicht, den gesamten Stream zu konsumieren. for await...of:

Javascript

const { on } = require('events');

async function processStream(readable) {
  for await (const chunk of on(readable, 'data')) {
    await processChunk(chunk);
  }
}

Seit Node 10 sind lesbare Streams in Node.js auch direkt als asynchrone Iterables iterierbar:

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;
}

TypeScript Async/Await Migrationsmuster

Bei der Migration von TypeScript-Codebasen zu async/await sind zusätzliche Aspekte zu beachten. Rückgabetypen müssen von Callback-Signaturen auf … aktualisiert werden. Promise<T>Der Compiler erzwingt, dass await nur innerhalb von asynchronen Funktionen verwendet wird.

Typoskript

// 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;
}

Die strikten Nullprüfungen von TypeScript interagieren mit asynchronem Code auf eine Weise, die häufige Migrationsfehler abfängt. Wenn eine Funktion zuvor einen Wert zurückgegeben hat User | null durch einen Callback und die Migration ändert es zu Promise<User> (Auslösen statt Rückgabe von null), TypeScript fängt Aufrufer ab, die auf null geprüft haben, dies aber nicht mehr müssen, und Aufrufer, die nicht auf Fehler prüfen, die sie nun behandeln müssen.

Für älteren TypeScript-Code, der Folgendes verwendet @types/node Rückrufsignaturen, util.promisify ist vollständig typisiert und leitet automatisch den korrekten Promise-Rückgabetyp für die integrierten Funktionen von Node.js ab.

Strategie für schrittweise Migration von Produktionssystemen

Ein Produktionssystem lässt sich nicht auf einmal vollständig migrieren. Der inkrementelle Ansatz konvertiert jeweils ein Modul, validiert es und fährt dann mit dem nächsten fort. Entscheidend ist, in jeder Phase eine klare Trennlinie zwischen konvertiertem und nicht konvertiertem Code zu wahren.

Die Migrationsreihenfolge sollte der Abhängigkeitsrichtung folgen: Zuerst die tiefsten Abhängigkeiten konvertieren, dann die aufrufenden Funktionen nach oben vorarbeiten. Dadurch wird sichergestellt, dass die Abhängigkeiten einer übergeordneten Funktion bereits Promises zurückgeben, sobald diese konvertiert ist, und die Wrapper-Schicht nicht mehr benötigt wird.

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 });
  }
}

Dieser stufenweise Ansatz wird direkt durch Analysetools unterstützt. Wie in [Referenz einfügen] erläutert, … Daten- und KontrollflussanalyseDas Verständnis des Datenflusses durch asynchrone Schichten vor deren Modifizierung ist Voraussetzung für sicheres inkrementelles Refactoring. Die Abhängigkeitsrichtung bestimmt, welche Module zuerst sicher konvertiert werden können und welche warten müssen, bis ihre Abhängigkeiten migriert sind.

Wrapper-Layer für Rückwärtskompatibilität

Während der Migration werden einige Aufrufer weiterhin Callback-basierte APIs erwarten. util.callbackify Die Funktion ist die Umkehrfunktion von util.promisify: Es wandelt eine asynchrone Funktion zurück in eine fehlerbasierte Callback-Schnittstelle um:

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);

Diese bidirektionale Kompatibilität bedeutet, dass die Umstellung kein einmaliges Ereignis ist. Einzelne Module können in jedem Sprint umgestellt werden, ohne dass eine gleichzeitige Abstimmung mit allen Beteiligten erforderlich ist.

Häufige Async/Await-Fehler bei der Migration

Fehlendes await bei asynchronen Funktionsaufrufen

Der häufigste Migrationsfehler besteht darin, eine asynchrone Funktion aufzurufen, ohne sie vorher abzuwarten. Dies bleibt für die JavaScript-Laufzeitumgebung unsichtbar, solange die Funktion nicht einen Fehler auslöst. In diesem Fall wird der Fehler als unbehandelte Promise-Ablehnung behandelt, anstatt als Fehler ausgelöst zu werden.

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 und ESLint no-floating-promises Diese Regel erkennt dieses Muster automatisch. Es wird dringend empfohlen, diese Lint-Regel während der Migration hinzuzufügen.

Asynchrone Funktionen in Array-Methoden

Array.prototype.forEach wartet nicht auf asynchrone Rückrufe. Dies führt zum gleichen sequenziellen, aber falschen Verhalten wie await in einer Schleife, nur dass der Code scheinbar funktioniert, während alle Rückrufe stillschweigend gleichzeitig ausgeführt werden, ohne auf einen einzigen zu warten:

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 fängt keine asynchronen Fehler außerhalb von Await ab.

Ein try/catch-Block fängt nur Fehler von erwarteten Ausdrücken ab. Ein asynchroner Funktionsaufruf ohne await wird nicht von einem umgebenden try/catch-Block abgefangen.

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);
  }
}

Testen von asynchronem Code nach der Migration

Migrierte asynchrone Funktionen erfordern asynchrone Testfälle. Moderne Testframeworks unterstützen dies nativ.

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();
  });
});

Um zu überprüfen, ob eine migrierte Funktion die gleiche Ausgabe wie ihre Callback-Vorgängerfunktion erzeugt, ist ein Vergleichstest effektiv:

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);
});

Dieses Testmuster ist besonders während der Übergangsphase nützlich, wenn beide Versionen parallel laufen. Wie im Kontext von Folgenabschätzung vor CodeänderungenDie Überprüfung, ob eine refaktorierte Funktion identische Ausgaben wie ihre Vorgängerfunktion erzeugt, ist die evidenzbasierte Bestätigung dafür, dass die Migration korrekt ist, bevor die alte Version entfernt wird.

Wie SMART TS XL Unterstützt sichere asynchrone Migration in großem Umfang

Bei Enterprise-Codebasen, in denen Callback-Ketten sich über mehrere Dateien, Dienste und Teams erstrecken, ist die erste Voraussetzung für eine sichere Migration eine vollständige Übersicht der bestehenden asynchronen Abhängigkeiten: welche Funktionen welche aufrufen, welche Daten zwischen ihnen ausgetauscht werden, welche Ketten kritische Pfade für die Kernoperationen der Anwendung darstellen und welche isolierte Hilfsfunktionen sind, die unabhängig migriert werden können.

SMART TS XL Diese Abhängigkeitskarte wird durch die Analyse des gesamten Quellcodes erstellt, nicht nur einzelner Dateien. Dabei wird ermittelt, wie Callback-basierte Funktionen über Modulgrenzen hinweg aufeinander verweisen, welcher gemeinsame Zustand durch Closures übertragen wird und welche Ausführungsketten während der Migration erhalten bleiben müssen. Diese Strukturanalyse liefert die Grundlage für den in diesem Leitfaden beschriebenen stufenweisen Migrationsansatz: Welche Module befinden sich am Ende des Abhängigkeitsgraphen und können zuerst migriert werden, und welche Module müssen warten, bis ihre Abhängigkeiten migriert sind?

Die Plattform ist Wirkungsanalyse Die Funktionalität erweitert dies auf die Änderungsbewertung. Bevor ein Callback-basiertes Modul so umgestellt wird, dass es Promises zurückgibt, identifiziert die Auswirkungsanalyse alle anderen Module im Code, die es über eine Callback-Schnittstelle aufrufen. Diese Aufrufer bilden den Migrationsbereich für den nächsten Schritt: Sie müssen entweder gleichzeitig umgestellt oder mithilfe eines abwärtskompatiblen Wrappers konvertiert werden. util.callbackify Sie müssen so lange aufrechterhalten werden, bis sie konvertiert sind. Ohne diese Aufzählung erfolgen Migrationen mit unbekanntem Umfang und führen zu unerwarteten Fehlern, wenn nicht identifizierte Aufrufer auf ein Promise stoßen, wo sie einen Callback erwartet haben.

Für Codebasen, die JavaScript mit TypeScript mischen oder Backend-Dienste in anderen Sprachen aufrufen, SMART TS XL sprachübergreifende Abhängigkeitsanalyse Es bietet Einblick in den gesamten Ausführungspfad, nicht nur in die JavaScript-Schicht. Eine Callback-Kette, die mit einem Aufruf eines externen, in Java oder Python geschriebenen Dienstes endet, weist Abhängigkeiten auf, die von Tools für einzelne Sprachen nicht erkannt werden können, und eine Migrationsplanung, die diese Abhängigkeiten ignoriert, ist unvollständig. Abhängigkeitsvisualisierung zur Abwicklung, Integrierung, Speicherung und SMART TS XL provides macht diese grenzüberschreitenden Beziehungen sichtbar, bevor Änderungen im Zuge der Migration vorgenommen werden.

Die Migration aufrechterhalten: Von Callback zu Async/Await im gesamten Quellcode

Der Übergang von Callbacks zu async/await ist nicht mit der Konvertierung des ersten Moduls abgeschlossen. Er ist erst abgeschlossen, wenn die letzte Wrapper-Schicht entfernt wurde und der Quellcode keine verbleibenden Funktionen mehr enthält. callback Konvention ist die Grundlage ihrer Logik. Um dorthin zu gelangen, ist Disziplin während der gesamten Migrationsphase erforderlich: Neuer Code muss in async/await geschrieben werden, Wrapper-Schichten müssen als temporär behandelt werden, und ESLint-Regeln müssen sicherstellen, dass in konvertierten Modulen keine Callback-Funktionen eingeführt werden.

Die praktischen Kennzeichen einer abgeschlossenen Migration sind: keine util.promisify Aufrufe im Anwendungscode (sie waren nur für die Übergangszeit erforderlich), nein (err, result) => Muster in der Kernlogik des Unternehmens (sie wurden durch try/catch in asynchronen Funktionen ersetzt), keine manuellen Promise-Konstruktoren, wo async/await würde genügen, und Promise.all überall dort, wo zuvor unabhängige Operationen nacheinander durchgeführt wurden.

Jeder dieser Aspekte lässt sich durch statische Analysen messen. Dadurch kann der Fortschritt objektiv verfolgt und dokumentiert werden, anstatt ihn nur zu schätzen. Für Teams, die in großem Umfang arbeiten, ist die Kombination aus automatisierter statischer Analyse zur Erkennung von Callback-Mustern und Abhängigkeitsanalyse zur Abgrenzung jeder Migrationsphase entscheidend dafür, ob die Migration innerhalb eines definierten Zeitrahmens abgeschlossen wird oder ob sie sich endlos hinzieht, weil der Umfang nie vollständig geklärt war.