Dieser Text wurde mit dem automatisierten Übersetzungssystem von Salesforce übersetzt. Nehmen Sie an unserer Umfrage teil, um Feedback zu diesem Inhalt zu geben und uns mitzuteilen, was Sie als Nächstes sehen möchten.
Note
Übersicht
Moderne Salesforce-Architekturen werden zunehmend durch asynchrone Verarbeitung unterstützt, nicht als Komfort, sondern als strategische Skalierungsanforderung. In den letzten Jahren haben wir immer mehr Unternehmen erlebt, die mit steigenden Datenvolumen, komplexen Integrationen mit mehreren Kontaktpunkten und der Zunahme autonomer Systeme rund um die Uhr zu kämpfen haben. All dies drängt Architekten dazu, asynchrone Systeme zu entwickeln.
Bei der asynchronen Verarbeitung in Salesforce müssen Sie oft Obergrenzen und Komplexität berücksichtigen. Diese Obergrenzen dienen als Leitplanken und architektonische Einschränkungen, die bei der Herstellung massensicherer, skalierbarer Systeme helfen. Auch wenn keine Plattformobergrenzen direkt zur Verwaltung der Komplexität dienen, können Designmuster dazu beitragen, Risiken in dieser Hinsicht zu minimieren. Intern überschreitet Salesforce oft die Grenzen der Plattform, um neue Funktionen zu testen und komplexe Geschäftsprozesse zu automatisieren. Es wurde ein Framework für die schrittbasierte asynchrone Verarbeitung entwickelt, um asynchrone Aufträge mit einer beliebigen Anzahl von Schritten auszuführen. Jeder Schritt kann unabhängig voneinander ausgeführt, wiederholt und neu gestartet werden. Dazu gehören freigegebene Steuerungen und vollständige betriebliche Transparenz durch die zentralisierte Protokollierung. In diesem Dokument werden die wichtigsten architektonischen Komponenten beschrieben: Warteschlangenfähiger Apex und Finalizer, geplanter Flow, Apex Cursors, aufrufbare Aktionen und Integrationen in Slack. Zusammen bieten diese Komponenten eine modulare, skalierbare und beobachtbare Architektur, die sich an sich ändernde Unternehmensanforderungen anpasst.
Wichtige Takeaways
Moderne Salesforce-Architekturen sollten einen asynchronen Ansatz verfolgen, um Skalierbarkeit, Widerstandsfähigkeit und betriebliche Transparenz zu erreichen.
Die Aufteilung komplexer Arbeiten in unabhängig ausführbare Schritte ermöglicht eine vorhersehbare Leistung, sichere Wiederholungen, Prüfpunkte, Rollback und modulare Weiterentwicklung, ohne die Kern-Workflows neu zu entwickeln.
Das Framework bietet eine skalierbare Alternative zu monolithischen und alternden Batchaufträgen, verketteten asynchronen Aufrufen und tief verschachtelten Flows und wurde für Arbeitslasten mit hohem Volumen entwickelt, die horizontal innerhalb von Salesforce ohne Orchestrierung außerhalb der Plattform skaliert werden müssen.
Die deterministische und beobachtbare Ausführung gewährleistet Fortschrittsverfolgung, SLA-Überwachung, Fehlerdiagnose und Transparenz auf Überwachungsebene durch zentralisierte Protokollierung und Governance.
Konzipiert für die Strenge von Unternehmen, einschließlich einheitlicher Governance, Compliance und verteilter Kontrolle über den Bundesstaat in langfristigen Geschäftsprozessen.
Bewährte Vorgehensweisen für die Plattform
Bevor Sie die Anforderungen überprüfen, finden Sie hier einige Tipps und Tricks für die Verwendung eines solchen Frameworks. Überlegen Sie vor allem, welches System die einzige Quelle der Wahrheit ist. Wenn Ihre Salesforce-Organisation nur minimal von externen Daten abhängig ist, jedoch von Hunderten auf Millionen Datensätze skaliert werden muss, sollten Sie ein schrittbasiertes asynchrones Framework in Betracht ziehen.
Verwenden Sie dieses Framework**,** wenn:
Die meisten (oder alle) Informationen, auf die reagiert werden soll, sind bereits in Ihrem CRM vorhanden.
Die Kosten für die Wartung eines Auftrags vom Typ "Extract Transform Load (ETL)" zur Harmonisierung externer Daten sind zu hoch.
Sie müssen die Verarbeitung einer großen Anzahl von Salesforce-Datensätzen nach einem festgelegten Zeitplan zurückstellen.
Sie können die Verarbeitung in separate Schritte unterteilen. Beispielsweise können Sie einen hierarchischen oder strukturbasierten Satz von Datensätzen erstellen, insbesondere wenn das Datenvolumen in der Hierarchie oder Struktur aufgefächert wird.
Verwenden Sie dieses Framework nicht in folgenden Fällen:
Für das Erstellen oder Aktualisieren von Datensätzen ist eine sofortige Neuberechnung erforderlich.
Die Integration ist schwierig, da externe Systeme Primärdaten für Datensatzaktualisierungen hosten. (Überlegen Sie, ob aktualisierte Daten mit der Bulk-API an Salesforce übertragen werden sollen.)
Unter Berücksichtigung dieser Vorgehensweisen sollten wir unsere Anforderungen überprüfen und mit der Erstellung beginnen.
Aufschlüsseln der Anforderungen
Beachten Sie die Problemanweisung:
Überprüfen Sie bei einem Auftrag, der täglich ausgeführt werden muss, ob bestimmte Datensätze vorab festgelegte Kriterien für die weitere Verarbeitung erfüllen. Falls ja, starten Sie die Verarbeitungsaufträge. Die Verarbeitung von Datensätzen kann bedeuten, dass Daten aus mehreren externen Systemen abgerufen werden, um Berechnungen durchzuführen. Schritte in Aufträgen sollten Personen über Slack benachrichtigen, dass verarbeitete Datensätze zur Überprüfung bereit sind. Die Schritte sollten auch Benachrichtigungen an Manager und übergeordnete Positionen in der Rollenhierarchie basierend auf einer konfigurierbaren Verzögerung nach der ersten Benachrichtigungsrunde eskalieren.
Dieses Problem umfasst mehrere verschiedene Schritte, von denen einige unabhängig voneinander ablaufen können. Es gibt viele Möglichkeiten, die Arbeit aufzuteilen. Hier eine Gruppierung:
Der Planer.
Die Schrittoberfläche und konkrete Implementierungen, die Datensätze verarbeiten (unabhängig vom Verarbeitungstyp).
Der Prozessor, der Schritte organisiert.
Die vom Planer aufgerufene Apex Invocable.
Der Benachrichtigungsteil. Wir verwenden das Apex Slack SDK.
Im Ausdruck "konfigurierbare Verzögerung" verbirgt sich etwas Komplexität. Diese Komplexität wird später in diesem Artikel beschrieben.
Im Folgenden finden Sie ein Diagramm mit Meinungsäußerungen für das integrierte Framework:
Nun schlüsseln Sie das Diagramm auf und erstellen Sie die Teile.
Planung mit geplantem Flow
Der geplante Flow bietet als Planungsmechanismus mehrere Vorteile:
Geplante Flows können als Metadaten in Pakete aufgenommen und bereitgestellt werden. Dies gilt nicht für Aufträge, die über Apex (oder über die Seite "Geplante Aufträge") geplant werden.
Das Warteelement ist wichtig für Frameworks, die Callouts erfordern. Durch die Verwendung in Flow sind Callouts im Teil "Aufrufbar" des Frameworks nicht erforderlich.
Die Planungsgranularität erfüllt die Anforderungen: Das Mindestintervall für geplante Flows beträgt täglich. Wenn Sie eine höhere Häufigkeit benötigen (z. B. stündlich), sollten Sie den geplanten Flow für diese Anforderung überdenken.
Eine weitere Überlegung beim Konfigurieren des geplanten Flows ist das Umgebungs-Gating. Fügen Sie vor dem Aufrufen der Apex-Aktion ein Entscheidungselement hinzu, das die {!$Api.Enterprise_Server_URL_100} auswertet. Dadurch wird sichergestellt, dass der Auftrag nur in den vorgesehenen Umgebungen ausgeführt wird, z. B. in UAT und Production. Dieses Muster ist wichtig, da Sandbox-Instanzen während des SDLC häufig aktualisiert oder neu erstellt werden und ein geplanter Flow ohne explizite Umgebungsprüfung versehentlich in Umgebungen ausgeführt werden könnte, in denen das Framework nicht ausgeführt werden soll. Durch die Verwendung des contains-Operators im Entscheidungselement wird das Setup widerstandsfähig gegen künftige Sandbox-Erstellungen oder URL-Änderungen.
Überlegen Sie abschließend, wie das Framework Fehler erfassen soll. Fügen Sie immer einen Fehlerpfad hinzu, wenn Flow eine beliebige Aktion aufruft. Beispielsweise können Sie Fehler mit der Aktion "Protokolleintrag hinzufügen" verdrahten. Der Nebelprotokollierer schreibt Protokolle in benutzerdefinierte Objekte. Daher sollten sich Kunden bewusst sein, dass Protokolldaten den Speicherplatz in der Organisation belegen. Protokolle werden standardmäßig 14 Tage in einer Organisation gespeichert und dann bereinigt. Dieser Aufbewahrungszeitraum kann konfiguriert werden. Der Nebelprotokollierer verwendet auch Plattformereignisse zum Veröffentlichen von Protokollen, sodass Protokolleinträge unabhängig von der Hauptdatenverarbeitungstransaktion gespeichert werden. Auf diese Weise wird sichergestellt, dass Fehler auch dann erfasst werden, wenn die primäre Flow- oder Apex-Aktion zurückgesetzt wird. Kunden sollten das erwartete Protokollvolumen und die Aufbewahrungsanforderungen auswerten, wenn sie das Hinzufügen eines Protokollierungs-Frameworks in Erwägung ziehen.
So sieht der Flow aus:
Gehen wir nun zu den ersten Teilen des Apex Codes über, wobei die Planungsanforderung nun erfüllt ist.
In diesem Artikel wird die step der Übersichtlichkeit halber als äußere Klasse angezeigt. Das Framework selbst ist flexibel. Teams können die Oberfläche und ihre Implementierungen mithilfe eines beliebigen Apex-Paketerstellungsmusters organisieren, sofern alle Schrittklassen auf dieselbe Oberfläche verweisen.
Zu den auf unserer Oberfläche definierten Methoden gibt es einige Hinweise:
Die execute wird verbessert, wenn eine State (oder Schnittstelle) übergeben wird, um Daten zwischen Schritten zu orchestrieren, wenn es auf die Reihenfolge ankommt.
getName kann anstelle eines String einen System.Type zurückgeben. Ziel ist es, der Orchestrierungsebene eine Möglichkeit zu geben, Schrittnamen zu protokollieren, ohne andere Eigenschaften offenzulegen.
Im Folgenden finden Sie die erste konkrete Implementierung, die zeigt, wie diese Teile zusammenpassen. Mit einer Ausnahme später wird empfohlen, warteschlangenfähigen Apex zu verwenden, um eine asynchrone Verarbeitung in Apex zu implementieren; Batch Apex ist in der Regel nicht erforderlich (und @future werden abgeraten). Warteschlangenfähiger Apex startet schnell und bietet mit Apex Cursors viele Vorteile gegenüber Batch Apex.
Eine Apex Cursor-ähnliche Implementierung
Apex Cursors bieten eine moderne Alternative zum herkömmlichen Batch Apex Modell. Ähnlich wie bei der Batch-Verarbeitung kann eine Cursor-Implementierung Datensätze in Blöcken abrufen (bis zu 2.000 pro Batch). Cursors ermöglichen jedoch mehrere Abrufe innerhalb einer einzelnen Transaktion und ermöglichen so einen deutlich höheren Durchsatz für Vorgänge mit großem Volumen.
Wenn Teams Cursors als Teil dieses Frameworks übernehmen, sollten sie die aktuellen Einschränkungen bei Tests und der Mockability beachten. Das Cursorverhalten in Tests kann sich vom Produktionsverhalten unterscheiden. Daher ist es wichtig, Teststrategien zu entwerfen, die vermeiden, dass sich Cursorinterna auf sie verlassen, und stattdessen die Orchestrierungslogik an den Grenzen zu validieren. Im Zuge der Weiterentwicklung der Plattform werden sich diese Bereiche weiter verbessern, die zentrale Anleitung bleibt jedoch: Cursors bieten im Vergleich zu Batch Apex in vielen Anwendungsfällen eine höhere Leistung und einen geringeren Orchestrierungsaufwand.
Wenn Sie eine klare Grenze zwischen dem vom System bereitgestellten Cursor und Ihrem eigenen Code definieren möchten, sollten Sie beim Implementieren der Step eine Cursor-ähnliche Darstellung erstellen. Beachten Sie diesen Code:
1public inherited sharing abstract class CursorStep implements Step{2 private static final Integer MAX_CHUNK_SIZE = 2000;34 protected Cursor cursor;56 private Integer chunkSize = System.Limits.getLimitDMLRows();7 private Integer position = 0;89 protected abstract Cursor getCursor();10 protected abstract void innerExecute(List<SObject>records);1112 public abstract String getName();1314 public virtual CursorStep withChunkSize(Integer chunkSize){15 this.chunkSize = chunkSize;16 return this;17}1819 public void execute(){20 this.cursor = this.cursor ?? this.getCursor();21 this.cursor.setFetchesPerTransaction(this.getFetchesPerTransaction());22 List<SObject>records = new List<SObject>();23 if(this.shouldAdvance()){24 records = this.cursor.fetch(this.position, this.chunkSize);25 this.position += this.chunkSize;26}27 this.innerExecute(records);28}2930 public virtual void finalize(){31 Logger.info('finished cursor step for ' + this.getName());32}3334 public virtual Boolean shouldRestart(){35 return this.position<this.cursor.getNumRecords();36}3738 protected virtual Integer getFetchesPerTransaction(){39 Integer maxRecordsPerFetchCall = 2000;40 if(this.chunkSize< maxRecordsPerFetchCall){41 return this.chunkSize;42}43 // Integer division rounds down44 // which is perfect for our use-case45 return this.chunkSize / maxRecordsPerFetchCall;46}4748 protected virtual Boolean shouldAdvance(){49 return true;50}51}
Beachten Sie die Cursor-Klasse. Apex-Cursors sind Database.Cursor, aber unsere Cursor-Implementierung bietet uns Flexibilität hinsichtlich der Mängel von Cursors. Folgende Implementierung:
1public virtual without sharing class Cursor{2 private static final Integer MAX_FETCHES_PER_TRANSACTION = Limits.getLimitFetchCallsOnApexCursor();34 @TestVisible5 private static Integer maxRecordsPerFetchCall = 2000;67 private Integer cursorNumRecords;8 private Integer fetchesPerTransaction = MAX_FETCHES_PER_TRANSACTION;9 private final Database.Cursor cursor;1011 public Cursor(12 String finalQuery,13 Map<String, Object>bindVars,14 System.AccessLevel accessLevel15){16 try{17 this.cursor = Database.getCursorWithBinds(finalQuery, bindVars, accessLevel);18}catch(FatalCursorException e){19 Logger.newEntry(20 System.LoggingLevel.WARN,21 'Error creating cursor. This can happen if there' +22 ' are no records returned by the query: ' + e.getMessage()23);24}25}2627 public Cursor setFetchesPerTransaction(Integer possibleFetchesPerTransaction){28 // Handle accidental round downs from Integer division29 if(possibleFetchesPerTransaction == 0){30 return this;31}32 if(possibleFetchesPerTransaction > MAX_FETCHES_PER_TRANSACTION){33 Logger.newEntry(34 System.LoggingLevel.DEBUG,35 'Fetches per transaction: ' +36 possibleFetchesPerTransaction +37 ' exceeded platform max fetches per transaction: ' +38 MAX_FETCHES_PER_TRANSACTION +39 ', defaulting to platform max'40);41 possibleFetchesPerTransaction = MAX_FETCHES_PER_TRANSACTION;42}43 this.fetchesPerTransaction = possibleFetchesPerTransaction;44 return this;45}4647 @SuppressWarnings('PMD.EmptyStatementBlock')48 protected Cursor(){49}5051 public virtual List<SObject>fetch(Integer start, Integer advanceBy){52 if(this.getNumRecords() == 0){53 Logger.newEntry(54 System.LoggingLevel.DEBUG,55 'Bypassing fetch call, no records to fetch'56);57 return new List<SObject>();58}59 Integer localStart = start;60 List<SObject>results = new List<SObject>();61 while(62 Limits.getFetchCallsOnApexCursor()<this.fetchesPerTransaction &&63 results.size()<this.getNumRecords() &&64 localStart < start + advanceBy65){66 Integer actualAdvanceBy = this.getAdvanceBy(localStart, advanceBy);67 results.addAll(this.cursor?.fetch(localStart, actualAdvanceBy)?? new List<SObject>());68 localStart += actualAdvanceBy;69}70 return results;71}7273 public virtual Integer getNumRecords(){74 this.cursorNumRecords = this.cursorNumRecords ?? this.cursor?.getNumRecords()?? 0;75 return this.cursorNumRecords;76}7778 protected Integer getAdvanceBy(Integer start, Integer advanceBy){79 Integer possibleFetchSize = Math.min(advanceBy, this.getNumRecords() - start);80 if(possibleFetchSize > maxRecordsPerFetchCall){81 Logger.newEntry(82 System.LoggingLevel.DEBUG,83 'Fetch size: ' +84 possibleFetchSize +85 ' exceeded platform max fetch size of ' +86 maxRecordsPerFetchCall +87 ', defaulting to max fetch size'88);89 possibleFetchSize = maxRecordsPerFetchCall;90}else if(possibleFetchSize <0){91 possibleFetchSize = 0;92}93 return possibleFetchSize;94}95}
Für den Rest dieses Artikels werden die sharing-Deklarationen beim Verweis auf Apex-Klassen weggelassen. Stellen Sie in der Praxis sicher, dass Klassen der obersten Ebene explizit mit oder ohne Freigabe verwendet werden, um Ihrem Objektmodell und Ihren Berechtigungen gerecht zu werden.
Beachten Sie auch, dass unsere Cursor-Implementierung an die Plattform-Database.Cursor delegiert wird, wobei die zusätzlichen Vorteile als Nächstes diskutiert werden.
Zunächst die entsprechenden Tests:
1@IsTest2private class CursorTest{3 @IsTest4 static void itCapsAdvanceByArgument(){5 String accountName = 'helloWorld!';6 insert new Account(Name = accountName);7 String query = 'SELECT Name FROM Account WHERE Name = :bindVar0';8 Map<String, Object>bindVars = new Map<String, Object>{'bindVar0' => accountName };910 Cursor instance = new Cursor(query, bindVars, System.AccessLevel.SYSTEM_MODE);1112 Assert.areEqual(1, instance.getNumRecords());13 Assert.areEqual(accountName, instance.fetch(0, 1000).get(0).get('Name'));14 Assert.areEqual(1, System.Limits.getApexCursorRows());15}1617 @IsTest18 static void itCapsMaxRecordsPerFetchCall(){19 Cursor.maxRecordsPerFetchCall = 20;20 Integer oneMoreThanMaxFetch = Cursor.maxRecordsPerFetchCall + 1;2122 List<Account>accounts = new List<Account>();23 for(Integer i = 0; i < oneMoreThanMaxFetch; i++){24 accounts.add(new Account(Name = 'Fetch ' + i));25}26 insert accounts;2728 Exception ex;29 List<SObject>results;30 Cursor instance = new Cursor(31 'SELECT Id FROM Account',32 new Map<String, Object>(),33 System.AccessLevel.SYSTEM_MODE34);35 try{36 results = instance.fetch(0, oneMoreThanMaxFetch);37}catch(System.InvalidParameterValueException e){38 ex = e;39}4041 Assert.areEqual(null, ex?.getMessage());42 Assert.areEqual(2, Limits.getFetchCallsOnApexCursor());43 Assert.areEqual(oneMoreThanMaxFetch, results.size());44}4546 @IsTest47 static void itFetchesMultipleTimesPerTransactionWhenMoreThanMaxFetch(){48 Cursor.maxRecordsPerFetchCall = 20;49 List<Account>accounts = new List<Account>();50 Set<String>expectedFetchNames = new Set<String>();51 for(Integer i = 0; i <Cursor.maxRecordsPerFetchCall + 1; i++){52 String accountName = 'Fetch' + i;53 expectedFetchNames.add(accountName);54 accounts.add(new Account(Name = accountName));55}56 insert accounts;5758 Integer oneMoreThanMaxFetch = Cursor.maxRecordsPerFetchCall + 1;59 Cursor instance = new Cursor(60 'SELECT Name FROM Account',61 new Map<String, Object>(),62 System.AccessLevel.SYSTEM_MODE63);64 List<SObject>results = instance.setFetchesPerTransaction(2).fetch(0, oneMoreThanMaxFetch);6566 Assert.areEqual(Cursor.maxRecordsPerFetchCall + 1, results.size());67 Assert.areEqual(2, Limits.getFetchCallsOnApexCursor());68 Set<String>actuallyFetchedNames = new Set<String>();69 for(Account account :(List<Account>) results){70 actuallyFetchedNames.add(account.Name);71}72 Assert.areEqual(expectedFetchNames, actuallyFetchedNames);73}7475 @IsTest76 static void itFetchesMultipleTimesPerTransaction(){77 Cursor.maxRecordsPerFetchCall = 1;78 insert new List<Account>{new Account(Name = 'One'), new Account(Name = 'Two')};7980 Cursor instance = new Cursor(81 'SELECT Id FROM Account',82 new Map<String, Object>(),83 System.AccessLevel.SYSTEM_MODE84)85 .setFetchesPerTransaction(2);86 List<SObject>results = instance.fetch(0, 2);8788 Assert.areEqual(2, instance.getNumRecords());89 Assert.areEqual(2, results.size());90 results = instance.fetch(2, 1);91 Assert.areEqual(0, results.size());92}9394 @IsTest95 static void fetchesCorrectAmountOfRecords(){96 List<Account>accounts = new List<Account>();97 for(Integer i = 0; i <10; i++){98 accounts.add(new Account(Name = 'Fetch ' + i));99}100 insert accounts;101102 Cursor instance = new Cursor(103 'SELECT Id FROM Account',104 new Map<String, Object>(),105 System.AccessLevel.SYSTEM_MODE106)107 .setFetchesPerTransaction(10);108 List<SObject>results = instance.fetch(0, 2);109110 Assert.areEqual(2, results.size(), '' + results);111 Assert.areEqual(1, Limits.getFetchCallsOnApexCursor());112}113114 @IsTest115 static void doesNotExceedPlatformMaxFetch(){116 List<Account>accounts = new List<Account>();117 for(Integer i = 0; i <101; i++){118 accounts.add(new Account(Name = 'Fetch ' + i));119}120 insert accounts;121122 Test.startTest();123 Cursor instance = new Cursor(124 'SELECT Id FROM Account',125 new Map<String, Object>(),126 System.AccessLevel.SYSTEM_MODE127)128 .setFetchesPerTransaction(100);129 Integer counter = 0;130 List<SObject>results;131 while(counter <= 100){132 results = instance.fetch(counter, counter + 1);133 counter++;134}135 Test.stopTest();136137 Assert.areEqual(101, counter);138 Assert.areEqual(0, results.size());139}140}
Indem Cursor virtuell wird, können konkrete CursorStep-Implementierungen ohne Database.Cursor ausgeführt werden, wenn sie keinen großen Datensatz wiederholen müssen – ähnlich wie die Rückgabe eines System.Iterable<T> anstelle eines Database.QueryLocator in Batch Apex. Hier ein Beispiel:
1public abstract class CursorLikeImplementation extends CursorStep{2 private final Cursor cursorLike;34 public CursorLikeImplementation(List<SObject>previouslyRetrievedRecords){5 this.cursorLike = new CursorLike(previouslyRetrievedRecords);6}78 public override String getName(){9 return CursorLikeImplementation.class.getName();10}1112 public override Cursor getCursor(){13 return this.cursorLike;14}1516 private class CursorLike extends Cursor{17 private final List<SObject>records;1819 public CursorLike(List<SObject>records){20 super();21 this.records = records;22}2324 public override List<SObject>fetch(Integer position, Integer chunkSize){25 // clone, to keep the underlying list type26 List<SObject>clonedRecords = this.records.clone();27 clonedRecords.clear();28 for(Integer i = position; i <this.getAdvanceBy(position, chunkSize); i++){29 clonedRecords.add(this.records[i]);30}31 return clonedRecords;32}3334 public override Integer getNumRecords(){35 return this.records.size();36}37}38}
Beachten Sie, dass diese Klasse ebenfalls abstrakt ist und die konkrete Implementierung von innerExecute Unterklassen überlässt.
Es gibt auch eine Alternative zur inneren Unterklasse CursorLike. Wenn Sie wissen, dass konkrete Versionen eines solchen Schritts andere Obergrenzen nicht überschreiten, können Sie this.records aus CursorLike.fetch zurückgeben und die übergeordnete CursorStep.shouldRestart() überschreiben, um false zurückzugeben. Dadurch können Sie eine Liste durchlaufen, die nur durch die Apex Heap-Obergrenze von 12 MB pro asynchroner Transaktion begrenzt ist.
Weitere mögliche schrittweise Implementierungen
Unsere Cursor-basierte Implementierung bietet uns viel Flexibilität beim Paginieren großer Datenmengen. Die Step bietet uns die Flexibilität, Schritte aller Art zu beschreiben und zu kapseln.
Betrachten Sie einen Flow-basierten Schritt:
1public virtual class FlowStep implements Step{2 private final Invocable.Action specificFlow;34 private Boolean shouldRestart = false;56 public FlowStep(String specificFlowName, Map<String, Object>inputs){7 this.specificFlow = Invocable.Action.createCustomAction('flow', specificFlowName);8 this.specificFlow.setInvocations(new List<Map<String,Object>>{ inputs });9}1011 public void execute(){12 List<Invocable.Action.Result>results = this.specificFlow.invoke();13 for(Invocable.Action.Result result : results){14 if(result.isSuccess()){15 Map<String, Object>outputParams = result.getOutputParameters();16 Object potentialShouldRestartValue = outputParams.get('shouldRestart');17 // Flow does not enforce Booleans being initialized18 // so a null check is sadly necessary here19 if(potentialShouldRestartValue != null){20 this.shouldRestart = this.shouldRestart ||21 Boolean.valueOf(potentialShouldRestartValue);22}23}else{24 List<String>errorMessages = new List<String>();25 for(Invocable.Action.Error error : result.getErrors()){26 errorMessages.add(27 'Error code: ' + error.getCode() +28 ', error message: ' + error.getMessage()29);30}31 Logger.error(32 'An error occurred within your auto-launched flow:\n' +33 String.join(errorMessages, '\n\t')34);35}36}37}3839 public virtual void finalize(){40 Logger.info(this.getName() + ' finished processing');41}4243 public String getName(){44 return FlowStep.class.getName() + ':' + this.specificFlow.getName();45}4647 public Boolean shouldRestart(){48 return this.shouldRestart;49}50}
Da Flows keine Ausgabeparameter zurückgeben können, die einem Apex-definierten Typ entsprechen, wird vor der Verwendung nach einem shouldRestart-Ausgabeparameter gesucht.
Einige Schritte sind möglicherweise funktionsgekennzeichnet. Sie können Logik implementieren, um zu entscheiden, welche Schritte einbezogen werden sollen, oder einen No-op-Schritt für eine deaktivierte Funktion verwenden. Das Muster "Null Object" ist eine gängige Möglichkeit, die Komplexität innerhalb der Orchestrierungsebene zu reduzieren:
1@SuppressWarnings('PMD.EmptyStatementBlock')2public class NoOpStep implements Step{3 // The null object pattern is commonly implemented4 // as a singleton to reduce memory consumption5 public static final NoOpStep SELF {6 get {7 SELF = SELF ?? new NoOpStep();8}9 private set;10}1112 public void execute(){13}1415 public void finalize(){16}1718 String getName(){19 return NoOpStep.class.getName();20}2122 Boolean shouldRestart(){23 return false;24}25}
Es gibt nun einige Bausteine, mit denen wir arbeiten müssen. Sehen wir uns die Orchestrierungsebene an, die für das Durchlaufen von Schritten verantwortlich ist.
Erstellen eines Schrittprozessors
Der Prozessor ist ein Wendepunkt in der Architektur. Wir müssen entscheiden, wer definiert, welche Schritte initialisiert werden und wo. Zu den Optionen zählen:
Lassen Sie den Prozessor definieren, welche Schritte der Geschäftslogik zugeordnet werden. Diese Option ist einfach, wird jedoch zur besseren Lesbarkeit schlecht skaliert.
Definieren Sie die Zuordnung mit benutzerdefinierten Metadaten (Custom Metadata, CMDT). Metadatenbeziehungsfelder unterstützen keine ApexClass, wodurch die Klassennamenschreibweise locker in Ihr Geschäftsprozess-Setup integriert wird. Sie können das Administratorrisiko reduzieren, indem Sie das Feld als Auswahlliste festlegen und den vorhandenen Typ validieren (Type.forName() oder ApexClass abfragen). Da CMDT-Datensätze jedoch keine Auslöser unterstützen, erfolgt die Validierung zur Laufzeit. Diese Route kann getestet werden. Administratoren können jedoch weiterhin nur CMDT-Datensätze in der Produktion erstellen.
Definieren Sie die Zuordnung mit Datensätzen. Nicht-Administratoren können Schritte konfigurieren, Bereitstellungen werden jedoch schwieriger und Umgebungen können abweichen. Gehen Sie mit Bedacht vor.
Es gibt ein berühmtes Zitat aus Clean Code über die Handhabung dieser besonderen Komplexität:
Die Lösung dieses Problems besteht darin, die switch-Anweisung [zum Erstellen von Objekten] im Keller einer abstrakten Fabrik zu vergraben und sie niemals jemandem anzeigen zu lassen.
In diesem Sinne und da unsere aktuelle Anzahl an Schritten genau definiert ist und nicht zu groß wird, ist es in Ordnung, dass der Schrittprozessor auch die Fabrik für Schritte ist. Dadurch kann eine Enumeration zum Steuern der switch-Anweisung verwendet werden:
1public StepProcessor implements System.Queueable, System.Finalizer,2 Database.AllowsCallouts{3 private final List<Step>steps = new List<Step>();45 private Step currentStep;67 public StepProcessor setSteps(List<StepType> stepTypes){8 for(StepType type : stepTypes){9 switch on type {10 WHEN TYPE_ONE {11 this.addTypeOneSteps();12}13 WHEN TYPE_TWO {14 this.addTypeTwoSteps();15}16 // ... etc17}18}19 this.cleanSteps();20 return this;21}2223 public void execute(System.QueueableContext context){24 this.currentStep = this.currentStep ?? this.steps.remove(0);25 if(context != null){26 System.attachFinalizer(this);27 Logger.setAsyncContext(context);28}29 Logger.info('Executing step ' + this.currentStep.getName());30 try{31 this.currentStep.execute();32}catch(Exception e){33 Logger.exception('Unexpected exception', e);34}35 Logger.info('Finished executing step ' + this.currentStep.getName());36 Logger.saveLog();37}3839 public void execute(System.FinalizerContext context){40 Logger.info('Executing finalizer for step ' + this.currentStep.getName());41 Logger.setAsyncContext(context);42 switch on context?.getResult(){43 when UNHANDLED_EXCEPTION {44 // see the note below about this logging paradigm45 Logger.warn(46 'Failed to run on step' + this.currentStep,47 context?.getException()48);49}50 when else{51 this.currentStep.finalize();52 if(this.currentStep.shouldRestart()){53 this.kickoff();54}else if(this.steps.isEmpty() == false){55 this.currentStep = this.steps.remove(0);56 this.kickoff();57}else{58 Logger.info('Finished executing steps');59}60}61}62 Logger.info(63 'Finished executing finalizer for step ' +64 this.currentStep.getName()65);66 Logger.saveLog();67}6869 public String kickoff(){70 return this.steps.isEmpty()? null : System.enqueueJob(this);71}7273 private void cleanSteps(){74 for(Integer reverseIndex = this.steps.size() - 1;75 reverseIndex >= 0; reverseIndex--){76 if(this.steps[reverseIndex]instanceof NoOpStep){77 this.steps.remove(reverseIndex);78}79}80}8182 private void addTypeOneSteps(){83 this.steps.addAll(84 new List<Step>{85 new ExampleCursorStepOne(),86 new ExampleCursorStepTwo()87}88);89}9091 private void addTypeTwoSteps(){92 this.steps.addAll(93 new List<Step>{94 new FlowStep('95 ExampleInvocableName',96 new Map<String, Object>('exampleParameter' =>true)97),98 new ExampleCursorStepThree()99}100);101}102}
Die angezeigten Werksmethoden können wie addTypeOneSteps() Bedenken wie die Kennzeichnung von Funktionen delegieren. cleanSteps() führt eine einmalige Überprüfung der erfassten Schritte durch, um sicherzustellen, dass keine "leeren" Schritte vorhanden sind, bevor es wirklich asynchron wird. Das könnte wie folgt aussehen:
Die Fehlerbehandlung wurde seit der Erwähnung des Nebelprotokollierers im Abschnitt "Geplanter Flow" nicht mehr diskutiert. Das liegt daran, dass wir mit System.Finalizer die Protokollierung für alle Fehlerbedingungen abdecken können, ohne in jedem Schritt eine spezifische Fehlerbehandlung hinzuzufügen. Jeder Step konzentriert sich auf die Ausführung, während alle unzufriedenen Pfade protokolliert und neu geworfen werden, damit sie in Einheitentests angezeigt werden. Dies unterstützt die sichere Iteration und Warnungen auf Produktionsebene (mit dem Slack Logger-Plugin für Nebula für alle WARN- und ERROR-Protokolle).
Hinweis zur Fehlerprotokollierung: Die Übergabe der Schrittinstanz an Protokollmeldungen setzt ein Maß an Trust voraus, was in Protokollen sichtbar wird. Die Standard-toString() für Apex-Klassen enthält alle statischen und instanzspezifischen Eigenschaften in der Meldung. Dies kann wünschenswert sein oder vertrauliche Informationen preisgeben. Auch wenn Protokollierung und Sicherheit hier nicht im Mittelpunkt stehen, sollten Sie beachten, dass die Einhaltung einer Schnittstelle wie Step bei einigen Systemen auch das Erzwingen einer Überschreibung für toString() beinhalten kann.
Bei einer solchen Methode müssen die einzelnen Objektersteller entscheiden, was gedruckt werden darf, was wünschenswert sein kann.
Auf Protokollierungsebenen: Auf StepProcessor wird INFO verwendet, die höchste Nicht-Fehler-Ebene. Wenn Sie in der Anwendung genauer werden, sollten die Protokollierungsebenen entsprechend reduziert werden. Bei einzelnen Schritten werden möglicherweise DEBUG für allgemeine Informationen verwendet, wobei FINE, FINER und FINEST für eine immer detailliertere Ausgabe reserviert sind. Protokollierung ist ebenso eine Kunst wie eine Wissenschaft, aber diese Prinzipien zu befolgen, hilft, Protokolle konsistent und nützlich zu halten.
Umgang mit zusätzlicher Komplexität im Schrittprozessor
Bevor wir fortfahren, sollten wir kurz über die Entscheidung nachdenken, dass unser Schrittprozessor die Logik hosten soll, für die Schritte verwendet werden. Ziehen Sie in einer großen Codebasis in Erwägung, StepProcessor virtuell oder abstrakt zu gestalten, und lassen Sie Unterklassen bestimmte Schritte identifizieren, um eine ordnungsgemäße Trennung von Bedenken zu erreichen.
Aufrufbare Apex-Ebene
Der Planer ruft schließlich Apex auf. Nach Abschluss des restlichen Setups kann der Abschnitt "Aufrufbares Apex" entscheiden, welche Schritte ausgeführt werden sollen, und die List<StepType> an den Prozessor übergeben:
1public class DailyJobExecutor{2 @InvocableMethod(label='Execute Daily Job')3 public static void executeJob(){4 Logger.info('Executing daily Job');56 List<StepType>correspondingTypes = new List<StepType>();7 // based on [business logic], determine which step types8 // should be included for any daily invocation910 if(correspondingTypes.isEmpty() == false){11 try{12 new StepProcessor().setSteps(correspondingTypes).kickoff();13}catch(Exception ex){14 Logger.exception('Error starting job', ex);15}16}17}1819 Logger.saveLog();20}
Dies ist ein einfacher Teil der Gleichung: Verwenden Sie Datensätze, Daten oder Logik, um zu bestimmen, welche Schritttypen ausgeführt werden sollen. Die aufrufbare Aktion ist einfach, da die Komplexität an anderer Stelle gekapselt wurde. Wir haben uns auch vor unerwarteten Ausnahmen geschützt und jedes Stück für sich genommen einfach getestet.
Umgang mit Verzögerungen vor dem Anruf von Slack
Das Apex Slack-SDK liegt außerhalb des Geltungsbereichs dieses Artikels, aber ein potenzieller Haken an den Anforderungen besteht darin, Personen anhand einer konfigurierbaren Verzögerung nach oben in der Rollenhierarchie zu benachrichtigen. Auf dem Papier ist dies einfach und Sie könnten (richtig) System.enqueueJob(this) in der StepProcessor berücksichtigen. Bei System.AsyncOptions bestand unsere anfängliche Neigung darin, die enqueueJob-Überlastung zu verwenden, um diese Anforderung zu erfüllen.
Derzeit beträgt die maximale Verzögerung über System.AsyncOptions.MinimumQueueableDelayInMinutes jedoch 10 Minuten. Da die Anforderung 120 Minuten beträgt, bleiben einige Optionen. Ein naiver Ansatz könnte wie folgt aussehen:
1public class ExampleDelayedNotifier implements Step{2 private final List<Slack.ChatPostMessageRequest>notifications = new List<Slack.ChatPostMessageRequest>();3 private final Slack.BotClient botClient = Slack.App4 .getAppByKey('some-slack-app-key')5 .getBotClientForTeam('slack team id');67 // account for the initial delay,8 // so 120 - 10 = 1109 private Integer delayMinutes = 110;1011 public void execute(){12 if(this.delayInMinutes>0){13 return;14}1516 Integer maximumAllowedCallouts = 100;17 while(this.notifications.isEmpty() == false && maximumAllowedCallouts >0){18 this.botClient.chatPostMessage(this.notifications.remove(0));19 maximumAllowedCallouts--;20}21}2223 public void finalize(){24 this.delayInMinutes -= 10;25}2627 public String getName(){28 return ExampleDelayedNotifier.class.getName();29}3031 public Boolean shouldRestart(){32 return this.delayInMinutes>0 || this.notifications.isEmpty() == false;33}34}
In der Praxis würde die Verzögerung an diese Klasse weitergegeben, da die Verzögerung konfigurationsgesteuert ist.
Dieser Ansatz wird nur empfohlen, wenn Sie sicher sind, dass es immer nur einen verzögerten Benachrichtigungstyp gibt. Es werden 11 zusätzliche asynchrone Aufträge durchgebrannt, bevor gestartet wird (oder mehr, wenn die Verzögerung zunimmt). Diese Kosten können für einen Auftrag in Ordnung sein – nicht für viele. Außerdem müssen Sie der Step-Oberfläche eine Methode hinzufügen, damit der Prozessor bei jedem Schritt feststellen kann, wie lange vor dem Neustart gewartet werden muss, was zu zusätzlichen Störungen führt.
Dadurch bleiben uns zwei interessante Möglichkeiten:
Sie können den verzögerten Schritt in Ihr vorhandenes Auftrags-Framework einfügen, wenn Sie bereits über einen in einem angemessenen Intervall geplanten Abstimmungsauftrag verfügen. Sie sollten auch damit einverstanden sein, dass die angegebene Verzögerung bis zu 15 Minuten später auftritt (15 Minuten sind das Mindestaktualisierungsintervall für einen Apex CRON-Ausdruck). Dies entspricht ungefähr dem Beispiel "Aufrufbares Apex". Die Planung erfolgt stattdessen über Apex. Anders ausgedrückt, Sie könnten dieselbe Step Architektur wiederverwenden, um Datensätze anhand eines Zeitstempels vom Typ "Start nach" zu verarbeiten und anhand einer Auswahlliste oder Mehrfachauswahllistenzuordnung zurück zu den zuvor angezeigten StepType zu entscheiden, welche Schritte verwendet werden sollen.
Wenn Sie eine zusätzliche äußere Apex-Klasse definieren möchten, können Sie alternativ mithilfe von System.scheduleBatch() auf Batch Apex zurückgreifen (anders als warteschlangenfähiges Apex, das innere Klassen unterstützt, müssen Batch Apex Klassen äußere Klassen sein).
Sehen Sie sich das Beispiel für Batch Apex an. Obwohl Queueable Apex im Allgemeinen für Flexibilität und Kontrolle empfohlen wird, ist dies ein Fall, in dem Batch-Apex weiterhin die Oberhand behält:
1public class DelayedNotifier implements Database.Batchable<Object>{2 private final StepProcessor processor = new StepProcessor();34 public Iterable<Object>start(Database.BatchableContext bc){5 return new List<Object>();6}78 @SuppressWarnings('PMD.EmptyStatementBlock')9 public void execute(Database.BatchableContext bc, Object scope){10 // we don't need to actually do anything in execute,11 // we just need to start up the processor in finish12}1314 public void finish(Database.BatchableContext bc){15 try{16 // you can imagine Notifier as an elided,17 // simpler version of the naive implementation18 // we showed above, now only focused on sending messages19 this.processor.setSteps(new List<Step>{new Notifier()}).kickoff();20}catch(Exception ex){21 Logger.exception('Unexpected error', ex);22}finally{23 Logger.saveLog();24}25}26}
Stellen Sie sich dann im StepProcessor vor, dass die zuvor angezeigte addTypeOneSteps() mit diesem verzögerten Schritt aktualisiert wird:
1public StepProcessor implements System.Queueable, System.Finalizer,2 Database.AllowsCallouts{3 // .... unchanged top of class elided45 private void addTypeOneSteps(){6 this.steps.addAll(7 new List<Step>{8 new ExampleCursorStepOne(),9 new ExampleCursorStepTwo(),10 new DelayedNotifierStep()11}12);13}1415 // ...1617 private class DelayedNotifierStep implements Step{18 private final DelayedNotifier delayedNotifier = new DelayedNotifier();19 // again — in practice this value would also be passed in20 private final Integer delayInMinutes = 120;2122 public void execute(){23 System.scheduleBatch(24 this.delayedNotifier,25 'Delayed notifier: ' + System.now().getTime(),26 this.delayInMinutes27);28}2930 public void finalize(){31 Logger.debug('Nothing to finalize, batch scheduled');32}3334 public String getName(){35 return DelayedNotifierStep.class.getName();36}3738 public Boolean shouldRestart(){39 return false;40}41}42}
Obwohl wir normalerweise nicht so viel Hoop-Jumping empfehlen würden, wird diese Schrittverzögerung zu einem weiteren wiederverwendbaren Baustein. Bis längere Verzögerungen in Queueable Apex zulässig sind, stellt dies auch die einfachste Möglichkeit dar, diesen Effekt zu erzeugen (ohne einen Abfragemechanismus, wie diskutiert).
Schlussfolgerung
Wir haben objektorientiertes Design verwendet, um die Anforderungen zu erfüllen, und ein System entwickelt, das skaliert und gleichzeitig die langfristigen Kosten für Gebäude und Wartung abwägt. Während die Schrittdeklaration und -instanziierung letztlich ihren Platz in der StepProcessor übersteigen können, gibt es hier wenig zusätzliche technische Schulden. Mit FlowStep können Administratoren und Entwickler gemeinsam entscheiden, wann No-Code- oder Pro-Code-Lösungen am sinnvollsten sind.
Durch die Verwendung der System.Finalizer-Schnittstelle im Queueable-Framework von Apex wurde zusammen mit Nebula Logger ein robustes, testbares System entwickelt, das uns auf unvorhergesehene Fehler hinweist, selbst wenn künftige Schritte keine explizite Protokollierung erfordern. Für uns ist dieses System ein glücklicher Zahlenschlag und reduziert Kosten und Komplexität. Außerdem erhalten wir wertvolle Statistiken zum Verhalten von Apex Cursors unter realen Arbeitslasten, wodurch wir unseren Ansatz optimieren und gleichzeitig die Funktion selbst verbessern können.
Indem komplexe Arbeitslasten mit hohem Volumen in modulare Ausführungsschritte zerlegt werden, wandelt das Framework für die schrittbasierte asynchrone Verarbeitung Plattformeinschränkungen in technische Vorteile um, die eine vorhersehbare Leistung, Beobachtbarkeit und Governance im Unternehmensmaßstab ermöglichen. Schritte können sowohl von Administratoren als auch von Entwicklern eingerichtet werden. In beiden Fällen können sich Schrittautoren auf die Einhaltung der grundlegenden Plattformobergrenzen (wie DML-Zeilen und abgerufene Abfragezeilen) konzentrieren, ohne sich Gedanken über die Skalierung jedes Schritts machen zu müssen.
Pfad vorwärts
Damit dieses Muster in Unternehmensimplementierungen operationalisiert und übernommen werden kann, sollten Architekten:
Bewertung vorhandener Automatisierungen, um Bereiche zu identifizieren, in denen die asynchrone Orchestrierung zur Leistungssteigerung und zur Verbesserung der Beobachtbarkeit beitragen kann.
Unterteilen Sie große Prozesse in diskrete, unabhängig ausführbare Schritte mit klaren Verarbeitungszielen und diskreten Autorenpunkten (wie Flow oder Apex).
Definieren und gruppieren Sie Schritttypen, um die Wiederverwendung und Standardisierung von Schritten über Geschäftseinheiten hinweg zu beschleunigen.
Pilotieren Sie den Ansatz mit neuen Prozessen oder vorhandenen Automatisierungen. Sie werden überrascht sein, wie viele Edge-Kundenvorgänge Sie innerhalb von Schritten kostenlos finden, was Ihre integrierte Protokollierung und Beobachtbarkeit gewährleistet!
Über den Autor
James Simone ist Principal Software Engineer bei Salesforce und verfügt über mehr als ein Jahrzehnt Erfahrung in der Arbeit an der Plattform. Er war Salesforce-Kunde – und Produktinhaber –, bevor er in die Entwicklung einstieg, und schreibt seit 2019 in The Joys Of Apex technische Details zu Salesforce. Zuvor hat er Artikel im Salesforce Developer Blog und im Salesforce Engineering Blog veröffentlicht.
We use cookies on our website to improve website performance, to analyze website usage and to tailor content and offers to your interests.
Advertising and functional cookies are only placed with your consent. By clicking “Accept All Cookies”, you consent to us placing these cookies. By clicking “Do Not Accept”, you reject the usage of such cookies. We always place required cookies that do not require consent, which are necessary for the website to work properly.
For more information about the different cookies we are using, read the Privacy Statement. To change your cookie settings and preferences, click the Cookie Consent Manager button.
Cookie Consent Manager
General Information
Required Cookies
Functional Cookies
Advertising Cookies
General Information
We use three kinds of cookies on our websites: required, functional, and advertising. You can choose whether functional and advertising cookies apply. Click on the different cookie categories to find out more about each category and to change the default settings.
Privacy Statement
Required Cookies
Always Active
Required cookies are necessary for basic website functionality. Some examples include: session cookies needed to transmit the website, authentication cookies, and security cookies.
Functional Cookies
Functional cookies enhance functions, performance, and services on the website. Some examples include: cookies used to analyze site traffic, cookies used for market research, and cookies used to display advertising that is not directed to a particular individual.
Advertising Cookies
Advertising cookies track activity across websites in order to understand a viewer’s interests, and direct them specific marketing. Some examples include: cookies used for remarketing, or interest-based advertising.