Azure Blob Storage: Thực Hiện CRUD Với AspNetCore MVC & SQL Server

Azure Blob Storage là một giải pháp lưu trữ được cung cấp bởi Microsoft. Bạn có thể lưu trữ dữ liệu như hình ảnh, âm thanh, video, file JSON, file ZIP, v.v. trên Azure.

Chúng ta sẽ học gì?

  • Cách tạo một ứng dụng web lưu trữ và thao tác với hình ảnh trên đám mây
  • Chúng ta sẽ thực hiện tất cả các thao tác CRUD (tạo, đọc, cập nhật và xóa)

Công nghệ sử dụng

  • .NET Core 9 (ứng dụng MVC)
  • SQL Server 2022
  • Tài khoản lưu trữ Azure

Tổng quan cấp cao

Chúng ta sẽ lưu image url trong cơ sở dữ liệu và hình ảnh trong storage account (blob container)

Hãy tạo một tài khoản lưu trữ trước

Bước 1: Đăng nhập vào Azure Portal và tìm kiếm “Storage Accounts”

Bước 2: Nhấn “Create” để tạo tài khoản lưu trữ mới

Bước 3: Điền các thông tin cần thiết như Subscription, Resource Group, Storage Account Name

Bước 4: Chuyển đến phần Advanced và cấu hình các tùy chọn. Sau đó nhấn “Review + Create” và tạo tài khoản

Tạo ứng dụng ASP.NET Core MVC mới

Tạo một dự án ASP.NET Core MVC mới.

📢 Có thể tôi thiếu một số thứ trong bài blog này. Nếu bạn tìm thấy điều gì thiếu, vui lòng kiểm tra mã nguồn.

💻 Mã nguồn

Cấu hình appsettings

Thêm các dòng này vào appsettings.json

"AzureBlobStorage": {
"ConnectionString": "",
"ContainerName": "",
"StorageAccountName": "",
"StorageAccountKey": ""
}

📢 Lưu ý: Hãy để trống các giá trị này và đặt chúng trong secrets-manager. Secrets manager lưu trữ các bí mật trên máy chứ không phải trong dự án. Bằng cách này, chúng ta có thể giữ các bí mật an toàn. Trong môi trường sản xuất, hãy đặt các bí mật này trong các biến môi trường của Azure App Services (nơi ứng dụng web của bạn được triển khai).

Quản lý Secrets

Nhấp chuột phải vào dự án, chọn “Manage Secrets”. Nó sẽ mở file secret.json

{
"AzureBlobStorage": {
"ConnectionString":"",
"ContainerName": "images", // chưa tồn tại, sẽ được tạo thông qua code
"StorageAccountName":"",
"StorageAccountKey": ""
},
}

Bạn có thể tìm thấy tất cả thông tin tại đây trong phần “Access keys” của tài khoản lưu trữ Azure.

Gói NuGet cần cài đặt

Azure.Storage.Blobs

Tạo Options/BlobStorageOptions.cs

namespace AzureBlobDemo.Options;

public class BlobStorageOptions
{
public string? ConnectionString { get; set; }
public string? ContainerName { get; set; }
public string? StorageAccountName { get; set; }
public string? StorageAccountKey { get; set; }
}

Chúng ta sử dụng options pattern để truy xuất các giá trị cấu hình.

Cấu hình trong Program.cs

builder.Services.AddOptions<BlobStorageOptions>()
.BindConfiguration("AzureBlobStorage");

Tạo Interface IBlobStorageService

namespace AzureBlobDemo.Services;

public interface IBlobStorageService
{
Task<string> UploadFileAsync(IFormFile file, string? fileNameWithExtension = "");

Task DeleteBlobAsync(string blobUrl);
}

Triển khai BlobStorageService

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using AzureBlobDemo.Options;
using Microsoft.Extensions.Options;

namespace AzureBlobDemo.Services;

