Τεχνικό Άρθρο

Αποστολή PDF μέσω CDO σε Delphi: Παγίδες Νηματικής Apartment

Το PDFlibPas, η βιβλιοθήκη ανάπτυξης PDF της losLab για Delphi και C++Builder, στέλνει ένα παραγμένο PDF ως συνημμένο email μέσω μιας ενιαίας, επίπεδης κλήσης API, της SendDocumentByMail. Στα Windows, η προεπιλεγμένη μεταφορά χρησιμοποιεί το CDO (Collaboration Data Objects), το ενσωματωμένο στο λειτουργικό σύστημα εξάρτημα αλληλογραφίας COM, και η λεπτομέρεια που πραγματικά σπάει τις πολυνηματικές μαζικές εργασίες είναι η αρχικοποίηση apartment του COM, όχι το SMTP

Το σενάριο πίσω από αυτό το API είναι ανεντυπωσιακό και εξαιρετικά κοινό: μια υπηρεσία αποδίδει μια παρτίδα PDF μηνιαίων καταστάσεων, μία ανά πελάτη, και πρέπει να τις στείλει όλες μέσω email χωρίς άνθρωπο στη διαδικασία. Ρίξτε αυτή τη δουλειά σε ένα thread pool για ταχύτητα, και ένα κλάσμα των αποστολών αρχίζει να αποτυγχάνει με ένα σφάλμα COM που ποτέ δεν αναπαράγεται όταν ο ίδιος κώδικας τρέχει σε ένα μόνο νήμα. Τίποτα δεν είναι λάθος με τον διακομιστή SMTP, το PDF, ή το συνημμένο. Το πρόβλημα είναι τι επιστρέφει η CoInitializeEx σε ένα νήμα που το CDO δεν περίμενε, και το PDFlibPas είναι γραμμένο για να χειρίζεται αυτή την περίπτωση σκόπιμα και όχι τυχαία

Τι κάνει πραγματικά η SendDocumentByMail μέσα στο PDFlibPas

Η SendDocumentByMail είναι ένας λεπτός συντονιστής, όχι ένας πελάτης αλληλογραφίας από μόνη της. Η TPDFlib.SendDocumentByMail αποθηκεύει το τρέχον φορτωμένο έγγραφο στο δικό του προσωρινό PDF, συσκευάζει τις ρυθμίσεις SMTP και το κείμενο μηνύματος σε μια εγγραφή TPDFlibMailRequest, την παραδίδει σε ό,τι υλοποιεί το IPDFlibMailProvider, και διαγράφει ξανά το προσωρινό αρχείο μόλις ο πάροχος επιστρέψει. Η διεπαφή παρόχου είναι ο πραγματικός πελάτης αλληλογραφίας, και το PDFlibPas διαθέτει ακριβώς μία ενσωματωμένη υλοποίηση: έναν πάροχο βασισμένο σε CDO που μεταγλωττίζεται μόνο σε Windows. Καλέστε την SendDocumentByMail χωρίς να αναθέσετε πρώτα την ιδιότητα MailProvider, και το PDFlibPas επιστρέφει αυτόματα σε εκείνη την προεπιλογή. Η τιμή επιστροφής παραμένει σκόπιμα στενή σε όλη τη διαδικασία: 1 για αποδεκτό, 0 για οτιδήποτε άλλο, είτε πρόκειται για ένα λείπον υποχρεωτικό πεδίο, μια αποτυχία εγγραφής προσωρινού αρχείου, ή απόρριψη του μηνύματος από τον πάροχο, με τον πραγματικό λόγο διαθέσιμο μόνο από την GetLastMailError στη συνέχεια

var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // a new instance already holds one blank document
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... draw the statement: fonts, text, totals ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // port 0 with SSL 1 falls back to 465
      'billing@example.com', 'app-password',    // SMTP auth
      'billing@example.com', 'customer@example.com', '', '',
      'Your statement is ready',
      'Please find the attached PDF statement.',
      'statement-4471.pdf');                    // attachment display name
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

Γιατί η CoInitializeEx επιστρέφει S_FALSE, και είναι αυτό αποτυχία;

