Wprowadzenie
Już kiedyś opisywałem na tym blogu, że jednym z większych problemów, które widzę w projektach moich kursantów, są migracje bazy danych. Kilka miesięcy temu opisywałem trzy różne podejścia: migrację w Entity Framework, Fluent Migratora oraz DbUp. Podejścia te, jak sądzę, pokrywają większość przypadków, które możesz mieć w swojej aplikacji.
Kolejnym problemem związanym z migracjami jest ich uruchamianie. O ile w jakimś projekcie jest już użyty jakiś mechanizm (na ogół są to migracje z Entity Framework), to pojawia się problem z jego uruchomieniem. Na ogół niestety migracje wykonują się przy starcie aplikacji, z użyciem tego samego connection stringa, na którym działa później aplikacja. To niestety później, w przypadku wystąpienia podatności SQL Injection w aplikacji, może prowadzić do tragicznych skutków. Użyty użytkownik musi mieć podwyższone uprawnienia, aby mógł wykonać migracje i zmienić strukturę bazy danych.
W tym wpisie pokażę Ci, jak można wykonać migrację bazy danych w trakcie wdrażania aplikacji (mówimy o aplikacjach webowych). Dorzucę tutaj również możliwość tworzenia migawek bazy danych, aby można było później na danej instancji aplikacji wykonywać automatyczne testy Postmana.
Migrator
We wspomnianych wyżej wpisach utworzyłem dedykowaną aplikację konsolową do wykonywania migracji baz danych. W tym wpisie rozbuduję trochę migratora z wpisu o migracjach w Entity Framework. Jeśli nie czytałeś tego wpisu, to najlepiej, abyś zrobił to przed przeczytaniem dalszej części.
Rozbudowa migratora będzie polegać na dodaniu możliwości tworzenia migawek bazy danych, aby można było je tworzyć po zmigrowaniu bazy danych do nowego schematu. Tutaj podobnie, jeśli nie czytałeś wpisu o wykorzystaniu Sql Server Snapsthops do resetowania danych w testach, zapraszam do przeczytania.
Migrator w przypadku wykonywania migawki będzie działał w następujący sposób: cofnie stan bazy na podstawie migawki, usunie migawkę, wykona migrację bazy, utworzy nową migawkę. W pozostałych sytuacjach wykona tylko samą migrację bazy.
W tym celu rozbudowałem DatabaseRestoreService o możliwość tworzenia oraz usuwania migawek. Dodatkowo usługa loguje operacje z użyciem nLoga:
public interface IDatabaseRestoreService | |
{ | |
Result Restore(string connectionString); | |
Result CreateSnapshot(string connectionString, string path); | |
Result DropSnapshot(string connectionString); | |
} |
public class DatabaseRestoreService : IDatabaseRestoreService | |
{ | |
private static NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger(); | |
public Result Restore(string connectionString) | |
{ | |
var connectionBuilder = new SqlConnectionStringBuilder(connectionString); | |
var databaseName = connectionBuilder.InitialCatalog; | |
_logger.Info($"Restore snapshot for {databaseName} database"); | |
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString) | |
{ InitialCatalog = "master" }; | |
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString)) | |
{ | |
conn.Open(); | |
using (var cmd = conn.CreateCommand()) | |
{ | |
cmd.CommandText = $@"IF DB_ID('{databaseName}_Snapshot') IS NOT NULL | |
BEGIN | |
ALTER DATABASE {databaseName} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; | |
RESTORE DATABASE {databaseName} FROM DATABASE_SNAPSHOT = '{databaseName}_Snapshot'; | |
ALTER DATABASE {databaseName} SET MULTI_USER; | |
END"; | |
_logger.Trace(cmd.CommandText); | |
cmd.ExecuteNonQuery(); | |
} | |
} | |
return Result.Ok(); | |
} | |
public Result CreateSnapshot(string connectionString, string path) | |
{ | |
var connectionBuilder = new SqlConnectionStringBuilder(connectionString); | |
var databaseName = connectionBuilder.InitialCatalog; | |
_logger.Info($"Create snapshot for {databaseName} database"); | |
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString) | |
{ InitialCatalog = "master" }; | |
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString)) | |
{ | |
conn.Open(); | |
using (var cmd = conn.CreateCommand()) | |
{ | |
cmd.CommandText = $@"CREATE DATABASE {databaseName}_Snapshot ON | |
( NAME = {databaseName}, FILENAME = '{path}' ) | |
AS SNAPSHOT OF {databaseName};"; | |
_logger.Trace(cmd.CommandText); | |
cmd.ExecuteNonQuery(); | |
} | |
} | |
return Result.Ok(); | |
} | |
public Result DropSnapshot(string connectionString) | |
{ | |
var connectionBuilder = new SqlConnectionStringBuilder(connectionString); | |
var databaseName = connectionBuilder.InitialCatalog; | |
_logger.Info($"Drop snapshot for {databaseName} database"); | |
var connectionToMasterBuilder = new SqlConnectionStringBuilder(connectionString) | |
{ InitialCatalog = "master" }; | |
using (var conn = new SqlConnection(connectionToMasterBuilder.ConnectionString)) | |
{ | |
conn.Open(); | |
using (var cmd = conn.CreateCommand()) | |
{ | |
cmd.CommandText = $@"IF DB_ID('{databaseName}_Snapshot') IS NOT NULL | |
DROP DATABASE {databaseName}_Snapshot;"; | |
_logger.Trace(cmd.CommandText); | |
cmd.ExecuteNonQuery(); | |
} | |
} | |
return Result.Ok(); | |
} | |
} |
W Migratorze rozszerzyłem klasę Options poprzez dodanie dwóch nowych właściwości. CreateSnapshot służy do przekazania informacji, czy podczas migracji wykonywane będą operacje na migawkach. Natomiast SnapshotPath posłuży do przekazania ścieżki, gdzie ma zostać zapisany plik migawki:
public class Options | |
{ | |
[Option('c', "connectionString", Required = true, HelpText = "The connection string to database that needs to be updated.")] | |
public string ConnectionString { get; set; } | |
[Option('s', "createSnapshot", Required = false, HelpText = "Should create database snapshot after running migrations.")] | |
public bool CreateSnapshot { get; set; } | |
[Option('p', "snapshotPath", Required = false, HelpText = "The path for snapshot database.")] | |
public string SnapshotPath { get; set; } | |
} |
Oczywiście tutaj można by dodać więcej parametrów. Na przykład parametr do przekazania nazwy migawki, zamiast budowania jej w określony sposób w DatabaseRestoreService.
Po wprowadzeniu nowych opcji zmieniła się również klasa Program Migratora:
class Program | |
{ | |
private static DatabaseRestoreService _databaseRestoreService; | |
protected static DatabaseRestoreService DatabaseRestoreService | |
{ | |
get | |
{ | |
if(_databaseRestoreService == null) | |
{ | |
_databaseRestoreService = new DatabaseRestoreService(); | |
} | |
return _databaseRestoreService; | |
} | |
} | |
static void Main(string[] args) | |
{ | |
var result = Parser.Default.ParseArguments<Options>(args); | |
result | |
.WithParsed(r => Migrate(r)); | |
} | |
private static void Migrate(Options options) | |
{ | |
if(options.CreateSnapshot) | |
{ | |
DatabaseRestoreService.Restore(options.ConnectionString); | |
DatabaseRestoreService.DropSnapshot(options.ConnectionString); | |
} | |
var configuration = new Configuration(); | |
configuration.TargetDatabase = new DbConnectionInfo( | |
options.ConnectionString, | |
"System.Data.SqlClient"); | |
var migrator = new DbMigrator(configuration); | |
MigratorLoggingDecorator logger = new MigratorLoggingDecorator(migrator, new MigrationLogger()); | |
logger.Update(); | |
if(options.CreateSnapshot) | |
{ | |
DatabaseRestoreService.CreateSnapshot(options.ConnectionString, options.SnapshotPath); | |
} | |
} | |
} |
Tak jak wspomniałem wyżej, w metodzie Migrate sprawdzamy wartość właściwości CreateSnapshot. Gdy jest na true, przywracamy bazę do stanu z migawki i migawkę usuwamy. Na końcu po wykonaniu migracji ponownie sprawdzamy CreateSnapshot i tworzymy nową migawkę, gdy właściwość ma wartość true.
Uruchomienie tak przygotowanego migratora wygląda w ten sposób:
WebApiTests.Migrator.exe -c "Server=.\sqlexpress;Database=WebApiTests;Trusted_Connection=True;" -s true -p "C:\db\snapshot\WebApiTests_Snapshot.ss" |
Uruchomienie migracji w Azure DevOps
Mając już przygotowanego migratora, możemy uruchomić migrację podczas wdrażania aplikacji. W tym wpisie rozbuduję definicję build oraz release z wpisu o Sql Server Snapsthops do resetowania danych w testach.
W definicji build dodałem kopiowanie plików „*.exe|*.dll|*.pdb|*.config” do folderu bin w artefaktach, aby później pliki te (w tym i migrator) były dostępne podczas wykonywania wdrożenia aplikacji. Sam krok wygląda tak:
- task: CopyFiles@2 | |
inputs: | |
SourceFolder: '$(Build.SourcesDirectory)' | |
Contents: '**/$(BuildConfiguration)/**/?(*.exe|*.dll|*.pdb|*.config)' | |
TargetFolder: '$(Build.ArtifactStagingDirectory)/bin' |
Cały plik definicji builda znajdziesz na githubie: https://github.com/danielplawgo/WebApiTests/blob/migration/azure-pipelines.yml.
W definicji release na początku dodałem dwie nowe zmienne (MigrationConnectionString oraz SnapshotPath), które później wykorzystam podczas wywoływania migratora:
W zmiennej MigrationConnectionString znajduje się connection string do bazy, który zostanie wykorzystany podczas migracji bazy danych do nowej wersji. Tak jak wspomniałem wcześniej, tutaj używamy użytkownika, który ma zwiększone uprawnienia i może zmieniać schemat bazy. Natomiast zmienna SnapshotPath zawiera ścieżkę do pliku migawki.
Do listy zadań dodałem na samej górze wywołanie komendy wiersza poleceń:
W konfiguracji taska poza zmianą nazwy ustawiłem dwie rzeczy:
- Working Directory (w sekcji Advanced) – wybrałem folder, w którym znajduje się skompilowany migrator – są to właśnie te pliki kopiowane przez dodany do buildu aplikacji task. Ustawienie tego folderu w ten sposób powoduje, że w Script mogę już wywoływać bezpośrednio migrator
- Script – wywołanie samego migratora z przekazaniem odpowiednich parametrów. Parametry są pobierane ze zmiennych z użyciem składni: $(nazwa zmiennej)
Dokładną zawartością Script jest:
WebApiTests.Migrator.exe -c "$(MigrationConnectionString)" -s true -p "$(SnapshotPath)" |
Tak skonfigurowany task spowoduje, że przed wdrożeniem nowej wersji aplikacji zostanie wykonana migracja bazy danych oraz snapshot na potrzeby testów WebApi z Postmana.
Przykładowy log wykonania tego nowego taska:
2019-09-15T06:55:52.2510007Z ##[section]Starting: Run database migration | |
2019-09-15T06:55:52.2637001Z ============================================================================== | |
2019-09-15T06:55:52.2637104Z Task : Command line | |
2019-09-15T06:55:52.2637193Z Description : Run a command line script using Bash on Linux and macOS and cmd.exe on Windows | |
2019-09-15T06:55:52.2637265Z Version : 2.151.2 | |
2019-09-15T06:55:52.2637340Z Author : Microsoft Corporation | |
2019-09-15T06:55:52.2637418Z Help : https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/command-line | |
2019-09-15T06:55:52.2637511Z ============================================================================== | |
2019-09-15T06:55:53.5204729Z Generating script. | |
2019-09-15T06:55:53.5431189Z Script contents: | |
2019-09-15T06:55:53.5438365Z WebApiTests.Migrator.exe -c "Server=104.40.135.99;Database=WebApiTests;User Id=daniel;Password='PasswordToSqlServer';" -s true -p "C:\db\snapshot\WebApiTests_Snapshot.ss" | |
2019-09-15T06:55:53.5787804Z ========================== Starting Command Output =========================== | |
2019-09-15T06:55:53.6058032Z ##[command]"C:\windows\system32\cmd.exe" /D /E:ON /V:OFF /S /C "CALL "d:\a\_temp\942d3048-d85e-4997-8ea3-aa3becf6f5ca.cmd"" | |
2019-09-15T06:56:00.1463923Z 2019-09-15 06:56:00.0025 INFO Restore snapshot for WebApiTests database | |
2019-09-15T06:56:01.4606166Z 2019-09-15 06:56:01.4271 TRACE IF DB_ID('WebApiTests_Snapshot') IS NOT NULL | |
2019-09-15T06:56:01.4606744Z BEGIN | |
2019-09-15T06:56:01.4606943Z ALTER DATABASE WebApiTests SET SINGLE_USER WITH ROLLBACK IMMEDIATE; | |
2019-09-15T06:56:01.4607153Z RESTORE DATABASE WebApiTests FROM DATABASE_SNAPSHOT = 'WebApiTests_Snapshot'; | |
2019-09-15T06:56:01.4607338Z ALTER DATABASE WebApiTests SET MULTI_USER; | |
2019-09-15T06:56:01.4607716Z END | |
2019-09-15T06:56:02.1985831Z 2019-09-15 06:56:02.1939 INFO Drop snapshot for WebApiTests database | |
2019-09-15T06:56:02.1986673Z 2019-09-15 06:56:02.1939 TRACE IF DB_ID('WebApiTests_Snapshot') IS NOT NULL | |
2019-09-15T06:56:02.1986915Z DROP DATABASE WebApiTests_Snapshot; | |
2019-09-15T06:56:06.6874425Z 2019-09-15 06:56:06.6766 TRACE Target database is: 'WebApiTests' (DataSource: 104.40.135.99, Provider: System.Data.SqlClient, Origin: Explicit). | |
2019-09-15T06:56:07.7639754Z 2019-09-15 06:56:07.7615 INFO No pending explicit migrations. | |
2019-09-15T06:56:07.8954448Z 2019-09-15 06:56:07.8708 INFO Running Seed method. | |
2019-09-15T06:56:07.8981609Z 2019-09-15 06:56:07.8865 INFO Create snapshot for WebApiTests database | |
2019-09-15T06:56:07.8984340Z 2019-09-15 06:56:07.8865 TRACE CREATE DATABASE WebApiTests_Snapshot ON | |
2019-09-15T06:56:07.8985341Z ( NAME = WebApiTests, FILENAME = 'C:\db\snapshot\WebApiTests_Snapshot.ss' ) | |
2019-09-15T06:56:07.8985500Z AS SNAPSHOT OF WebApiTests; | |
2019-09-15T06:56:09.8687363Z ##[section]Finishing: Run database migration |
Przykład
Kod wykorzystany w tym wpisie znajdziesz na githubie. Rozbudowałem wcześniejszy przykład i wszystkie dodatkowe zmiany znajdują się w branch migration – https://github.com/danielplawgo/WebApiTests/tree/migration. W przykładzie poza zmianą connection stringa w Web.config w projekcie WebApi należy jeszcze zmienić connection stringa we właściwościach projektu migratora w zakładce Debug, w której ustawia się parametry przekazywane w wierszu poleceń podczas uruchamiania aplikacji z poziomu Visual Studio.
Podsumowanie
Mam nadzieję, że udało mi się pokazać Ci, że wykonywanie migracji bazy danych z użyciem innego użytkownika podczas wdrażania jej nie musi być czymś skomplikowanym. Gorąco Ci polecam, abyś nie wykonywał migracji podczas startu aplikacji, bo niestety zawsze niesie to za sobą jakieś dodatkowe ryzyko.
A może u Ciebie w projekcie ten problem rozwiązaliście w inny sposób? Z chęcią dowiem się, jak to jest u Was. Zostaw komentarz pod wpisem!
Bardzo przydatny tekst – dużo ciekawych i nowych rzeczy się dowiedziałam. Przyjemnie się czytało, będę zaglądała częściej. Migracja azure jest naprawdę świetnym rozwiązaniem.