public class BlobStorageServie : IBlobStorageService
{
private readonly BlobStorageOptions _blobStorage;

public BlobStorageServie(IOptions<BlobStorageOptions> options)
{
_blobStorage = options.Value;
}

public async Task<string> UploadFileAsync(IFormFile file, string? fileNameWithExtension = "")
{
if (string.IsNullOrEmpty(fileNameWithExtension))
{
var fileExtension = Path.GetExtension(file.FileName)?.ToLowerInvariant();
fileNameWithExtension = Guid.NewGuid().ToString("N") + fileExtension;
}
BlobContainerClient containerClient = new(_blobStorage.ConnectionString, _blobStorage.ContainerName);

await containerClient.CreateIfNotExistsAsync();

await containerClient.SetAccessPolicyAsync(PublicAccessType.Blob);

BlobClient blobClient = containerClient.GetBlobClient(fileNameWithExtension);

await using var stream = file.OpenReadStream();

await blobClient.UploadAsync(stream, true);

return blobClient.Uri.ToString();
}

public async Task DeleteBlobAsync(string blobUrl)
{
string blobName = Path.GetFileName(blobUrl);
if (string.IsNullOrEmpty(blobName))
{
throw new InvalidOperationException("Invalid blob url");
}
BlobContainerClient containerClient = new(_blobStorage.ConnectionString, _blobStorage.ContainerName);

BlobClient blobClient = containerClient.GetBlobClient(blobName);

await blobClient.DeleteIfExistsAsync(snapshotsOption: DeleteSnapshotsOption.IncludeSnapshots);
}
}

Đăng ký BlobStorage service trong program.cs

builder.Services.AddSingleton<IBlobStorageService, BlobStorageServie>();

Kết nối với cơ sở dữ liệu

Trong phần này, chúng ta sẽ kết nối với cơ sở dữ liệu, vì vậy chúng ta sẽ tạo Models, Dtos và AppDbContext.

Đầu tiên, cài đặt các gói NuGet cần thiết:

Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools

Domain Model

namespace AzureBlobDemo.Models;

public class Person
{
public int PersonId { get; set; }

[Required]
[MaxLength(30)]
public string FirstName { get; set; } = null!;

[Required]
[MaxLength(30)]
public string LastName { get; set; } = null!;

[Required]
[MaxLength(250)]
public string ProfilePicture { get; set; } = null!;
}

📢 ProfilePicture sẽ chứa URL hình ảnh từ blob container.

DTOs

PersonDto:

namespace AzureBlobDemo.Models;

public class PersonDto
{
[Required]
[MaxLength(30)]
public string FirstName { get; set; } = null!;

[Required]
[MaxLength(30)]
public string LastName { get; set; } = null!;

[Required]
public IFormFile File { get; set; } = null!;

public string? SuccessMessage { get; set; }
public string? ErrorMessage { get; set; }
}

PersonUpdateDto:

namespace AzureBlobDemo.Models;

public class PersonUpdateDto
{
public int PersonId { get; set; }

[Required]
[MaxLength(30)]
public string FirstName { get; set; } = null!;

[Required]
[MaxLength(30)]
public string LastName { get; set; } = null!;

[Required]
public string ProfilePicture { get; set; } = null!;

public IFormFile? File { get; set; }

public string? SuccessMessage { get; set; }
public string? ErrorMessage { get; set; }
}

AppDbContext

public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{

}

public DbSet<Person> People { get; set; }
}

Chuỗi kết nối cơ sở dữ liệu

"ConnectionStrings": {
"Default": "Server=localhost,1433;Database=PersonDb;User Id=<username>; Password=<password>; Trust Server Certificate=true"
}

Đăng ký AppDbContext trong Program.cs

var connectionString = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(op => op.UseSqlServer(connectionString));

Controllers

  • Tạo một controller với tên PersonController
  • Inject các services cần thiết

public class PersonController : Controller
{
private readonly AppDbContext _dbcontext;
private readonly IBlobStorageService _blobStorage;

public PersonController(AppDbContext dbcontext, IBlobStorageService blobStorage)
{
_dbcontext = dbcontext;
_blobStorage = blobStorage;
}

// Index method

// CreatePerson (get method)

// CreatePerson (post method)

// EditPerson (get method)

// EditPerson (post method)

// DeletePerson (method)
}