Το S_FALSE από την CoInitializeEx δεν είναι αποτυχία, και κώδικας που το αντιμετωπίζει ως τέτοια αναφέρει αποτυχίες σε νήματα όπου στην πραγματικότητα δεν πήγε τίποτα στραβά. Η CoInitializeEx επιστρέφει S_OK την πρώτη φορά που ένα νήμα αρχικοποιεί επιτυχώς το COM, και επιστρέφει S_FALSE όταν εκείνο το νήμα είχε ήδη το COM αρχικοποιημένο με ένα συμβατό μοντέλο ταυτοχρονισμού, αυξάνοντας το ίδιο μετρητή αναφορών ανά νήμα και στις δύο περιπτώσεις, οπότε και τα δύο αποτελέσματα χρειάζονται μια αντίστοιχη κλήση CoUninitialize πριν το νήμα εξέλθει ή προχωρήσει σε άσχετη εργασία. Η ίδια η TPDFlib ακολουθεί αυτό ακριβώς το μοτίβο: η κατασκευή μιας παρουσίας TPDFlib ήδη καλεί την CoInitialize και καταγράφει αν οφείλεται μια αντίστοιχη CoUninitialize, χρησιμοποιώντας τον ίδιο ακριβώς έλεγχο S_OK-ή-S_FALSE. Μέχρι η SendDocumentByMail να φτάσει στον πάροχο CDO της και εκείνος ο πάροχος να καλέσει ξανά την CoInitializeEx, το COM είναι επομένως ήδη αρχικοποιημένο στο νήμα στη συνηθισμένη περίπτωση, οπότε ο πάροχος σχεδόν πάντα παρατηρεί S_FALSE αντί για S_OK. Η αντιμετώπιση του S_FALSE ως οτιδήποτε άλλο εκτός από επιτυχία δεν είναι σπάνια οριακή περίπτωση σε αυτή τη βιβλιοθήκη· είναι η συνηθισμένη διαδρομή

InitResult := CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
NeedUninitialize := (InitResult = S_OK) or (InitResult = S_FALSE);
if Failed(InitResult) and (InitResult <> RPC_E_CHANGED_MODE) then
begin
  ErrorText := 'COM initialization failed';
  Exit;
end;
try
  // ... create CDO.Message, CDO.Configuration, send ...
finally
  if NeedUninitialize then
    CoUninitialize;
end;

Γιατί η CoInitializeEx επιστρέφει RPC_E_CHANGED_MODE;

Το RPC_E_CHANGED_MODE σημαίνει ότι το τρέχον νήμα αρχικοποίησε το COM νωρίτερα κάτω από διαφορετικό μοντέλο ταυτοχρονισμού από αυτό που ζητά αυτή η κλήση, τυπικά γιατί το νήμα προηγουμένως έγινε πολυνηματικό (MTA) και το CDO τώρα ζητά σημασιολογία apartment ενός νήματος (STA) μέσω του COINIT_APARTMENTTHREADED. Ένα νήμα επιλέγει το μοντέλο apartment του μία φορά, και τίποτα δεν μπορεί να αλλάξει εκείνο το μοντέλο για την υπόλοιπη ζωή του νήματος· η επανάληψη της CoInitializeEx με διαφορετικές σημαίες δεν διορθώνει την αναντιστοιχία, και η κλήση της CoUninitialize πρώτα θα κατέρριπτε ένα apartment από το οποίο μπορεί ακόμα να εξαρτάται άλλος κώδικας σε εκείνο το νήμα. Το PDFlibPas αντιμετωπίζει το RPC_E_CHANGED_MODE ως μια κατάσταση με την οποία πρέπει να δουλέψει παρά ως σφάλμα προς αναφορά: παραλείπει την αντίστοιχη CoUninitialize, αφού η κλήση ποτέ δεν απέκτησε πράγματι μια αναφορά προς απελευθέρωση, και αφήνει την αποστολή να συνεχίσει στο υπάρχον apartment

Το RPC_E_CHANGED_MODE εμφανίζεται σχεδόν αποκλειστικά σε επαναχρησιμοποιημένα νήματα: έναν εργάτη thread-pool, ένα νήμα IIS ή host υπηρεσίας, ή οποιοδήποτε νήμα όπου προηγούμενος κώδικας όπως ADO ή WMI είχε ήδη καλέσει την CoInitializeEx με COINIT_MULTITHREADED πριν ο κώδικας αλληλογραφίας φτάσει έστω κοντά του. Ένα ολοκαίνουριο νήμα που δεν κάνει τίποτα άλλο εκτός από την κλήση της SendDocumentByMail δεν θα πέσει σε αυτή τη διαδρομή. Ένα νήμα-εργάτης που ανακυκλώνεται χιλιάδες φορές την ημέρα από έναν προγραμματιστή παρτίδων, και μοιράζεται με άλλη εργασία βασισμένη σε COM, σίγουρα θα το κάνει, και θα το κάνει διαλείπουσα, ακριβώς το μοτίβο που στέλνει τους ανθρώπους να κοιτάξουν πρώτα τον διακομιστή SMTP και το μοντέλο νηματισμού δεύτερο

