<?xml version="1.0" encoding="utf-8"?><rss version="2.0"><channel><title>Edi Wang</title><description>Microsoft MVP for Azure</description><link>https://edi.wang/</link><pubDate>2026-11-11T12:25:35Z</pubDate><copyright>© 2009 - 2026 edi.wang</copyright><generator>Moonglade v16.10.0-beta.1</generator><language>en-US</language><item><title>From Azure Communication Services to Resend: Simplifying Moonglade Email with SMTP</title><link>https://edi.wang/post/2026/10/24/from-azure-communication-services-to-resend-simplifying-moonglade-email-with-smtp</link><description>&lt;p&gt;I recently migrated email notifications in my blogging system, Moonglade, from Azure Communication Services to Resend. Along the way, I consolidated email delivery into a single SMTP implementation.&lt;/p&gt;
&lt;p&gt;For a personal blog, the email module has a straightforward job: notify the right people when someone leaves a comment, receives a reply, or sends a Webmention. It should be reliable, easy to configure, and easy to move between email services.&lt;/p&gt;
&lt;p&gt;Those requirements guided this migration. The goals were to simplify the architecture and reduce dependence on a particular cloud vendor. The result uses no Resend SDK and introduces no &lt;code&gt;ResendEmailSender&lt;/code&gt;. Resend connects through SMTP, using the same implementation that can serve other compatible SMTP services.&lt;/p&gt;
&lt;h2&gt;Where the Complexity Came From&lt;/h2&gt;
&lt;p&gt;Before the migration, Moonglade supported both SMTP and Azure Communication Services. Both paths served the same notification features, but delivery went through a dispatcher that selected a provider at runtime.&lt;/p&gt;
&lt;p&gt;The previous &lt;code&gt;EmailDispatcher&lt;/code&gt; read the configuration, located the matching implementation, and forwarded the message:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class EmailDispatcher(
    IOptions&amp;lt;EmailServiceOptions&amp;gt; options,
    IEnumerable&amp;lt;IEmailProviderSender&amp;gt; senders) : IEmailDispatcher
{
    public async Task SendAsync(CommonMailMessage message)
    {
        var sender = options.Value.NormalizedProvider;
        var providerSender = senders.FirstOrDefault(s =&amp;gt; string.Equals(s.Provider, sender, StringComparison.OrdinalIgnoreCase));

        if (providerSender == null)
        {
            throw new InvalidOperationException($&amp;quot;Email provider '{sender}' is not supported.&amp;quot;);
        }

        await providerSender.SendAsync(message);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This design had a purpose. When an application integrates directly with several email APIs, a dispatcher can keep their differences out of the business logic. However, the abstraction still left the project responsible for maintaining:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;Azure.Communication.Email&lt;/code&gt; SDK dependency.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AzureCommunicationSender&lt;/code&gt;, plus a wrapper and interface for the SDK client.&lt;/li&gt;
&lt;li&gt;Configuration fields such as &lt;code&gt;Provider&lt;/code&gt;, &lt;code&gt;AcsConnectionString&lt;/code&gt;, and &lt;code&gt;AcsSenderAddress&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Provider name normalization, separate validation branches, and provider-specific admin presentation.&lt;/li&gt;
&lt;li&gt;Translation of Azure SDK exceptions into the application's retry decisions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, the Azure implementation constructed the SDK's &lt;code&gt;EmailMessage&lt;/code&gt; and initiated sending with &lt;code&gt;WaitUntil.Started&lt;/code&gt;. The notification itself was simple, but the integration had to understand the vendor's message types and operation model.&lt;/p&gt;
&lt;p&gt;Resend supports SMTP, and Moonglade already had SMTP support. That made this a good opportunity to reconsider whether both delivery paths were still worth maintaining.&lt;/p&gt;
&lt;h2&gt;Moving Provider Differences into Configuration&lt;/h2&gt;
&lt;p&gt;The new boundary is straightforward: Moonglade generates notifications, manages pending messages, and submits email through SMTP. The email service handles delivery after submission.&lt;/p&gt;
&lt;p&gt;The application still needs a server address and credentials, and it still depends on an external service. However, switching to another SMTP service that meets the implementation's authentication, sender, and TLS requirements can usually be handled through configuration.&lt;/p&gt;
&lt;p&gt;Replacing the Azure SDK with a Resend SDK would have introduced another vendor API into the application. Choosing SMTP instead lets the application depend on a common protocol, while the service provider's identity remains in deployment configuration.&lt;/p&gt;
&lt;p&gt;There is a tradeoff. The current implementation does not integrate provider-specific delivery callbacks, analytics APIs, or idempotency headers. For the blog's existing notification requirements, that keeps the amount of code manageable. Additional integrations can be evaluated when there is a concrete need for them.&lt;/p&gt;
&lt;h2&gt;Separating Authentication from the Sender Address&lt;/h2&gt;
&lt;p&gt;One practical detail mattered immediately: an SMTP login username is not necessarily an email address.&lt;/p&gt;
&lt;p&gt;Resend uses &lt;code&gt;resend&lt;/code&gt; as its SMTP username and an API key as the password. The sender address must belong to a verified domain. A configuration that treats the SMTP username as the sender address cannot represent this setup correctly. These requirements are documented in &lt;a href="https://resend.com/docs/send-with-smtp"&gt;Resend's SMTP documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;To support this, Moonglade gained a general-purpose &lt;code&gt;SmtpSenderAddress&lt;/code&gt; setting:&lt;/p&gt;
&lt;table class="table"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Resend example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SmtpUserName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SMTP authentication username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resend&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SmtpPassword&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SMTP authentication credential&lt;/td&gt;
&lt;td&gt;API key supplied through deployment secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SmtpSenderAddress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Email sender address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications@example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The implementation does not detect Resend from the username or add a provider-specific branch. Authentication and sender identity are separate concepts, and the configuration now expresses that distinction.&lt;/p&gt;
&lt;p&gt;To preserve existing configurations where the username is an email address, an omitted sender address falls back to the username:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public string EffectiveSmtpSenderAddress =&amp;gt;
    string.IsNullOrWhiteSpace(SmtpSenderAddress) ? SmtpUserName : SmtpSenderAddress;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sender constructs the message with MimeKit and applies the effective address to both &lt;code&gt;From&lt;/code&gt; and &lt;code&gt;Sender&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var senderAddress = options.Value.EffectiveSmtpSenderAddress;
var mimeMessage = new MimeMessage
{
    Sender = new MailboxAddress(smtpSettings.SenderName, senderAddress),
    Subject = message.Subject,
    Body = new TextPart(message.BodyIsHtml ? TextFormat.Html : TextFormat.Plain) { Text = message.Body }
};
mimeMessage.From.Add(new MailboxAddress(smtpSettings.EmailDisplayName, senderAddress));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Authentication continues to use &lt;code&gt;SmtpUserName&lt;/code&gt; and &lt;code&gt;SmtpPassword&lt;/code&gt;. The account can therefore authenticate as &lt;code&gt;resend&lt;/code&gt;, while the message headers and SMTP envelope sender use the actual email address.&lt;/p&gt;
&lt;p&gt;Validation changed alongside the configuration. An explicitly supplied &lt;code&gt;SmtpSenderAddress&lt;/code&gt; must be a valid bare email address. Display names are configured separately, so a value such as &lt;code&gt;Blog &amp;lt;notifications@example.com&amp;gt;&lt;/code&gt; does not belong in this field.&lt;/p&gt;
&lt;p&gt;The relevant code is in &lt;a href="https://github.com/EdiWang/Moonglade/blob/2360f5be30d20394b788735438c3cb82884b58b0/src/Moonglade.Email/Core/EmailServiceOptions.cs"&gt;EmailServiceOptions.cs&lt;/a&gt; and &lt;a href="https://github.com/EdiWang/Moonglade/blob/2360f5be30d20394b788735438c3cb82884b58b0/src/Moonglade.Email/Core/SmtpEmailSender.cs"&gt;SmtpEmailSender.cs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Removing the Provider Selection Layer&lt;/h2&gt;
&lt;p&gt;With the sender address issue resolved, the next step was to remove the Azure delivery path and the code whose only purpose was choosing between providers.&lt;/p&gt;
&lt;p&gt;Previously, the delivery registrations included the Azure client wrapper, two provider implementations, and the dispatcher:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;services.AddSingleton&amp;lt;IAzureCommunicationEmailClient, AzureCommunicationEmailClient&amp;gt;();
services.AddSingleton&amp;lt;IEmailProviderSender, SmtpEmailSender&amp;gt;();
services.AddSingleton&amp;lt;IEmailProviderSender, AzureCommunicationSender&amp;gt;();
services.AddSingleton&amp;lt;IEmailDispatcher, EmailDispatcher&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the migration, that part became one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;services.AddSingleton&amp;lt;IEmailDispatcher, SmtpEmailSender&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SmtpEmailSender&lt;/code&gt; now implements the existing &lt;code&gt;IEmailDispatcher&lt;/code&gt; directly. The background processor still calls the same interface and does not need to know which SMTP service is configured.&lt;/p&gt;
&lt;p&gt;Keeping &lt;code&gt;IEmailDispatcher&lt;/code&gt; is useful because it remains the boundary between background processing and network delivery, and it allows the sender to be replaced in tests. The deleted &lt;code&gt;IEmailProviderSender&lt;/code&gt; and &lt;code&gt;EmailDispatcher&lt;/code&gt; mainly existed to select a provider. With one delivery implementation, that extra layer no longer served a purpose.&lt;/p&gt;
&lt;p&gt;The cleanup extended beyond those classes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Removed the &lt;code&gt;Azure.Communication.Email&lt;/code&gt; package reference.&lt;/li&gt;
&lt;li&gt;Deleted the Azure client wrapper and its interface.&lt;/li&gt;
&lt;li&gt;Removed provider selection, name normalization, and ACS configuration fields.&lt;/li&gt;
&lt;li&gt;Changed configuration validation to check SMTP settings directly.&lt;/li&gt;
&lt;li&gt;Updated the admin notification page to show SMTP and the effective sender address.&lt;/li&gt;
&lt;li&gt;Removed Azure &lt;code&gt;RequestFailedException&lt;/code&gt; classification while retaining SMTP error handling.&lt;/li&gt;
&lt;li&gt;Updated configuration examples, documentation, and tests.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This removed an entire branch running through configuration, registration, execution, and failure handling. There is less code to understand when diagnosing a delivery problem.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/EdiWang/Moonglade/commit/ae54a8878da593b54fd5186cc63668538f5e0a54"&gt;commit removing Azure email support&lt;/a&gt; changed 26 files, adding 39 lines and deleting 541: a net reduction of 502 lines. That includes tests and documentation, so it should not be presented as 502 lines of production code removed. It does show that this migration was primarily about consolidating and deleting code.&lt;/p&gt;
&lt;h2&gt;Keeping the Existing Outbox&lt;/h2&gt;
&lt;p&gt;Consolidating delivery into SMTP did not move network calls back into comment requests. Moonglade already had a database-backed outbox before this migration, and that pipeline remains in place:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-mermaid"&gt;flowchart TD
    A[Comment, reply, and Webmention events] --&amp;gt; B[Notification handlers]
    B --&amp;gt; C[Database outbox]
    C --&amp;gt; D[EmailOutboxWorker]
    D --&amp;gt; E[EmailOutboxMessageProcessor]
    E --&amp;gt; F[IEmailDispatcher / SmtpEmailSender]
    F --&amp;gt; G[SMTP service, currently Resend]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notification handlers enqueue messages. The background worker creates a dependency injection scope and processes pending work. The processor validates each message, builds the email, invokes the sender, and records the result.&lt;/p&gt;
&lt;p&gt;Template generation, retries, claim leases, and dead-letter handling stay inside the application. They do not have to be implemented separately for each email service. The SMTP sender handles connecting, authenticating, submitting the message, and disconnecting.&lt;/p&gt;
&lt;p&gt;The reliability guarantees also remain explicit. When every recipient fails and at least one failure is transient, the processor schedules a retry. Permanent failures or exhausted attempts lead to dead-letter handling. If only some recipients fail, the processor logs a warning and completes the message, avoiding a retry of the whole notification for recipients whose messages were already submitted successfully.&lt;/p&gt;
&lt;p&gt;The outbox uses at-least-once processing. If the SMTP service accepts an email but the application stops before recording success, a later retry can submit it again. SMTP does not automatically provide exactly-once delivery, and successful submission does not prove that a message reached the recipient's inbox.&lt;/p&gt;
&lt;p&gt;There is another boundary worth being precise about: notification enqueueing uses its own &lt;code&gt;SaveChangesAsync&lt;/code&gt;. Having an outbox alone does not mean that saving a comment and enqueueing its notification happen in the same database transaction.&lt;/p&gt;
&lt;h2&gt;Handling SMTP Transport Details&lt;/h2&gt;
&lt;p&gt;A later change fixed TLS behavior on port 465. Although the configuration property is called &lt;code&gt;EnableSsl&lt;/code&gt;, SMTP supports different ways of establishing an encrypted connection. Port 465 typically starts TLS immediately when connecting, while port 587 typically upgrades an existing connection through STARTTLS.&lt;/p&gt;
&lt;p&gt;The current implementation keeps that decision in a small method:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;internal static SecureSocketOptions GetSocketOptions(int port, bool enableTls) =&amp;gt;
    port == 465 ? SecureSocketOptions.SslOnConnect :
    enableTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The connection and authentication code uses that result:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;await smtp.ConnectAsync(
    settings.SmtpServer,
    settings.SmtpServerPort,
    GetSocketOptions(settings.SmtpServerPort, settings.EnableTls));
if (!string.IsNullOrEmpty(settings.SmtpUserName))
{
    await smtp.AuthenticateAsync(settings.SmtpUserName, settings.SmtpPassword);
}

var response = await smtp.SendAsync(mimeMessage);
await smtp.DisconnectAsync(true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this implementation, port 465 always uses implicit TLS. On other ports, &lt;code&gt;EnableSsl=true&lt;/code&gt; requires STARTTLS. Setting it to &lt;code&gt;false&lt;/code&gt; selects &lt;code&gt;Auto&lt;/code&gt;, which uses STARTTLS when the server supports it but otherwise permits an unencrypted connection. The Resend example below uses port 587 with TLS explicitly enabled.&lt;/p&gt;
&lt;p&gt;Standardizing on a protocol reduces vendor-specific behavior, but the protocol still needs to be implemented and tested correctly.&lt;/p&gt;
&lt;h2&gt;Configuring Resend&lt;/h2&gt;
&lt;p&gt;The current version can send through Resend with the following configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;quot;Email&amp;quot;: {
    &amp;quot;Enabled&amp;quot;: true,
    &amp;quot;SmtpServer&amp;quot;: &amp;quot;smtp.resend.com&amp;quot;,
    &amp;quot;SmtpUserName&amp;quot;: &amp;quot;resend&amp;quot;,
    &amp;quot;SmtpSenderAddress&amp;quot;: &amp;quot;notifications@example.com&amp;quot;,
    &amp;quot;SmtpPort&amp;quot;: 587,
    &amp;quot;EnableSsl&amp;quot;: true
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;notifications@example.com&lt;/code&gt; with a permitted sender address on your verified domain. Supply the API key through the &lt;code&gt;Email__SmtpPassword&lt;/code&gt; environment variable or your deployment's secret configuration, rather than committing it to &lt;code&gt;appsettings.json&lt;/code&gt;. The host, authentication requirements, and supported ports are covered in &lt;a href="https://resend.com/docs/send-with-smtp"&gt;Resend's SMTP documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For an existing deployment, the migration steps are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Remove &lt;code&gt;Email:Provider&lt;/code&gt;, &lt;code&gt;Email:AcsConnectionString&lt;/code&gt;, and &lt;code&gt;Email:AcsSenderAddress&lt;/code&gt;. The current code no longer reads them.&lt;/li&gt;
&lt;li&gt;Configure the SMTP host, port, credentials, and sender address.&lt;/li&gt;
&lt;li&gt;If the deployment uses the old &lt;code&gt;Email:OutboxWorker:Enabled&lt;/code&gt; switch, replace it with &lt;code&gt;Email:Enabled&lt;/code&gt;. The old key is no longer read; worker tuning settings remain under &lt;code&gt;Email:OutboxWorker&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Restart the application, enable email sending at &lt;code&gt;/admin/settings/notification&lt;/code&gt;, and use &lt;strong&gt;Send Test Email&lt;/strong&gt; to check actual delivery.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Email remains an optional capability. Missing configuration leaves email inactive while the blog starts normally. Invalid configuration produces an error log and validation details in the admin portal. While email is unavailable, the worker does not claim messages and built-in notification handlers do not enqueue new ones. Existing pending messages remain in the database and can be processed after valid configuration is supplied and the application is restarted.&lt;/p&gt;
&lt;p&gt;The application-level &lt;code&gt;Email:Enabled&lt;/code&gt; setting and the admin notification switch retain separate responsibilities. The former controls whether the email capability is enabled; the latter controls whether the blog produces notifications. Enabling notifications in the admin portal does not replace SMTP configuration.&lt;/p&gt;
&lt;h2&gt;What the Migration Achieved&lt;/h2&gt;
&lt;p&gt;The email module now has one fewer vendor SDK, no Azure client wrapper, and no provider selection layer. Their associated configuration and error-handling branches are gone. Notification features, templates, and the outbox continue to use the existing application pipeline.&lt;/p&gt;
&lt;p&gt;Reducing vendor lock-in does not eliminate dependence on an external service. Domain verification, quotas, sending reputation, and operational data still belong to the provider's side of the boundary. The improvement is lower coupling between application code and provider APIs, with less application work required for a future switch.&lt;/p&gt;
&lt;p&gt;Resend serves Moonglade's current email needs, and SMTP keeps that choice easier to change. If the next service meets the existing implementation's requirements, migration can focus on configuration, domain verification, and delivery testing, without adding another sender implementation to the application.&lt;/p&gt;
</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">db62855b-38ec-4580-8f2e-0978b8107ec4</guid><pubDate>2026-10-24T01:38:23Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-10-24T01:38:23Z</updated></item><item><title>Not Only Azure: Migrating this Blog from Entra ID to Standard OIDC</title><link>https://edi.wang/post/2026/9/2/not-only-azure-migrating-this-blog-from-entra-id-to-standard-oidc</link><description>&lt;p&gt;Moonglade has supported two authentication approaches for a long time: a built-in local administrator account and Microsoft Entra ID. The Entra ID integration worked well, especially for blogs hosted on Azure, but it also introduced an architectural limitation: the application understood a particular identity provider instead of the protocol behind it.&lt;/p&gt;
&lt;p&gt;In &lt;a href="https://github.com/EdiWang/Moonglade/pull/1001"&gt;PR #1001&lt;/a&gt;, I replaced the Entra-specific authentication path with a standards-based OpenID Connect implementation.&lt;/p&gt;
&lt;p&gt;This does not mean Moonglade no longer supports Entra ID. Entra ID remains supported—as an OpenID Connect provider. The important change is that Moonglade is no longer designed around Azure identity alone.&lt;/p&gt;
&lt;h2&gt;Why Change a Working Authentication System?&lt;/h2&gt;
&lt;p&gt;The previous design used &lt;code&gt;Microsoft.Identity.Web&lt;/code&gt; and configuration concepts specific to Entra ID, including tenant ID, instance, and domain.&lt;/p&gt;
&lt;p&gt;That was convenient, but it made one identity provider part of the application’s architecture. Adding support for another provider would likely have required another authentication mode, another configuration model, and more provider-specific branches.&lt;/p&gt;
&lt;p&gt;The new design reduces the authentication choices to two application-level modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Local&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OpenIdConnect&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moonglade now cares about standard OIDC concepts such as authority, client ID, scopes, callback paths, and claims. Provider-specific details stay at the identity provider.&lt;/p&gt;
&lt;p&gt;This creates a cleaner boundary: Moonglade defines what it needs from an identity system, while the OIDC provider decides how users authenticate.&lt;/p&gt;
&lt;h2&gt;A Provider-Neutral Configuration&lt;/h2&gt;
&lt;p&gt;The old &lt;code&gt;Authentication:EntraID&lt;/code&gt; section has been replaced with &lt;code&gt;Authentication:OpenIdConnect&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;Authentication&amp;quot;: {
    &amp;quot;Provider&amp;quot;: &amp;quot;OpenIdConnect&amp;quot;,
    &amp;quot;OpenIdConnect&amp;quot;: {
      &amp;quot;Authority&amp;quot;: &amp;quot;https://identity.example.com/&amp;quot;,
      &amp;quot;ClientId&amp;quot;: &amp;quot;moonglade&amp;quot;,
      &amp;quot;CallbackPath&amp;quot;: &amp;quot;/signin-oidc&amp;quot;,
      &amp;quot;SignedOutCallbackPath&amp;quot;: &amp;quot;/signout-callback-oidc&amp;quot;,
      &amp;quot;NameClaimType&amp;quot;: &amp;quot;name&amp;quot;,
      &amp;quot;Scopes&amp;quot;: [ &amp;quot;openid&amp;quot;, &amp;quot;profile&amp;quot;, &amp;quot;email&amp;quot; ],
      &amp;quot;AllowedSubjects&amp;quot;: []
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client secret is intentionally excluded from the configuration file. It should come from a secure external source, such as an environment variable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Authentication__OpenIdConnect__ClientSecret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any provider used with Moonglade must publish discovery metadata over HTTPS and support the authorization code flow with PKCE.&lt;/p&gt;
&lt;h2&gt;Using the Standard ASP.NET Core OIDC Handler&lt;/h2&gt;
&lt;p&gt;The implementation now uses &lt;code&gt;Microsoft.AspNetCore.Authentication.OpenIdConnect&lt;/code&gt; directly. The OIDC handler performs the external authentication, while Moonglade continues to use an encrypted application cookie for its local session.&lt;/p&gt;
&lt;p&gt;The essential registration looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.AddAuthentication(options =&amp;gt;
{
    options.DefaultScheme =
        CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme =
        BlogAuthSchemas.OpenIdConnect;
})
.AddCookie(
    CookieAuthenticationDefaults.AuthenticationScheme,
    ConfigureApplicationCookie)
.AddOpenIdConnect(BlogAuthSchemas.OpenIdConnect, options =&amp;gt;
{
    options.Authority = oidc.Authority;
    options.ClientId = oidc.ClientId;
    options.ClientSecret = oidc.ClientSecret;

    options.ResponseType = OpenIdConnectResponseType.Code;
    options.UsePkce = true;
    options.RequireHttpsMetadata = true;

    options.SignInScheme =
        CookieAuthenticationDefaults.AuthenticationScheme;
    options.MapInboundClaims = false;
    options.SaveTokens = false;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few choices here are deliberate.&lt;/p&gt;
&lt;p&gt;Authorization code flow with PKCE avoids the older implicit-flow model. HTTPS metadata is mandatory, claim names are kept in their original OIDC form, and access or refresh tokens are not persisted in the application cookie. Moonglade needs an authenticated identity, not long-term access to the provider’s APIs.&lt;/p&gt;
&lt;p&gt;Sign-out now clears the Moonglade cookie and also invokes the OIDC provider’s logout endpoint when one is advertised in its metadata.&lt;/p&gt;
&lt;h2&gt;Authentication Is Not Authorization&lt;/h2&gt;
&lt;p&gt;The most important part of this change is not the replacement of a package. It is the separation of two questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Who is the user?&lt;/li&gt;
&lt;li&gt;Is this user allowed to administer the blog?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A successful OIDC sign-in answers only the first question. It must not automatically grant access to the Admin Portal.&lt;/p&gt;
&lt;p&gt;Moonglade now has one administrator policy named &lt;code&gt;MoongladeAdministrator&lt;/code&gt;. Its behavior depends on the configured authentication mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.AddAuthorizationBuilder()
    .AddPolicy(BlogAuthSchemas.AdministratorPolicy, policy =&amp;gt;
    {
        policy.RequireAuthenticatedUser();

        if (authentication.Provider == AuthenticationProvider.Local)
        {
            policy.RequireRole(&amp;quot;Administrator&amp;quot;);
            return;
        }

        var allowedSubjects = oidc.AllowedSubjects
            .ToHashSet(StringComparer.Ordinal);

        policy.RequireAssertion(context =&amp;gt;
            context.User.FindAll(&amp;quot;sub&amp;quot;)
                .Any(claim =&amp;gt; allowedSubjects.Contains(claim.Value)));
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Local accounts continue to use the existing administrator role. OIDC identities are checked against an explicit allowlist of &lt;code&gt;sub&lt;/code&gt; claims.&lt;/p&gt;
&lt;p&gt;The same policy protects Admin Razor Pages, administrative API controllers, and UI elements that expose administrative actions. This keeps the authorization rule centralized instead of scattering provider checks throughout the Web layer.&lt;/p&gt;
&lt;h2&gt;Why Authorize with &lt;code&gt;sub&lt;/code&gt;?&lt;/h2&gt;
&lt;p&gt;OIDC providers commonly return claims such as email address, display name, or preferred username. These values are useful for presentation, but they are poor authorization keys: they may change, be reassigned, or have different semantics across providers.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; claim is the subject identifier defined by OpenID Connect. Within the issuer boundary validated by the OIDC handler, it provides the stable identifier needed for an administrator allowlist.&lt;/p&gt;
&lt;p&gt;Moonglade therefore does not grant administrator access based on email or display name. Every administrator must have an exact &lt;code&gt;sub&lt;/code&gt; value in &lt;code&gt;AllowedSubjects&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Some providers use pairwise subject identifiers, so changing the provider or client registration may produce a different &lt;code&gt;sub&lt;/code&gt; for the same person. This is expected and must be considered during migration.&lt;/p&gt;
&lt;h2&gt;Bootstrapping the First Administrator&lt;/h2&gt;
&lt;p&gt;A new deployment starts safely with an empty allowlist:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;AllowedSubjects&amp;quot;: []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Users can still complete OIDC authentication, but nobody can access the Admin Portal or administrative APIs.&lt;/p&gt;
&lt;p&gt;To discover the first administrator identity, Moonglade provides:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/auth/identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An authenticated OIDC user can open this endpoint to retrieve their own issuer, subject, and display name. The endpoint does not grant any permission and cannot inspect another user’s identity.&lt;/p&gt;
&lt;p&gt;The administrator can then copy the returned subject into the configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;AllowedSubjects&amp;quot;: [
  &amp;quot;administrator-subject-1&amp;quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After restarting the application and signing in again, the administrator policy can authorize that identity.&lt;/p&gt;
&lt;p&gt;When local authentication is configured, &lt;code&gt;/auth/identity&lt;/code&gt; returns HTTP 404 because the endpoint is relevant only to the OIDC bootstrap process.&lt;/p&gt;
&lt;h2&gt;Failing Fast on Invalid Configuration&lt;/h2&gt;
&lt;p&gt;External authentication problems are often discovered only when someone tries to sign in. Moonglade now validates its OIDC configuration during startup instead.&lt;/p&gt;
&lt;p&gt;The validator checks that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The authority is an absolute HTTPS URL.&lt;/li&gt;
&lt;li&gt;Client ID and client secret are present.&lt;/li&gt;
&lt;li&gt;Callback paths are application-relative.&lt;/li&gt;
&lt;li&gt;The scopes include &lt;code&gt;openid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The name claim type is configured.&lt;/li&gt;
&lt;li&gt;The subject allowlist contains no blank entries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This uses ASP.NET Core options validation with &lt;code&gt;ValidateOnStart()&lt;/code&gt;. A deployment with incomplete authentication configuration fails immediately with a useful error instead of starting successfully with a broken sign-in flow.&lt;/p&gt;
&lt;h2&gt;Entra ID Still Works&lt;/h2&gt;
&lt;p&gt;Existing Entra ID deployments can continue using Microsoft identities through Entra’s standard OIDC endpoints. For a single-tenant blog, the authority should use the tenant-specific v2 endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://login.microsoftonline.com/{tenant-id}/v2.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The existing application client ID can usually be retained, but the application needs a Web client secret and registered sign-in and sign-out callback URLs.&lt;/p&gt;
&lt;p&gt;No database migration is required. This is an application configuration and authentication architecture change.&lt;/p&gt;
&lt;h2&gt;A Smaller Dependency and a Larger Possibility&lt;/h2&gt;
&lt;p&gt;The goal of this migration was not to replace Microsoft Entra ID with another identity provider. It was to stop making any single provider part of Moonglade’s core authentication design.&lt;/p&gt;
&lt;p&gt;Entra ID remains an excellent choice, particularly for Azure-hosted deployments. It now participates through the same standard protocol boundary as other compatible OIDC providers.&lt;/p&gt;
&lt;p&gt;The resulting architecture is easier to understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OIDC authenticates the user.&lt;/li&gt;
&lt;li&gt;Moonglade’s cookie maintains the application session.&lt;/li&gt;
&lt;li&gt;A centralized policy authorizes administrators.&lt;/li&gt;
&lt;li&gt;Stable subject identifiers define who may manage the blog.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By designing around a standard instead of a vendor, Moonglade keeps its existing Azure capabilities while gaining the freedom to work beyond Azure.&lt;/p&gt;
</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">00028b8b-eb10-4e98-ba56-00d2e8c857db</guid><pubDate>2026-09-02T11:18:16Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-09-02T11:18:16Z</updated></item><item><title>From Azure to Anywhere: Redesigning Image Storage Architecture for this Blog</title><link>https://edi.wang/post/2026/8/25/from-azure-to-anywhere-redesigning-image-storage-architecture-for-this-blog</link><description>&lt;p&gt;Image storage sounds simple: accept an upload, save the bytes, and return a URL. In practice, it can quickly pull cloud SDKs, credentials, provider-specific configuration, deployment logic, and operational assumptions deep into an application.&lt;/p&gt;
&lt;p&gt;That was the situation in Moonglade. It supported the local filesystem, Azure Blob Storage, and S3-compatible object storage directly in the application. This worked, but each provider made the application responsible for more infrastructure knowledge.&lt;/p&gt;
&lt;p&gt;In &lt;a href="https://github.com/EdiWang/Moonglade/pull/999"&gt;PR #999&lt;/a&gt;, I redesigned this subsystem around a simpler principle:&lt;/p&gt;
&lt;blockquote class="blockquote"&gt;
&lt;p&gt;Moonglade should manage image files. The hosting environment should decide how those files are persisted.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The result is a filesystem-only application architecture that can still run on Azure, Docker, traditional servers, shared network storage, or other platforms.&lt;/p&gt;
&lt;h2&gt;The Previous Architecture&lt;/h2&gt;
&lt;p&gt;Moonglade already had an &lt;code&gt;IBlogImageStorage&lt;/code&gt; abstraction, so most business code did not directly call Azure or AWS APIs. However, the application still contained three separate implementations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Local filesystem storage&lt;/li&gt;
&lt;li&gt;Azure Blob Storage&lt;/li&gt;
&lt;li&gt;S3-compatible storage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The dependency injection layer selected one of them based on configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;Moonglade
    └── IBlogImageStorage
          ├── FileSystemImageStorage
          ├── AzureBlobImageStorage
          └── S3CompatibleImageStorage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This meant the application still needed to understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Azure Storage connection strings and container names&lt;/li&gt;
&lt;li&gt;S3 endpoints, regions, buckets, and access keys&lt;/li&gt;
&lt;li&gt;Azure and AWS SDK initialization&lt;/li&gt;
&lt;li&gt;Provider-specific validation&lt;/li&gt;
&lt;li&gt;Remote container creation during startup&lt;/li&gt;
&lt;li&gt;Different behaviors and test suites for each provider&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Although image consumers depended on an interface, the application as a whole remained coupled to specific storage platforms.&lt;/p&gt;
&lt;p&gt;Adding another provider would have required another implementation, another SDK, more configuration, and more tests. That was not the direction I wanted for a personal blogging platform.&lt;/p&gt;
&lt;h2&gt;A New Responsibility Boundary&lt;/h2&gt;
&lt;p&gt;The redesigned architecture removes cloud storage providers from the application entirely:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;Moonglade
    ↓
IBlogImageStorage
    ↓
Filesystem paths
    ↓
Local disk / Docker volume / Azure Files / SMB / NFS / mounted storage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Moonglade now reads and writes ordinary files. Durability, replication, sharing, backup, and cloud integration belong to the deployment environment.&lt;/p&gt;
&lt;p&gt;This does not mean Moonglade can no longer use cloud storage. It means cloud storage is attached below the application boundary.&lt;/p&gt;
&lt;p&gt;For example, the official Azure deployment mounts Azure Files shares into the Linux App Service container. Moonglade sees &lt;code&gt;/app/images&lt;/code&gt;, while Azure provides durable storage behind that path. No Azure Storage SDK is required in the application.&lt;/p&gt;
&lt;h2&gt;A Smaller Storage Contract&lt;/h2&gt;
&lt;p&gt;The new interface describes only the operations Moonglade actually needs:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface IBlogImageStorage
{
    Task&amp;lt;string&amp;gt; InsertAsync(string fileName, byte[] imageBytes);

    Task&amp;lt;string&amp;gt; InsertOriginalAsync(string fileName, byte[] imageBytes);

    Task&amp;lt;ImageInfo&amp;gt; GetInfoAsync(string fileName);

    Task&amp;lt;Stream&amp;gt; OpenReadAsync(string fileName);

    Task DeleteAsync(string fileName);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no provider name, container concept, bucket concept, or cloud-specific operation.&lt;/p&gt;
&lt;p&gt;The dependency injection registration also became much smaller. The application resolves two filesystem paths and registers one implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var primaryPath = string.IsNullOrWhiteSpace(settings.FileSystemPath)
    ? FileSystemImageStorage.DefaultPath
    : settings.FileSystemPath;

var originalPath = string.IsNullOrWhiteSpace(settings.OriginalFileSystemPath)
    ? FileSystemImageStorage.DefaultOriginalPath
    : settings.OriginalFileSystemPath;

var imageConfiguration =
    FileSystemImageStorage.ResolveImageStoragePaths(
        primaryPath,
        originalPath);

services.AddSingleton(imageConfiguration)
    .AddSingleton&amp;lt;IBlogImageStorage, FileSystemImageStorage&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As part of this change, Moonglade removed the Azure Blob and S3-compatible implementations, together with the &lt;code&gt;Azure.Storage.Blobs&lt;/code&gt; and &lt;code&gt;AWSSDK.S3&lt;/code&gt; package references.&lt;/p&gt;
&lt;p&gt;Fewer SDKs mean fewer transitive dependencies, fewer provider-specific code paths, and a smaller configuration surface.&lt;/p&gt;
&lt;h2&gt;Public Images and Private Originals&lt;/h2&gt;
&lt;p&gt;The redesign also clarified an important security boundary.&lt;/p&gt;
&lt;p&gt;Moonglade can retain both a processed image and its original upload. Previously, this was described using “primary” and “secondary” storage terminology. The new model expresses the actual intent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FileSystemPath&lt;/code&gt; stores processed, publicly retrievable images.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OriginalFileSystemPath&lt;/code&gt; stores private original uploads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A typical production configuration looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;&amp;quot;ImageStorage&amp;quot;: {
  &amp;quot;CacheMinutes&amp;quot;: 60,
  &amp;quot;FileSystemPath&amp;quot;: &amp;quot;/app/images&amp;quot;,
  &amp;quot;OriginalFileSystemPath&amp;quot;: &amp;quot;/app/images-origin&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two paths must be absolute and must not overlap. One cannot contain the other.&lt;/p&gt;
&lt;p&gt;This is more than configuration validation. It prevents an original-image directory from accidentally appearing under a public root. Only the primary path is read by &lt;code&gt;/image/{filename}&lt;/code&gt; and only that path may be exposed to a CDN.&lt;/p&gt;
&lt;p&gt;The original-image root must remain outside static-file middleware, public endpoints, and CDN origins.&lt;/p&gt;
&lt;h2&gt;Cloud Integration Moves to Deployment&lt;/h2&gt;
&lt;p&gt;Removing cloud SDKs changes where infrastructure decisions are made, not whether they exist.&lt;/p&gt;
&lt;p&gt;In Docker Compose, the two roots can be backed by separate named volumes:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-yaml"&gt;services:
  moonglade:
    environment:
      ImageStorage__FileSystemPath: /app/images
      ImageStorage__OriginalFileSystemPath: /app/images-origin
    volumes:
      - moonglade-images:/app/images
      - moonglade-images-origin:/app/images-origin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The official Azure deployment follows the same model. It creates two Azure Files shares and mounts them into the App Service container at those paths.&lt;/p&gt;
&lt;p&gt;Other environments can provide the filesystem contract differently:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A physical or virtual server can use durable local disks.&lt;/li&gt;
&lt;li&gt;Multiple application replicas can use SMB or NFS.&lt;/li&gt;
&lt;li&gt;Kubernetes can attach persistent volumes.&lt;/li&gt;
&lt;li&gt;A hosting platform can use a suitable mounted-storage adapter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moonglade does not install or configure mount drivers, CSI integrations, sidecars, or vendor-specific volume plugins. Those are operational choices and should remain outside the application.&lt;/p&gt;
&lt;p&gt;The storage presented to Moonglade must still behave like a reliable filesystem: writes must become visible when completed, data must survive restarts, and all replicas must see a coherent view.&lt;/p&gt;
&lt;h3&gt;Example: Mapping Azure Files into App Service&lt;/h3&gt;
&lt;p&gt;A filesystem-first design does not prevent Moonglade from using Azure platform capabilities. In the official deployment, two Azure Files shares are mounted into the Linux App Service container. Assuming the shares already exist, the volume mappings can be declared in Bicep like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bicep"&gt;resource storageMounts 'Microsoft.Web/sites/config@2022-03-01' = {
  parent: webApp
  name: 'azurestorageaccounts'
  properties: {
    primaryImages: {
      type: 'AzureFiles'
      accountName: storageAccount.name
      shareName: 'moonglade-images'
      accessKey: storageAccount.listKeys().keys[0].value
      mountPath: '/app/images'
    }
    originalImages: {
      type: 'AzureFiles'
      accountName: storageAccount.name
      shareName: 'moonglade-images-origin'
      accessKey: storageAccount.listKeys().keys[0].value
      mountPath: '/app/images-origin'
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application settings then point Moonglade to those mounted directories:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bicep"&gt;{
  name: 'ImageStorage__FileSystemPath'
  value: '/app/images'
}
{
  name: 'ImageStorage__OriginalFileSystemPath'
  value: '/app/images-origin'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="/image/20260825-302c8a47.png" class="img-fluid" alt="image" /&gt;&lt;/p&gt;
&lt;p&gt;From Moonglade’s perspective, these are ordinary filesystem paths. App Service and Azure Files provide the persistent, shared storage behind them. This demonstrates the new architectural boundary clearly: the application remains platform-neutral while the deployment can still take full advantage of Azure’s managed infrastructure. Azure also supports configuring these mounts through the portal or Azure CLI, as described in the &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/configure-connect-to-azure-storage"&gt;App Service storage-mount documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;CDN Delivery Remains Independent&lt;/h2&gt;
&lt;p&gt;Storage and image delivery are separate concerns.&lt;/p&gt;
&lt;p&gt;Moonglade continues to store image references using URLs such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;/image/example.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When CDN redirection is enabled, rendered posts and feeds use the configured CDN endpoint. Requests to the legacy application URL can also redirect to the CDN.&lt;/p&gt;
&lt;p&gt;The browser retrieves the image directly from the CDN; the bytes do not need to pass through Moonglade.&lt;/p&gt;
&lt;p&gt;This behavior did not require a cloud storage SDK before, and it does not require one now. The only important rule is that the CDN origin may expose the public image root, but never the private original-image root.&lt;/p&gt;
&lt;p&gt;CDN caching, media types, invalidation, TLS, and origin access policies remain deployment responsibilities.&lt;/p&gt;
&lt;h2&gt;The Trade-Off&lt;/h2&gt;
&lt;p&gt;This design makes the application smaller and more portable, but it does not make storage operations disappear.&lt;/p&gt;
&lt;p&gt;Operators must now ensure that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Both paths are writable and durable.&lt;/li&gt;
&lt;li&gt;Containers do not store images only in ephemeral layers.&lt;/li&gt;
&lt;li&gt;Multiple replicas share the same image data.&lt;/li&gt;
&lt;li&gt;Backups include both roots.&lt;/li&gt;
&lt;li&gt;The private original root is not publicly exposed.&lt;/li&gt;
&lt;li&gt;Mounted storage has acceptable consistency and performance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I consider this a better trade-off. These concerns already existed; keeping cloud SDKs inside Moonglade merely distributed them across both application and infrastructure code.&lt;/p&gt;
&lt;p&gt;The new architecture gives them a clear owner.&lt;/p&gt;
&lt;h2&gt;Upgrading Existing Installations&lt;/h2&gt;
&lt;p&gt;This is a breaking deployment change for installations using Azure Blob Storage, S3-compatible storage, or a single shared filesystem directory.&lt;/p&gt;
&lt;p&gt;Existing images must be copied into the new roots before upgrading:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copy processed images into &lt;code&gt;FileSystemPath&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Copy retained originals into &lt;code&gt;OriginalFileSystemPath&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Preserve existing filenames.&lt;/li&gt;
&lt;li&gt;Configure both new paths.&lt;/li&gt;
&lt;li&gt;Remove the old provider settings and credentials.&lt;/li&gt;
&lt;li&gt;Verify persistence, permissions, and public/private isolation before restoring traffic.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Database records and saved post content do not require URL rewriting because &lt;code&gt;/image/{filename}&lt;/code&gt; remains the stable application-level URL.&lt;/p&gt;
&lt;p&gt;Moonglade deliberately does not perform this data migration automatically. Copying data between storage systems is an infrastructure operation with deployment-specific authentication, availability, and rollback requirements.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The most important outcome of this redesign is not simply that some SDK packages were removed. It is that Moonglade now has a clearer architectural boundary.&lt;/p&gt;
&lt;p&gt;The application owns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Image validation and processing&lt;/li&gt;
&lt;li&gt;Filename generation&lt;/li&gt;
&lt;li&gt;Public and original image separation&lt;/li&gt;
&lt;li&gt;Image HTTP behavior&lt;/li&gt;
&lt;li&gt;CDN URL generation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The infrastructure owns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Durable storage&lt;/li&gt;
&lt;li&gt;Cloud-provider integration&lt;/li&gt;
&lt;li&gt;Shared access between replicas&lt;/li&gt;
&lt;li&gt;Mounting, backup, and recovery&lt;/li&gt;
&lt;li&gt;CDN origin configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moonglade can still run on Azure, but Azure is no longer embedded in its image-storage code. The same application can run with Docker volumes, shared network storage, or another platform without introducing another provider implementation.&lt;/p&gt;
&lt;p&gt;That is the real meaning of moving from Azure to anywhere.&lt;/p&gt;
</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">400e3801-a915-407f-895c-2aa61c5d7c06</guid><pubDate>2026-08-25T00:04:45Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-08-25T00:04:45Z</updated></item><item><title>From Azure Functions to a Database Outbox: Simplifying Moonglade Email Notifications</title><link>https://edi.wang/post/2026/8/3/from-azure-functions-to-a-database-outbox-simplifying-moonglade-email-notifications</link><description>&lt;p&gt;Moonglade used to send email notifications through a standalone service named &lt;code&gt;Moonglade.Email&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That service was deployed as an Azure Function. The blog application submitted email notification messages, Azure Storage Queue held those messages, and the function processed them asynchronously before sending email through Azure Communication Services or SMTP.&lt;/p&gt;
&lt;p&gt;It worked. But for a personal blogging system, it was more infrastructure than I wanted to keep.&lt;/p&gt;
&lt;p&gt;Moonglade usually sends very few notification emails: new comments, admin replies, Webmentions, and test messages. Even for a busy post, the workload is tiny compared with what Azure Queue Storage and Azure Functions are designed to handle.&lt;/p&gt;
&lt;p&gt;So I moved email notification processing back into the main Moonglade application.&lt;/p&gt;
&lt;h2&gt;Before: A Separate Azure Function Service&lt;/h2&gt;
&lt;p&gt;The old design looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-mermaid"&gt;sequenceDiagram
    participant Blog as Moonglade Web App
    participant Api as Moonglade.Email HTTP Function
    participant Queue as Azure Storage Queue
    participant Function as Azure Function Queue Trigger
    participant Template as Email Template Renderer
    participant Provider as ACS / SMTP

    Blog-&amp;gt;&amp;gt;Api: Send email request
    Api-&amp;gt;&amp;gt;Queue: Enqueue email message
    Queue-&amp;gt;&amp;gt;Function: Trigger with queued message
    Function-&amp;gt;&amp;gt;Template: Render email template
    Template--&amp;gt;&amp;gt;Function: Return rendered email
    Function-&amp;gt;&amp;gt;Provider: Send email
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This architecture had a few benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;email sending did not block comment submission&lt;/li&gt;
&lt;li&gt;queue messages survived restarts&lt;/li&gt;
&lt;li&gt;retries were handled outside the web request&lt;/li&gt;
&lt;li&gt;email delivery was separated from the blog process&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But it also required:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;another repository&lt;/li&gt;
&lt;li&gt;another deployment unit&lt;/li&gt;
&lt;li&gt;Azure Functions&lt;/li&gt;
&lt;li&gt;Azure Storage Queue&lt;/li&gt;
&lt;li&gt;extra secrets and configuration&lt;/li&gt;
&lt;li&gt;more operational knowledge for a small personal blog&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For Moonglade, the cost was no longer justified.&lt;/p&gt;
&lt;h2&gt;After: An In-App Database Outbox&lt;/h2&gt;
&lt;p&gt;The new design keeps asynchronous and durable delivery, but removes the standalone Azure Function and Azure Storage Queue.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-mermaid"&gt;flowchart TD
    Event[&amp;quot;Comment / Reply / Webmention&amp;quot;] --&amp;gt; Queue[&amp;quot;IEmailNotificationQueue&amp;quot;]
    Queue --&amp;gt; Table[&amp;quot;EmailOutboxMessage Table&amp;quot;]
    Table --&amp;gt; Worker[&amp;quot;EmailOutboxWorker&amp;quot;]
    Worker --&amp;gt; Processor[&amp;quot;EmailOutboxMessageProcessor&amp;quot;]
    Processor --&amp;gt; Builder[&amp;quot;MessageBuilder&amp;quot;]
    Builder --&amp;gt; Dispatcher[&amp;quot;EmailDispatcher&amp;quot;]
    Dispatcher --&amp;gt; ACS[&amp;quot;Azure Communication Services&amp;quot;]
    Dispatcher --&amp;gt; SMTP[&amp;quot;SMTP&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database is now the durable queue.&lt;/p&gt;
&lt;p&gt;This is intentionally simple. Moonglade already requires SQL Server or PostgreSQL, so using the existing database avoids introducing RabbitMQ, Redis, Azure Queue Storage, or another hosted dependency.&lt;/p&gt;
&lt;h2&gt;Code Structure&lt;/h2&gt;
&lt;p&gt;The email implementation now lives inside the main Moonglade solution:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;src/
  Moonglade.Email/
    CommentEvent.cs
    CommentReplyEvent.cs
    MentionEvent.cs
    TestEmailEvent.cs
    ServiceCollectionExtensions.cs
    Core/
      IEmailNotificationQueue.cs
      IEmailOutboxStore.cs
      DbEmailNotificationQueue.cs
      EmailOutboxWorker.cs
      EmailOutboxMessageProcessor.cs
      EmailOutboxWorkerOptions.cs

  Moonglade.Data/
    Entities/
      EmailOutboxMessageEntity.cs
      EmailOutboxMessageStatus.cs
    Configurations/
      EmailOutboxMessageConfiguration.cs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The main idea is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;event handlers enqueue email work&lt;/li&gt;
&lt;li&gt;the database stores pending messages&lt;/li&gt;
&lt;li&gt;a hosted service polls and claims messages&lt;/li&gt;
&lt;li&gt;the processor sends emails and records success, retry, or dead-letter status&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Event Handlers Only Enqueue Work&lt;/h2&gt;
&lt;p&gt;Request handlers do not send email directly. They publish LiteBus events such as &lt;code&gt;CommentEvent&lt;/code&gt;, &lt;code&gt;CommentReplyEvent&lt;/code&gt;, or &lt;code&gt;MentionEvent&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The email event handlers turn those events into outbox messages.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class CommentNotificationEventHandler(
    IEmailNotificationQueue queue,
    IBlogConfig blogConfig) : IEventHandler&amp;lt;CommentEvent&amp;gt;
{
    public async Task HandleAsync(CommentEvent notification, CancellationToken ct)
    {
        if (!blogConfig.NotificationSettings.EnableEmailSending)
        {
            return;
        }

        var payload = new NewCommentPayload
        {
            Username = notification.Username,
            Email = notification.Email,
            IpAddress = notification.IPAddress,
            PostTitle = notification.PostTitle,
            CommentContent = ContentProcessor.MarkdownToCommentHtml(notification.CommentContent)
        };

        await queue.EnqueueAsync(new EmailNotification
        {
            MessageType = MessageTypes.NewCommentNotification,
            DistributionList = blogConfig.GeneralSettings.OwnerEmail,
            MessageBody = JsonSerializer.Serialize(payload, MoongladeJsonSerializerOptions.Default)
        }, ct);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps the web request path short. The user action only persists a notification message. Actual email delivery happens later.&lt;/p&gt;
&lt;h2&gt;The Outbox Table&lt;/h2&gt;
&lt;p&gt;The outbox message entity stores everything needed to process, retry, and diagnose email delivery.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class EmailOutboxMessageEntity
{
    public Guid Id { get; set; }
    public string MessageType { get; set; }
    public string DistributionList { get; set; }
    public string MessageBody { get; set; }
    public EmailOutboxMessageStatus Status { get; set; }
    public int AttemptCount { get; set; }
    public DateTime CreatedTimeUtc { get; set; }
    public DateTime? LastAttemptTimeUtc { get; set; }
    public DateTime? NotBeforeUtc { get; set; }
    public DateTime? LockedUntilUtc { get; set; }
    public string LockedBy { get; set; }
    public DateTime? SentTimeUtc { get; set; }
    public string LastError { get; set; }
    public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dequeue path has an index optimized for pending and retryable messages:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;builder.HasIndex(
    nameof(EmailOutboxMessageEntity.Status),
    nameof(EmailOutboxMessageEntity.NotBeforeUtc),
    nameof(EmailOutboxMessageEntity.LockedUntilUtc),
    nameof(EmailOutboxMessageEntity.CreatedTimeUtc))
    .HasDatabaseName(&amp;quot;IX_EmailOutboxMessage_Dequeue&amp;quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The status model is simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;Pending -&amp;gt; Processing -&amp;gt; Succeeded
Pending -&amp;gt; Processing -&amp;gt; Failed -&amp;gt; Processing -&amp;gt; Succeeded
Pending -&amp;gt; Processing -&amp;gt; Failed -&amp;gt; DeadLettered
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Claiming Messages With A Lease&lt;/h2&gt;
&lt;p&gt;The outbox store claims one available message at a time. A worker sets &lt;code&gt;LockedBy&lt;/code&gt;, &lt;code&gt;LockedUntilUtc&lt;/code&gt;, increments &lt;code&gt;AttemptCount&lt;/code&gt;, and updates the concurrency token.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;entity.Status = EmailOutboxMessageStatus.Processing;
entity.LockedBy = request.WorkerId;
entity.LockedUntilUtc = request.UtcNow.Add(request.LeaseDuration);
entity.LastAttemptTimeUtc = request.UtcNow;
entity.AttemptCount++;
entity.ConcurrencyToken = Guid.NewGuid();

await db.SaveChangesAsync(cancellationToken);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claimable messages include:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;return db.EmailOutboxMessage.Where(m =&amp;gt;
    (m.Status == EmailOutboxMessageStatus.Pending ||
     m.Status == EmailOutboxMessageStatus.Failed ||
     (m.Status == EmailOutboxMessageStatus.Processing &amp;amp;&amp;amp; m.LockedUntilUtc &amp;lt;= utcNow)) &amp;amp;&amp;amp;
    (m.NotBeforeUtc == null || m.NotBeforeUtc &amp;lt;= utcNow) &amp;amp;&amp;amp;
    (m.LockedUntilUtc == null || m.LockedUntilUtc &amp;lt;= utcNow));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives the system basic durability:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;pending messages survive app restarts&lt;/li&gt;
&lt;li&gt;failed messages can be retried later&lt;/li&gt;
&lt;li&gt;stale processing locks can be recovered&lt;/li&gt;
&lt;li&gt;multiple workers can avoid processing the same message through optimistic concurrency&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Background Worker&lt;/h2&gt;
&lt;p&gt;Email delivery runs in an ASP.NET Core hosted service.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class EmailOutboxWorker(
    IServiceScopeFactory scopeFactory,
    IOptions&amp;lt;EmailOutboxWorkerOptions&amp;gt; options,
    ILogger&amp;lt;EmailOutboxWorker&amp;gt; logger) : BackgroundService
{
    private readonly string _workerId = $&amp;quot;{Environment.MachineName}-{Guid.NewGuid():N}&amp;quot;;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var workerOptions = options.Value;
        if (!workerOptions.Enabled)
        {
            logger.LogInformation(&amp;quot;EmailOutboxWorker is disabled.&amp;quot;);
            return;
        }

        logger.LogInformation(&amp;quot;EmailOutboxWorker started with worker ID {WorkerId}.&amp;quot;, _workerId);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessBatchAsync(workerOptions, stoppingToken);
                await Task.Delay(workerOptions.PollInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, &amp;quot;Error in EmailOutboxWorker.&amp;quot;);
                await Task.Delay(workerOptions.PollInterval, stoppingToken);
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worker is intentionally boring. It polls, processes a small batch, waits, and repeats.&lt;/p&gt;
&lt;p&gt;That is enough for Moonglade’s workload.&lt;/p&gt;
&lt;h2&gt;Processing, Retry, And Dead Letter&lt;/h2&gt;
&lt;p&gt;The processor validates the message, builds the email, sends it to each recipient, and then updates the outbox state.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var failures = await SendToRecipientsAsync(message, recipients, cancellationToken);

if (failures.Count == 0)
{
    await outboxStore.CompleteAsync(message.Id, utcNow, cancellationToken);
    return;
}

if (failures.Count != recipients.Length)
{
    await outboxStore.CompleteAsync(message.Id, utcNow, cancellationToken);
    return;
}

var transientFailures = failures.Count(f =&amp;gt; f.Kind == EmailDeliveryFailureKind.Transient);

if (transientFailures == 0)
{
    await outboxStore.DeadLetterAsync(
        new EmailOutboxFailure(message.Id, errorMessage, utcNow),
        cancellationToken);
    return;
}

await outboxStore.MarkFailedAsync(
    new EmailOutboxFailure(message.Id, errorMessage, utcNow),
    utcNow.Add(GetRetryDelay(message.AttemptCount)),
    cancellationToken);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The retry delay grows until it reaches the configured maximum.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private TimeSpan GetRetryDelay(int attemptCount)
{
    var delay = options.Value.InitialRetryDelay;

    for (var i = 1; i &amp;lt; attemptCount; i++)
    {
        delay = delay + delay;
        if (delay &amp;gt;= options.Value.MaxRetryDelay)
        {
            return options.Value.MaxRetryDelay;
        }
    }

    return delay;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is not a full message broker. It is a small durable outbox designed for a personal blog.&lt;/p&gt;
&lt;h2&gt;Dependency Injection&lt;/h2&gt;
&lt;p&gt;The email system is registered as part of the main application.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;services.AddScoped&amp;lt;DbEmailNotificationQueue&amp;gt;();
services.AddScoped&amp;lt;IEmailOutboxStore&amp;gt;(sp =&amp;gt; sp.GetRequiredService&amp;lt;DbEmailNotificationQueue&amp;gt;());
services.AddScoped&amp;lt;IEmailNotificationQueue&amp;gt;(sp =&amp;gt; sp.GetRequiredService&amp;lt;DbEmailNotificationQueue&amp;gt;());
services.AddScoped&amp;lt;IEmailOutboxMessageProcessor, EmailOutboxMessageProcessor&amp;gt;();
services.AddHostedService&amp;lt;EmailOutboxWorker&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same database-backed implementation serves both roles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;IEmailNotificationQueue&lt;/code&gt; for enqueueing&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IEmailOutboxStore&lt;/code&gt; for worker-side claiming and state transitions&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configuration&lt;/h2&gt;
&lt;p&gt;The outbox worker is configured under the main &lt;code&gt;Email&lt;/code&gt; section.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
  &amp;quot;Email&amp;quot;: {
    &amp;quot;Provider&amp;quot;: &amp;quot;AzureCommunication&amp;quot;,
    &amp;quot;OutboxWorker&amp;quot;: {
      &amp;quot;Enabled&amp;quot;: true,
      &amp;quot;BatchSize&amp;quot;: 5,
      &amp;quot;PollIntervalSeconds&amp;quot;: 30,
      &amp;quot;LeaseDurationSeconds&amp;quot;: 300,
      &amp;quot;MaxAttempts&amp;quot;: 3,
      &amp;quot;InitialRetryDelaySeconds&amp;quot;: 60,
      &amp;quot;MaxRetryDelaySeconds&amp;quot;: 3600
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Email:OutboxWorker:Enabled=false&lt;/code&gt; stops in-process delivery but does not prevent enqueueing. That can be useful if only one app instance should process the outbox.&lt;/p&gt;
&lt;h2&gt;A Linux Docker Issue&lt;/h2&gt;
&lt;p&gt;After deploying to Azure App Service on Linux with Docker, I saw this error repeatedly:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;System.UnauthorizedAccessException:
Access to the path '/app/mailConfiguration.xml' is denied.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The container runs as the non-root &lt;code&gt;app&lt;/code&gt; user. The published files in &lt;code&gt;/app&lt;/code&gt; are owned by root and should be treated as read-only.&lt;/p&gt;
&lt;p&gt;The issue was caused by initializing the email template helper with a file path. Internally, that path-based constructor required write access.&lt;/p&gt;
&lt;p&gt;The fix was to load the XML file explicitly with read-only access, deserialize it, and pass the configuration object to the email helper.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static MailConfiguration LoadMailConfiguration()
{
    var configSource = Path.Join(AppContext.BaseDirectory, &amp;quot;mailConfiguration.xml&amp;quot;);
    if (!File.Exists(configSource))
    {
        configSource = Path.Join(AppContext.BaseDirectory, &amp;quot;Moonglade.Email&amp;quot;, &amp;quot;mailConfiguration.xml&amp;quot;);
    }

    var serializer = new XmlSerializer(typeof(MailConfiguration));
    using var stream = File.Open(configSource, FileMode.Open, FileAccess.Read, FileShare.Read);

    return serializer.Deserialize(stream) as MailConfiguration
        ?? throw new InvalidOperationException(&amp;quot;Configuration file for EmailHelper is invalid.&amp;quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then DI can create the helper without requiring write access to &lt;code&gt;/app&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;services.AddSingleton&amp;lt;IEmailHelper&amp;gt;(_ =&amp;gt; new EmailHelper(LoadMailConfiguration()));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I verified the fix locally with Docker Desktop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the container ran as user &lt;code&gt;app&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/app/mailConfiguration.xml&lt;/code&gt; was owned by root&lt;/li&gt;
&lt;li&gt;writing to the file failed with &lt;code&gt;Permission denied&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EmailOutboxWorker&lt;/code&gt; started successfully&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;UnauthorizedAccessException&lt;/code&gt; appeared in the logs&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why This Design Fits Moonglade&lt;/h2&gt;
&lt;p&gt;This migration is not about building a better queue than Azure Storage Queue.&lt;/p&gt;
&lt;p&gt;It is about choosing the right amount of infrastructure.&lt;/p&gt;
&lt;p&gt;For Moonglade, the email workload is small. The important requirements are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;do not block user requests while sending email&lt;/li&gt;
&lt;li&gt;persist notifications across app restarts&lt;/li&gt;
&lt;li&gt;retry transient failures&lt;/li&gt;
&lt;li&gt;avoid losing messages&lt;/li&gt;
&lt;li&gt;reduce Azure dependencies&lt;/li&gt;
&lt;li&gt;keep deployment simple&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A database outbox meets those requirements with fewer moving parts.&lt;/p&gt;
&lt;h2&gt;Deprecating The Standalone Repository&lt;/h2&gt;
&lt;p&gt;With email processing integrated into the main application, the standalone &lt;code&gt;Moonglade.Email&lt;/code&gt; Azure Functions repository is deprecated and will be archived.&lt;/p&gt;
&lt;p&gt;Future email notification development will happen in the main Moonglade repository.&lt;/p&gt;
&lt;p&gt;That leaves Moonglade with a simpler model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;one application&lt;/li&gt;
&lt;li&gt;one deployment&lt;/li&gt;
&lt;li&gt;one database&lt;/li&gt;
&lt;li&gt;one configuration surface&lt;/li&gt;
&lt;li&gt;one place to maintain email behavior&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Azure Functions and Azure Storage Queue are good services. They were not the problem.&lt;/p&gt;
&lt;p&gt;The problem was that the architecture had become larger than the product requirement.&lt;/p&gt;
&lt;p&gt;For a personal blog, reliability matters, but simplicity matters too. Moving email notifications from Azure Functions into Moonglade’s own database outbox keeps delivery asynchronous and durable while removing an entire external service from the deployment.&lt;/p&gt;
&lt;p&gt;That is the kind of tradeoff I want Moonglade to make more often.&lt;/p&gt;
</description><category>.NET</category><category>Data Platform</category><category>Microsoft Azure</category><guid isPermaLink="false">3140ee35-bc41-45a5-b0be-eedd41aa7d97</guid><pubDate>2026-08-03T23:38:30Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-08-03T23:38:30Z</updated></item><item><title>Hot Reloading a File in Azure Container Apps with Zero Down Time</title><link>https://edi.wang/post/2026/3/3/hot-reloading-a-file-in-azure-container-apps-with-zero-down-time</link><description>&lt;h2&gt;Background&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Recently, I developed an ASP.NET Core application that can query a local data file for IP address information. The application is deployed to Azure Container Apps, and the data file is mounted in Azure File Share as a volume for the container. There is another job that will update the data file every day on schedule. The application would not read the entire data file on each request for performance concerns, it would load the data into memory on start up. So, the problem is, when the data file is updated, how to reload it in my application?&amp;nbsp;&lt;/p&gt;
&lt;p&gt;The most easy way is to restart the container. But this approach clearly would cause downtime for my app, because there's currently only one instance running. So, I need a way to reload the data on the fly without downtime. Let's see how to do it.&lt;/p&gt;
&lt;h2&gt;The Design&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;The entire design has 3 core files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;QqwryDb&lt;/strong&gt;: Immutable Snapshot of the File&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QqwryDbProvider&lt;/strong&gt;: The Thread-Safe Pointer Swap&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QqwryDbWatcher&lt;/strong&gt;: The Polling Engine&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let me explain them one by one.&lt;/p&gt;
&lt;h3&gt;QqwryDb&lt;/h3&gt;
&lt;p&gt;The entire &lt;code&gt;.dat&lt;/code&gt; file is eagerly read into a &lt;code&gt;byte[]&lt;/code&gt; in the constructor. After construction the object never touches the filesystem again. It is a completely immutable, self-contained snapshot.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public sealed class QqwryDb(string path)
{
    private readonly byte[] _data;
    // ...
    public QqwryDb(string path)
    {
        _data = File.ReadAllBytes(path); // entire file read into memory at construction time
        _indexStart = ReadUInt32LE(0);
        _indexEnd   = ReadUInt32LE(4);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the foundational design decision that makes safe hot-reload possible: once a QqwryDb instance exists, it can be queried concurrently from any number of threads without any locks, because its data never changes.&lt;/p&gt;
&lt;h3&gt;QqwryDbProvider&lt;/h3&gt;
&lt;p&gt;This is the heart of the hot-reload design.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public sealed class QqwryDbProvider
{
    private volatile QqwryDb? _current;
    // ...
    internal void TryReload()
    {
        // Guard 1: file disappeared
        if (!File.Exists(_path)) { _current = null; return; }

        // Guard 2: write time hasn't changed &amp;mdash; skip
        if (_current is not null &amp;amp;&amp;amp; newWriteTime == _lastWriteTime) return;

        // Guard 3: file is suspiciously small &amp;mdash; likely mid-write, skip
        if (fileSize &amp;lt; MinValidFileSizeBytes) return;

        var newDb = new QqwryDb(_path);           // construct new snapshot off to the side
        Interlocked.Exchange(ref _current, newDb); // atomically publish it
        _lastWriteTime = newWriteTime;
    }

    public IpLocation Query(IPAddress ip)
    {
        var db = _current ?? throw ...;
        return db.Query(ip); // reads from the snapshot captured at method entry
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;volatile&lt;/code&gt; on _current ensures every thread always reads the freshest pointer value from main memory, not a CPU-cache copy.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Interlocked.Exchange&lt;/code&gt; makes publishing the new &lt;code&gt;QqwryDb &lt;/code&gt;instance a single atomic operation. There is no window where another thread can see a half-initialized state.&lt;/li&gt;
&lt;li&gt;The new instance is fully constructed before it is published. The call to new &lt;code&gt;QqwryDb(_path)&lt;/code&gt; reads the file and builds all internal state; only then does the swap happen. This means the old instance stays live and queryable right up to the moment the new one takes over.&lt;/li&gt;
&lt;li&gt;The file-size guard (&lt;code&gt;MinValidFileSizeBytes = 1 MB&lt;/code&gt;) handles the classic race between the writer and the reader: If the external process is still writing the new file when the watcher fires, the file will be smaller than a valid database, so the reload is skipped and retried on the next tick.&lt;/li&gt;
&lt;li&gt;The last-write-time guard means the file is only re-read when the OS timestamp actually changed, so normal ticks where nothing happened are completely free.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In-flight requests that captured &lt;code&gt;_current &lt;/code&gt;before the swap continue to use the old &lt;code&gt;QqwryDb &lt;/code&gt;object until they finish. Because &lt;code&gt;QqwryDb &lt;/code&gt;holds its data in a plain managed array, the GC will collect the old instance automatically once no threads hold a reference to it. Zero manual cleanup required!&lt;/p&gt;
&lt;h3&gt;QqwryDbWatcher&lt;/h3&gt;
&lt;p&gt;This is a standard ASP.NET Core BackgroundService. It uses &lt;code&gt;PeriodicTimer&lt;/code&gt;, which is preferable to Timer + callbacks because it is non-reentrant by design. If &lt;code&gt;TryReload()&lt;/code&gt; takes longer than the interval, the next tick simply waits instead of firing a second concurrent reload.&amp;nbsp;The interval is configurable via appsettings.json.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public sealed class QqwryDbWatcher(
    QqwryDbProvider provider,
    IConfiguration configuration,
    ILogger&amp;lt;QqwryDbWatcher&amp;gt; logger) : BackgroundService
{
    private readonly TimeSpan _interval = TimeSpan.FromSeconds(configuration.GetValue("IpDb:ReloadIntervalSeconds", 60));

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(_interval);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                provider.TryReload();
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Failed to reload QQWry database.");
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;DI Registration&lt;/h3&gt;
&lt;p&gt;Finally, register them in DI container. Notice, &lt;code&gt;QqwryDbProvider &lt;/code&gt;is a singleton, there is exactly one pointer in the process, shared by all requests.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;builder.Services.AddSingleton(sp =&amp;gt;
    new QqwryDbProvider(qqwryPath, sp.GetRequiredService&amp;lt;ILogger&amp;lt;QqwryDbProvider&amp;gt;&amp;gt;()));

builder.Services.AddHostedService&amp;lt;QqwryDbWatcher&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Why Polling Instead of FileSystemWatcher?&lt;/h3&gt;
&lt;p&gt;It's because &lt;code&gt;FileSystemWatcher &lt;/code&gt;is unreliable on Linux (which this Docker-targeted app is designed for) and especially inside containers, where bind-mounts and volume drivers do not always propagate &lt;code&gt;INotify &lt;/code&gt;events correctly. A periodic poll with a last-write-time check is simpler, portable, and nearly as fast (the cost of &lt;code&gt;File.GetLastWriteTimeUtc&lt;/code&gt; is acceptable compared to the interval).&lt;/p&gt;
&lt;h3&gt;Summary of the Data Flow&lt;/h3&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;[External updater replaces qqwry.dat on disk]
        &amp;darr;
[QqwryDbWatcher fires every N seconds]
        &amp;darr;
[QqwryDbProvider.TryReload()]
    &amp;rarr; checks last-write-time  (cheap, skip if same)
    &amp;rarr; checks file size        (guard against partial writes)
    &amp;rarr; new QqwryDb(_path)      (reads full file into byte[])
    &amp;rarr; Interlocked.Exchange    (atomic pointer swap)
        &amp;darr;
[All new requests pick up the new QqwryDb]
[In-flight requests finish against the old QqwryDb, then it is GC'd]&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Hot-reloading a binary database file in a live ASP.NET Core can be solved without restarting your server, this three-layer approach of immutable snapshot, atomic pointer swap, background poller is a solid template to do it!&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">9c2d6ca5-03c9-4522-ac56-9d19f601803f</guid><pubDate>2026-03-03T09:37:30Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-03-03T09:37:30Z</updated></item><item><title>Migrating this Blog to Docker on Linux with Azure App Service</title><link>https://edi.wang/post/2026/2/5/migrating-this-blog-to-docker-on-linux-with-azure-app-service</link><description>&lt;p&gt;This blog runs on my open-source project &lt;a href="https://github.com/EdiWang/Moonglade" target="_blank" rel="noopener"&gt;Moonglade&lt;/a&gt;, a blogging platform built with ASP.NET Core and hosted on Azure. Recently, I migrated it from a Windows Server environment to Docker on Linux, still running on &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt;. It&amp;rsquo;s been running smoothly in production for about two months now.&lt;/p&gt;
&lt;p&gt;In this post, I&amp;rsquo;ll walk through that migration journey: what I changed, a few tips and tricks along the way, and some lessons learned that might help you if you&amp;rsquo;re considering a similar move.&lt;/p&gt;
&lt;h2&gt;A Little Bit of History&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;This project has a long history. Back in the summer of 2008, I was a high school graduate getting ready for university. I learned ASP.NET WebForms from a Microsoft MVP and started building my own website using VB.NET and an Access database. At that time, cloud platforms were still very new. I hosted my site on a shared VPS, which ran into issues almost daily because the provider was unprofessional and unreliable.&lt;/p&gt;
&lt;p&gt;In 2012, a Microsoft employee introduced me to Azure, and I became one of the early users of &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt;. The idea of deploying a website without managing a full VM was very new back then. The nice part of using a PaaS service was that I no longer had to worry about the underlying infrastructure. Microsoft managed the VMs for me, so I could focus on building features for my blog engine and writing content. At that time, Azure App Service only supported Windows plans: Windows Server and IIS were the underlying stack running my code. For .NET Framework applications, that was basically the default choice, so my ASP.NET WebForms blog ran on Windows, which was a very typical setup.&lt;/p&gt;
&lt;p&gt;In 2018, with the release of .NET Core 2.1, I rewrote the entire blog system using ASP.NET Core and open-sourced it on GitHub. The final release of this rewrite completed in 2019 on .NET Core 3.0. I wrote two detailed posts about that migration: &amp;ldquo;&lt;a href="/post/2018/10/31/migrating-old-aspnet-applications-to-net-core" target="_blank" rel="noopener"&gt;Migrating Old ASP.NET Applications to .NET Core&lt;/a&gt;&amp;rdquo; and &amp;ldquo;&lt;a href="/post/2019/9/25/this-blog-now-runs-on-net-core-30-and-azure" target="_blank" rel="noopener"&gt;This Blog Now Runs on .NET Core 3.0 and Azure&lt;/a&gt;&amp;rdquo;. However, even after the rewrite, it wasn&amp;rsquo;t truly cross-platform. There were still legacy design choices, like using Windows-style folder paths. On top of that, I wasn&amp;rsquo;t very familiar with Linux, so I didn&amp;rsquo;t feel confident troubleshooting issues if I moved the blog to App Service with Linux. As a result, the blog continued to run on Windows Server behind Azure App Service.&lt;/p&gt;
&lt;p&gt;Later, I made an effort to add Docker support so the system could run on Linux, but with limited functionality. Because it&amp;rsquo;s an open-source project, I can&amp;rsquo;t expect everyone to use the exact same setup I do. Many users prefer to run the blog engine on-premises on Linux machines, and that&amp;rsquo;s where they hit feature gaps: my Linux support just wasn&amp;rsquo;t as complete as on Windows. Ironically, even though my quick deployment script used Linux, my own blog was still running on Windows, which understandably confused new users trying to get started.&lt;/p&gt;
&lt;p&gt;By the end of 2025, I decided it was time to fully move to Docker on Linux and start treating Linux as a first-class citizen in my codebase. That migration was successful, and that&amp;rsquo;s why you&amp;rsquo;re now reading this blog post running on Docker on Linux in Azure App Service. 🤣&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-79a30e46.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Why Migrating to Docker on Linux&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;There are a few reasons I decided to make this migration:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cost: &lt;/strong&gt;Linux plans are typically about half the price of Windows plans on Azure App Service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consistency: &lt;/strong&gt;Docker gives me a consistent environment across all deployments, which greatly reduces production &amp;ldquo;surprises&amp;rdquo;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deployment options: &lt;/strong&gt;With Docker, my blog is no longer tied to Azure App Service only. Users can also run it on Azure Container Apps, AKS, or pretty much any container-friendly platform.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simpler deployment: &lt;/strong&gt;The codebase now uses a single deployment path based on Docker. By dropping the separate &amp;ldquo;code deployment on Windows vs. Linux&amp;rdquo; options, the scripts are easier to maintain and there&amp;rsquo;s less room for bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Faster onboarding: &lt;/strong&gt;Setting up a new Moonglade instance used to be painful, even with automated scripts. Now, with a single docker compose up, users can get a blog up and running in about a minute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sidecar support&lt;/strong&gt;: Azure App Service supports sidecar pattern for Docker deployments, which makes it possible for me to integrate with Phi-4 SLM as sidecar in the future.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That said, this doesn&amp;rsquo;t mean I&amp;rsquo;ve stopped liking Windows Server, or that the blog no longer runs on Windows. Windows is still a supported option, it&amp;rsquo;s just no longer the preferred one.&lt;/p&gt;
&lt;p&gt;Now, Let's walk through the key steps of this migration.&lt;/p&gt;
&lt;h2&gt;0. Prepare the Code for Fully Cross Platform&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;This is a checklist to ensure your code runs on Linux. I spent most of the time refactoring my code and tested it on Linux environments to make sure every feature works.&lt;/p&gt;
&lt;h3&gt;File system paths, separators, and drives&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Remove hardcoded Windows paths ( C:\, D:\, UNC paths like \\server\share).&lt;/li&gt;
&lt;li&gt;Replace backslash path building with &lt;code&gt;Path.Combine(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;Path.DirectorySeparatorChar&lt;/code&gt; only when needed; prefer &lt;code&gt;Path.Combine&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;IWebHostEnvironment.ContentRootPath &lt;/code&gt;/ &lt;code&gt;WebRootPath &lt;/code&gt;as base paths (not &lt;code&gt;AppDomain.CurrentDomain.BaseDirectory&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Ensure paths don&amp;rsquo;t assume drive letters; use mount points or configurable base directories.&lt;/li&gt;
&lt;li&gt;Treat file paths as case-sensitive (e.g.,&amp;nbsp;&lt;code&gt;Images/logo.png&lt;/code&gt; &amp;ne; &lt;code&gt;images/logo.png&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Configuration differences&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Remove dependencies on Windows Registry&lt;/li&gt;
&lt;li&gt;Remove dependencies on &lt;code&gt;machine.config&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;File permissions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Don&amp;rsquo;t write into the application install directory.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;Path.GetTempPath()&lt;/code&gt; instead of hard coded Windows temp path.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Encoding and Time&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Do not rely on Windows only encoding providers.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Do not hard code Windows time zone names like "Pacific Standard Time", use IANA names.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. For Local or On-Prem Deployments: Docker Compose&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;To make it easier for users to spin up Moonglade on their own machine or in an on-prem environment, I chose Docker Compose. With Compose, all the dependencies like database, network, file storage, can be set up with a single command. Users don&amp;rsquo;t need to understand all the details or wire up each piece manually, Docker Compose takes care of that for them.&lt;/p&gt;
&lt;p&gt;The final &lt;code&gt;compose.yaml&lt;/code&gt; file is:&lt;/p&gt;
&lt;pre class="language-yaml"&gt;&lt;code&gt;services:
  web:
    image: ${MOONGLADE_IMAGE:-ediwang/moonglade:latest}
    build:
      context: .
      dockerfile: Dockerfile
    container_name: moonglade-web
    environment:
      ASPNETCORE_ENVIRONMENT: "Production"
      ConnectionStrings__MoongladeDatabase: "Server=moonglade-sqlserver;Database=moonglade;User Id=sa;Password=${MSSQL_SA_PASSWORD:-Work@996};Trusted_Connection=False;TrustServerCertificate=True;"
      ImageStorage__FileSystemPath: "/app/images"
    ports:
      - "8080:8080"
    depends_on:
      - database
    volumes:
      - moonglade-images:/app/images
    networks:
      - moonglade-network
  database:
    image: mcr.microsoft.com/mssql/server:2025-latest
    container_name: moonglade-sqlserver
    environment:
      MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD:-Work@996}"
      MSSQL_PID: "Express"
      ACCEPT_EULA: "Y"
    ports:
      - "1433:1433"
    volumes:
      - mssql-data:/var/opt/mssql
    networks:
      - moonglade-network

volumes:
  mssql-data:
  moonglade-images:

networks:
  moonglade-network:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Image and build&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;image&lt;/code&gt;: The image name is configurable via the &lt;code&gt;MOONGLADE_IMAGE &lt;/code&gt;environment variable.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;MOONGLADE_IMAGE &lt;/code&gt;is not set, it falls back to &lt;code&gt;ediwang/moonglade:latest&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This makes it easy to use a custom image in CI/CD, or just run the default public image locally.&lt;/p&gt;
&lt;h3&gt;Environment variables&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ASPNETCORE_ENVIRONMENT: "Production"&lt;/code&gt;: Runs the app in Production mode (as opposed to Development or Staging). This impacts things like error pages, logging, and configuration behavior.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConnectionStrings__MoongladeDatabase&lt;/code&gt;: This is the standard ASP.NET Core config convention: &lt;code&gt;ConnectionStrings:MoongladeDatabase&lt;/code&gt; in appsettings maps to &lt;code&gt;ConnectionStrings__MoongladeDatabase &lt;/code&gt;in environment variables.&lt;/p&gt;
&lt;h4&gt;About image storage&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ImageStorage__FileSystemPath: "/app/images"&lt;/code&gt;:&amp;nbsp;Tells the app to store images under &lt;code&gt;/app/images&lt;/code&gt; inside the container.&lt;/p&gt;
&lt;p&gt;This blog is storing images with file system by default. Users can configure the location of the image files in &lt;code&gt;appsettings.json&lt;/code&gt; like this.&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;"ImageStorage": {
  "Provider": "filesystem",
  "FileSystemPath": "/app/images"
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Docker deployments, this will be typically mapped to a volume. To enable that, I need to first modify Dockerfile so that it can have correct ownership for image storage.&lt;/p&gt;
&lt;pre class="language-dockerfile"&gt;&lt;code&gt;USER root
RUN mkdir -p /app/images &amp;amp;&amp;amp; chown -R app:app /app/images&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So that this will work in Docker Compose.&lt;/p&gt;
&lt;pre class="language-yaml"&gt;&lt;code&gt;volumes:
  - moonglade-images:/app/images&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This mounts a named volume called &lt;code&gt;moonglade-images&lt;/code&gt; at &lt;code&gt;/app/images&lt;/code&gt; inside the web container.&lt;/p&gt;
&lt;p&gt;It matches the &lt;code&gt;ImageStorage__FileSystemPath &lt;/code&gt;setting, and ensures uploaded images persist even if the container is recreated.&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;Network&lt;/h3&gt;
&lt;p&gt;The web container is attached to the &lt;code&gt;moonglade-network&lt;/code&gt; Docker network (defined later). This is how it can resolve &lt;code&gt;moonglade-sqlserver&lt;/code&gt; as the DB server.&lt;/p&gt;
&lt;h3&gt;Database&lt;/h3&gt;
&lt;p&gt;Uses the official SQL Server container image from the Microsoft Container Registry. Express is free and sufficient for many small/medium workloads like a blog system.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MSSQL_SA_PASSWORD&lt;/code&gt;: Sets the password for the sa login. It reads from the host environment variable &lt;code&gt;MSSQL_SA_PASSWORD &lt;/code&gt;if present.&amp;nbsp;Otherwise falls back to &lt;code&gt;Work@996&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In a real deployment, you should override this with a secure password (e.g., via a secret or environment variable).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mssql-data&lt;/code&gt; is a named volume.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/var/opt/mssql&lt;/code&gt; is where SQL Server stores its data files inside the container.&lt;/p&gt;
&lt;p&gt;This ensures that your database data persists across container restarts. You can update or recreate the database container without losing data.&lt;/p&gt;
&lt;h3&gt;Running Docker Compose&lt;/h3&gt;
&lt;p&gt;Now, users can deploy Moonglade on any machine that has Docker installed with one single command: &lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-dc720651.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Visiting port 8080, you can see Moonglade is now running.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-7a95ebb2.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;You can also use Docker Compose with Azure Container Apps, but in my case it&amp;rsquo;s not the right choice. Azure already provides SQL Database as a fully managed service, which is easier and more secure to use. There&amp;rsquo;s no real benefit in running a SQL Express instance alongside the web container when a managed Azure SQL Database is available.&lt;/p&gt;
&lt;h2&gt;2. For Azure Deployment&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Migrating App Service Instance&lt;/h3&gt;
&lt;p&gt;First, there is a limit: You can not change an App Service from Windows Plan to Linux, you have to re-create a new App Service with a Linux Plan.&lt;/p&gt;
&lt;p&gt;You need to choose "&lt;strong&gt;Container&lt;/strong&gt;" as publish method when creating the App Service. You will no longer be able to change it after creation for now (Feb 2026).&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-9c1684b7.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;The container image configuration can be changed after the initial creation. I publish my image to Docker Hub, the setup is like below.&lt;/p&gt;
&lt;p&gt;Please note, the "&lt;strong&gt;Continuous deployment&lt;/strong&gt;" checkbox will sometimes does not work with Docker Hub, although it seem to work fine with Azure Container Registry. So I would prefer leave this option unselected. If I need to update my production deployment, I will just restart my App Service instance. This will force Azure to check for image update.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-50c88637.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Please also be aware that App Service by default, check for common HTTP port like 80, 443, 8080. In my case, I am using 8080, so the App Service can find my workload automatically. If your container uses other ports, you need to setup it in &lt;code&gt;HTTP_PORT &lt;/code&gt;environment variable.&lt;/p&gt;
&lt;h3&gt;Update Environment Variables&lt;/h3&gt;
&lt;p&gt;This is a key difference between Windows and Linux configuration for an ASP.NET Core application. On Linux, you need to use a double underscore &lt;code&gt;__ &lt;/code&gt;to represent a single colon &lt;code&gt;:&lt;/code&gt; in configuration keys.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-26462b6f.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;About ASP.NET Core Runtime&lt;/h3&gt;
&lt;p&gt;In the past, if you are using FDD on Azure App Service with Windows, the ASP.NET Core runtime will be pre-installed on the underlying web worker VM. You can install a new version in the "Extension" blade when a new release comes even before Microsoft pushes the update to every server farm.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Now, it is not possible and not necessary any more. Because Docker just contains the ASP.NET Core runtime itself when building the container image. In fact, the code is now served by Kestrel instead of IIS. So you don't need to worry about things like IIS magic, ANCM blow up etc.&lt;/p&gt;
&lt;h3&gt;Migrating IIS Magics&lt;/h3&gt;
&lt;p&gt;Previously, a lot of things were handled at the web server level through the &lt;code&gt;web.config&lt;/code&gt; file. In a typical IIS-hosted ASP.NET Core app, you might configure things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Static and dynamic compression (gzip)&lt;/li&gt;
&lt;li&gt;Adding CSP headers&lt;/li&gt;
&lt;li&gt;MIME type registrations&lt;/li&gt;
&lt;li&gt;Removing IIS server headers&lt;/li&gt;
&lt;li&gt;URL rewrite rules&lt;/li&gt;
&lt;li&gt;Reverse proxy via ARR&lt;/li&gt;
&lt;li&gt;HTTPS redirection&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On Linux with Docker, there&amp;rsquo;s no IIS and no &lt;code&gt;web.config&lt;/code&gt;, so these concerns need to be handled differently. You either move them into your application code, or put a proxy in front of the app (like Nginx, or in my case, Azure Front Door) and configure them there instead.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/20260205-e41b980a.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Database&lt;/h3&gt;
&lt;p&gt;In Azure, I don&amp;rsquo;t run SQL Server as another container in the same Docker network. Instead, I use Azure SQL Database, just like before. So the data layer stays exactly the same, and I don&amp;rsquo;t need Docker Compose for the Azure deployment at this point.&lt;/p&gt;
&lt;h3&gt;Networking&lt;/h3&gt;
&lt;p&gt;ASP.NET Core running in Docker needs to enable forwarded headers to correctly read client IP address. In Moonglade, it can be enabled by a simple switch.&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;"ForwardedHeaders": {
  "Enabled": true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typically, in an ASP.NET Core application, you can either setup it in the code or use the &lt;code&gt;ASPNETCORE_FORWARDEDHEADERS_ENABLED=true&lt;/code&gt; environment variable.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Migrating an ASP.NET Core application from Windows to Docker on Linux and running it on Azure App Service involves a few key steps:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remove Windows-specific dependencies in code&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Eliminate assumptions about Windows paths, file systems, IIS integration, or registry access. Make sure the app is truly cross-platform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prepare your Dockerfile (and optional Docker Compose)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Create a Dockerfile with the right base image, permissions, and volume mappings so your data persists across container restarts. Optionally, use Docker Compose for local or on-prem scenarios.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configure Azure App Service for Linux&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Move to an App Service Linux plan, point it to your container image, configure environment variables with the correct naming conventions, and remove any IIS-specific configuration or reliance on &lt;code&gt;web.config&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Once these pieces are in place, your ASP.NET Core app should run smoothly in Docker on Linux in Azure App Service.&lt;/p&gt;
&lt;p&gt;Happy coding!&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">ee9ee084-e170-4a5b-bfa5-16772f7aec53</guid><pubDate>2026-02-05T05:09:58Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2026-02-05T05:09:58Z</updated></item><item><title>程序员的情绪价值：用 C# 构建实时 MIDI 播放器</title><link>https://edi.wang/post/2025/9/25/build-a-c-sharp-midi-player</link><description>&lt;h2&gt;引言&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;在数字音乐的世界里，MIDI（Musical Instrument Digital Interface）就像是音乐的"基因密码"。作为直男程序员，我最近使用.NET 9.0 C# 写了一个命令行 MIDI 播放器，用来增加自己的情绪价值。本文将分析这个有趣的开源项目 &lt;a href="https://github.com/EdiWang/Edi.MIDIPlayer" target="_blank" rel="noopener"&gt;Edi.MIDIPlayer&lt;/a&gt;，它不仅能播放MIDI音乐，还能实时显示每一个音符的"生命轨迹"。&lt;/p&gt;
&lt;p&gt;&lt;video poster="" controls="controls" width="100%" height="auto"&gt;
&lt;source src="https://cdn.edi.wang/web-assets/csharp-sunny-day.mp4" type="video/mp4"&gt;&lt;/video&gt;&lt;/p&gt;
&lt;h2&gt;什么是 MIDI？&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;MIDI 格式介绍&lt;/h3&gt;
&lt;p&gt;MIDI 并不是音频文件，而是一种指令协议，一种面向电子乐器、计算机和其他相关设备的标准通讯协议与数据格式。想象一下，如果音频文件是录制好的演奏录音，那么 MIDI 就是乐谱上的指令。MIDI 协议定义了乐器与乐器、乐器与电脑之间的数字信息传输方式，但它本身并不直接包含声音数据，而是传递&amp;ldquo;事件信息&amp;rdquo;（例如：演奏某个音符、控制某个参数、开始/停止等），例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;何时 按下哪个键（Note On 事件）&lt;/li&gt;
&lt;li&gt;多大力度 按下（Velocity，0-127）&lt;/li&gt;
&lt;li&gt;何时 松开键（Note Off 事件）&lt;/li&gt;
&lt;li&gt;哪个通道 发出声音（Channel，支持16个通道）&lt;/li&gt;
&lt;li&gt;用什么乐器 演奏（Program Change）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MIDI 广泛应用于音乐制作、游戏音效、电子乐器等领域。它的优势在于文件小、易于编辑和实时控制。&lt;/p&gt;
&lt;p&gt;标准 MIDI 文件（SMF，Standard MIDI File）由三部分组成：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1 Header Chunk&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;标记为"MThd"，存储文件类型（0/1/2）、轨道数量、时间单位（Ticks per Quarter Note，TPQN）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2 Track Chunk&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;标记为"MTrk"&lt;/p&gt;
&lt;p&gt;每个 Track 为一个 MIDI 数据流，内容为一组有时间戳标记的 MIDI 事件（例如演奏音符、控件变化）。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;类型0：单一轨道&lt;/li&gt;
&lt;li&gt;类型1：多轨道（常见于编曲，有主控轨+乐器轨）&lt;/li&gt;
&lt;li&gt;类型2：多序列轨道，罕见&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3 MIDI 事件&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;频道消息（Channel Messages）：音符开/关、力度、控制器、乐器选择等&lt;/li&gt;
&lt;li&gt;系统消息（System Messages）：同步、元事件（如歌词、节拍、作者信息等）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;MIDI 文件示例&lt;/h3&gt;
&lt;p&gt;&lt;img src="/image/20250926-5a657558.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;4D 54 68 64    // "MThd" Header
00 00 00 06    // Head Size = 6
00 01          // Type 1 (多轨道)
00 02          // 两个轨道
00 60          // 96 ticks/quarter note

4D 54 72 6B    // "MTrk" Track
00 00 00 30    // Track Size

00 90 3C 64    // DeltaTime=0, Note On, middle C, velocity 100
81 40 80 3C 40 // DeltaTime=192, Note Off, middle C, velocity 64
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好消息是，我们不需要自己 &lt;a href="https://996.icu/" target="_blank" rel="noopener"&gt;996&lt;/a&gt; 实现 MIDI 文件的解析，这个播放器程序使用 &lt;a href="https://github.com/naudio/NAudio" target="_blank" rel="noopener"&gt;NAudio&lt;/a&gt; 库来处理 MIDI 文件。&lt;/p&gt;
&lt;h2&gt;乐理知识的代码实现&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;音符&lt;/h3&gt;
&lt;p&gt;在深入代码之前，我们需要了解一些音乐基础：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;音符命名：C、C#、D、D#、E、F、F#、G、G#、A、A#、B（12个半音）&lt;/li&gt;
&lt;li&gt;八度：每12个半音为一个八度，中央C为C4&lt;/li&gt;
&lt;li&gt;MIDI音符编号：0-127，其中60号是中央C（C4）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在代码中，我们可以看到音符名称的转换逻辑：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;private static readonly Dictionary&amp;lt;int, string&amp;gt; NoteNames = new()
{
    { 0, "C" }, { 1, "C#" }, { 2, "D" }, { 3, "D#" }, { 4, "E" }, { 5, "F" },
    { 6, "F#" }, { 7, "G" }, { 8, "G#" }, { 9, "A" }, { 10, "A#" }, { 11, "B" }
};

public string GetNoteName(int noteNumber)
{
    var octave = (noteNumber / 12) - 1;  // 计算八度
    var note = NoteNames[noteNumber % 12];  // 计算音符名称
    return $"{note}{octave}";
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个算法巧妙地将 MIDI 音符编号转换为我们熟悉的音符名称。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;音符60 &amp;rarr; 60 % 12 = 0 (C), 60 / 12 - 1 = 4 &amp;rarr; "C4"&lt;/li&gt;
&lt;li&gt;音符73 &amp;rarr; 73 % 12 = 1 (C#), 73 / 12 - 1 = 5 &amp;rarr; "C#5"&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;节拍&lt;/h3&gt;
&lt;p&gt;节拍，是乐理中的一个重要概念，用于表示音乐中强弱规律的循环。&lt;/p&gt;
&lt;p&gt;乐谱开头通常会标注一个拍号，比如&amp;ldquo;4/4&amp;rdquo;、&amp;ldquo;3/4&amp;rdquo;，这表示每小节有多少拍（上数字），以及每一拍用了哪种音符（下数字，例如&amp;ldquo;4&amp;rdquo;代表四分音符）。&lt;/p&gt;
&lt;p&gt;在 MIDI 文件中，节拍的相关参数主要通过一种特殊的元事件（Meta Event）来表示，这个事件叫做&amp;ldquo;节拍事件&amp;rdquo;（Set Tempo）和&amp;ldquo;时基（PPQN／TPQN）&amp;rdquo;，&amp;ldquo;但严格来说，拍号（Time Signature）表示的是每小节包含的拍数和每拍的音符类型&amp;rdquo;。&lt;/p&gt;
&lt;p&gt;MIDI 文件内部不直接用秒来表示时间，而是用&amp;ldquo;Tick&amp;rdquo;（时基）单位。Tick数取决于MIDI文件头中设定的&amp;ldquo;PPQN&amp;rdquo;（Parts Per Quarter Note，四分音符每分多少Tick）。例如：如果PPQN设为480，则一个四分音符等于480 Tick。&lt;/p&gt;
&lt;p&gt;通过Meta事件类型 &lt;code&gt;0xFF 0x51&lt;/code&gt;，设置每个四分音符的微秒数，从而间接决定每分钟的节拍数（BPM）。&lt;/p&gt;
&lt;p&gt;拍号/节拍事件用 Meta Event 类型 &lt;code&gt;0xFF 0x58&lt;/code&gt; 表示，一般描述每小节有几拍，每拍是什么音符类型，与乐理中的拍号类似。&lt;/p&gt;
&lt;p&gt;程序里需要将 Tick 转换为实际时间：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public TimeSpan TicksToTimeSpan(long ticks, List&amp;lt;TempoChange&amp;gt; tempoMap, int ticksPerQuarterNote)
{
    var totalMicroseconds = 0.0;
    var currentTick = 0L;

    for (int i = 0; i &amp;lt; tempoMap.Count; i++)
    {
        var tempoChange = tempoMap[i];
        var nextTick = (i + 1 &amp;lt; tempoMap.Count) ? tempoMap[i + 1].Tick : ticks;

        if (nextTick &amp;gt; ticks) nextTick = ticks;

        if (nextTick &amp;gt; currentTick)
        {
            var ticksInThisSegment = nextTick - currentTick;
            var microsecondsPerTick = (double)tempoChange.MicrosecondsPerQuarterNote / ticksPerQuarterNote;
            totalMicroseconds += ticksInThisSegment * microsecondsPerTick;
        }

        currentTick = nextTick;
        if (currentTick &amp;gt;= ticks) break;
    }

    return TimeSpan.FromMilliseconds(totalMicroseconds / 1000.0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个算法处理了 MIDI 中的变速情况&amp;mdash;&amp;mdash;一首曲子中可能有多个速度变化，每个段落都需要用不同的时间计算公式。&lt;/p&gt;
&lt;p&gt;程序还需要构建整个 MIDI 文件的速度变化映射表，查找类型为 MetaEvent 且子类型为 &lt;code&gt;SetTempo&lt;/code&gt; 的事件，并记录速度变化：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;tempoMap.Add(new TempoChange
   {
       Tick = eventInfo.AbsoluteTime,  // 速度变化的时钟位置
       MicrosecondsPerQuarterNote = tempoEvent.MicrosecondsPerQuarterNote  // 新的速度值
   });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这对于准确播放 MIDI 文件至关重要，确保音乐按照正确的速度和节拍进行播放。&lt;/p&gt;
&lt;h2&gt;程序架构解析&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;依赖注入与服务架构&lt;/h3&gt;
&lt;p&gt;这个MIDI播放器采用了现代.NET的最佳实践，使用了依赖注入来管理各个组件：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;private static IHostBuilder CreateHostBuilder(string[] args) =&amp;gt;
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((context, services) =&amp;gt;
        {
            services.AddSingleton&amp;lt;IConsoleDisplay, ConsoleDisplayService&amp;gt;();
            services.AddSingleton&amp;lt;IInputHandler, InputHandlerService&amp;gt;();
            services.AddSingleton&amp;lt;ITempoManager, TempoManagerService&amp;gt;();
            services.AddSingleton&amp;lt;INoteProcessor, NoteProcessorService&amp;gt;();
        });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个系统分为几个核心服务：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ConsoleDisplayService&lt;/code&gt;：负责所有的控制台输出和视觉效果&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TempoManagerService&lt;/code&gt;：处理MIDI的节拍和时间计算&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NoteProcessorService&lt;/code&gt;：处理音符事件的显示逻辑&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MidiPlayerService&lt;/code&gt;：核心播放逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;实时MIDI事件处理&lt;/h3&gt;
&lt;p&gt;程序的核心在于如何准确地按时间播放 MIDI 事件。让我们看看这个精巧的时间控制算法：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;private async Task PlayEventsAsync(List&amp;lt;MidiEventInfo&amp;gt; allEvents, IMidiDeviceWrapper midiDevice, int ticksPerQuarterNote)
{
    var stopwatch = Stopwatch.StartNew();
    var playbackStart = stopwatch.Elapsed;

    foreach (var midiEntry in allEvents)
    {
        // 计算这个事件的预期播放时间
        var expectedTime = playbackStart.Add(tempoManager.TicksToTimeSpan(midiEntry.AbsoluteTime, tempoMap, ticksPerQuarterNote));
        var currentTime = stopwatch.Elapsed;

        // 等待直到该播放这个事件
        var delayNeeded = expectedTime - currentTime;
        if (delayNeeded &amp;gt; TimeSpan.Zero)
        {
            await Task.Delay(delayNeeded);
        }

        ProcessMidiEvent(midiEntry, midiDevice, stopwatch, activeNotes);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的关键是精确的时间同步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用 &lt;code&gt;Stopwatch&lt;/code&gt; 获得高精度计时&lt;/li&gt;
&lt;li&gt;计算每个 MIDI 事件的绝对播放时间&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;Task.Delay&lt;/code&gt; 进行非阻塞等待&lt;/li&gt;
&lt;li&gt;确保音乐按照正确的节拍播放&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;视觉魔法：让音乐可见&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;实时力度&lt;/h3&gt;
&lt;p&gt;最有趣的功能之一是力度条的显示。每个音符的演奏力度（0-127）会被转换为彩色的条形图：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public string CreateVelocityBar(int velocity)
{
    var barLength = 10;
    var filledLength = (int)((velocity / 127.0) * barLength);
    var bar = new StringBuilder();

    for (int i = 0; i &amp;lt; barLength; i++)
    {
        if (i &amp;lt; filledLength)
        {
            if (i &amp;lt; 3) Console.ForegroundColor = ConsoleColor.Green;      // 轻柔
            else if (i &amp;lt; 7) Console.ForegroundColor = ConsoleColor.Yellow; // 中等
            else Console.ForegroundColor = ConsoleColor.Red;              // 强烈
            bar.Append('█');
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.DarkGray;
            bar.Append('░');
        }
    }
    
    return bar.ToString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样，我们就能直观地看到音乐的动态变化：&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;VEL:0x4F ██████░░░░  // 中等力度
VEL:0x7F ██████████  // 最大力度
VEL:0x20 ███░░░░░░░  // 轻柔力度&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;彩色音符显示&lt;/h3&gt;
&lt;p&gt;程序还根据音符类型显示不同颜色：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public ConsoleColor GetNoteColor(int noteNumber)
{
    return (noteNumber % 12) switch
    {
        0 or 2 or 4 or 5 or 7 or 9 or 11 =&amp;gt; ConsoleColor.White,  // 自然音（白键）
        _ =&amp;gt; ConsoleColor.Magenta  // 升降音（黑键）
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个设计模拟了钢琴键盘的布局，白键音符显示为白色，黑键音符显示为紫色。&lt;/p&gt;
&lt;h2&gt;控制台输出的实时同步&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;线程安全的输出&lt;/h3&gt;
&lt;p&gt;由于 MIDI 事件可能来自多个通道，程序使用了锁机制确保输出的一致性：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;lock (consoleDisplay.GetConsoleLock())
{
    Console.Write("[");
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.Write(timestamp);
    Console.ResetColor();
    Console.Write("] ");

    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write("▲ NOTE_ON  ");
    // ... 更多输出逻辑
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;信息密度的平衡&lt;/h3&gt;
&lt;p&gt;每行输出都包含了丰富的信息，但又保持了很好的可读性：&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;[14:23:45.123] ▲ NOTE_ON  C4   CH01 VEL:0x64 ████████░░ │ ACTV: 0x03 │ NOTE: 0x3C
[14:23:45.823] ▼ NOTE_OFF C4   CH01 VEL:0x00 ░░░░░░░░░░ │ ACTV: 0x02 │ NOTE: 0x3C&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行输出告诉我们：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;时间戳：精确到毫秒的播放时间&lt;/li&gt;
&lt;li&gt;事件类型：▲表示按键，▼表示释放&lt;/li&gt;
&lt;li&gt;音符信息：C4 表示中央C&lt;/li&gt;
&lt;li&gt;通道信息：CH01 表示第1通道&lt;/li&gt;
&lt;li&gt;力度信息：0x64 表示中等力度，用条形图可视化&lt;/li&gt;
&lt;li&gt;状态信息：当前活跃音符数量和原始 MIDI 数据&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;这个 MIDI 播放器不仅仅是一个播放工具，更是一个音乐数据的实时可视化器。它让我们能够"看见"音乐的流动，理解 MIDI 数据的结构，体验数字音乐的魅力。&lt;/p&gt;
&lt;p&gt;通过这个项目，我们学到了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MIDI 格式的内部结构&lt;/li&gt;
&lt;li&gt;精确时间控制的编程技巧&lt;/li&gt;
&lt;li&gt;控制台应用的视觉设计&lt;/li&gt;
&lt;li&gt;现代 .NET 的最佳实践&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;无论你是音乐爱好者还是程序员，这个项目都展示了技术与艺术结合的美妙可能性。下次当你听到 MIDI 音乐时，不妨想象一下背后那些精密的数字指令，它们正在控制着每一个音符的生命周期。&lt;/p&gt;
&lt;p&gt;源代码地址：&lt;a href="https://github.com/EdiWang/Edi.MIDIPlayer" target="_blank" rel="noopener"&gt;https://github.com/EdiWang/Edi.MIDIPlayer&lt;/a&gt;&lt;/p&gt;</description><category>.NET</category><guid isPermaLink="false">23bdb83d-04c1-4cb4-acc8-384c59ffdb70</guid><pubDate>2025-09-25T13:27:34Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2025-09-25T13:27:34Z</updated></item><item><title>凑发票算法：经典背包问题的变种</title><link>https://edi.wang/post/2025/2/8/invoice-algorithm-variant-of-the-backpack-problem</link><description>&lt;p&gt;在日常工作中，特别是报销或财务处理时，经常会遇到这样的问题：如何从一堆发票中找到金额满足某个值的最优组合？本文将基于 C#，使用背包问题的思路来卷这个问题，并详细讲解其设计和实现细节。&lt;/p&gt;
&lt;h2&gt;问题&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;假设我们有一组发票，每张发票以文件名的形式存储，文件名包含发票金额，例如&amp;nbsp;&lt;code&gt;酒店-996.35.pdf&lt;/code&gt; &lt;code&gt;打车-251.00&lt;/code&gt;&amp;nbsp;&lt;code&gt;医疗-404.00&lt;/code&gt; 。我们的目标是从这些发票中，找到金额总和 &lt;strong&gt;至少等于目标金额&lt;/strong&gt; 的最优组合，并且总金额尽可能接近目标金额。以最大程度的节省发票以供下次报销使用。&lt;/p&gt;
&lt;p&gt;例如：我需要报销1000元，现有发票如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;酒店-996.00.pdf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;咖喱饭-35.00.pdf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;文具-7.00.pdf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;显然，要凑1000元的发票，最优解是使用 &lt;code&gt;酒店-996.00.pdf&lt;/code&gt; + &lt;code&gt;文具-7.00.pdf&lt;/code&gt; = 1003.00 元，这样的话，&lt;code&gt;咖喱饭-35.00.pdf&lt;/code&gt; 可用于下次报销使用。在文件较少的情况下，肉眼就能找到最优组合，但当文件夹里有几十、上百张发票的时候，人工凑发票就会让你&lt;a href="https://996.icu/" target="_blank" rel="noopener"&gt;996&lt;/a&gt;。&lt;/p&gt;
&lt;h2&gt;算法设计&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;要解决这个问题，我们可以将其看作一个&amp;nbsp;&lt;strong&gt;&lt;a href="https://zh.wikipedia.org/zh-cn/%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98" target="_blank" rel="noopener"&gt;背包问题&lt;/a&gt;的变种&lt;/strong&gt;，即在给定一组发票的情况下，找到满足条件的金额组合。本文的算法采用&amp;nbsp;&lt;strong&gt;回溯法&lt;/strong&gt; 来实现，逐步尝试每种可能的组合，并记录满足条件的最佳结果。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;背包问题（英语：Knapsack problem）是一种组合优化的NP完全问题。问题可以描述为：给定一组物品，每种物品都有自己的重量和价格，在限定的总重量内，我们如何选择，才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中，背包的空间有限，但我们需要最大化背包内所装物品的价值。背包问题通常出现在资源分配中，决策者必须分别从一组不可分割的项目或任务中进行选择，而这些项目又有时间或预算的限制。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;首先，我们定义一个&amp;nbsp;&lt;code&gt;Invoice&lt;/code&gt; 类，用来存储发票的文件名和金额：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;private class Invoice
{
    public string FileName { get; set; }
    public double Amount { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;从当前目录中读取所有 PDF 文件，并将文件名存储到一个列表中：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;// 获取当前目录
string currentDirectory = Path.GetDirectoryName(Util.CurrentQueryPath);

// 获取当前目录下所有PDF文件的文件名
string[] pdfFiles = Directory.GetFiles(currentDirectory, "*.pdf");

// 创建一个List&amp;lt;string&amp;gt;集合来存储文件名
List&amp;lt;string&amp;gt; invoiceFileNames = new List&amp;lt;string&amp;gt;();

// 将每个文件的文件名添加到List&amp;lt;string&amp;gt;集合中
foreach (string file in pdfFiles)
{
    // 只获取文件名，不包括路径
    string fileName = Path.GetFileName(file);
    invoiceFileNames.Add(fileName);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发票的文件名采用了固定的格式，例如 &lt;code&gt;加班福报-996.35.pdf&lt;/code&gt;，我们通过解析文件名来提取金额：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;var invoices = invoiceFileNames.Select(fileName =&amp;gt;
{
    var parts = Path.GetFileNameWithoutExtension(fileName).Split('-');
    return new Invoice
    {
        FileName = fileName,
        Amount = double.Parse(parts[1], CultureInfo.InvariantCulture)
    };
}).ToList();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里我们将文件名按&amp;nbsp;&lt;code&gt;-&lt;/code&gt;&amp;nbsp;分割，提取第二部分作为金额，并将其转换为&amp;nbsp;&lt;code&gt;double&lt;/code&gt; 类型（其实金融计算更适合用&lt;code&gt;decimal&lt;/code&gt;，但不要在意这些细节）&lt;/p&gt;
&lt;p&gt;核心算法部分是一个回溯函数，用来尝试所有可能的发票组合：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;void FindCombination(List&amp;lt;Invoice&amp;gt; currentCombination, int startIndex, double currentSum)
{
    // 如果当前组合的金额满足条件，且比之前的最佳组合更优，则更新最佳组合
    if (currentSum &amp;gt;= targetAmount &amp;amp;&amp;amp; (bestCombination == null || currentSum &amp;lt; bestAmount))
    {
        bestAmount = currentSum;
        bestCombination = new List&amp;lt;Invoice&amp;gt;(currentCombination);
        return;
    }

    // 遍历剩余的发票，尝试加入当前组合
    for (int i = startIndex; i &amp;lt; invoices.Count; i++)
    {
        currentCombination.Add(invoices[i]);
        FindCombination(currentCombination, i + 1, currentSum + invoices[i].Amount);
        currentCombination.RemoveAt(currentCombination.Count - 1); // 回溯
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回溯的核心逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;递归搜索&lt;/strong&gt;：从起始位置开始，逐步尝试将每张发票加入当前组合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;剪枝条件&lt;/strong&gt;：如果当前组合的金额已经满足条件且优于之前的组合，则更新最佳组合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回溯&lt;/strong&gt;：在递归返回时，移除最后一张发票，尝试其他可能的组合。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;找到最佳组合后，我们将结果输出到控制台：&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;if (selectedInvoices != null)
{
    Console.WriteLine($"满足至少{targetAmount}的最优发票组合:");
    Console.WriteLine("----------------------------------");
    
    foreach (var invoice in selectedInvoices)
    {
        Console.WriteLine(invoice.FileName);
    }
    
    Console.WriteLine("----------------------------------");
    Console.WriteLine(selectedInvoices.Sum(p =&amp;gt; p.Amount));
}
else
{
    Console.WriteLine("发票不够");
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;示例运行&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;当前目录有发票文件如下：&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;医疗-404.00.pdf
咖喱饭-35.00.pdf
打车-251.00.pdf
文具-7.00.pdf
酒店-996.00.pdf&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设定目标金额为 290.00 元，程序输出结果为：&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;满足至少290的最优发票组合:
----------------------------------
咖喱饭-35.00.pdf
打车-251.00.pdf
文具-7.00.pdf
----------------------------------
293&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设定目标金额为 1000.00 元，程序输出结果为：&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;满足至少1000的最优发票组合:
----------------------------------
文具-7.00.pdf
酒店-996.00.pdf
----------------------------------
1003&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;直观图示&lt;/h3&gt;
&lt;p&gt;拿文章开头的问题来说，在 &lt;code&gt;酒店-996.00.pdf&lt;/code&gt; &lt;code&gt;咖喱饭-35.00.pdf&lt;/code&gt; &lt;code&gt;文具-7.00.pdf&lt;/code&gt; 中寻找满足1000元的最佳发票组合的过程是将问题转化为一个递归决策树，每个节点表示当前组合的金额总和和发票选择状态。算法从根节点开始，每次递归尝试包含或不包含当前发票，直到遍历所有可能的组合。&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;Start (Sum = 0, Combination = [])
├── Include 酒店-996.00.pdf (Sum = 996.00, Combination = [酒店-996.00.pdf])
│   ├── Include 咖喱饭-35.00.pdf (Sum = 1031.00, Combination = [酒店-996.00.pdf, 咖喱饭-35.00.pdf])
│   │   ├── Include 文具-7.00.pdf (Sum = 1038.00, Combination = [酒店-996.00.pdf, 咖喱饭-35.00.pdf, 文具-7.00.pdf])
│   │   └── Exclude 文具-7.00.pdf (Sum = 1031.00, Combination = [酒店-996.00.pdf, 咖喱饭-35.00.pdf])
│   └── Exclude 咖喱饭-35.00.pdf (Sum = 996.00, Combination = [酒店-996.00.pdf])
│       ├── Include 文具-7.00.pdf (Sum = 1003.00, Combination = [酒店-996.00.pdf, 文具-7.00.pdf]) -&amp;gt; 满足条件，记录最佳组合
│       └── Exclude 文具-7.00.pdf (Sum = 996.00, Combination = [酒店-996.00.pdf])
├── Exclude 酒店-996.00.pdf (Sum = 0, Combination = [])
    ├── Include 咖喱饭-35.00.pdf (Sum = 35.00, Combination = [咖喱饭-35.00.pdf])
    │   ├── Include 文具-7.00.pdf (Sum = 42.00, Combination = [咖喱饭-35.00.pdf, 文具-7.00.pdf])
    │   └── Exclude 文具-7.00.pdf (Sum = 35.00, Combination = [咖喱饭-35.00.pdf])
    └── Exclude 咖喱饭-35.00.pdf (Sum = 0, Combination = [])
        ├── Include 文具-7.00.pdf (Sum = 7.00, Combination = [文具-7.00.pdf])
        └── Exclude 文具-7.00.pdf (Sum = 0, Combination = [])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;算法从金额为 0 的空组合开始。每个分支表示对当前发票的两种选择：&lt;strong&gt;Include&lt;/strong&gt;&amp;nbsp;或&amp;nbsp;&lt;strong&gt;Exclude&lt;/strong&gt;。叶子节点表示所有发票都已处理完成，此时检查组合是否满足条件，即金额 &amp;gt;= 1000 元。如果当前金额已经满足条件，则记录组合并停止进一步递归。&lt;/p&gt;
&lt;p dir="auto"&gt;从递归树中可以看出，满足至少 1000 元的组合有以下路径：&lt;/p&gt;
&lt;ul&gt;
&lt;li dir="auto"&gt;&lt;strong&gt;路径 A&lt;/strong&gt;：&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;包含&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;酒店-996.00.pdf&lt;/code&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;&amp;nbsp;和&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;文具-7.00.pdf&lt;/code&gt;&amp;nbsp;，&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;总金额：996.00 + 7.00 = 1003.00&lt;/span&gt;&lt;/li&gt;
&lt;li dir="auto"&gt;&lt;strong&gt;路径 B&lt;/strong&gt;：&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;包含&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;酒店-996.00.pdf&lt;/code&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;&amp;nbsp;和&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;咖喱饭-35.00.pdf&lt;/code&gt;&amp;nbsp;，&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;总金额：996.00 + 35.00 = 1031.00&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p dir="auto"&gt;最终，算法选择金额最接近目标值的路径A。&lt;/p&gt;
&lt;h2&gt;算法复杂度分析&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;时间复杂度&lt;/strong&gt;：由于回溯法会尝试所有可能的组合，其时间复杂度为&amp;nbsp;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;O(2^n)&lt;/span&gt;&lt;/span&gt;，其中 &lt;span class="katex"&gt;&lt;span class="mord mathnormal"&gt;n&lt;/span&gt;&lt;/span&gt; 是发票的数量。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;空间复杂度&lt;/strong&gt;：递归调用栈的深度为&amp;nbsp;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;O(n)&lt;/span&gt;&lt;/span&gt;。&lt;/p&gt;
&lt;p&gt;虽然时间复杂度较高，但对于发票数量较少的场景（例如几十张发票），该算法依然可以在可接受的时间内完成计算。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;本文通过一个实际的发票金额匹配问题，详细介绍了如何使用 C# 实现一个基于回溯法的凑发票算法。该算法的核心在于递归搜索所有可能的组合，并通过剪枝优化来提高效率。希望本文的讲解能够帮助大家更好地理解回溯法的应用场景，并为实际工作中的类似问题提供参考解决方案。&lt;/p&gt;</description><category>.NET</category><guid isPermaLink="false">394eb470-0f6e-45fe-8416-0e346f3c4fdf</guid><pubDate>2025-02-08T01:59:22Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2025-02-08T01:59:22Z</updated></item><item><title>Understanding the "Efficient Shipping" Interview Question in C#</title><link>https://edi.wang/post/2025/1/28/understanding-the-efficient-shipping-interview-question-in-c-sharp</link><description>&lt;blockquote&gt;
&lt;p&gt;This blog post is assisted by AI&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When preparing for interviews, you'll often come across optimization problems that test both your problem-solving skills and your ability to write efficient code. I have such an interesting programming interview question years ago. The &lt;strong&gt;Efficient Shipping&lt;/strong&gt; problem. Let's break it down and analyze the solution. And to understand the flaws in the first version of my code and how we can improve it.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Problem Statement: Efficient Shipping&lt;/h2&gt;
&lt;hr&gt;
&lt;p dir="auto"&gt;You are tasked with maximizing the number of units that can be shipped using a truck with a limited capacity. Here's how the problem is defined:&lt;/p&gt;
&lt;p dir="auto"&gt;&lt;em&gt;"A warehouse manager needs to create a shipment to fill a truck. All of the products in the warehouse are in boxes of the same size. Each product is packed in some number of units per box. Given the number of boxes the truck can hold, determine the maximum number of units of any mix of products that can be shipped."&lt;/em&gt;&lt;/p&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Example&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;boxes = [1, 2, 3]&lt;/code&gt; (1 box of Product 0, 2 boxes of Product 1, 3 boxes of Product 2)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unitsPerBox = [3, 2, 1]&lt;/code&gt; (Product 0 has 3 units/box, Product 1 has 2 units/box, Product 2 has 1 unit/box)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trucksize = 3&lt;/code&gt;&amp;nbsp;(The truck can carry 3 boxes in total)&lt;/li&gt;
&lt;/ul&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Output&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The maximum number of units that can be shipped is &lt;strong&gt;3+2+2 = 7&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="/image/img-06542cbb-f482-4153-b1b5-276434ab0214.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Issues in the First Version of My Code&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;At the interview, the code I use was:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public static long GetMaxUnits(List&amp;lt;long&amp;gt; boxes, List&amp;lt;long&amp;gt; unitsPerBox, long trucksize)
{
	long result = 0;
	long cap = 0; 
	long max = 0;

	for (int i = 0; i &amp;lt; boxes.Count; i++)
	{
		cap += boxes[i]; 
		if (cap &amp;lt;= trucksize)
		{
			max += boxes[i] * unitsPerBox[i]; 
			if (result &amp;lt; max) result = max;
		}
	}

	return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's one of the solution that you can easily Googled it online, like this &lt;a href="https://www.youtube.com/watch?v=GadpHxosSTw" target="_blank" rel="noopener"&gt;https://www.youtube.com/watch?v=GadpHxosSTw&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;At first glance, the code seems like it's trying to calculate the maximum units by iterating through the list of boxes and adding them to the truck until the capacity is reached. However, there are several&amp;nbsp;&lt;strong&gt;issues&lt;/strong&gt; with this implementation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;No Sorting by Units per Box:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The problem is about maximizing the number of units, but the code does not prioritize products with the highest units per box. Instead, it processes the boxes in the order they appear in the input.&lt;/li&gt;
&lt;li&gt;For example, if&amp;nbsp;&lt;code&gt;unitsPerBox = [1, 3, 2]&lt;/code&gt;, the truck might pick low-value boxes first, leading to a suboptimal result.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Incorrect Capacity Handling (&lt;code&gt;cap&lt;/code&gt;):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The variable&amp;nbsp;&lt;code&gt;cap&lt;/code&gt; tracks how many boxes have been added so far. However, it doesn't handle cases where adding a product would exceed the truck&amp;rsquo;s capacity. The code assumes that all boxes of a product can fit into the truck, which is not always true.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Inefficiency:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The algorithm has a time complexity of O(n), but it doesn't maximize the units shipped because it doesn't sort or prioritize the boxes effectively.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Improved Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;To solve this problem correctly, we need to focus on&amp;nbsp;&lt;strong&gt;greedy optimization&lt;/strong&gt;. The key idea is to always pick boxes with the highest&amp;nbsp;&lt;code&gt;unitsPerBox&lt;/code&gt; first. Here&amp;rsquo;s how we can approach it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Pair Boxes with Units per Box: &lt;/strong&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;Create a list of tuples where each tuple represents a product (&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;boxes[i]&lt;/code&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;,&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;unitsPerBox[i]&lt;/code&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;).&lt;/span&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Sort by Units per Box: &lt;/strong&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;Sort the list in descending order of&amp;nbsp;&lt;/span&gt;&lt;code style="background-color: var(--bs-body-bg); font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;unitsPerBox&lt;/code&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;&lt;strong&gt;Iterate and Fill the Truck: &lt;/strong&gt;&lt;span style="background-color: var(--bs-body-bg); color: var(--bs-body-color); font-size: 0.9rem; font-weight: var(--bs-body-font-weight); text-align: var(--bs-body-text-align);"&gt;Start adding boxes to the truck, prioritizing products with the most units per box. If the truck's capacity is exceeded, take only the number of boxes that fit.&lt;/span&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is now the corrected code:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public static long GetMaxUnits(List&amp;lt;long&amp;gt; boxes, List&amp;lt;long&amp;gt; unitsPerBox, long trucksize)
{
    // Pair boxes with unitsPerBox and sort by unitsPerBox in descending order
    var products = boxes
        .Select((box, index) =&amp;gt; new { Boxes = box, UnitsPerBox = unitsPerBox[index] })
        .OrderByDescending(product =&amp;gt; product.UnitsPerBox)
        .ToList();

    long result = 0;

    foreach (var product in products)
    {
        if (trucksize == 0) break;

        // Take as many boxes as the truck can fit
        long boxesToTake = Math.Min(product.Boxes, trucksize);

        // Add units to the result
        result += boxesToTake * product.UnitsPerBox;

        // Decrease truck capacity
        trucksize -= boxesToTake;
    }

    return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step-by-Step Explanation of the Code&lt;/h3&gt;
&lt;h4&gt;Pairing and Sorting&lt;/h4&gt;
&lt;p&gt;We pair each product's&amp;nbsp;&lt;code&gt;boxes&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;unitsPerBox&lt;/code&gt; into a list of objects.&amp;nbsp;Then, we sort this list in descending order based on&amp;nbsp;&lt;code&gt;unitsPerBox&lt;/code&gt; to prioritize the most valuable products.&lt;/p&gt;
&lt;h4&gt;Iterating Through Products&lt;/h4&gt;
&lt;p&gt;For each product, we calculate how many boxes can be taken without exceeding the truck&amp;rsquo;s capacity. Multiply the number of boxes taken by the&amp;nbsp;&lt;code&gt;unitsPerBox&lt;/code&gt;&amp;nbsp;to calculate the total units added to the truck.&lt;/p&gt;
&lt;h4 dir="auto"&gt;Updating the Truck Capacity&lt;/h4&gt;
&lt;p&gt;After adding boxes of a product, we reduce the truck&amp;rsquo;s remaining capacity (&lt;code&gt;trucksize&lt;/code&gt;).&lt;/p&gt;
&lt;h4 dir="auto"&gt;Breaking Early&lt;/h4&gt;
&lt;p&gt;If the truck is full (&lt;code&gt;trucksize == 0&lt;/code&gt;), we stop processing further products.&lt;/p&gt;
&lt;h3&gt;Complexity Analysis&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sorting:&lt;/strong&gt;&amp;nbsp;Sorting the products by&amp;nbsp;&lt;code&gt;unitsPerBox&lt;/code&gt;&amp;nbsp;takes&amp;nbsp;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;O(n log ⁡n)&lt;/span&gt;&lt;/span&gt;, where&amp;nbsp;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;n&lt;/span&gt;&lt;/span&gt;&amp;nbsp;is the number of products.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Iteration:&lt;/strong&gt;&amp;nbsp;Iterating through the sorted products takes&amp;nbsp;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;O(n)&lt;/span&gt;&lt;/span&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Overall Time Complexity:&lt;/strong&gt; O(n log⁡ n).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Example Walkthrough&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Let&amp;rsquo;s run the example input:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;void Main()
{
	List&amp;lt;long&amp;gt; boxes = [1, 2, 3];
	List&amp;lt;long&amp;gt; unitsPerBox = [3, 2, 1];
	long trucksize = 3;
	
	var result = GetMaxUnits(boxes, unitsPerBox, trucksize);
	Console.WriteLine(result);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;Pair and sort:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Products:&amp;nbsp;&lt;code&gt;[{Boxes = 1, UnitsPerBox = 3}, {Boxes = 2, UnitsPerBox = 2}, {Boxes = 3, UnitsPerBox = 1}]&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;Start filling the truck:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Take 1 box of Product 0 (1&amp;times;3=3&amp;nbsp;units).&lt;/li&gt;
&lt;li&gt;Take 2 boxes of Product 1 (2&amp;times;2=4&amp;nbsp;units).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p dir="auto"&gt;Truck is full. Total units =&amp;nbsp;3+4=7.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;hr&gt;
&lt;p dir="auto"&gt;This problem is a great example of how sorting and greedy algorithms can be applied to solve real-world optimization problems. The corrected solution uses a&amp;nbsp;&lt;strong&gt;greedy approach&lt;/strong&gt; to maximize the number of units shipped. By sorting the products based on their value (units per box) and strategically filling the truck, we ensure the most efficient use of the truck&amp;rsquo;s capacity.&amp;nbsp;&lt;/p&gt;</description><category>.NET</category><guid isPermaLink="false">ed0e743d-a374-46a2-b334-830b36f3da64</guid><pubDate>2025-01-28T01:22:47Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2025-01-28T01:22:47Z</updated></item><item><title>Migrating Moonglade.Email to Azure Communication Service</title><link>https://edi.wang/post/2024/11/25/migrating-moongladeemail-to-azure-communication-service</link><description>&lt;h2&gt;Background&lt;/h2&gt;
&lt;hr&gt;
&lt;p dir="auto"&gt;The email service used by this blog system, &lt;a href="https://github.com/EdiWang/Moonglade.Email" target="_blank" rel="noopener"&gt;Moonglade.Email&lt;/a&gt;, is an Azure Function that originally relied on SMTP for sending emails. I introduced its design in a previous blog post "&lt;a href="/post/2023/2/21/cloud-architecture-design-of-my-email-notification-infrastructure-on-azure" target="_blank" rel="noopener"&gt;Cloud Architecture Design of My Email Notification Infrastructure on Azure&lt;/a&gt;."&amp;nbsp;&lt;/p&gt;
&lt;p dir="auto"&gt;Recently, Microsoft implemented a change that prevents connections to both personal and enterprise Office 365 Outlook mailboxes. The issue stems from the discontinuation of basic authentication due to security concerns. For enterprise M365 users, migrating to the modern OAuth method is straightforward. However, this isn't feasible for personal Outlook.com users since we don't control the server.&lt;/p&gt;
&lt;p dir="auto"&gt;To ensure the email service continues functioning, I needed an alternative method for sending emails. Since my entire system operates on Azure, I decided to integrate &lt;a href="https://learn.microsoft.com/en-us/azure/communication-services/overview?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Communication Services&lt;/a&gt;.&lt;/p&gt;
&lt;p dir="auto"&gt;In this blog post, I will explain how I implemented support for Azure Communication Services in the Moonglade.Email Azure Function.&lt;/p&gt;
&lt;h2&gt;Setup Azure Communication Service&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Create Azure Communication Service&lt;/h3&gt;
&lt;p&gt;In Azure portal, deploy a new Azure Communication Service is super easy. Select your subscription and resource group, give it a name, and choose your desired Data location. Nothing more.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-c80e5ca1-27d2-4722-8168-aa9736e5c88a.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Add Email Communication Service&lt;/h3&gt;
&lt;p&gt;Once the deployment is completed, search "&lt;strong&gt;email&lt;/strong&gt;" in Azure portal, and then create a Email Communication Service&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-e2a92f45-1321-4a2d-95da-26bef125b2c3.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Select the same Data location as your primary Azure Communication Service. In my case, they should be both "United States"&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-9d41691c-bebd-4b43-ad08-944068d9be08.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Try Email&lt;/h3&gt;
&lt;p&gt;Once the Email Communication Service is deployed, go back to your Azure Communication Service instance. Go to "&lt;strong&gt;Try Email&lt;/strong&gt;" menu, then select "&lt;strong&gt;Set up a free Azure subdomain&lt;/strong&gt;" in the "Send email from" dropdown list.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-8ba61c7a-afcd-432a-861c-f9fbada45a9a.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Select the email service that was previously deployed, and hit "&lt;strong&gt;Create + Activate&lt;/strong&gt;".&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-55efd9cb-aa87-4a00-8fb0-4585c3ef7a65.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Once the subdomain is activated, go back and select this domain from the dropdown list. Fill everything else, then you can send a test email.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-65fec23f-912b-42ef-bffb-fc8f6ac3c296.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-af8c4545-66fe-4ca6-8337-7ee0cc2243d7.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Please be noted, the test email from a random subdomain is likely to go to your junk email folder, make sure to check your junk box for that.&lt;/p&gt;
&lt;h3&gt;Bind Custom Domain&lt;/h3&gt;
&lt;p&gt;Now that our test email works, it's time to bind your custom domain to avoid your emails going to user's junk boxes.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;You can do this by following the instructions in "&lt;strong&gt;Domains&lt;/strong&gt;" menu.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-9894293d-6773-4d34-bc6a-af8a268b367a.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Get Connection String&lt;/h3&gt;
&lt;p&gt;Finally, copy the connection string in "&lt;strong&gt;Keys&lt;/strong&gt;" menu. This will be used in our code to call the Azure Communication Service API for sending emails.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-5fe0c4ed-6450-4b82-ba5f-622d19289778.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Implementing the Code&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;C#&amp;nbsp;Sample code in an Azure Function:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class AzureCommunicationSender
{
    public async Task&amp;lt;EmailSendOperation&amp;gt; SendAsync(CommonMailMessage message)
    {
        var connectionString = EnvHelper.Get&amp;lt;string&amp;gt;("AzureCommunicationConnection");
        var senderAddress = EnvHelper.Get&amp;lt;string&amp;gt;("AzureCommunicationSenderAddress");

        var emailClient = new EmailClient(connectionString);

        var emailMessage = new EmailMessage(
            senderAddress: senderAddress,
            content: new EmailContent(message.Subject),
            recipients: new EmailRecipients(message.Receipts.Select(p =&amp;gt; new EmailAddress(p))));

        if (message.BodyIsHtml)
        {
            emailMessage.Content.Html = message.Body;
        }
        else
        {
            emailMessage.Content.PlainText = message.Body;
        }

        var emailSendOperation = await emailClient.SendAsync(WaitUntil.Completed, emailMessage);
        return emailSendOperation;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure to add &lt;code&gt;Azure.Communication.Email&lt;/code&gt; nuget package first.&lt;/p&gt;
&lt;p&gt;The code is pretty straight forward, just one thing to mention. There are two options for sending an email:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;namespace Azure
{
    /// &amp;lt;summary&amp;gt;
    /// Indicates whether the invocation of a long running operation should return once it has
    /// started or wait for the server operation to fully complete before returning.
    /// &amp;lt;/summary&amp;gt;
    public enum WaitUntil
    {
        /// &amp;lt;summary&amp;gt;
        /// Indicates the method should wait until the server operation fully completes.
        /// &amp;lt;/summary&amp;gt;
        Completed,
        /// &amp;lt;summary&amp;gt;
        /// Indicates the method should return once the server operation has started.
        /// &amp;lt;/summary&amp;gt;
        Started
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Azure Communication Service is designed to use a queue, just like how Moonglade.Email is designed. This is a typical cloud native design pattern. If you just want to "fire and forget", set the option to "Started", then your code will finish once the email message is enqueue, no need to wait.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;For me, the fundamental design of Moonglade.Email didn't change, as you can refer to previous blog post "&lt;a href="/post/2023/2/21/cloud-architecture-design-of-my-email-notification-infrastructure-on-azure" target="_blank" rel="noopener"&gt;Cloud Architecture Design of My Email Notification Infrastructure on Azure&lt;/a&gt;.". Azure Communication Service is just another provider besides the original SMTP.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;private async Task SendMessageInternal(CommonMailMessage message)
{
    string sender = "smtp";
    var envSender = EnvHelper.Get&amp;lt;string&amp;gt;("Sender");
    if (!string.IsNullOrWhiteSpace(envSender))
    {
        sender = envSender.ToLower();
    }

    switch (sender)
    {
        case "smtp":
            var response = await message.SendAsync();
            logger.LogInformation($"SMTP response: {response}");
            break;

        case "azurecommunication":
            var result = await message.SendAzureCommunicationAsync();
            logger.LogInformation($"AzureCommunication operation ID: {result.Id}");
            break;

        default:
            throw new InvalidOperationException("Sender not supported");
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, in my case, there are two queue used to send emails now. A message will first go to the original Azure Storage Queue used in Moonglade.Email. The &lt;code&gt;QueueProcessor &lt;/code&gt;then take it out, and decide to use SMTP or Azure Communication Service to process the message. If it is Azure, the email message will then go to another queue internally used by the Azure Communication Service, which is transparent to us.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Azure Communication Service offers a convenient solution for sending email notifications. It's easy to set up and doesn't require complex coding for integration.&amp;nbsp;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">b3b44bf5-0d2c-48ac-a615-309385690f28</guid><pubDate>2024-11-25T02:58:17Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2024-11-25T02:58:17Z</updated></item><item><title>Upload Files to Azure Blob Storage without Azure SDK</title><link>https://edi.wang/post/2024/9/2/upload-files-to-azure-blob-storage-without-azure-sdk</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Typically, we use the &lt;a href="https://learn.microsoft.com/en-us/dotnet/azure/sdk/azure-sdk-for-dotnet?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure SDK&lt;/a&gt; to upload files to &lt;a href="https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Blob Storage&lt;/a&gt;, as it is the easiest method supported by Microsoft. However, there are situations where the Azure SDK might not be available. So, how can we still upload files to Azure Blob Storage in such cases? In this post, I will guide you through the steps to upload files to your Azure Storage Account without using the Azure SDK.&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Azure Blob Storage provides &lt;a href="https://learn.microsoft.com/en-us/rest/api/storageservices/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;REST APIs&lt;/a&gt; that allow clients to perform all kinds of operations. This is the underlying mechanism behind the Azure SDK, there's no magic involved. This means that even without the Azure SDK, we can still use REST APIs to upload files. The basic steps are as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Generates an authorization header for HTTP PUT request.
&lt;ol style="list-style-type: lower-alpha;"&gt;
&lt;li&gt;Constructs a string that includes HTTP verb, content length, content type, date, and other required headers.&lt;/li&gt;
&lt;li&gt;Uses HMAC-SHA256 to sign the string with the account key, then encodes it in base64.&lt;/li&gt;
&lt;li&gt;Please note, things may change when you read this post, please always refer to this &lt;a href="https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key" target="_blank" rel="noopener"&gt;Microsoft official document&lt;/a&gt; for latest updates.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Constructs the necessary headers including the date, version, content-type, and blob type.&lt;/li&gt;
&lt;li&gt;Sends a PUT request to upload the file to Azure Blob Storage.&lt;/li&gt;
&lt;li&gt;Checks the response to determine if the upload was successful.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I've setup a storage account, named&amp;nbsp;&lt;code&gt;test996&lt;/code&gt;, with a container named &lt;code&gt;goodstuff&lt;/code&gt;. Now I need to upload a file on my local disk named &lt;code&gt;myfile.txt&lt;/code&gt; to the container.&lt;/p&gt;
&lt;p&gt;I also need storage account key to generate authentication header in HTTP requests, which can be found in the "&lt;strong&gt;Access keys&lt;/strong&gt;" blade of your Azure Storage Account instance.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-248e46c2-a80b-4e33-bebb-1ad3936b013c.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;C# Example&lt;/h3&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;class Program
{
    static async Task Main(string[] args)
    {
        string accountName = "test996";
        string accountKey = "*******************";
        string filePath = "myfile.txt";
        string containerName = "goodstuff";
        string blobName = "myfile1.txt";
        string contentType = "text/plain";
        string date = DateTime.UtcNow.ToString("R");

        string urlPath = $"/{containerName}/{blobName}";
        string url = $"https://{accountName}.blob.core.windows.net{urlPath}";

        long contentLength = new FileInfo(filePath).Length;

        string authorizationHeader = GenerateAuthorizationHeader(accountName, accountKey, "PUT", contentLength, contentType, date, urlPath);

        Console.WriteLine($"Authorization: {authorizationHeader}");
        Console.WriteLine($"x-ms-date: {date}");

        using (HttpClient client = new HttpClient())
        {
            client.DefaultRequestHeaders.Add("x-ms-date", date);
            client.DefaultRequestHeaders.Add("x-ms-version", "2020-10-02");
            client.DefaultRequestHeaders.Add("Authorization", authorizationHeader);
            client.DefaultRequestHeaders.Add("x-ms-blob-type", "BlockBlob");

            using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                HttpContent content = new StreamContent(fileStream);
                content.Headers.Add("Content-Type", contentType);
                content.Headers.Add("Content-Length", contentLength.ToString());

                HttpResponseMessage response = await client.PutAsync(url, content);

                if (response.StatusCode == System.Net.HttpStatusCode.Created)
                {
                    Console.WriteLine("File uploaded successfully");
                }
                else
                {
                    Console.WriteLine($"File upload blow up sky high: {(int)response.StatusCode}");
                    Console.WriteLine(await response.Content.ReadAsStringAsync());
                }
            }
        }
    }

    static string GenerateAuthorizationHeader(string accountName, string accountKey, string verb, long contentLength, string contentType, string date, string urlPath)
    {
        string stringToSign = $"{verb}\n\n\n{contentLength}\n\n{contentType}\n\n\n\n\n\n\nx-ms-blob-type:BlockBlob\nx-ms-date:{date}\nx-ms-version:2020-10-02\n/{accountName}{urlPath}";
        byte[] key = Convert.FromBase64String(accountKey);
        using (HMACSHA256 hmac = new HMACSHA256(key))
        {
            byte[] signatureBytes = Encoding.UTF8.GetBytes(stringToSign);
            byte[] hash = hmac.ComputeHash(signatureBytes);
            string signedString = Convert.ToBase64String(hash);
            return $"SharedKey {accountName}:{signedString}";
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="/image/img-aa284e54-fc1f-419d-94a3-c8416f0adf54.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Python Example&lt;/h3&gt;
&lt;pre class="language-python"&gt;&lt;code&gt;import hmac
import hashlib
import base64
from datetime import datetime
import os
import requests

def generate_authorization_header(account_name, account_key, verb, content_length, content_type, date, url_path):
    string_to_sign = f"{verb}\n\n\n{content_length}\n\n{content_type}\n\n\n\n\n\n\nx-ms-blob-type:BlockBlob\nx-ms-date:{date}\nx-ms-version:2020-10-02\n/{account_name}{url_path}"
    signed_string = base64.b64encode(hmac.new(base64.b64decode(account_key), string_to_sign.encode('utf-8'), hashlib.sha256).digest()).decode('utf-8')
    authorization_header = f"SharedKey {account_name}:{signed_string}"
    return authorization_header

account_name = 'test996'
account_key = '************'
file_path = 'myfile.txt'
container_name = 'goodstuff'
blob_name = 'myfile.txt'
content_length = str(os.path.getsize(file_path))
content_type = 'text/plain'
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
url_path = f'/{container_name}/{blob_name}'
authorization_header = generate_authorization_header(account_name, account_key, 'PUT', content_length, content_type, date, url_path)

print(f"Authorization: {authorization_header}")
print(f"x-ms-date: {date}")

url = f"https://{account_name}.blob.core.windows.net{url_path}"
headers = {
    'x-ms-date': date,
    'x-ms-version': '2020-10-02',
    'Authorization': authorization_header,
    'Content-Type': content_type,
    'Content-Length': content_length,
    'x-ms-blob-type': 'BlockBlob'
}

with open(file_path, 'rb') as f:
    response = requests.put(url, headers=headers, data=f)

if response.status_code == 201:
    print("File uploaded successfully")
else:
    print(f"File upload blow up sky high: {response.status_code}")
    print(response.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="/image/img-aac7c131-47ab-48cc-a5ab-ef3b2cac2e60.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-40482508-34af-4514-8ffe-234c13153b5c.png" border="0" loading="lazy"&gt;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">216857f2-8b2c-4ffa-be43-3d5c501221e5</guid><pubDate>2024-09-02T01:38:36Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2024-09-02T01:38:36Z</updated></item><item><title>Solving NuGet Package Target Naming Conflict</title><link>https://edi.wang/post/2024/8/15/solving-nuget-package-target-naming-conflict</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;In my ASP.NET Core project, I referenced the NuGet package &lt;code&gt;tinymce.7.3.0.nupkg&lt;/code&gt;, which contains the following &lt;code&gt;build\TinyMCE.targets&lt;/code&gt; file:&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-91a1ed5d-f5c9-4c87-94d8-797505cd4596.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"&amp;gt;
    &amp;lt;ItemGroup&amp;gt;
        &amp;lt;SourceScriptFiles Include="$(MSBuildThisFileDirectory)..\content\scripts\tinymce\**\*.*" /&amp;gt;
    &amp;lt;/ItemGroup&amp;gt;
    &amp;lt;Target Name="CopyScriptsToProject" BeforeTargets="Build"&amp;gt;
        &amp;lt;Copy SourceFiles="@(SourceScriptFiles)" DestinationFolder="$(ProjectDir)\wwwroot\lib\tinymce\%(RecursiveDir)" /&amp;gt;
    &amp;lt;/Target&amp;gt;
&amp;lt;/Project&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After installing this package, the TinyMCE files are copied to the &lt;code&gt;wwwroot\lib\tinymce&lt;/code&gt; directory during the build.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-70f1fb8e-7c92-4b15-9cf4-fdb9f34d5bc4.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;I created a similar NuGet package &lt;code&gt;Moonglade.MonacoEditor&lt;/code&gt; with the following &lt;code&gt;build\Moonglade.MonacoEditor.targets &lt;/code&gt;file:&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-d8e3e76a-cc65-4c30-982f-c9c510585348.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"&amp;gt;
    &amp;lt;ItemGroup&amp;gt;
        &amp;lt;SourceFiles Include="$(MSBuildThisFileDirectory)..\content\monaco\**\*.*" /&amp;gt;
    &amp;lt;/ItemGroup&amp;gt;
    &amp;lt;Target Name="CopyScriptsToProject" BeforeTargets="Build"&amp;gt;
        &amp;lt;Copy SourceFiles="@(SourceFiles)" DestinationFolder="$(ProjectDir)\wwwroot\lib\moonglade-monaco\%(RecursiveDir)" /&amp;gt;
    &amp;lt;/Target&amp;gt;
&amp;lt;/Project&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This package also successfully copies files to &lt;code&gt;wwwroot\lib\moonglade-monaco&lt;/code&gt; during the build.&lt;/p&gt;
&lt;p&gt;However, when both packages are referenced in the same ASP.NET Core project, only the files from &lt;code&gt;Moonglade.MonacoEditor&lt;/code&gt; are copied, and no files from the TinyMCE package are copied to &lt;code&gt;wwwroot\lib&lt;/code&gt;.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;After &lt;a href="https://996.icu/" target="_blank" rel="noopener"&gt;work 996&lt;/a&gt;. I found that in MSBuild, target names must be unique. If two `.targets` files define targets with the same name (e.g., &lt;code&gt;CopyScriptsToProject&lt;/code&gt;), the latter will overwrite the former, resulting in only the files from the last loaded package being copied.&lt;/p&gt;
&lt;p&gt;To resolve this issue, I need to rename the target, ensure each `.targets` file has a unique target name. Since I do not own TinyMCE, I have to rename target in my own &lt;code&gt;Moonglade.MonacoEditor&lt;/code&gt;&amp;nbsp;package.&lt;/p&gt;
&lt;pre class="language-xml"&gt;&lt;code&gt;&amp;lt;Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"&amp;gt;
    &amp;lt;ItemGroup&amp;gt;
        &amp;lt;SourceFiles Include="$(MSBuildThisFileDirectory)..\content\monaco\**\*.*" /&amp;gt;
    &amp;lt;/ItemGroup&amp;gt;
    &amp;lt;Target Name="CopyMonacoScriptsToProject" BeforeTargets="Build"&amp;gt;
        &amp;lt;Copy SourceFiles="@(SourceFiles)" DestinationFolder="$(ProjectDir)\wwwroot\lib\moonglade-monaco\%(RecursiveDir)"
        /&amp;gt;
    &amp;lt;/Target&amp;gt;
&amp;lt;/Project&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, both packages can correctly copy their own files!&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-9363fc32-99cc-4c2c-8d92-0b93617cf3cf.png" border="0" loading="lazy"&gt;&lt;/p&gt;</description><category>.NET</category><guid isPermaLink="false">d08cc05e-fa1f-4621-bfe0-7b12be3bdafd</guid><pubDate>2024-08-15T00:59:33Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2024-08-15T00:59:33Z</updated></item><item><title>How to Add JsonStringEnumConverter in Azure Function Isolated Worker</title><link>https://edi.wang/post/2024/4/8/how-to-add-jsonstringenumconverter-in-azure-function-isolated-worker</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide?WT.mc_id=AZ-MVP-5002809&amp;amp;tabs=windows" target="_blank" rel="noopener"&gt;Azure Function Isolated worker for .NET&lt;/a&gt; replaced its default Json Serializer from &lt;code&gt;Newtonsoft.Json&lt;/code&gt; with &lt;code&gt;System.Text.Json&lt;/code&gt;. This change caused certain &lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-in-process-differences?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;behavior differences&lt;/a&gt;. In my case, I am not able to deserialize an Enum type from a string value.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;My request object is &lt;code&gt;EventLogQueryRequest&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class EventLogQueryRequest
{
    public int Take { get; set; } = 100;

    public MpsEventId[] EventIds { get; set; }

    public Guid? UserId { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MpsEventId &lt;/code&gt;is a C# enum type.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public enum MpsEventId
{
    General = 0,

    Login = 1001,
    Logout = 1002
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When client send request that uses string value for the Enum, an exception will be thrown.&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;{
    "take": 10,
    "eventIds": [
        "Login",
        "CreateEditCard",
        "SubmitReviewCard"
    ],
    "userId": null
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class="language-powershell"&gt;&lt;code&gt;Exception: System.InvalidOperationException: The JSON value could not be converted to MPSFunc.EventLog.MpsEventId. Path: $.eventIds[0] | LineNumber: 3 | BytePositionInLine: 15.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="/image/img-55f2a7b6-7316-40c2-b3c4-c3bfdd54dd99.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;At first, among my search from the Internet, I can Configure &lt;code&gt;JsonSerializerOptions &lt;/code&gt;in &lt;code&gt;Program.cs&lt;/code&gt; in this way. &lt;span style="color: #e03e2d;"&gt;&lt;strong&gt;But unfortunately, this won't work.&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;services.Configure&amp;lt;JsonSerializerOptions&amp;gt;(options =&amp;gt;
{
    options.PropertyNameCaseInsensitive = true;
    options.Converters.Add(new JsonStringEnumConverter())
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;span style="color: #169179;"&gt;&lt;strong&gt;correct way&lt;/strong&gt;&lt;/span&gt; is to use &lt;code&gt;AddJsonOptions()&lt;/code&gt; method just like a normal ASP.NET Core application.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;services.AddControllers()
        .AddJsonOptions(options =&amp;gt; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The complete &lt;code&gt;Program.cs&lt;/code&gt; now looks like this:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;using System.Text.Json.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services =&amp;gt;
    {
        services.AddControllers()
                .AddJsonOptions(options =&amp;gt; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));

        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
    })
    .Build();

host.Run();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, the request can be successfully serialized, the code works like a charm!&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-c8847a46-9881-42d7-8289-a6a223b16552.png" border="0" loading="lazy"&gt;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">17af1693-3a22-4776-ae79-e70dae76b485</guid><pubDate>2024-04-08T01:02:26Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2024-04-08T01:02:26Z</updated></item><item><title>Json Serialization Caveat in Azure Function</title><link>https://edi.wang/post/2024/2/7/json-serialization-caveat-in-azure-function</link><description>&lt;h2&gt;Background&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Creating &lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Functions&lt;/a&gt; using C# and .NET can feel quite familiar to those versed in creating ASP.NET Core Web APIs. Despite the similarities, one key difference lies in the process of JSON serialization, which can present unexpected challenges. In this post, I'll explore the two most common issues developers encounter with JSON in Azure Functions and provide practical solutions to overcome them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Please be noted: This blog post only talks about Azure Function V4 with .NET 6.0 in process worker. Things may change in the future version of Azure Function.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Customize Json Property Name&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Problem&lt;/h3&gt;
&lt;p&gt;Define a class, with &lt;code&gt;JsonPropertyName &lt;/code&gt;attribute, like this:&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;class Product
{
    [JsonPropertyName("displayName")]
    public string Name { get; set; }
    public decimal Price { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What I want is to output the &lt;code&gt;Name &lt;/code&gt;property in Json as "&lt;code&gt;displayName&lt;/code&gt;"&lt;/p&gt;
&lt;p&gt;Now, guess what's the result of this function?&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("Function1")]
public static IActionResult Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");

    var products = new List&amp;lt;Product&amp;gt;
    {
        new() { Name = "Apple", Price = 1.99m },
        new() { Name = "Banana", Price = 2.99m },
        new() { Name = "Cherry", Price = 3.99m }
    };

    return new OkObjectResult(products);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;JsonPropertyName &lt;/code&gt;does not work.&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;[
    {
        "name": "Apple",
        "price": 1.99
    },
    {
        "name": "Banana",
        "price": 2.99
    },
    {
        "name": "Cherry",
        "price": 3.99
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Root cause&lt;/h3&gt;
&lt;p&gt;The underlying issue stems from Azure Functions' reliance on a different serialization engine than one might expect. Instead of utilizing&amp;nbsp;&lt;code&gt;System.Text.Json&lt;/code&gt;, which is common in modern .NET applications, Azure Functions default to the venerable&amp;nbsp;&lt;code&gt;Newtonsoft.Json&lt;/code&gt;&amp;nbsp;package. This package isn't explicitly listed in your project file; rather, it's included as a dependency of the&amp;nbsp;&lt;code&gt;Microsoft.NET.Sdk.Functions&lt;/code&gt; package.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-59718cd9-bfbc-4550-b4d5-fbe5f42e5c03.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Although the coding experience in C# for Azure Functions closely mirrors that of ASP.NET Core, and both may run on .NET 6.0, assumptions about consistency in JSON serialization can lead to confusion. Contrary to ASP.NET Core 6.0, which defaults to&amp;nbsp;&lt;code&gt;System.Text.Json&lt;/code&gt;&amp;nbsp;for serialization, Azure Functions diverge in this aspect. Despite using the same&amp;nbsp;&lt;code&gt;OkObjectResult&lt;/code&gt;&amp;nbsp;class from ASP.NET Core's framework, the serialization process under the hood is handled differently due to the reliance on&amp;nbsp;&lt;code&gt;Newtonsoft.Json&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;#region Assembly Microsoft.AspNetCore.Mvc.Core, Version=2.2.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
// location unknown
// Decompiled with ICSharpCode.Decompiler 8.1.1.7464
#endregion

using Microsoft.AspNetCore.Mvc.Infrastructure;

namespace Microsoft.AspNetCore.Mvc;

//
// Summary:
//     An Microsoft.AspNetCore.Mvc.ObjectResult that when executed performs content
//     negotiation, formats the entity body, and will produce a Microsoft.AspNetCore.Http.StatusCodes.Status200OK
//     response if negotiation and formatting succeed.
[DefaultStatusCode(200)]
public class OkObjectResult : ObjectResult
{
    private const int DefaultStatusCode = 200;

    //
    // Summary:
    //     Initializes a new instance of the Microsoft.AspNetCore.Mvc.OkObjectResult class.
    //
    //
    // Parameters:
    //   value:
    //     The content to format into the entity body.
    public OkObjectResult(object value)
        : base(value)
    {
        base.StatusCode = 200;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Fix&lt;/h3&gt;
&lt;p&gt;Use &lt;code&gt;JsonProperty&lt;/code&gt; from &lt;code&gt;Newtonsoft.Json&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;class Product
{
    [JsonProperty("displayName")]
    public string Name { get; set; }
    public decimal Price { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, the response is what I desired.&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;[
    {
        "displayName": "Apple",
        "price": 1.99
    },
    {
        "displayName": "Banana",
        "price": 2.99
    },
    {
        "displayName": "Cherry",
        "price": 3.99
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Model Binding&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Problem&lt;/h3&gt;
&lt;p&gt;This is a real bug in my blog's email sending function. This code intends to serialize &lt;code&gt;req.Payload&lt;/code&gt; to a string to be inserted in to Azure Storage Queue.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("Enqueue")]
public static async Task&amp;lt;IActionResult&amp;gt; Run(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] EnqueueRequest req,
    ILogger log)
{
    log.LogInformation($"C# HTTP trigger function `Enqueue` processed a request. OriginAspNetRequestId={req.OriginAspNetRequestId}");

    var conn = Environment.GetEnvironmentVariable("moongladestorage");
    var queue = new QueueClient(conn, "moongladeemailqueue");

    var en = new EmailNotification
    {
        DistributionList = string.Join(';', req.Receipts),
        MessageType = req.Type,
        MessageBody = System.Text.Json.JsonSerializer.Serialize(req.Payload, MoongladeJsonSerializerOptions.Default),
    };

    await InsertMessageAsync(queue, en, log);

    return new AcceptedResult();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;EnqueueRequest &lt;/code&gt;class has a &lt;code&gt;Payload &lt;/code&gt;property with &lt;code&gt;object &lt;/code&gt;type.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class EnqueueRequest
{
    public string Type { get; set; }
    public string[] Receipts { get; set; }
    public object Payload { get; set; }
    public string OriginAspNetRequestId { get; set; } = Guid.Empty.ToString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But in runtime, the &lt;code&gt;MessageBody &lt;/code&gt;never get the correct Json string.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;The values in the original &lt;code&gt;req &lt;/code&gt;object are removed after serialization.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;It intends to be:&lt;/p&gt;
&lt;pre class="language-json"&gt;&lt;code&gt;{
  "name": "Fubao",
  "price": 996.35
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But it just become this:&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;"{\r\n  \"name\": [],\r\n  \"price\": []\r\n}"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="/image/img-2ca4020d-7a0c-4833-995a-fab253a662c9.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Root cause&lt;/h3&gt;
&lt;p&gt;Again, this is because the model binding in Azure Function is also using &lt;code&gt;Newtonsoft.Json&lt;/code&gt; instead of &lt;code&gt;System.Text.Json&lt;/code&gt; like many will assume it to be. In this case, I use &lt;code&gt;object &lt;/code&gt;type for &lt;code&gt;Payload &lt;/code&gt;property. This will become &lt;code&gt;Newtonsoft.Json.Linq.Object&lt;/code&gt;, which &lt;code&gt;System.Text.Json.JsonSerializer&lt;/code&gt; has no idea how to serialize it correctly.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-4cf66652-94dd-4a65-bc4c-689ffb193d47.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Fix&lt;/h3&gt;
&lt;p&gt;Change the code to use &lt;code&gt;JsonConvert&lt;/code&gt; from &lt;code&gt;Newtonsoft.Json&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;MessageBody = JsonConvert.SerializeObject(req.Payload)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now it works fine&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-2e9702f9-6715-4bed-91e8-2e9c92929414.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;While C# Azure Functions and ASP.NET Core share many similarities in their development models, there's a significant difference in how they handle JSON serialization and deserialization. Azure Functions, by default, use&amp;nbsp;&lt;code&gt;Newtonsoft.Json&lt;/code&gt;&amp;nbsp;for both serializing response objects and deserializing incoming request data during model binding. This is a critical point of consideration for developers who might be accustomed to&amp;nbsp;&lt;code&gt;System.Text.Json&lt;/code&gt;, which is the default in ASP.NET Core. Special attention is needed when working with Azure Functions to ensure that any custom serialization settings or attributes are compatible with&amp;nbsp;&lt;code&gt;Newtonsoft.Json&lt;/code&gt;, and developers should be cautious when attempting to use&amp;nbsp;&lt;code&gt;System.Text.Json&lt;/code&gt;&amp;nbsp;in scenarios where Azure Functions implicitly expect&amp;nbsp;&lt;code&gt;Newtonsoft.Json&lt;/code&gt;.&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">18fa315a-0e55-426e-9466-59db706836bc</guid><pubDate>2024-02-07T08:35:05Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2024-02-07T08:35:05Z</updated></item><item><title>How to Detect If Your Application is Running in Azure App Service</title><link>https://edi.wang/post/2023/12/14/how-to-detect-if-your-application-is-running-in-azure-app-service</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;An application needs to know if itself is running in &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt;, so that it can apply special logic and optimization for Azure.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;When deploying an application to &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt;, the platform automatically assigns a set of environment variables that can be accessed by the application code.&lt;/p&gt;
&lt;p&gt;To view the complete list of environment variables applicable to your application at runtime, you can navigate to the "&lt;strong&gt;Advanced Tools&lt;/strong&gt;" blade within your App Service Instance.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-f98c0164-08c7-44e9-8621-43db4cc98e40.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Then, go to "&lt;strong&gt;Environment&lt;/strong&gt;" page. You will see the entire list here. Among them, are a few environment variables that are suitable for identify if the application is running on &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WEBSITE_SKU&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WEBSITE_SITE_NAME&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WEBSITE_HOSTNAME&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="/image/img-47b044e9-8456-4072-b22c-63f256fed3df.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Now, in our code, we can try to get the values of those environment variables, example code for ASP.NET Core Razor Page is like:&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;&amp;lt;tr&amp;gt;
    &amp;lt;td&amp;gt;@SharedLocalizer["Azure App Service"]&amp;lt;/td&amp;gt;
    &amp;lt;td class="text-muted"&amp;gt;
        WEBSITE_SKU : @Environment.GetEnvironmentVariable("WEBSITE_SKU") &amp;lt;br /&amp;gt;
        WEBSITE_SITE_NAME: @Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME") &amp;lt;br /&amp;gt;
        WEBSITE_HOSTNAME : @Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME") &amp;lt;br /&amp;gt;
        REGION_NAME  : @Environment.GetEnvironmentVariable("REGION_NAME") &amp;lt;br /&amp;gt;
    &amp;lt;/td&amp;gt;
&amp;lt;/tr&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example result:&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-6655443e-a6f3-43b3-a7cb-8abab61ea1db.png" border="0" loading="lazy"&gt;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">34230828-8408-4830-8972-c9dd22f8eec7</guid><pubDate>2023-12-14T08:34:41Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2023-12-14T08:34:41Z</updated></item><item><title>Call API with Windows Authentication from Blazor Web Assembly</title><link>https://edi.wang/post/2023/10/20/call-api-with-windows-authentication-from-blazor-web-assembly</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;I was rewriting an old Angular application to Blazor Web Assembly a couple of days ago. The App is an internal tool, which uses a backend API that has Windows Authentication. In Angular, we used to add &lt;code&gt;withCredentials: true&lt;/code&gt; to HTTP interceptor to make it work. The most equivalent method in .NET is typically adding &lt;code&gt;UseDefaultCredentials = true&lt;/code&gt; in HttpClient. However, Blazor WASM will gives you an exception for this as indicated in &lt;a href="https://stackoverflow.com/questions/56542696/using-blazor-client-side-consuming-web-api-with-windows-authentication" target="_blank" rel="noopener"&gt;this StackOverflow thread&lt;/a&gt;: WASM: System.PlatformNotSupportedException: System.Net.Http.HttpClientHandler is not supported on the current platform.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;The majority of internet search result so far tell you it's not possible to call Windows Authentication API from Blazor. Really? Let's see how to make it work with a few lines of code.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Approach A&lt;/h3&gt;
&lt;p&gt;The original&amp;nbsp;&lt;a href="https://stackoverflow.com/questions/56542696/using-blazor-client-side-consuming-web-api-with-windows-authentication" target="_blank" rel="noopener"&gt;StackOverflow thread&lt;/a&gt; has 2 answers currently. The answer by &lt;a href="https://stackoverflow.com/users/27754/ca8msm"&gt;ca8msm&lt;/a&gt; works, but the cost is not to use &lt;code&gt;HttpClient&lt;/code&gt;, that we need to do everything manually in order to add &lt;code&gt;BrowserRequestCredentials.Include&lt;/code&gt; to the request header. This approach sacrifices what we love about the &lt;code&gt;HttpClient&lt;/code&gt;, but gives you ultimate control over the http request details.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;string APIURL = "https://localhost:44390/api/models";

// create request object and pass windows authentication credentials
var request = new HttpRequestMessage(HttpMethod.Get, APIURL);
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);

// send the request and convert the results to a list
var httpResponse = await Http.SendAsync(request);
models = await httpResponse.Content.ReadFromJsonAsync&amp;lt;myModel[]&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Approach B&lt;/h3&gt;
&lt;p&gt;I come up with a better approach, which can keep the &lt;code&gt;HttpClient &lt;/code&gt;we already like.&lt;/p&gt;
&lt;p&gt;First, add a custom &lt;code&gt;DelegatingHandler&lt;/code&gt;, this is used for adding &lt;code&gt;BrowserRequestCredentials.Include&lt;/code&gt; to each request.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;using Microsoft.AspNetCore.Components.WebAssembly.Http;

public class CredentialsMessageHandler : DelegatingHandler
{
    protected override Task&amp;lt;HttpResponseMessage&amp;gt; SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
        return base.SendAsync(request, cancellationToken);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Second, create your typed client as usual. I prefer typed client, you may also use &lt;code&gt;HttpClientFactory&lt;/code&gt;, it doesn't matter. You can find more information about how to make http request in best practice in &lt;a href="https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;this document&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class CardClient
{
    private readonly HttpClient http;

    public CardClient(HttpClient http)
    {
        // ...
        this.http = http;
    }

    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, register the client in DI, with the &lt;code&gt;CredentialsMessageHandler&lt;/code&gt; we wrote in the first step.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;builder.Services.AddHttpClient&amp;lt;CardClient&amp;gt;(client =&amp;gt; client.BaseAddress = new Uri(ApiEndpoint.FullUrl)).AddHttpMessageHandler&amp;lt;CredentialsMessageHandler&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, you can still use the &lt;code&gt;HttpClient&lt;/code&gt; the way you always been using it with Windows Authentication enabled API!&lt;/p&gt;
&lt;p&gt;BTW, Why I am not answering this in StackOverflow? Because I lost access to my account years ago :(&lt;/p&gt;</description><category>Web Development</category><category>.NET</category><guid isPermaLink="false">9c4d64ca-5b5b-432e-8ae5-4c06125658be</guid><pubDate>2023-10-20T07:17:07Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2023-10-20T07:17:07Z</updated></item><item><title>How Azure App Service for Linux Runs Your .NET Application</title><link>https://edi.wang/post/2023/9/1/how-azure-app-service-for-linux-runs-your-net-application</link><description>&lt;h2&gt;Abstract&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/app-service/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service&lt;/a&gt; expanded its support to &lt;a href="https://azure.microsoft.com/en-us/blog/general-availability-of-app-service-on-linux-and-web-app-for-containers/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;include Linux and containers&lt;/a&gt; in 2017. This update brought similar functionalities to the classic App Service on Windows Server. Both versions allow you to run various types of applications, such as .NET, Java, NodeJS, PHP, and Ruby, with features like continuous integration and deployment (CI/CD) and automatic scaling. To illustrate the workings of Azure App Service, this post will focus on an ASP.NET Core application as an example.&lt;/p&gt;
&lt;h2&gt;A Simple Test&amp;nbsp;&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;I created a new web app using &lt;strong&gt;Code &lt;/strong&gt;deployment on &lt;strong&gt;Linux&lt;/strong&gt;. Notice, I am NOT choosing Docker Container here.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-95294143-4b6b-45b9-8981-9a10fa57f0e3.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Code vs Docker Container&lt;/h3&gt;
&lt;p&gt;The difference between &lt;strong&gt;Code &lt;/strong&gt;and &lt;strong&gt;Docker Container&lt;/strong&gt; is that Code deployment doesn't require you to have a &lt;code&gt;Dockerfile&lt;/code&gt; and build your docker image before publishing, while Docker Container requires your image to be ready before publishing to Azure App Service, which means you need to write a &lt;code&gt;Dockerfile&lt;/code&gt; for your website and also build the image, push it to Docker Hub or other container registries.&lt;/p&gt;
&lt;p&gt;A typical ASP.NET Core application that uses Docker will have a &lt;code&gt;Dockerfile &lt;/code&gt;look like this:&lt;/p&gt;
&lt;pre class="language-dockerfile"&gt;&lt;code&gt;FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
LABEL maintainer="edi.wang@outlook.com"
LABEL repo="https://github.com/EdiWang/Elf"

WORKDIR /app
EXPOSE 80
EXPOSE 443

ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["Elf.Api/Elf.Api.csproj", "Elf.Api/"]
RUN dotnet restore "Elf.Api/Elf.Api.csproj"
COPY . .
WORKDIR "/src/Elf.Api"
RUN dotnet build "Elf.Api.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "Elf.Api.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Elf.Api.dll"]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And, because you need to build your own image when using Docker Container option, this means the Runtime stack is also included in your image, so Azure won't let you choose the program runtime here. When use Code deployment, you only need to build the code, in my case, I am using ASP.NET Core 6.0, Azure manages the runtime for me, so Azure will need to setup the ASP.NET Core 6.0 runtime ready to run my code.&lt;/p&gt;
&lt;h3&gt;Detecting container&lt;/h3&gt;
&lt;p&gt;This application I wrote is just to detect if the current process is running in Docker as mentioned in &lt;a href="https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables?WT.mc_id=AZ-MVP-5002809#dotnet_running_in_container-and-dotnet_running_in_containers?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;this Microsoft document&lt;/a&gt;.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var isInDocker = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") ?? false.ToString();

app.MapGet("/", () =&amp;gt; $"DOTNET_RUNNING_IN_CONTAINER: {isInDocker}");

app.Run();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And it doesn't have a &lt;code&gt;Dockerfile&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-44fa1090-a0b8-4a7b-bed0-653b507d19a1.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;When running on my local machine without docker, it shows&amp;nbsp;&lt;code&gt;False&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-f63c285a-d499-4d0b-b707-eb8204745f17.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Now, I publish this code to the Azure App Service Linux instance I created before from Visual Studio.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-97cbdb4f-0db3-4f57-b339-15265537ed6d.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;As the output indicates, this ASP.NET Core application is running in Docker.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-c8f5394a-aa25-4425-9ccc-96f12f0dfd60.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;As we can see, even if I did not choose "Docker Container" when creating Azure App Service, my code still runs in Docker. I don't have to build a Docker image for my application, I don't need to learn Docker, I don't even need to be aware of the Docker underlying, Azure manages the runtime for me.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Explore the underlying Docker infrastructure&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;To future explore how it works. I use the &lt;a href="https://learn.microsoft.com/en-us/azure/app-service/resources-kudu?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Kudu tool&lt;/a&gt; that integrated in every Azure App Service resource.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-83fc6b88-1e2c-4b3f-ad98-bb93cd015d32.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Logs&lt;/h3&gt;
&lt;p&gt;From the landing page, I can download "Current Docker logs".&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-fbdeb459-1253-401d-a10a-db44b87ef996.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;In the "&lt;strong&gt;Environment&lt;/strong&gt;" menu, I can also find a few information about the Docker version Microsoft is using, and even Azure App Service's SSH password.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-6008c430-7fe6-4824-9b77-e6b14288ef56.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-0b722006-2a4c-40a3-81e8-31c07b9e0b90.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Docker log file shows how Azure App Service is using Docker to run my application.&lt;/p&gt;
&lt;pre class="language-plaintext"&gt;&lt;code&gt;2023-08-23T01:47:56.969Z INFO  - Logging is not enabled for this container.
Please use https://aka.ms/linux-diagnostics to enable logging to see container logs here.
2023-08-23T01:48:04.719Z INFO  - Initiating warmup request to container linux996_3_f13b8c09 for site linux996
2023-08-23T01:48:24.563Z INFO  - Container linux996_3_f13b8c09 for site linux996 initialized successfully and is ready to serve requests.
2023-08-23T01:48:25.195Z INFO  - Starting container for site
2023-08-23T01:48:25.213Z INFO  - docker run -d --expose=8080 --name linux996_4_ed97a4fb -e WEBSITE_SITE_NAME=linux996 -e WEBSITE_AUTH_ENABLED=False -e WEBSITE_ROLE_INSTANCE_ID=0 -e WEBSITE_HOSTNAME=linux996.azurewebsites.net -e WEBSITE_INSTANCE_ID=90f7789cd4cc1d5a4fe2a8c0e9db0d4e3d0c73856fa77c2d8c30202608ce99f6 -e ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=Microsoft.ApplicationInsights.StartupBootstrapper -e DOTNET_STARTUP_HOOKS=/agents/core/StartupHook/Microsoft.ApplicationInsights.StartupHook.dll -e WEBSITE_USE_DIAGNOSTIC_SERVER=True appsvc/dotnetcore:6.0_20230726.2.tuxprod dotnet DockerDetect.dll 

2023-08-23T01:48:25.214Z INFO  - Logging is not enabled for this container.
Please use https://aka.ms/linux-diagnostics to enable logging to see container logs here.
2023-08-23T01:48:33.368Z INFO  - Initiating warmup request to container linux996_4_ed97a4fb for site linux996
2023-08-23T01:48:45.009Z INFO  - Container linux996_4_ed97a4fb for site linux996 initialized successfully and is ready to serve requests.&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Docker image&lt;/h3&gt;
&lt;p&gt;From the log, I can see it is using a Docker image named&amp;nbsp;&lt;strong&gt;appsvc/dotnetcore&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This used to be published in Docker Hub (&lt;a href="https://hub.docker.com/r/appsvc/dotnetcore/tags"&gt;https://hub.docker.com/r/appsvc/dotnetcore/tags&lt;/a&gt;), but it hasn't been updated for 2 years. So is its code on GitHub (&lt;a href="https://github.com/Azure-App-Service/dotnetcore)"&gt;https://github.com/Azure-App-Service/dotnetcore)&lt;/a&gt;. The tag from the log is &lt;strong&gt;6.0_20230726.2.tuxprod&lt;/strong&gt;, it isn't on the outdated Docker Hub image. After some digging, it is moved to Microsoft Artifact Registry (mcr.microsoft.com), which can be pulled from anywhere:&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;docker pull mcr.microsoft.com/appsvc/dotnetcore:6.0_20230726.2.tuxprod&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How does it run my code&lt;/h3&gt;
&lt;p&gt;This &lt;strong&gt;appsvc/dotnetcore&lt;/strong&gt; image is only the ASP.NET Core 6.0 runtime, it doesn't include the application files used by Azure customers. My code isn't in the image, how does it run my application?&lt;/p&gt;
&lt;p&gt;From the log above, I can see a parameter that starts my application&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;dotnet DockerDetect.dll &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the exact same command used by .NET runtime to run any ASP.NET Core application.&lt;/p&gt;
&lt;p&gt;Now, go deeper into the image itself to find it's&amp;nbsp;&lt;code&gt;Entrypoint&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;docker inspect mcr.microsoft.com/appsvc/dotnetcore:6.0_20230726.2.tuxprod&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gives&amp;nbsp;&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;"WorkingDir": "/home/site/wwwroot",
"Entrypoint": [
    "/bin/init_container.sh"
],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also shows how "&lt;code&gt;DOTNET_RUNNING_IN_CONTAINER&lt;/code&gt;" environment variable is set to &lt;code&gt;true&lt;/code&gt;, which is what I used to determine a Docker environment in the previous section.&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;"Env": [
    "PATH=/opt/dotnetcore-tools:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/site/wwwroot",
    "ASPNETCORE_URLS=",
    "DOTNET_RUNNING_IN_CONTAINER=true",
    "USER_DOTNET_AI_VERSION=2.8.42",
    "ORYX_AI_CONNECTION_STRING=InstrumentationKey=4aadba6b-30c8-42db-9b93-024d5c62b887",
    "DOTNET_VERSION=6.0",
    "ASPNETCORE_LOGGING__CONSOLE__DISABLECOLORS=true",
    "PATH_CA_CERTIFICATE=/etc/ssl/certs/ca-certificate.crt",
    "LANG=C.UTF-8",
    "LANGUAGE=C.UTF-8",
    "LC_ALL=C.UTF-8",
    "CNB_STACK_ID=oryx.stacks.skeleton",
    "PORT=8080",
    "SSH_PORT=2222",
    "WEBSITE_ROLE_INSTANCE_ID=localRoleInstance",
    "WEBSITE_INSTANCE_ID=localInstance",
    "ASPNETCORE_FORWARDEDHEADERS_ENABLED=true",
    "HOME=/home"
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run the image on my local machine to see the content of&amp;nbsp;&lt;code&gt;init_container.sh&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;docker run -it --rm --entrypoint "bash" mcr.microsoft.com/appsvc/dotnetcore:6.0_20230726.2.tuxprod&lt;/code&gt;&lt;/pre&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;cat /bin/init_container.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From this file, we can see the startup command location&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;startupCommandPath="/opt/startup/startup.sh"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this file is created on the fly, so we can't &lt;code&gt;cat &lt;/code&gt;this currently. It is generated by &lt;code&gt;generate_and_execute_startup_script.sh&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;# Dockerfile copies generate_and_execute_startup_script.sh from /templates/startup_scripts directory based on assetDirs in tuxib.yml
source /bin/generate_and_execute_startup_script.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This file is simple. We can see it is calling &lt;a href="https://github.com/microsoft/Oryx" target="_blank" rel="noopener"&gt;Oryx&lt;/a&gt; to generate startup command.&amp;nbsp;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Oryx is a build system which automatically compiles source code repos into runnable artifacts. It is used to build web apps for&amp;nbsp;&lt;a href="https://azure.microsoft.com/services/app-service/?WT.mc_id=AZ-MVP-5002809"&gt;Azure App Service&lt;/a&gt; and other platforms.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;#!/bin/bash

oryxArgs="create-script -appPath $appPath -output $startupCommandPath -defaultAppFilePath $defaultAppPath \
    -bindPort $PORT -bindPort2 '$HTTP20_ONLY_PORT' -userStartupCommand '$userStartupCommand' $runFromPathArg"

echo "Running oryx $oryxArgs"
eval oryx $oryxArgs
exec $startupCommandPath&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I don't want to &lt;a href="https://996.icu/" target="_blank" rel="noopener"&gt;work 996&lt;/a&gt; to test the script generation on my local environment. Instead, the generated script can be found from the web SSH in Kudu.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-77af18a7-71a8-44e7-99b2-640172676a98.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;As you can see, the script is finally calling &lt;code&gt;dotnet DockerDetect.dll&lt;/code&gt;, which is from the &lt;code&gt;docker run&lt;/code&gt; parameter that Azure provided.&lt;/p&gt;
&lt;h3&gt;What else is in the image&lt;/h3&gt;
&lt;p&gt;Labels that show its build information.&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;"Labels": {
    "com.microsoft.oryx.build-number": "20230721.1",
    "com.microsoft.oryx.git-commit": "e01b34550f75a38df92af2041687cb2b0ad41ec0",
    "com.microsoft.oryx.release-tag-name": "20230721.1",
    "io.buildpacks.stack.id": "oryx.stacks.skeleton",
    "maintainer": "Azure App Services Container Images &amp;lt;appsvc-images@microsoft.com&amp;gt;"
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A bunch of .NET tools are also available inside the image. App Service can use these tools to monitor and trouble shoot the application.&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;root@55a2f2c9e303:/opt# ls
dotnetcore-tools  startup  startupcmdgen
root@55a2f2c9e303:/opt# cd dotnetcore-tools/
root@55a2f2c9e303:/opt/dotnetcore-tools# ls -la
total 856
drwxr-xr-x 3 root root   4096 Jul 21 01:34 .
drwxr-xr-x 1 root root   4096 Jul 26 11:53 ..
drwxr-xr-x 9 root root   4096 Jul 21 01:34 .store
-rwxr-xr-x 1 root root 142840 Jul 21 01:34 dotnet-counters
-rwxr-xr-x 1 root root 142840 Jul 21 01:33 dotnet-dump
-rwxr-xr-x 1 root root 142840 Jul 21 01:34 dotnet-gcdump
-rwxr--r-- 1 root root 142840 Jul  6 20:17 dotnet-monitor
-rwxr-xr-x 1 root root 142840 Jul 21 01:33 dotnet-sos
-rwxr-xr-x 1 root root 142840 Jul 21 01:33 dotnet-trace&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;.NET runtime is located in &lt;code&gt;/usr/share/dotnet&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;root@55a2f2c9e303:/usr/share/dotnet# ls -la
total 244
drwxrwxr-x 4 root root   4096 Jun 20 19:39 .
drwxr-xr-x 1 root root   4096 Jul 26 11:53 ..
-rw-rw-r-- 1 root root   1116 Jun 20 19:31 LICENSE.txt
-rw-rw-r-- 1 root root  78479 Jun 20 19:31 ThirdPartyNotices.txt
-rwxr-xr-x 1 root root 141760 Jun 20 19:33 dotnet
drwxrwxr-x 3 root root   4096 Jun 20 19:39 host
drwxrwxr-x 4 root root   4096 Jul 21 01:34 shared&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Default application that shows a welcome page of a new created App Service instance is located in &lt;code&gt;/defaulthome/hostingstart&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-bash"&gt;&lt;code&gt;root@55a2f2c9e303:/defaulthome/hostingstart# ls -la
total 296
drwxr-xr-x 1 root root   4096 Jul 26 11:53 .
drwxr-xr-x 1 root root   4096 Jul 26 11:53 ..
-rwxr-xr-x 1 root root 142760 May 10  2021 hostingstart
-rw-r--r-- 1 root root 103261 May 10  2021 hostingstart.deps.json
-rw-r--r-- 1 root root   5632 May 10  2021 hostingstart.dll
-rw-r--r-- 1 root root  18756 May 10  2021 hostingstart.pdb
-rw-r--r-- 1 root root    311 May 10  2021 hostingstart.runtimeconfig.json
-rw-r--r-- 1 root root    488 May 10  2021 web.config
drwxr-xr-x 2 root root   4096 Jul 26 11:53 wwwroot
root@55a2f2c9e303:/defaulthome/hostingstart# cd wwwroot/
root@55a2f2c9e303:/defaulthome/hostingstart/wwwroot# ls
hostingstart.html&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A part of the HTML code&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;&amp;lt;div class="pl-5 ml-5 col-xl-5 col-lg-5 col-md-10 col-sm-11 col-xs-11"&amp;gt;
    &amp;lt;div class="container-fluid"&amp;gt;
        &amp;lt;div class="row"&amp;gt;
            &amp;lt;h2 id="upRunning"&amp;gt;Your web app is running and waiting for your content&amp;lt;/h2&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="row mt-4 pt-4"&amp;gt;
            &amp;lt;div id="appIsLive" style="font-size:16px;width: 516px;"&amp;gt;Your web app is live, but we don&amp;rsquo;t have
                your content yet. If you&amp;rsquo;ve already deployed, it could take up to 5 minutes for your content
                to show up, so come back soon.&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class="row mt-4"&amp;gt;
            &amp;lt;h5 id="supporting" class="mt-5"&amp;gt;&amp;lt;img height="50" width="50"
                    src="https://appservice.azureedge.net/images/linux-landing-page/v4/built-dotnet.svg" /&amp;gt; Built with .NET&amp;lt;/h5&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Application files&lt;/h3&gt;
&lt;p&gt;The final puzzle is how it find my &lt;code&gt;DockerDetect.dll&lt;/code&gt;, it took some tricks to find my application file. Just&amp;nbsp;&lt;code&gt;ls&lt;/code&gt; in the default SSH landing directory won't show anything.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-6a0a5213-fdd7-4a5b-b208-6ab9437e41fa.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;In this post, I created an ASP.NET Core 6.0 web application and deployed it to &lt;a href="https://learn.microsoft.com/en-us/troubleshoot/azure/app-service/faqs-app-service-linux?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure App Service for Linux&lt;/a&gt;. Azure is using a Docker image to run my code. The image contains ASP.NET Core runtime and uses Oryx to generate a startup script that finally calls dotnet to run my application files.&amp;nbsp;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">c94932ad-7fba-4e49-9150-fad157f216ea</guid><pubDate>2023-09-01T00:53:09Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2023-09-01T00:53:09Z</updated></item><item><title>Cloud Architecture Design of My Email Notification Infrastructure on Azure</title><link>https://edi.wang/post/2023/2/21/cloud-architecture-design-of-my-email-notification-infrastructure-on-azure</link><description>&lt;h2&gt;Background&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;My blog system "&lt;a href="https://github.com/EdiWang/Moonglade" target="_blank" rel="noopener"&gt;Moonglade&lt;/a&gt;" needs to send email notifications to blog owner when there are events like new comments, new pingbacks, and send email to readers when their comments are replied.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;There are two options for sending emails in web application. Build my own infrastructure or use a third-party service like &lt;a href="https://docs.sendgrid.com/for-developers/partners/microsoft-azure-2021" target="_blank" rel="noopener"&gt;SendGrid&lt;/a&gt;. I've explored both options and finally decided to build my own infrastructure. During the last few years, this infrastructure has been redesigned many times. In this post, I will share the journey of how this email infrastructure evolved and the things I learnt.&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Version 1: Inside Moonglade Application&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;The initial design is to send email directly from Moonglade itself, which is a ASP.NET Core web application. I've built a &lt;a href="https://github.com/EdiWang/Edi.TemplateEmail" target="_blank" rel="noopener"&gt;.NET library&lt;/a&gt; that can configure email in XML template and send messages by SMTP.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-6e75554b-907e-4548-b5ab-bdf13c260bbd.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;It's adding an xml configuration file under application folder like this:&lt;/p&gt;
&lt;pre class="language-markup"&gt;&lt;code&gt;&amp;lt;?xml version="1.0"?&amp;gt;
&amp;lt;MailConfiguration&amp;gt;
  &amp;lt;CommonConfiguration OverrideToAddress="false" ToAddress="overridetest@test.com" /&amp;gt;
  &amp;lt;MailMessage MessageType="TestMail" IsHtml="true"&amp;gt;
    &amp;lt;MessageSubject&amp;gt;Test Mail on {MachineName.Value}&amp;lt;/MessageSubject&amp;gt;
    &amp;lt;MessageBody&amp;gt;
      &amp;lt;![CDATA[
Mail configuration on {MachineName.Value} is good: &amp;lt;br /&amp;gt;
Smtp Server: {SmtpServer.Value}&amp;lt;br /&amp;gt;
Smtp Port: {SmtpServerPort.Value}&amp;lt;br /&amp;gt;
Smtp Username: {SmtpUserName.Value}&amp;lt;br /&amp;gt;
Email Display Name: {EmailDisplayName.Value}&amp;lt;br /&amp;gt;
Enable SSL: {EnableSsl.Value}&amp;lt;br /&amp;gt;
      ]]&amp;gt;
    &amp;lt;/MessageBody&amp;gt;
  &amp;lt;/MailMessage&amp;gt;
&amp;lt;/MailConfiguration&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then send email in code&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;var smtpServer = "smtp-mail.outlook.com";
var userName = "Edi.Test@outlook.com";
var password = "***********";
var port = 587;
var toAddress = "Edi.Wang@outlook.com";

var configSource = $"{Directory.GetCurrentDirectory()}\\mailConfiguration.xml";
var emailHelper = new EmailHelper(configSource, smtpServer, userName, password, port)
     .WithTls()
     .WithSenderName("Test Sender")
     .WithDisplayName("Edi.TemplateEmail.TestConsole");&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pros&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Only need to deploy one application, which saves cost on Azure.&lt;/li&gt;
&lt;li&gt;Straight forward coding experience&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Cons&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Bad cloud architecture. I cannot scale the email infrastructure alone when there is large amount of traffic. The web server is doing one more job, sending email is not a "Web" thing.&lt;/li&gt;
&lt;li&gt;Upgrading or fixing bugs in email component requires redeploying the entire web application.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Hard to deal with failures. Without a database or queue, I cannot track failed email messages and resend them.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Potential security and network restriction. In certain scenarios, web servers won't be allowed to access SMTP services, which makes email fail to send.&lt;/li&gt;
&lt;li&gt;Coding language must be the same withing one application. If some day, there is a much better email library written in another language, it's hard to migrate because other pieces of the application is written in C#.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Version 2: A standalone ASP.NET Web API&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;The next version separates web workload and email sending workload by building a standalone API, just doing one thing: Send emails. It's a typical architecture design.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-45ddfe06-07f2-4ec2-be53-667c64e704e4.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Pros&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Email API can be scaled, upgraded, and deployed separately without affecting the web application.&lt;/li&gt;
&lt;li&gt;I can choose different versions of .NET and C# or even another language to build the email API.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Cons&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I have to pay for an extra server instance even when most of the time there is no email sending API calls.&lt;/li&gt;
&lt;li&gt;Extra deployment and management for another server instance.&lt;/li&gt;
&lt;li&gt;Still hard to deal with failures. During email API deployment or down time, any API call from web application will fail, and users won't be able to receive emails.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Version 3: Single Azure Function&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;The most significant problem for using a full ASP.NET Web API is the cost, including operation and maintenance costs and development costs.&lt;/p&gt;
&lt;p&gt;First, the App Service Plan that hosts the Web API is a huge overhead. For an ASP.NET Core application, there is currently no way to choose&amp;nbsp;&lt;a href="https://docs.microsoft.com/en-us/azure/azure-functions/functions-scale#consumption-plan?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Consumption Plan&lt;/a&gt;, so the API will be billed even when it is idle. According to business, the API is only called about ten times a day, and the total processing time is less than 2 minutes. But I have to pay for the rest 1438 minutes every day.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-25267fc1-1a29-4741-bdba-d408b888ac8b.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Secondly, even a simple and ordinary Email notification function still needs a complete basic framework to host it. Although ASP.NET Core provides a very flexible and powerful framework, Azure Function can completely free up these basic tasks. Azure Function only cares about business code, not infrastructure. In general, developers only need to write the business logic of the function, and do not need to write code such as how to start, route, and assign API Keys.&lt;/p&gt;
&lt;p&gt;This is fully explained in my previous blog post '&lt;a href="/post/2020/7/7/migrating-an-aspnet-core-web-api-project-to-azure-function" target="_blank" rel="noopener"&gt;Migrating an ASP.NET Core Web API Project to Azure Function&lt;/a&gt;'.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-6b50f224-6640-4e5a-916e-0733e94ac113.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;The function code looks like this&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public class EmailSendingFunction
{
    [FunctionName("EmailSending")]
    public async Task&amp;lt;IActionResult&amp;gt; Run(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] NotificationRequest request,
        ILogger log, ExecutionContext executionContext)
    {
        T GetModelFromPayload&amp;lt;T&amp;gt;() where T : class
        {
            var json = request.Payload.ToString();
            return JsonSerializer.Deserialize&amp;lt;T&amp;gt;(json);
        }

        log.LogInformation("EmailSending HTTP trigger function processed a request.");

        try
        {

            var configRootDirectory = executionContext.FunctionAppDirectory;
            AppDomain.CurrentDomain.SetData(Constants.AppBaseDirectory, configRootDirectory);
            log.LogInformation($"Function App Directory: {configRootDirectory}");

            IMoongladeNotification notification = new EmailHandler(log)
            {
                AdminEmail = request.AdminEmail,
                EmailDisplayName = request.EmailDisplayName
            };

            switch (request.MessageType)
            {
                case MailMesageTypes.TestMail:
                    await notification.SendTestNotificationAsync();
                    return new OkObjectResult("TestMail Sent");

                //  skip a few code...

                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
        catch (Exception e)
        {
            log.LogError(e, e.Message);
            return new ConflictObjectResult(e.Message);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pros&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Save cost by a serverless consumption plan&lt;/li&gt;
&lt;li&gt;Free from writing ASP.NET Core infrastructure code, only focus on business code&lt;/li&gt;
&lt;li&gt;Easy to migrate to another programming language like Java, Python, NodeJS, or even PowerShell&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Cons&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Still hard to deal with failures. During email function deployment or down time, any API call from web application will fail, and users won't be able to receive emails.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Version 4: Azure Function and SQL Database&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;To deal with failures, I added a SQL database for storing email messages and their status. The web application no longer makes direct API calls to the Azure Function. Instead, it only writes messages to a database table and forgets about it. It's the Azure Function's responsibility to pick new messages from database and send them. If email sending failed, it will record and retry a few times until mark the message as failed completely and then move to next message. Yes, it is using database as a queue.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-5ad4a2cf-adff-4e2f-a2ae-6cc2e34b5fdc.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;In this version, the function code looks like this&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("NotificationV2")]
public async Task Run([TimerTrigger("*/2 * * * *")] TimerInfo myTimer, ILogger log)
{
    log.LogInformation($"NotificationV2 Timer trigger function executed at UTC: {DateTime.UtcNow}");
    // logic ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pros&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Dealing with failures. As long as the web application is alive and can write messages into database, any deployment or downtime of Azure Function won't lose any email. When the function is restored up running again, it will continue where it's left.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Cons&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Using database as a queue requires writing every queue feature manually. Like peek message and retry logic.&lt;/li&gt;
&lt;li&gt;Azure Function needs to have a timer trigger to query database for new messages. It is an extra consumption because most of the time the query will return no new messages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Version 4: Azure Function and Storage Queue&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Using database as a queue makes me &lt;a href="https://996.icu/" target="_blank" rel="noopener"&gt;work 996&lt;/a&gt;. Azure has provided real message queue services, &lt;a href="https://learn.microsoft.com/en-us/azure/storage/queues/storage-queues-introduction?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Storage Queue&lt;/a&gt; and &lt;a href="https://learn.microsoft.com/en-us/azure/service-bus-messaging/?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Service Bus&lt;/a&gt;. For sending emails, I prefer using Storage Queue.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;"Azure Queue storage is a service for storing large numbers of messages that can be accessed from anywhere in the world via authenticated calls using HTTP or HTTPS. A single queue message can be up to 64 KB in size, and a queue can contain millions of messages, up to the total capacity limit of a storage account. Queue storage is often used to create a backlog of work to process asynchronously."&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you need more details when choosing architecture design, you can see &lt;a href="https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-azure-and-service-bus-queues-compared-contrasted" target="_blank" rel="noopener"&gt;this document&lt;/a&gt; for detailed comparison between these two.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-eda7a9f1-8bd2-440f-8318-6bc6beeb83e9.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Now I am free from writing basic queue features like enqueue, dequeue, peek, retry, etc.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;When an Azure Function instance picks up a message, it will be invisible to other instances for a period, this is called visibility timeout mechanism. So that when you have multiple instances at scale, the same message won't be processed multiple times.&lt;/p&gt;
&lt;p&gt;By default, Azure will try 5 times for failed message and put them into a poison queue automatically. So that a failed message won't block current instance or other instances from picking up following up messages.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-42f5b6c0-2933-4a19-b094-6ef09bfa0d49.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Web application code using &lt;code&gt;Azure.Storage.Queues&lt;/code&gt; package.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;public async Task EnqueueNotification&amp;lt;T&amp;gt;(MailMesageTypes type, string[] toAddresses, T payload) where T : class
{
    try
    {
        var queue = new QueueClient(_notificationSettings.AzureStorageQueueConnection, "moongladeemailqueue");

        var en = new EmailNotification
        {
            DistributionList = string.Join(';', toAddresses),
            MessageType = type.ToString(),
            MessageBody = JsonSerializer.Serialize(payload, MoongladeJsonSerializerOptions.Default),
        };

        await InsertMessageAsync(queue, en);
    }
    catch (Exception e)
    {
        _logger.LogError(e, e.Message);
        throw;
    }
}

private async Task InsertMessageAsync(QueueClient queue, EmailNotification emailNotification)
{
    if (null != await queue.CreateIfNotExistsAsync())
    {
        _logger.LogInformation($"Azure Storage Queue '{queue.Name}' was created.");
    }

    var json = JsonSerializer.Serialize(emailNotification);
    var bytes = Encoding.UTF8.GetBytes(json);
    var base64Json = Convert.ToBase64String(bytes);

    await queue.SendMessageAsync(base64Json);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Function code.&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("NotificationStorageQueue")]
public async Task Run(
    [QueueTrigger("moongladeemailqueue", Connection = "moongladestorage")] QueueMessage queueMessage,
    ILogger log,
    Microsoft.Azure.WebJobs.ExecutionContext executionContext)
{
    log.LogInformation($"C# Queue trigger function processed: {queueMessage.MessageId}");
    // logic...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pros&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Free from writing queue logic&lt;/li&gt;
&lt;li&gt;Change timed trigger to queue trigger, saving a lot of database and compute consumption&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Cons&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Learning curve for how to use Azure Storage Queue&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion and more&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;When designing systems for cloud. Keep in mind cloud native patterns. Message queue is a common way to decoupling system parts that are scaled independently. Email sending is one of those scenarios that can use a message queue. By decoupling email component from main application, it can be scaled to handle large amount of traffic, use a different programming language, resilient to failure, and save cost in certain ways.&amp;nbsp;&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">4aba1052-2c5c-4a23-a942-340a36dad46c</guid><pubDate>2023-02-21T01:47:36Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2023-02-21T01:47:36Z</updated></item><item><title>Get Client IP When Your ASP.NET Core App is behind Reversed Proxy on Azure App Service</title><link>https://edi.wang/post/2022/12/9/get-client-ip-when-your-aspnet-core-app-is-behind-reversed-proxy-on-azure-app-service</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;I have a web app with a web front end and API backend both deployed to Azure App Service. I reversed proxy the API using &lt;a href="/post/2020/11/2/how-to-enable-application-request-routing-on-azure-app-service" target="_blank" rel="noopener"&gt;this method&lt;/a&gt; I described before, so that my web front end can access API without having CORS issue, and the end users won't need to access my API address directly. However, for the API code, the client IP address I got is always Azure app service's outbound IP instead of real client IP. Let's see how to fix this problem.&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Just like with any reverse proxy, ASP.NET Core already has built in support for respecting "X-Forwarded-For" HTTP header.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Typically, we will add this in DI&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;builder.Services.Configure&amp;lt;ForwardedHeadersOptions&amp;gt;(options =&amp;gt;
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And use in Middleware&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;app.UseForwardedHeaders();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, this is not enough on Azure. We also need to manually add all the possible proxy IP addresses to the &lt;code&gt;ForwardedHeadersOptions&lt;/code&gt;.&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;Get your App Service's outbound IP addresses&lt;/h3&gt;
&lt;p&gt;In my case, I call API from Web server. So, I go to the "&lt;strong&gt;Networking&lt;/strong&gt;" blade in the web app's instance and copy all &lt;strong&gt;Outbound addresses&lt;/strong&gt; to clipboard.&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-e509889b-8aa8-4460-9124-042b604c0b60.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;h3&gt;Set known proxies&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;I don't like hard code IP addresses in code. So I go to appsettings.json file and add a new section named "&lt;strong&gt;AzureKnownProxies&lt;/strong&gt;" with the IP I copied before.&lt;/p&gt;
&lt;pre class="language-javascript"&gt;&lt;code&gt; "AzureKnownProxies": [
   "52.163.244.128",
   "13.75.34.175",
   "168.63.220.81",
   "207.46.136.220",
   "168.63.210.90",
   "23.101.15.21",
   "23.101.7.253",
   "207.46.136.152",
   "65.52.180.140",
   "23.101.13.231",
   "23.101.3.51"
 ],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, modify DI code for adding forwarded headers to this&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;var knownProxies = builder.Configuration.GetSection("AzureKnownProxies").Get&amp;lt;string[]&amp;gt;();
builder.Services.Configure&amp;lt;ForwardedHeadersOptions&amp;gt;(options =&amp;gt;
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.ForwardLimit = null;
    options.KnownProxies.Clear();
    if (knownProxies != null)
    {
        foreach (var ip in knownProxies)
        {
            options.KnownProxies.Add(IPAddress.Parse(ip));
        }
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, we can successfully get real client IP address!&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">7db42e5b-275b-4a84-86a3-2dafa48f3730</guid><pubDate>2022-12-09T07:54:03Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2022-12-09T07:54:03Z</updated></item><item><title>Move CRON Expression from Hard Code to Configuration in Azure Function App</title><link>https://edi.wang/post/2022/9/13/move-cron-expression-from-hard-code-to-configuration-in-azure-function-app</link><description>&lt;h2&gt;Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;When creating &lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?WT.mc_id=AZ-MVP-5002809" target="_blank" rel="noopener"&gt;Azure Function&lt;/a&gt; with Timer trigger, we usually write CRON expression like &lt;code&gt;[TimerTrigger("*/2 * * * *")]&lt;/code&gt; in our function code. It works most of the cases, but when you need to dynamically adjust your timer based on business needs, you will have to update your code and redeploy the function, which may not be so easy in an enterprise application.&lt;/p&gt;
&lt;p&gt;Let's see how we can remove the hard code CRON expression and put it into configuration so that we can easily update on the Azure Portal without redeploying the code.&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;In my case, I created a function that will execute every 2 minutes to send email notifications. The CRON expression is hard coded as &lt;code&gt;*/2 * * * *&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("NotificationV2")]
public async Task Run([TimerTrigger("*/2 * * * *")] TimerInfo myTimer, ILogger log)
{
    log.LogInformation($"NotificationV2 Timer trigger function executed at UTC: {DateTime.UtcNow}");
    // logic ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make it read values from configuration, we need to first create a &lt;strong&gt;local.settings.json&lt;/strong&gt; file if you don't have one. This is typically already created by VS, or the Azure Function tools. It is just for local development use.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Then, add a key and value in "Values" node, which represents the CRON expression. In my case, I use "&lt;code&gt;NotificationV2CRON&lt;/code&gt;" with "&lt;code&gt;*/2 * * * *&lt;/code&gt;"&lt;/p&gt;
&lt;pre class="language-javascript"&gt;&lt;code&gt;{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "NotificationV2CRON": "*/2 * * * *"
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, change the original code to &lt;code&gt;%NotificationV2CRON%&lt;/code&gt;&lt;/p&gt;
&lt;pre class="language-csharp"&gt;&lt;code&gt;[FunctionName("NotificationV2")]
public async Task Run([TimerTrigger("%NotificationV2CRON%")] TimerInfo myTimer, ILogger log)
{
    log.LogInformation($"NotificationV2 Timer trigger function executed at UTC: {DateTime.UtcNow}");
    // logic ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run your code and test if it works&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-d6b4c65d-59bc-4f55-bec6-28811da31d99.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Deploy your code to Azure. Then go to "Configuration" tab under your Azure Function App. Add a new application setting with the name of your CRON expression, in my case "&lt;code&gt;NotificationV2CRON&lt;/code&gt;".&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-b931d5f9-7f8f-4d2e-8754-375bff7cffff.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Observe the function log to make sure it works&lt;/p&gt;
&lt;p&gt;&lt;img src="/image/img-b4433146-2017-4c56-98e4-a0559b9b6604.png" border="0" loading="lazy"&gt;&lt;/p&gt;
&lt;p&gt;Now you will be able to update the CRON value on the fly within a few clicks in Azure Portal!&lt;/p&gt;</description><category>.NET</category><category>Microsoft Azure</category><guid isPermaLink="false">ee163cff-25cc-43ea-8393-dcd7044a352e</guid><pubDate>2022-09-13T07:24:59Z</pubDate><updated xmlns="http://www.w3.org/2005/Atom">2022-09-13T07:24:59Z</updated></item></channel></rss>