Index method

public async Task<IActionResult> Index()
{
var people = await _dbcontext.People.ToListAsync();
return View(people);
}

CreatePerson (get method)

public IActionResult CreatePerson()
{
return View(new PersonDto());
}

CreatePerson (post method)

[HttpPost]
public async Task<IActionResult> CreatePerson(PersonDto personToCreate)
{
if (!ModelState.IsValid)
{
return View(personToCreate);
}
try
{
if (personToCreate.File.Length > 300 * 1024)
{
personToCreate.ErrorMessage = "File can not exceed 300kb length";
return View(personToCreate);
}

var allowedExtensions = new[] { ".jpg", ".jpeg", ".png" };
var fileExtension = Path.GetExtension(personToCreate.File.FileName)?.ToLowerInvariant();

if (!allowedExtensions.Contains(fileExtension))
{
personToCreate.ErrorMessage = "You can only upload .jpg, .jpeg, .png files";
return View(personToCreate);
}

string profilePicUrl = await _blobStorage.UploadFileAsync(personToCreate.File);

var person = new Person
{
FirstName = personToCreate.FirstName,
LastName = personToCreate.LastName,
ProfilePicture = profilePicUrl
};
_dbcontext.Add(person);
await _dbcontext.SaveChangesAsync();
personToCreate.SuccessMessage = "Saved successfully";
}
catch (Exception ex)
{
personToCreate.ErrorMessage = ex.Message;
Console.WriteLine(ex.Message);
}
return View(personToCreate);
}

EditPerson (get method)

public async Task<IActionResult> EditPerson(int id)
{
var person = await _dbcontext.People.FindAsync(id);
if (person is null) throw new InvalidOperationException("Person does not exists");

var personUpdateDto = new PersonUpdateDto
{
PersonId = person.PersonId,
FirstName = person.FirstName,
LastName = person.LastName,
ProfilePicture = person.ProfilePicture
};
return View(personUpdateDto);
}

EditPerson (post method)

[HttpPost]
public async Task<IActionResult> EditPerson(PersonUpdateDto personToUpdate)
{
if (!ModelState.IsValid)
{
return View(personToUpdate);
}
try
{
if (personToUpdate.File != null)
{

if (personToUpdate.File.Length > 300 * 1024)
{
personToUpdate.ErrorMessage = "File can not exceed 300kb length";
return View(personToUpdate);
}

var allowedExtensions = new[] { ".jpg", ".jpeg", ".png" };

var fileExtension = Path.GetExtension(personToUpdate.File.FileName)?.ToLowerInvariant();

if (string.IsNullOrEmpty(fileExtension) || !allowedExtensions.Contains(fileExtension))
{
personToUpdate.ErrorMessage = "You can only upload .jpg, .jpeg, .png files";
return View(personToUpdate);
}
string oldPictureName = personToUpdate.ProfilePicture;
personToUpdate.ProfilePicture = await _blobStorage.UploadFileAsync(personToUpdate.File, oldPictureName);
}

var person = new Person
{
PersonId = personToUpdate.PersonId,
FirstName = personToUpdate.FirstName,
LastName = personToUpdate.LastName,
ProfilePicture = personToUpdate.ProfilePicture!
};
_dbcontext.Update(person);
await _dbcontext.SaveChangesAsync();
personToUpdate.SuccessMessage = "Saved successfully";
}
catch (Exception ex)
{
personToUpdate.ErrorMessage = ex.Message;
Console.WriteLine(ex.Message);
}
return View(personToUpdate);
}

DeletePerson (method)

public async Task<IActionResult> DeletePerson(int id)
{
var transaction = await _dbcontext.Database.BeginTransactionAsync();
try
{
var person = await _dbcontext.People.FindAsync(id);
if (person == null)
{
throw new InvalidOperationException("No person found to delete.");
}
var blobUrl = person.ProfilePicture;
_dbcontext.People.Remove(person);
_dbcontext.SaveChanges();
await _blobStorage.DeleteBlobAsync(blobUrl);
await transaction.CommitAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
await transaction.RollbackAsync();
}
return RedirectToAction(nameof(Index));
}