Κρατώντας ένα Συνημμένο Email Έξω από τον Λάθος Κατάλογο

Το PDFlibPas γράφει κάθε εξερχόμενο συνημμένο σε έναν φρέσκο κατάλογο ονομασμένο βάσει ενός GUID που παράγει σε κάθε κλήση SendDocumentByMail, ειδικά ώστε ταυτόχρονες αποστολές να μην μπορούν ποτέ να συγκρουστούν στο ίδιο όνομα αρχείου και ώστε ένα όνομα συνημμένου να μην μπορεί να βγει έξω από εκείνον τον κατάλογο. Το όνομα που περνιέται ως συνημμένο δεν αντιμετωπίζεται ως έμπιστη διαδρομή: περνά μέσα από την PLSanitizeAttachmentName, η οποία αφαιρεί οποιοδήποτε στοιχείο καταλόγου, απορρίπτει το κενό αλφαριθμητικό και τα ειδικά ονόματα . και .., και αντικαθιστά κάθε χαρακτήρα που τα Windows θεωρούν παράνομο σε όνομα αρχείου, μαζί με κάθε χαρακτήρα ελέγχου, με κάτω παύλα. Δώστε της ..\quarter:report.pdf, εν μέρει διάτρηση καταλόγου και εν μέρει παράνομη άνω-κάτω τελεία, και αυτό που φτάνει στον δίσκο είναι quarter_report.pdf: οτιδήποτε μέχρι τον τελευταίο διαχωριστή διαδρομής απορρίπτεται, και η άνω-κάτω τελεία γίνεται κάτω παύλα γιατί δεν μπορεί να εμφανιστεί σε όνομα αρχείου Windows

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // strip any directory part
  if (Result = '') or (Result = '.') or (Result = '..') then
    Result := 'document.pdf';
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], WideString('<>:"/\|?*')) > 0) then
      Result[I] := '_';
end;

Ένας αποκλειστικός κατάλογος ανά κλήση δεν είναι απλώς τακτοποίηση. Η SendDocumentByMail διαγράφει το προσωρινό αρχείο και αφαιρεί τον κατάλογό του σε ένα μπλοκ finally αφού σταλεί το μήνυμα, χρησιμοποιώντας ακριβώς τη διαδρομή στην οποία έγραψε, οπότε ένα όνομα συνημμένου που θα έφτανε σε αυτόν τον κώδικα χωρίς εξυγίανση δεν θα τοποθετούσε απλώς λάθος τη γραφή. Η ίδια μη εξυγιασμένη διαδρομή θα έφτανε τότε σε ένα βήμα καθαρισμού που καλεί την DeleteFile χωρίς περαιτέρω ερωτήσεις, και σε έναν κοινόχρηστο προσωρινό φάκελο, δύο ταυτόχρονες αποστολές θα μπορούσαν επίσης να αντικαταστήσουν σιωπηλά η μία το συνημμένο της άλλης κάτω από το ίδιο όνομα πριν ολοκληρωθεί έστω μία παράδοση. Η εξυγίανση του ονόματος κλείνει την περίπτωση διάτρησης, και ο κατάλογος GUID ανά κλήση κλείνει την περίπτωση σύγκρουσης, και κανένα από τα δύο μόνο του δεν θα ήταν αρκετό

Ταιριάζοντας τον Κύκλο Ζωής του COM με τον Κύκλο Ζωής του Νήματος σε ένα Pool Εργατών

