Questo testo è stato tradotto utilizzando il sistema di traduzione automatica di Salesforce. Partecipa al nostro sondaggio per fornire un feedback su questo contenuto e dirci cosa vorresti vedere dopo.
Note
Panoramica
Le moderne architetture Salesforce sono sempre più basate sull'elaborazione asincrona, non come comodità, ma come requisito strategico per la scalabilità. Negli ultimi anni, abbiamo visto sempre più aziende alle prese con volumi di dati crescenti, integrazioni complesse che coinvolgono più touchpoint e l'ascesa di sistemi autonomi in esecuzione 24/7/365. Tutte queste cose spingono gli architetti verso la progettazione di sistemi che sono asincroni.
L'elaborazione asincrona in Salesforce spesso implica la necessità di aggirare i limiti e la complessità del governor. Questi limiti fungono da guardrail e vincoli architetturali che aiutano a produrre sistemi scalabili e sicuri in blocco. Benché nessun limite della piattaforma serva direttamente a gestire la complessità, gli schemi di progettazione possono contribuire a mitigare i rischi su questo fronte. Internamente, Salesforce spesso supera i limiti della piattaforma per testare in avanti le nuove funzioni e automatizzare i processi aziendali complessi. Abbiamo creato un framework di elaborazione asincrona basato su fasi per l'esecuzione di processi asincroni con un numero arbitrario di passaggi. Ogni fase può essere eseguita, riprovata e riavviata in modo indipendente con controlli di governance condivisi e visibilità operativa completa tramite la registrazione centralizzata. Questo documento ne descrive i componenti architettonici chiave: Apex e finalizzatori in area di attesa, flusso pianificato, cursori Apex, azioni invocabili e integrazioni con Slack. Insieme, questi componenti offrono un'architettura modulare, scalabile e osservabile adatta alle esigenze aziendali in evoluzione.
Punti salienti
Le moderne architetture Salesforce dovrebbero adottare un approccio asincrono per ottenere scalabilità, affidabilità e trasparenza operativa.
Suddividere il lavoro complesso in fasi eseguibili in modo indipendente consente prestazioni prevedibili, tentativi più sicuri, checkpointing, ritiro ed evoluzione modulare senza riprogettare i flussi di lavoro principali.
Il framework offre un'alternativa scalabile ai processi batch monolitici e datati, alle chiamate asincrone concatenate e ai flussi profondamente nidificati ed è creato per carichi di lavoro a volume elevato che devono essere scalati orizzontalmente all'interno di Salesforce senza orchestrazione esterna alla piattaforma.
L'esecuzione deterministica e osservabile garantisce il tracciamento dell'avanzamento, il monitoraggio degli SLA, la diagnostica dei guasti e la trasparenza a livello di controllo tramite la registrazione e la governance centralizzate.
Progettato per un rigore di livello aziendale, che include governance unificata, conformità e controllo statale distribuito in processi aziendali di lunga durata.
Procedure consigliate per la piattaforma
Prima di rivedere i requisiti, di seguito sono riportate alcune cose da fare e da non fare per quando utilizzare un framework come questo. Soprattutto, considerare quale sistema è l'unica fonte di verità. Se l'organizzazione Salesforce si basa in minima parte su dati esterni ma deve scalare da centinaia a milioni di record, considerare un framework asincrono basato su fasi.
Utilizzare questo framework se**:**
La maggior parte (o tutte) le informazioni su cui intervenire esistono già nel CRM.
Il costo iniziale o continuativo del mantenimento di un processo ETL (Extract Transform Load) per armonizzare i dati esterni è troppo elevato.
È necessario rinviare l'elaborazione di un numero elevato di record Salesforce secondo una pianificazione impostata.
È possibile suddividere l'elaborazione in fasi discrete. Ad esempio, è possibile creare un insieme di record gerarchico o basato su albero, in particolare se il volume di dati si estende verso il basso nella gerarchia o nella struttura.
Non utilizzare questo framework se:
La creazione o l'aggiornamento dei record richiede un ricalcolo immediato.
L'integrazione è difficile perché i sistemi esterni ospitano i dati principali per gli aggiornamenti dei record. Considerare la possibilità di inviare i dati aggiornati a Salesforce con l'API in blocco.
Tenendo presenti queste procedure, rivedere i requisiti e iniziare a creare.
Suddivisione dei requisiti
Considerare l'istruzione del problema:
Dato un processo che deve essere eseguito quotidianamente, verificare se alcuni record soddisfano criteri prestabiliti per un'ulteriore elaborazione. Se lo fanno, avviare quei processi di elaborazione. L'elaborazione dei record può richiedere l'estrazione di dati da più sistemi esterni per eseguire calcoli. Le fasi dei processi devono informare le persone tramite Slack che i record elaborati sono pronti per la revisione. Le fasi dovrebbero anche inoltrare le notifiche ai responsabili e ai livelli superiori della gerarchia dei ruoli in base a un ritardo configurabile dopo la prima serie di notifiche.
Questo problema comporta diversi passaggi, alcuni dei quali possono verificarsi indipendentemente l'uno dall'altro. Esistono molti modi per suddividere il lavoro. Ecco un raggruppamento:
Il pianificatore.
L'interfaccia della fase e le implementazioni concrete che elaborano i record (indipendentemente dal tipo di elaborazione).
Processore che organizza le fasi.
Apex invocabile chiamato dallo strumento di pianificazione.
C'è una certa complessità nascosta nella frase "ritardo configurabile". Esamineremo questa complessità più avanti in questo articolo.
Di seguito è riportato un diagramma ponderato per il framework predefinito:
Ora, scomponi il diagramma e inizia a costruire i pezzi.
Pianificazione con flusso pianificato
Flusso pianificato offre diversi vantaggi come meccanismo di pianificazione:
I flussi pianificati possono essere inseriti in pacchetti e distribuiti come metadati. Questo non è vero per i processi pianificati tramite Apex (o tramite la pagina Processi pianificati).
L'elemento Attesa è fondamentale per i framework che richiedono chiamate. Utilizzandola nel flusso, le chiamate non sono necessarie nella parte Invocabile del framework.
La granularità della pianificazione soddisfa i requisiti: l'intervallo minimo per i flussi pianificati è giornaliero. Se è necessaria una frequenza più alta (ad esempio, oraria), riconsiderare Flusso pianificato per questo requisito.
Un'altra considerazione quando si configura il flusso pianificato è il gating dell'ambiente. Prima di invocare l'azione Apex, aggiungere un elemento Decisione che valuta la variabile {!$Api.Enterprise_Server_URL_100}. Ciò garantisce che il processo venga eseguito solo negli ambienti previsti, ad esempio UAT e Produzione. Questo schema è importante perché i Sandbox vengono aggiornati di frequente o creati di recente durante l'SDLC e, senza un controllo esplicito dell'ambiente, un flusso pianificato potrebbe essere eseguito involontariamente in ambienti in cui il framework non è progettato per l'esecuzione. L'uso dell'operatore contains nell'elemento Decisione rende l'impostazione resiliente alle future creazioni Sandbox o modifiche degli URL.
Infine, considerare in che modo il framework dovrebbe acquisire gli errori. Aggiungere sempre un percorso di errore quando il flusso chiama un'azione; ad esempio, è possibile collegare i guasti all'azione "Aggiungi voce registro" di Nebula Logger. Nebula Logger scrive i registri negli oggetti personalizzati, quindi i clienti devono sapere che i dati dei registri consumano memoria dell'organizzazione: per impostazione predefinita, i registri vengono memorizzati per 14 giorni all'interno di un'organizzazione e quindi puliti; questo periodo di conservazione è configurabile. Nebula Logger utilizza anche Eventi piattaforma per pubblicare i registri, quindi le voci di registro vengono salvate indipendentemente dalla transazione di elaborazione dati principale, in modo da garantire l'acquisizione degli errori anche se l'azione Flusso principale o Apex viene ritirata. I clienti devono valutare il volume dei registri previsto e i requisiti di conservazione quando valutano l'aggiunta di un framework di registrazione.
Ecco come si presenta il flusso:
Passiamo alle prime parti di Apex Code con il requisito di pianificazione ora soddisfatto.
Per questo articolo, l'interfaccia step viene visualizzata come classe esterna per chiarezza. Il framework è flessibile: i team possono organizzare l'interfaccia e le relative implementazioni utilizzando qualsiasi schema di creazione pacchetti Apex preferito, a condizione che tutte le classi Fase facciano riferimento alla stessa interfaccia.
Ci sono alcune cose da notare sui metodi definiti nella nostra interfaccia:
La execute, anche se al momento non richiede argomenti, migliora quando si passa una classe di State (o interfaccia) per orchestrare i dati tra le fasi quando l'ordine è importante.
getName potrebbe restituire un valore System.Type anziché un String. L'obiettivo è fornire al livello di orchestrazione un modo per registrare i nomi delle fasi senza esporre altre proprietà.
Ecco la prima implementazione concreta che mostra come questi pezzi si adattano. Con un'eccezione in seguito, si consiglia di utilizzare Queueable Apex per implementare l'elaborazione asincrona in Apex; Batch Apex è in genere inutile (e i metodi di @future sono scoraggiati). Queueable Apex si avvia rapidamente e, con i cursori Apex, presenta molti vantaggi rispetto a Batch Apex.
Un'implementazione Apex Cursor-Like
I cursori Apex offrono un'alternativa moderna al modello Apex batch tradizionale. Analogamente all'elaborazione batch, un'implementazione Cursore può recuperare i record in blocchi (fino a 2.000 per batch). Tuttavia, i Cursori consentono più recuperi all'interno di una singola transazione, consentendo una produttività significativamente superiore per le operazioni a volume elevato.
Quando si adottano i Cursori come parte di questo framework, i team devono essere consapevoli delle limitazioni attuali dei test e della possibilità di falsificazione. Il comportamento del cursore nei test può essere diverso dal comportamento di produzione, quindi è importante progettare strategie di test che evitino di affidarsi ai componenti interni del cursore e convalidino invece la logica di orchestrazione ai confini. Man mano che la piattaforma si evolve, queste aree continueranno a migliorare, ma le linee guida principali rimangono: I cursori offrono prestazioni superiori e un sovraccarico di orchestrazione ridotto rispetto ad Apex batch per molti casi d'uso.
Per definire un confine chiaro tra il Cursore fornito dal sistema e il proprio codice, si consiglia di creare una rappresentazione simile al Cursore quando si implementa l'interfaccia Step. Considerare questo codice:
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}
Notare la classe Cursor. I cursori Apex sono esempi di Database.Cursor, ma la nostra implementazione di Cursor ci offre flessibilità per risolvere le carenze dei cursori. Ecco l'implementazione:
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}
Per il resto di questo articolo, omettiamo le dichiarazioni sharing quando ci riferiamo alle classi Apex. In pratica, assicurarsi che le classi di livello superiore utilizzino esplicitamente con o senza condivisione per conformarsi al modello di oggetto e alle autorizzazioni.
Si noti inoltre che l'implementazione di Cursor delega all'Database.Cursor piattaforma, con ulteriori vantaggi descritti di seguito.
Innanzitutto, ecco i test corrispondenti:
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}
Rendendo virtuali i Cursor, le implementazioni di CursorStep concrete possono funzionare senza Database.Cursor quando non è necessario ripetere un insieme di record di grandi dimensioni, come quando si restituisce un System.Iterable<T> anziché un Database.QueryLocator in Batch Apex. Ecco un esempio:
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}
Tenere presente che, poiché questa classe è anche astratta, lascia l'implementazione concreta di innerExecute alle sottoclassi.
Esiste anche un'alternativa alla sottoclasse interna CursorLike. Se si sa che le versioni concrete di un passaggio come questo non supereranno altri limiti del governor, è possibile restituire this.records da CursorLike.fetch e sostituire il CursorStep.shouldRestart() controllante per restituire false. Ciò consente di eseguire l'iterazione su un elenco delimitato solo dal limite di heap Apex di 12 MB per transazione asincrona.
Altre possibili implementazioni basate su fasi
La nostra implementazione basata sul cursore offre molta flessibilità quando si esegue la paginazione su grandi quantità di dati. L'interfaccia Step, nel frattempo, ci offre la flessibilità di descrivere e incapsulare fasi di ogni tipo.
Considerare una fase basata su flusso:
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}
Poiché i flussi non possono restituire parametri di output conformi a un tipo definito da Apex, viene verificato un parametro di output shouldRestart prima di utilizzarlo.
Alcuni passaggi potrebbero essere contrassegnati dalla funzione. È possibile implementare la logica per decidere quali fasi includere, oppure utilizzare una fase no-op per una funzione disabilitata. Lo schema Oggetto nullo è un metodo comune per ridurre la complessità all'interno del livello di orchestrazione:
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}
Ora abbiamo un bel po' di elementi base su cui lavorare. Vediamo il livello di orchestrazione responsabile dell'iterazione delle fasi.
Creazione di un processore di fase
Il processore è un punto di flessione nell'architettura. Dobbiamo decidere chi definisce quali fasi vengono inizializzate e dove. Le opzioni includono:
Chiedere al processore di definire le fasi da mappare alla logica aziendale. Questa opzione è semplice, ma la scalabilità è scarsa.
Definire la mappatura con i metadati personalizzati (CMDT). I campi Relazione metadati non supportano ApexClass, che associa in modo non preciso l'ortografia dei nomi delle classi nell'impostazione dei processi aziendali. È possibile ridurre il rischio per l'amministratore impostando il campo come elenco di selezione e convalidando l'esistenza del tipo (Type.forName() o eseguendo query su ApexClass), ma poiché i record CMDT non supportano i trigger, la convalida avviene in fase di esecuzione. Questo percorso è testabile, ma gli amministratori possono comunque creare record CMDT solo in produzione.
Definire la mappatura con i record. I non amministratori possono configurare le fasi, ma le distribuzioni diventano più complesse e gli ambienti possono andare alla deriva. Procedere con cautela.
C'è una famosa citazione da Clean Code su come gestire questo particolare pezzo di complessità:
La soluzione a questo problema è seppellire la dichiarazione di switch [per fare oggetti] nel seminterrato di una fabbrica astratta, e non farla mai vedere a nessuno.
Tenendo presente ciò, e poiché il nostro numero attuale di fasi è ben definito e difficilmente crescerà troppo, è normale che il processore di fasi sia anche la fabbrica per le fasi. Questo può utilizzare un enum per guidare l'istruzione switch:
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}
I metodi di fabbrica visualizzati, ad esempio addTypeOneSteps(), possono delegare preoccupazioni come la segnalazione delle funzioni; cleanSteps() esegue un controllo singolo sulle fasi raccolte per assicurarsi che non vi siano fasi "vuote" prima di passare alla vera asincrona. Potrebbe essere simile a questo:
Non abbiamo più discusso della gestione degli errori dopo aver menzionato Nebula Logger nella sezione Flusso pianificato. Questo perché System.Finalizer ci consente di coprire la registrazione per tutte le condizioni di errore senza aggiungere una gestione specifica degli errori in ogni fase. Ogni Step si concentra sull'esecuzione, mentre noi registriamo e revochiamo eventuali percorsi non felici in modo che emergano nei test di unità. Questo supporta l'iterazione sicura e gli avvisi a livello di produzione (utilizzando il plug-in Slack Logger per Nebula per tutti i registri WARN ed ERROR).
Una nota sulla registrazione degli errori: il passaggio dell'istanza della fase nei messaggi di registro presuppone un livello di Trust in ciò che diventa visibile nei registri. Il toString() predefinito per le classi Apex include tutte le proprietà statiche e a livello di istanza nel messaggio. Questo può essere auspicabile, oppure può far trapelare informazioni sensibili. Anche se la registrazione e la sicurezza non sono al centro dell'attenzione, tenere presente che per alcuni sistemi, l'osservanza di un'interfaccia come Step può anche comportare la forzatura di una sostituzione per toString().
Tale metodo impone a ogni creatore di oggetti di decidere che cosa è consentito stampare, il che può essere auspicabile.
A livello di registrazione: a livello di StepProcessor, viene utilizzato INFO, il livello massimo senza errori. Man mano che si aumenta la granularità dell'applicazione, i livelli di registrazione dovrebbero diminuire di conseguenza. Singole fasi potrebbero utilizzare DEBUG per informazioni di alto livello, con FINE, FINER e FINEST riservati a output sempre più dettagliati. Registrare è un'arte quanto una scienza, ma seguire questi principi aiuta a mantenere i registri coerenti e utili.
Gestione di ulteriori complessità all'interno del Processore fase
Prima di procedere, riflettiamo brevemente sulla decisione di fare in modo che il nostro processore di fasi ospiti la logica per cui vengono utilizzati i passaggi. In una base di codice di grandi dimensioni, valutare la possibilità di rendere i StepProcessor virtuali o astratti e fare in modo che le sottoclassi individuino passaggi specifici per stabilire una separazione corretta delle preoccupazioni.
Livello invocabile Apex
Lo strumento di pianificazione alla fine invoca Apex. Con il resto dell'impostazione completato, la sezione Apex invocabile può decidere quali fasi eseguire e passare il List<StepType> al processore:
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}
Questa è una parte semplice dell'equazione: utilizzare record, dati o logica per determinare quali tipi di fasi eseguire. L'azione invocabile è semplice perché la complessità è stata incapsulata altrove. Abbiamo anche protetto da eccezioni impreviste e reso ogni pezzo facile da testare in isolamento.
Gestione dei ritardi prima della chiamata a Slack
L'SDK Apex Slack esula dall'ambito di questo articolo, ma vale la pena rivedere un potenziale intoppo dei requisiti: informare le persone in alto nella gerarchia dei ruoli in base a un ritardo configurabile. Sulla carta, questo è semplice e potresti (correttamente) considerare la System.enqueueJob(this) nel StepProcessor. Con System.AsyncOptions, la nostra inclinazione iniziale era di utilizzare il sovraccarico di enqueueJob per soddisfare questo requisito.
Per ora, invece, il ritardo massimo via System.AsyncOptions.MinimumQueueableDelayInMinutes è di 10 minuti. Poiché il requisito è 120 minuti, rimangono alcune opzioni. Un approccio ingenuo potrebbe essere il seguente:
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 pratica, il ritardo verrebbe passato a questa classe perché è determinato dalla configurazione.
Questo approccio non è consigliabile a meno che non si sia certi che sarà sempre presente un solo tipo di notifica ritardata. Viene masterizzato in 11 processi asincroni aggiuntivi prima di iniziare (o più, se il ritardo aumenta). Quel costo potrebbe andare bene per un lavoro, non per molti. È anche necessario aggiungere un metodo all'interfaccia Step in modo che ogni fase possa indicare al processore quanto tempo attendere prima di riavviare, il che aggiunge rumore.
Questo ci lascia con due possibilità interessanti:
È possibile inserire la fase ritardata nel framework lavorativo esistente se è già stato pianificato un processo di polling a un intervallo appropriato. Dovrebbe anche essere possibile che il ritardo specificato arrivi fino a 15 minuti dopo (15 minuti è l'intervallo di aggiornamento minimo per un'espressione CRON pianificata Apex). Corrisponde grosso modo all'esempio Apex invocabile; la pianificazione viene eseguita tramite Apex. In altre parole, è possibile riutilizzare la stessa architettura basata su Step per elaborare i record in base a un'indicazione oraria "Inizia dopo" e decidere quali fasi utilizzare in base a un elenco di selezione o a una mappatura elenco di selezione a selezione multipla ai valori StepType enum visualizzati in precedenza.
In alternativa, se si preferisce definire una classe Apex esterna aggiuntiva, ripiegare su Batch Apex (a differenza di Queueable Apex, che supporta le classi interne, le classi Batch Apex devono essere classi esterne) utilizzando System.scheduleBatch().
Si consideri l'esempio Batch Apex. Benché in genere si raccomandi Queueable Apex per flessibilità e controllo, questo è un caso in cui Batch Apex regna ancora sovrano:
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}
E poi, nel StepProcessor, si immagini che il metodo di addTypeOneSteps() mostrato in precedenza venga aggiornato con questo passaggio ritardato:
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}
Anche se in genere non raccomandiamo di fare così tanti salti mortali, questo ritardo della fase diventa un altro elemento fondamentale riutilizzabile. Finché non sono consentiti ritardi più lunghi in Queueable Apex, rappresenta anche il modo più semplice per produrre questo effetto (senza un meccanismo di polling, come discusso).
Conclusione
Per soddisfare i requisiti, abbiamo utilizzato la progettazione orientata agli oggetti e creato un sistema che si espanderà bilanciando i costi di costruzione e manutenzione a lungo termine. Benché la dichiarazione e l'istanziazione delle fasi possano in ultima analisi superare il loro posto in StepProcessor, in questo caso l'indebitamento tecnico aggiuntivo è minimo. Con FlowStep, amministratori e sviluppatori possono decidere insieme quando le soluzioni no-code o pro-code hanno più senso.
Utilizzando l'interfaccia System.Finalizer all'interno del framework Queueable di Apex, insieme a Nebula Logger, abbiamo creato un sistema robusto e testabile che ci avvisa in caso di errori imprevisti anche se le fasi future non prevedono una registrazione esplicita. Per noi, questo sistema sta felicemente snocciolando numeri e riducendo costi e complessità. Ci ha anche fornito informazioni preziose sul comportamento dei Cursori Apex sotto carichi di lavoro reali, aiutandoci a perfezionare il nostro approccio e migliorando la funzione stessa.
Scomponendo carichi di lavoro complessi a volume elevato in fasi di esecuzione modulari, il framework di elaborazione asincrona basata su fasi trasforma i vincoli della piattaforma in vantaggi ingegnerizzati, consentendo prestazioni, osservabilità e governance prevedibili su scala aziendale. Le fasi possono essere impostate sia dagli amministratori che dagli sviluppatori e, in entrambi i casi, gli autori delle fasi possono concentrarsi tranquillamente sull'osservanza dei limiti di base del governor della piattaforma (ad esempio le righe DML e le righe di query recuperate) senza doversi preoccupare di come scalare ogni fase.
Percorso in avanti
Per rendere operativo e adottare questo schema nelle implementazioni aziendali, gli architetti dovrebbero:
Valutare le automazioni esistenti per identificare le aree in cui l'orchestrazione asincrona può contribuire a migliorare le prestazioni e l'osservabilità.
Suddividere grandi processi in fasi eseguibili discrete e indipendenti con obiettivi di elaborazione chiari e punti autore discreti (come Flusso o Apex).
Definire e raggruppare i tipi di fasi per accelerare il riutilizzo e la standardizzazione delle fasi nelle unità operative.
È possibile pilotare l'approccio con nuovi processi o automazioni esistenti. Potresti essere sorpreso di scoprire quanti casi edge trovi gratuitamente a portata di mano, cura della registrazione integrata e osservabilità!
Informazioni sull'autore
James Simone è un Principal Software Engineer di Salesforce e ha più di un decennio di esperienza di lavoro sulla piattaforma. Era un cliente Salesforce — e titolare di un prodotto — prima di passare allo sviluppo e dal 2019 scrive approfondimenti tecnici su Salesforce all’interno di The Joys Of Apex. In precedenza ha pubblicato articoli sul blog Salesforce Developer e anche sul blog Salesforce Engineering.
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.