Views cho từng phương thức controller

  • Tạo một thư mục mới có tên Person bên trong Views
  • Tạo ba views: CreatePerson.cshtml, EditPerson.cshtmlIndex.cshtml
  • Các views này đại diện cho các phương thức controller tương ứng

CreatePerson.cshtml

@model PersonDto
@{
ViewData["Title"] = "Add person";
}

<div style="width: 50%;">
<h1>Save person</h1>
<a class="btn btn-info mb-2" href="/Person/Index">Show all</a>

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Model.SuccessMessage</div>
}

@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-success">@Model.ErrorMessage</div>
}

<form asp-action="CreatePerson" method="post" enctype="multipart/form-data">

<div class="mb-3">
<label>FirstName*</label>
<input type="text" class="form-control" asp-for="FirstName">
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>

<div class="mb-3">
<label>LastName*</label>
<input type="text" class="form-control" asp-for="LastName">
<span asp-validation-for="LastName" class="text-danger"></span>
</div>

<div class="mb-3">
<label>Profile Pic*</label>
<input type="file" class="form-control" asp-for="File">
<span asp-validation-for="File" class="text-danger"></span>
</div>

<div class="mb-3">
<button type="submit" class="btn btn-primary">Save</button>
</div>

</form>

</div>

EditPerson.cshtml

@model PersonUpdateDto
@{
ViewData["Title"] = "Update person";
}

<div style="width: 50%;">
<h1>Update person</h1>
<a class="btn btn-info mb-2" href="/Person/Index">Show all</a>

@Html.ValidationSummary()

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Model.SuccessMessage</div>
}

@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-success">@Model.ErrorMessage</div>
}

<form asp-action="EditPerson" method="post" enctype="multipart/form-data">
<input type="hidden" asp-for="PersonId" />
<input type="hidden" asp-for="ProfilePicture" />
<div class="mb-3">
<label>FirstName*</label>
<input type="text" class="form-control" asp-for="FirstName">
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>

<div class="mb-3">
<label>LastName*</label>
<input type="text" class="form-control" asp-for="LastName">
<span asp-validation-for="LastName" class="text-danger"></span>
</div>

<div class="mb-3">
<label>Profile Pic</label>
@if (!string.IsNullOrEmpty(Model.ProfilePicture))
{
<img class="mb-1" style="width:100px;height:80px" src="@Model.ProfilePicture" />
}
<input type="file" class="form-control" asp-for="File">

</div>

<div class="mb-3">
<button type="submit" class="btn btn-primary">Save</button>
</div>

</form>

</div>

Index.cshtml

@model IEnumerable<Person>
@{
ViewData["Title"] = "People";
}

<h1>People</h1>

<a class="btn btn-info mb-2" href="/Person/CreatePerson">Add more</a>

<table class="table table-striped table-bordered">

<thead>
<tr>
<th>FirstName</th>
<th>LastName</th>
<th>Picture</th>
<th>Actions</th>
</tr>
</thead>

<tbody>
@foreach (var person in Model)
{
<tr>
<td>@person.FirstName</td>
<td>@person.LastName</td>
<td>
@if (string.IsNullOrEmpty(person.ProfilePicture))
{
<span>No Image</span>
}
else
{
<img style="width:120px;height:90px" src="@person.ProfilePicture" />
}
</td>
<td>
<a href="/Person/EditPerson?id=@person.PersonId" class="btn btn-success">Edit</a>
<a onclick="return window.confirm('Are you sure to delete?')"
href="/Person/DeletePerson?id=@person.PersonId" class="btn btn-danger">Delete</a>
</td>
</tr>
}
</tbody>

</table>

Bạn có thể xem các hình ảnh đã lưu trữ của mình trong Azure Blob Container.

Với hướng dẫn này, bạn đã tạo thành công một ứng dụng ASP.NET Core MVC tích hợp với Azure Blob Storage để thực hiện các thao tác CRUD trên hình ảnh, đồng thời lưu trữ metadata trong SQL Server.

Chỉ mục