Η πιο αξιόπιστη διόρθωση για αποτυχίες νηματισμού apartment σε έναν μαζικό αποστολέα αλληλογραφίας είναι να σταματήσετε να αντιμετωπίζετε κάθε κλήση SendDocumentByMail ως τον δικό της απομονωμένο κύκλο ζωής COM, και αντ' αυτού να αρχικοποιείτε το COM μία φορά ανά νήμα-εργάτη, για όλη τη ζωή εκείνου του νήματος. Ένας εργάτης που καλεί την CoInitializeEx(nil, COINIT_APARTMENTTHREADED) όταν ξεκινά, κρατά εκείνο το apartment για κάθε κλήση SendDocumentByMail που κάνει, και καλεί την CoUninitialize ακριβώς μία φορά όταν εξέρχεται, ποτέ δεν θα δει RPC_E_CHANGED_MODE από τις δικές του αποστολές αλληλογραφίας, γιατί τίποτα άλλο σε εκείνο το νήμα δεν έχει την ευκαιρία να αρχικοποιήσει το COM σε αντικρουόμενη λειτουργία πρώτο. Κάθε μεμονωμένη κλήση SendDocumentByMail εξακολουθεί να τρέχει το δικό της ζεύγος CoInitializeEx και CoUninitialize εσωτερικά κάτω από αυτό το μοτίβο, και αυτό είναι αβλαβές: με το apartment ήδη εγκατεστημένο από το νήμα-εργάτη, καθεμία από αυτές τις εσωτερικές κλήσεις τώρα βλέπει S_FALSE, αυξάνει και μειώνει τον ίδιο μετρητή αναφορών, και αφήνει το δικό του apartment COM του νήματος-εργάτη ανέγγιχτο

type
  TMailWorker = class(TThread)
  protected
    procedure Execute; override;
  end;

procedure TMailWorker.Execute;
var
  PDF: TPDFlib;
  Job: TStatementJob;
begin
  CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
  try
    while not Terminated do
    begin
      if not TryGetNextJob(Job) then
        Break;
      PDF := TPDFlib.Create;
      try
        BuildStatement(PDF, Job);
        if PDF.SendDocumentByMail(Job.Host, 0, 1, Job.User, Job.Pass,
             Job.From, Job.Recipient, '', '', Job.Subject, Job.Body,
             Job.AttachmentName) <> 1 then
          LogFailure(Job, PDF.GetLastMailError);
      finally
        PDF.Free;
      end;
    end;
  finally
    CoUninitialize;
  end;
end;

Διάγνωση Αποτυχιών και Δοκιμή Χωρίς Ζωντανό Γραμματοκιβώτιο

Η GetLastMailError είναι το άλλο μισό αυτού του API που αξίζει να χτιστεί μέσα στην καταγραφή από την πρώτη μέρα, γιατί η τιμή επιστροφής 1-ή-0 μόνη της δεν λέει αν μια αποτυχημένη αποστολή ήταν πρόβλημα αρχικοποίησης COM, απόρριψη αυθεντικοποίησης SMTP, ή λείπον συνημμένο. Η ιδιότητα MailProvider είναι αυτό που κάνει ολόκληρη τη διαδρομή δοκιμάσιμη χωρίς πραγματικό γραμματοκιβώτιο: αναθέστε της μια υλοποίηση IPDFlibMailProvider που καταγράφει αιτήματα αντί να τα στέλνει, τρέξτε μια μαζική εργασία ενάντια σε εκείνον τον ψεύτικο πάροχο σε ένα pipeline CI, και τα ίδια σημεία κλήσης SendDocumentByMail συνεχίζουν να λειτουργούν αμετάβλητα μόλις το MailProvider μείνει μη ορισμένο και το PDFlibPas επιστρέψει στην ενσωματωμένη μεταφορά CDO στην παραγωγή

Μια μαζική εργασία που στέλνει καταστάσεις μέσω email σπάνια σταματά στην αποστολή: το ίδιο pipeline συχνά χρειάζεται να επικυρώσει και να υπογράψει το PDF πριν φύγει, κάτι που καλύπτεται ξεχωριστά στο άρθρο για τον πάγκο εργασίας συμμόρφωσης και υπογραφής, αφού ο preflight έλεγχος και η επαλήθευση υπογραφής είναι διαφορετικό ζήτημα από την παράδοση αλληλογραφίας ακόμα κι όταν και τα δύο τρέχουν διαδοχικά. Όταν τα έγγραφα που στέλνονται μέσω email είναι τα ίδια έξοδος μιας μεγάλης εργασίας συγχώνευσης ή διαχωρισμού αντί για ένα μοναδικό φρεσκοφτιαγμένο PDF, ο οδηγός direct-access για μεγάλα PDF καλύπτει αυτό το βήμα παραγωγής. Η SendDocumentByMail και το μοντέλο παρόχου αλληλογραφίας που περιγράφεται εδώ είναι μέρος της τυπικής βιβλιοθήκης ανάπτυξης PDF PDFlibPas για Delphi και C++Builder, και η σελίδα προϊόντος φέρει την πλήρη αναφορά API μαζί με λήψη δοκιμαστικής έκδοσης