feat: complete Oracle and MariaDB WebView data layer
This commit is contained in:
@@ -19,6 +19,12 @@ public enum DataSourceKind
|
||||
public interface IDataQueryExecutor
|
||||
{
|
||||
DataTable Execute(DataSourceKind source, string tableName, string query);
|
||||
|
||||
Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,9 +34,9 @@ public static class DataQueryExecutor
|
||||
{
|
||||
private static IDataQueryExecutor? _current;
|
||||
|
||||
public static bool IsConfigured => _current is not null;
|
||||
public static bool IsConfigured => Volatile.Read(ref _current) is not null;
|
||||
|
||||
public static IDataQueryExecutor Current => _current
|
||||
public static IDataQueryExecutor Current => Volatile.Read(ref _current)
|
||||
?? throw new InvalidOperationException(
|
||||
"A database query executor has not been configured. " +
|
||||
"Configure the infrastructure adapter before requesting live data.");
|
||||
@@ -38,8 +44,8 @@ public static class DataQueryExecutor
|
||||
public static void Configure(IDataQueryExecutor executor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(executor);
|
||||
_current = executor;
|
||||
Interlocked.Exchange(ref _current, executor);
|
||||
}
|
||||
|
||||
public static void Reset() => _current = null;
|
||||
public static void Reset() => Interlocked.Exchange(ref _current, null);
|
||||
}
|
||||
|
||||
165
src/MBN_STOCK_WEBVIEW.Core/Data/MarketData.cs
Normal file
165
src/MBN_STOCK_WEBVIEW.Core/Data/MarketData.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
public enum MarketDataView
|
||||
{
|
||||
Kospi,
|
||||
Kosdaq,
|
||||
Index,
|
||||
Overseas
|
||||
}
|
||||
|
||||
public sealed record MarketDataTable(
|
||||
string Name,
|
||||
DataSourceKind Source,
|
||||
IReadOnlyList<string> Columns,
|
||||
IReadOnlyList<IReadOnlyList<string?>> Rows,
|
||||
int TotalRowCount);
|
||||
|
||||
public sealed record MarketDataSnapshot(
|
||||
MarketDataView View,
|
||||
DateTimeOffset RetrievedAt,
|
||||
IReadOnlyList<MarketDataTable> Tables);
|
||||
|
||||
public interface IMarketDataService
|
||||
{
|
||||
Task<MarketDataSnapshot> GetAsync(
|
||||
MarketDataView view,
|
||||
int maximumRowsPerTable = 250,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the same stock and index lookup queries used by the legacy application.
|
||||
/// Query text is intentionally kept unchanged during the migration.
|
||||
/// </summary>
|
||||
public sealed class LegacyMarketDataService(IDataQueryExecutor executor) : IMarketDataService
|
||||
{
|
||||
private readonly IDataQueryExecutor _executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
|
||||
public async Task<MarketDataSnapshot> GetAsync(
|
||||
MarketDataView view,
|
||||
int maximumRowsPerTable = 250,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (maximumRowsPerTable is < 1 or > 2_000)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maximumRowsPerTable),
|
||||
"The requested row limit must be between 1 and 2,000.");
|
||||
}
|
||||
|
||||
var queries = LegacyMarketQueries.For(view);
|
||||
var loads = queries.Select(query => LoadAsync(query, maximumRowsPerTable, cancellationToken));
|
||||
var tables = await Task.WhenAll(loads).ConfigureAwait(false);
|
||||
return new MarketDataSnapshot(view, DateTimeOffset.Now, tables);
|
||||
}
|
||||
|
||||
private async Task<MarketDataTable> LoadAsync(
|
||||
LegacyMarketQuery query,
|
||||
int maximumRows,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var table = await _executor
|
||||
.ExecuteAsync(query.Source, query.Name, query.Sql, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var columns = table.Columns.Cast<DataColumn>().Select(column => column.ColumnName).ToArray();
|
||||
var rows = table.Rows.Cast<DataRow>()
|
||||
.Take(maximumRows)
|
||||
.Select(row => (IReadOnlyList<string?>)columns
|
||||
.Select(column => FormatValue(row[column]))
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
|
||||
return new MarketDataTable(query.Name, query.Source, columns, rows, table.Rows.Count);
|
||||
}
|
||||
|
||||
private static string? FormatValue(object? value)
|
||||
{
|
||||
if (value is null or DBNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
DateTime dateTime => dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LegacyMarketQuery(string Name, DataSourceKind Source, string Sql);
|
||||
|
||||
internal static class LegacyMarketQueries
|
||||
{
|
||||
internal static IReadOnlyList<LegacyMarketQuery> For(MarketDataView view) => view switch
|
||||
{
|
||||
MarketDataView.Kospi =>
|
||||
[
|
||||
new("코스피", DataSourceKind.Oracle,
|
||||
"SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||
new("NXT코스피", DataSourceKind.MariaDb,
|
||||
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'")
|
||||
],
|
||||
MarketDataView.Kosdaq =>
|
||||
[
|
||||
new("코스닥", DataSourceKind.Oracle,
|
||||
"SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||
new("NXT코스닥", DataSourceKind.MariaDb,
|
||||
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
|
||||
],
|
||||
MarketDataView.Index =>
|
||||
[
|
||||
new("코스피", DataSourceKind.Oracle, IndexKospi),
|
||||
new("코스닥", DataSourceKind.Oracle, IndexKosdaq),
|
||||
new("코스피200", DataSourceKind.Oracle, IndexKospi200),
|
||||
new("KRX100", DataSourceKind.Oracle, IndexKrx100),
|
||||
new("선물", DataSourceKind.Oracle, IndexFuture)
|
||||
],
|
||||
MarketDataView.Overseas =>
|
||||
[
|
||||
new("해외", DataSourceKind.Oracle,
|
||||
"SELECT F_KNAM, F_SYMB FROM T_WORLD_IX_EQ_MASTER")
|
||||
],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(view), view, "Unsupported market data view.")
|
||||
};
|
||||
|
||||
private const string IndexKospi = @"SELECT '코스피' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
|
||||
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
|
||||
FROM t_index
|
||||
WHERE f_part_code = '001'";
|
||||
|
||||
private const string IndexKosdaq = @"SELECT '코스닥' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
|
||||
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
|
||||
FROM t_kosdaq_index
|
||||
WHERE f_part_code = '001'";
|
||||
|
||||
private const string IndexKospi200 = @"SELECT '코스피200' name,f_part_idx/100 part_idx, f_chg_type chg_type, f_part_chg/100 part_chg,
|
||||
round(f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1) * 100, 2) rate
|
||||
FROM T_200_INDEX
|
||||
WHERE f_part_code = '029'";
|
||||
|
||||
private const string IndexKrx100 = @"SELECT 'KRX100' name, f_part_idx/100 part_idx, f_chg_type chg_type, f_part_chg/100 part_chg,
|
||||
round(f_part_chg / decode(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100), '-', (f_part_idx / 100) + (f_part_chg / 100)), 2) rate
|
||||
FROM t_krx100_index WHERE f_part_code = '043'";
|
||||
|
||||
private const string IndexFuture = @"SELECT '선물' name, part_idx part_idx, chg_type, ABS(part_chg) part_chg,F_NET_VOL,f_out_vol, F_NET_TURNOVER,
|
||||
ABS(round(part_chg / decode(chg_type, '+', (part_idx / 100) - (part_chg / 100),
|
||||
'-', (part_idx / 100) + (part_chg / 100), 1), 2)) rate
|
||||
FROM(SELECT decode(a.f_curr_price,0, b.F_JUN_LAST_PRICE/100, a.f_curr_price/100) part_idx, a.F_NET_VOL, a.f_out_vol, a.F_NET_TURNOVER,
|
||||
decode(a.f_curr_price, 0, 0, (a.f_curr_price - b.F_JUN_LAST_PRICE*100)/100) part_chg,
|
||||
decode(a.f_curr_price, 0, ' ', decode(sign(a.f_curr_price -b.F_JUN_LAST_PRICE*100),1,'+',-1,'-',0,' ')) chg_type
|
||||
FROM t_sunmul_online a, t_sunmul_batch b
|
||||
WHERE a.f_stock_code = b.f_stock_code
|
||||
AND a.f_stock_seq = 1
|
||||
AND b.f_market_date = (select max(OPEN_DAY) from v_open_day)
|
||||
AND b.F_MONTH_GUBUN = '1') ";
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public enum MariaDbTlsMode
|
||||
{
|
||||
Disabled,
|
||||
Preferred,
|
||||
Required,
|
||||
VerifyCertificate,
|
||||
VerifyIdentity
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
{
|
||||
public OracleDatabaseOptions Oracle { get; set; } = new();
|
||||
|
||||
public MariaDbDatabaseOptions MariaDb { get; set; } = new();
|
||||
|
||||
public DatabaseResilienceOptions Resilience { get; set; } = new();
|
||||
|
||||
public bool IsConfigured(DataSourceKind source) => GetConfigurationErrors(source).Count == 0;
|
||||
|
||||
public IReadOnlyList<string> GetConfigurationErrors(DataSourceKind source) => source switch
|
||||
{
|
||||
DataSourceKind.Oracle => Oracle.GetConfigurationErrors(),
|
||||
DataSourceKind.MariaDb => MariaDb.GetConfigurationErrors(),
|
||||
_ => ["DataSource"]
|
||||
};
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
Oracle ??= new OracleDatabaseOptions();
|
||||
MariaDb ??= new MariaDbDatabaseOptions();
|
||||
Resilience ??= new DatabaseResilienceOptions();
|
||||
}
|
||||
|
||||
internal void ValidateRuntimeSettings()
|
||||
{
|
||||
if (Resilience.OperationTimeoutSeconds is < 1 or > 600)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"OperationTimeoutSeconds는 1초에서 600초 사이여야 합니다.");
|
||||
}
|
||||
|
||||
if (Resilience.MaximumRetryCount is < 0 or > 5)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"MaximumRetryCount는 0에서 5 사이여야 합니다.");
|
||||
}
|
||||
|
||||
if (Resilience.InitialRetryDelayMilliseconds is < 0 or > 30_000)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"InitialRetryDelayMilliseconds는 0에서 30000 사이여야 합니다.");
|
||||
}
|
||||
|
||||
Oracle.ValidateTimeouts();
|
||||
MariaDb.ValidateTimeouts();
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"DatabaseOptions {{ Oracle = {ConfigurationState(Oracle.GetConfigurationErrors())}, " +
|
||||
$"MariaDb = {ConfigurationState(MariaDb.GetConfigurationErrors())}, " +
|
||||
$"Resilience = {Resilience} }}";
|
||||
|
||||
private static string ConfigurationState(IReadOnlyCollection<string> errors) =>
|
||||
errors.Count == 0 ? "Configured" : "NotConfigured";
|
||||
}
|
||||
|
||||
public sealed class OracleDatabaseOptions
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
|
||||
public int Port { get; set; } = 1521;
|
||||
|
||||
public string? Sid { get; set; }
|
||||
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
public string? UserName { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public int ConnectTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public bool Pooling { get; set; } = true;
|
||||
|
||||
internal IReadOnlyList<string> GetConfigurationErrors()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
AddIfBlank(errors, Host, nameof(Host));
|
||||
AddIfBlank(errors, UserName, nameof(UserName));
|
||||
AddIfBlank(errors, Password, nameof(Password));
|
||||
|
||||
var hasSid = !string.IsNullOrWhiteSpace(Sid);
|
||||
var hasServiceName = !string.IsNullOrWhiteSpace(ServiceName);
|
||||
if (hasSid == hasServiceName)
|
||||
{
|
||||
errors.Add("SidOrServiceName");
|
||||
}
|
||||
|
||||
if (Port is < 1 or > 65_535)
|
||||
{
|
||||
errors.Add(nameof(Port));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
internal void ValidateTimeouts()
|
||||
{
|
||||
ValidateTimeout(ConnectTimeoutSeconds, nameof(ConnectTimeoutSeconds));
|
||||
ValidateTimeout(CommandTimeoutSeconds, nameof(CommandTimeoutSeconds));
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"OracleDatabaseOptions {{ State = {(GetConfigurationErrors().Count == 0 ? "Configured" : "NotConfigured")}, " +
|
||||
$"Port = {Port}, ConnectTimeoutSeconds = {ConnectTimeoutSeconds}, " +
|
||||
$"CommandTimeoutSeconds = {CommandTimeoutSeconds}, Pooling = {Pooling} }}";
|
||||
|
||||
private static void ValidateTimeout(int value, string settingName)
|
||||
{
|
||||
if (value is < 1 or > 600)
|
||||
{
|
||||
throw new DatabaseConfigurationException($"{settingName}는 1초에서 600초 사이여야 합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(ICollection<string> errors, string? value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
errors.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MariaDbDatabaseOptions
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
|
||||
public int Port { get; set; } = 3306;
|
||||
|
||||
public string? Database { get; set; }
|
||||
|
||||
public string? UserName { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public MariaDbTlsMode TlsMode { get; set; } = MariaDbTlsMode.Preferred;
|
||||
|
||||
public int ConnectTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public bool Pooling { get; set; } = true;
|
||||
|
||||
internal IReadOnlyList<string> GetConfigurationErrors()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
AddIfBlank(errors, Host, nameof(Host));
|
||||
AddIfBlank(errors, Database, nameof(Database));
|
||||
AddIfBlank(errors, UserName, nameof(UserName));
|
||||
AddIfBlank(errors, Password, nameof(Password));
|
||||
|
||||
if (Port is < 1 or > 65_535)
|
||||
{
|
||||
errors.Add(nameof(Port));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
internal void ValidateTimeouts()
|
||||
{
|
||||
ValidateTimeout(ConnectTimeoutSeconds, nameof(ConnectTimeoutSeconds));
|
||||
ValidateTimeout(CommandTimeoutSeconds, nameof(CommandTimeoutSeconds));
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"MariaDbDatabaseOptions {{ State = {(GetConfigurationErrors().Count == 0 ? "Configured" : "NotConfigured")}, " +
|
||||
$"Port = {Port}, TlsMode = {TlsMode}, ConnectTimeoutSeconds = {ConnectTimeoutSeconds}, " +
|
||||
$"CommandTimeoutSeconds = {CommandTimeoutSeconds}, Pooling = {Pooling} }}";
|
||||
|
||||
private static void ValidateTimeout(int value, string settingName)
|
||||
{
|
||||
if (value is < 1 or > 600)
|
||||
{
|
||||
throw new DatabaseConfigurationException($"{settingName}는 1초에서 600초 사이여야 합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(ICollection<string> errors, string? value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
errors.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DatabaseResilienceOptions
|
||||
{
|
||||
public int OperationTimeoutSeconds { get; set; } = 45;
|
||||
|
||||
public int MaximumRetryCount { get; set; } = 2;
|
||||
|
||||
public int InitialRetryDelayMilliseconds { get; set; } = 250;
|
||||
|
||||
public override string ToString() =>
|
||||
$"DatabaseResilienceOptions {{ OperationTimeoutSeconds = {OperationTimeoutSeconds}, " +
|
||||
$"MaximumRetryCount = {MaximumRetryCount}, " +
|
||||
$"InitialRetryDelayMilliseconds = {InitialRetryDelayMilliseconds} }}";
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public sealed class DatabaseOptionsLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly Func<string, string?> _readEnvironmentVariable;
|
||||
|
||||
public DatabaseOptionsLoader(Func<string, string?>? readEnvironmentVariable = null)
|
||||
{
|
||||
_readEnvironmentVariable = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
|
||||
}
|
||||
|
||||
public static string DefaultConfigurationPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"사용자 로컬 애플리케이션 데이터 폴더를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Config",
|
||||
"database.local.json");
|
||||
}
|
||||
}
|
||||
|
||||
public DatabaseOptions Load(string? jsonPath = null)
|
||||
{
|
||||
var path = jsonPath ?? DefaultConfigurationPath;
|
||||
var options = LoadJson(path);
|
||||
ApplyEnvironmentOverrides(options);
|
||||
options.Normalize();
|
||||
options.ValidateRuntimeSettings();
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task<DatabaseOptions> LoadAsync(
|
||||
string? jsonPath = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = jsonPath ?? DefaultConfigurationPath;
|
||||
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
ApplyEnvironmentOverrides(options);
|
||||
options.Normalize();
|
||||
options.ValidateRuntimeSettings();
|
||||
return options;
|
||||
}
|
||||
|
||||
private static DatabaseOptions LoadJson(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new DatabaseOptions();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<DatabaseOptions>(json, SerializerOptions)
|
||||
?? new DatabaseOptions();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"로컬 데이터베이스 설정 파일을 읽을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DatabaseOptions> LoadJsonAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new DatabaseOptions();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4_096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<DatabaseOptions>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? new DatabaseOptions();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"로컬 데이터베이스 설정 파일을 읽을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyEnvironmentOverrides(DatabaseOptions options)
|
||||
{
|
||||
options.Normalize();
|
||||
|
||||
OverrideString("MBN_STOCK_ORACLE_HOST", value => options.Oracle.Host = value);
|
||||
OverrideInt32("MBN_STOCK_ORACLE_PORT", value => options.Oracle.Port = value);
|
||||
OverrideString("MBN_STOCK_ORACLE_SID", value => options.Oracle.Sid = value);
|
||||
OverrideString("MBN_STOCK_ORACLE_SERVICE_NAME", value => options.Oracle.ServiceName = value);
|
||||
OverrideString("MBN_STOCK_ORACLE_USERNAME", value => options.Oracle.UserName = value);
|
||||
OverrideString("MBN_STOCK_ORACLE_PASSWORD", value => options.Oracle.Password = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_ORACLE_CONNECT_TIMEOUT_SECONDS",
|
||||
value => options.Oracle.ConnectTimeoutSeconds = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_ORACLE_COMMAND_TIMEOUT_SECONDS",
|
||||
value => options.Oracle.CommandTimeoutSeconds = value);
|
||||
OverrideBoolean("MBN_STOCK_ORACLE_POOLING", value => options.Oracle.Pooling = value);
|
||||
|
||||
OverrideString("MBN_STOCK_MARIADB_HOST", value => options.MariaDb.Host = value);
|
||||
OverrideInt32("MBN_STOCK_MARIADB_PORT", value => options.MariaDb.Port = value);
|
||||
OverrideString("MBN_STOCK_MARIADB_DATABASE", value => options.MariaDb.Database = value);
|
||||
OverrideString("MBN_STOCK_MARIADB_USERNAME", value => options.MariaDb.UserName = value);
|
||||
OverrideString("MBN_STOCK_MARIADB_PASSWORD", value => options.MariaDb.Password = value);
|
||||
OverrideEnum<MariaDbTlsMode>(
|
||||
"MBN_STOCK_MARIADB_TLS_MODE",
|
||||
value => options.MariaDb.TlsMode = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_MARIADB_CONNECT_TIMEOUT_SECONDS",
|
||||
value => options.MariaDb.ConnectTimeoutSeconds = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_MARIADB_COMMAND_TIMEOUT_SECONDS",
|
||||
value => options.MariaDb.CommandTimeoutSeconds = value);
|
||||
OverrideBoolean("MBN_STOCK_MARIADB_POOLING", value => options.MariaDb.Pooling = value);
|
||||
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_DB_OPERATION_TIMEOUT_SECONDS",
|
||||
value => options.Resilience.OperationTimeoutSeconds = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_DB_MAX_RETRY_COUNT",
|
||||
value => options.Resilience.MaximumRetryCount = value);
|
||||
OverrideInt32(
|
||||
"MBN_STOCK_DB_INITIAL_RETRY_DELAY_MS",
|
||||
value => options.Resilience.InitialRetryDelayMilliseconds = value);
|
||||
}
|
||||
|
||||
private void OverrideString(string name, Action<string> setter)
|
||||
{
|
||||
var value = _readEnvironmentVariable(name);
|
||||
if (value is not null)
|
||||
{
|
||||
setter(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private void OverrideInt32(string name, Action<int> setter)
|
||||
{
|
||||
var value = _readEnvironmentVariable(name);
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
throw InvalidEnvironmentSetting(name);
|
||||
}
|
||||
|
||||
setter(parsed);
|
||||
}
|
||||
|
||||
private void OverrideBoolean(string name, Action<bool> setter)
|
||||
{
|
||||
var value = _readEnvironmentVariable(name);
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bool.TryParse(value, out var parsed))
|
||||
{
|
||||
throw InvalidEnvironmentSetting(name);
|
||||
}
|
||||
|
||||
setter(parsed);
|
||||
}
|
||||
|
||||
private void OverrideEnum<T>(string name, Action<T> setter)
|
||||
where T : struct, Enum
|
||||
{
|
||||
var value = _readEnvironmentVariable(name);
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<T>(value, ignoreCase: true, out var parsed)
|
||||
|| !Enum.IsDefined(parsed))
|
||||
{
|
||||
throw InvalidEnvironmentSetting(name);
|
||||
}
|
||||
|
||||
setter(parsed);
|
||||
}
|
||||
|
||||
private static DatabaseConfigurationException InvalidEnvironmentSetting(string name) =>
|
||||
new($"환경 설정 {name}의 값이 올바르지 않습니다.");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Data.Common;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public interface IDatabaseConnectionFactory
|
||||
{
|
||||
DbConnection Create(DataSourceKind source);
|
||||
|
||||
int GetCommandTimeoutSeconds(DataSourceKind source);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Data.Common;
|
||||
using MMoneyCoderSharp.Data;
|
||||
using MySqlConnector;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public sealed class ProviderDatabaseConnectionFactory : IDatabaseConnectionFactory
|
||||
{
|
||||
private readonly DatabaseOptions _options;
|
||||
|
||||
public ProviderDatabaseConnectionFactory(DatabaseOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Normalize();
|
||||
_options.ValidateRuntimeSettings();
|
||||
}
|
||||
|
||||
public DbConnection Create(DataSourceKind source)
|
||||
{
|
||||
var errors = _options.GetConfigurationErrors(source);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new DatabaseNotConfiguredException(source, errors);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return source switch
|
||||
{
|
||||
DataSourceKind.Oracle => CreateOracleConnection(),
|
||||
DataSourceKind.MariaDb => CreateMariaDbConnection(),
|
||||
_ => throw new DatabaseConfigurationException("지원하지 않는 데이터 원본입니다.")
|
||||
};
|
||||
}
|
||||
catch (DatabaseInfrastructureException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
$"{source} 데이터베이스 연결 설정을 구성할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public int GetCommandTimeoutSeconds(DataSourceKind source) => source switch
|
||||
{
|
||||
DataSourceKind.Oracle => _options.Oracle.CommandTimeoutSeconds,
|
||||
DataSourceKind.MariaDb => _options.MariaDb.CommandTimeoutSeconds,
|
||||
_ => throw new DatabaseConfigurationException("지원하지 않는 데이터 원본입니다.")
|
||||
};
|
||||
|
||||
private OracleConnection CreateOracleConnection()
|
||||
{
|
||||
var options = _options.Oracle;
|
||||
var host = RequireOracleDescriptorToken(options.Host, nameof(options.Host));
|
||||
var identifierName = string.IsNullOrWhiteSpace(options.ServiceName) ? "SID" : "SERVICE_NAME";
|
||||
var identifierValue = string.IsNullOrWhiteSpace(options.ServiceName)
|
||||
? RequireOracleDescriptorToken(options.Sid, nameof(options.Sid))
|
||||
: RequireOracleDescriptorToken(options.ServiceName, nameof(options.ServiceName));
|
||||
|
||||
var dataSource =
|
||||
$"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={host})(PORT={options.Port}))" +
|
||||
$"(CONNECT_DATA=(SERVER=DEDICATED)({identifierName}={identifierValue})))";
|
||||
|
||||
var builder = new OracleConnectionStringBuilder
|
||||
{
|
||||
DataSource = dataSource,
|
||||
UserID = options.UserName,
|
||||
Password = options.Password,
|
||||
Pooling = options.Pooling,
|
||||
PersistSecurityInfo = false,
|
||||
ConnectionTimeout = options.ConnectTimeoutSeconds
|
||||
};
|
||||
|
||||
return new OracleConnection(builder.ConnectionString);
|
||||
}
|
||||
|
||||
private MySqlConnection CreateMariaDbConnection()
|
||||
{
|
||||
var options = _options.MariaDb;
|
||||
var builder = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = options.Host,
|
||||
Port = checked((uint)options.Port),
|
||||
Database = options.Database,
|
||||
UserID = options.UserName,
|
||||
Password = options.Password,
|
||||
Pooling = options.Pooling,
|
||||
PersistSecurityInfo = false,
|
||||
ConnectionTimeout = checked((uint)options.ConnectTimeoutSeconds),
|
||||
DefaultCommandTimeout = checked((uint)options.CommandTimeoutSeconds),
|
||||
CancellationTimeout = 2,
|
||||
SslMode = options.TlsMode switch
|
||||
{
|
||||
MariaDbTlsMode.Disabled => MySqlSslMode.None,
|
||||
MariaDbTlsMode.Preferred => MySqlSslMode.Preferred,
|
||||
MariaDbTlsMode.Required => MySqlSslMode.Required,
|
||||
MariaDbTlsMode.VerifyCertificate => MySqlSslMode.VerifyCA,
|
||||
MariaDbTlsMode.VerifyIdentity => MySqlSslMode.VerifyFull,
|
||||
_ => throw new DatabaseConfigurationException("MariaDB TLS 설정이 올바르지 않습니다.")
|
||||
}
|
||||
};
|
||||
|
||||
return new MySqlConnection(builder.ConnectionString);
|
||||
}
|
||||
|
||||
private static string RequireOracleDescriptorToken(string? value, string settingName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new DatabaseConfigurationException($"Oracle {settingName} 설정이 필요합니다.");
|
||||
}
|
||||
|
||||
if (value.IndexOfAny(['(', ')', '=', ';', '\'', '"', '\r', '\n']) >= 0)
|
||||
{
|
||||
throw new DatabaseConfigurationException($"Oracle {settingName} 설정 형식이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
44
src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs
Normal file
44
src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public sealed class DatabaseRuntime
|
||||
{
|
||||
private DatabaseRuntime(
|
||||
DatabaseOptions options,
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
IDataQueryExecutor executor,
|
||||
IDatabaseHealthService healthService)
|
||||
{
|
||||
Options = options;
|
||||
ConnectionFactory = connectionFactory;
|
||||
Executor = executor;
|
||||
HealthService = healthService;
|
||||
}
|
||||
|
||||
public DatabaseOptions Options { get; }
|
||||
|
||||
public IDatabaseConnectionFactory ConnectionFactory { get; }
|
||||
|
||||
public IDataQueryExecutor Executor { get; }
|
||||
|
||||
public IDatabaseHealthService HealthService { get; }
|
||||
|
||||
public static DatabaseRuntime CreateDefault(string? configurationPath = null)
|
||||
{
|
||||
var options = new DatabaseOptionsLoader().Load(configurationPath);
|
||||
return Create(options);
|
||||
}
|
||||
|
||||
public static DatabaseRuntime Create(DatabaseOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Normalize();
|
||||
options.ValidateRuntimeSettings();
|
||||
|
||||
var connectionFactory = new ProviderDatabaseConnectionFactory(options);
|
||||
var executor = new ResilientDataQueryExecutor(connectionFactory, options.Resilience);
|
||||
var healthService = new DatabaseHealthService(executor, options);
|
||||
return new DatabaseRuntime(options, connectionFactory, executor, healthService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public class DatabaseInfrastructureException : Exception
|
||||
{
|
||||
public DatabaseInfrastructureException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DatabaseConfigurationException : DatabaseInfrastructureException
|
||||
{
|
||||
public DatabaseConfigurationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class DatabaseOperationException : DatabaseInfrastructureException
|
||||
{
|
||||
protected DatabaseOperationException(
|
||||
DataSourceKind source,
|
||||
string message,
|
||||
bool isTransient,
|
||||
int? providerErrorCode = null)
|
||||
: base(message)
|
||||
{
|
||||
DataSource = source;
|
||||
IsTransient = isTransient;
|
||||
ProviderErrorCode = providerErrorCode;
|
||||
}
|
||||
|
||||
public DataSourceKind DataSource { get; }
|
||||
|
||||
public bool IsTransient { get; }
|
||||
|
||||
public int? ProviderErrorCode { get; }
|
||||
}
|
||||
|
||||
public sealed class DatabaseNotConfiguredException : DatabaseOperationException
|
||||
{
|
||||
public DatabaseNotConfiguredException(DataSourceKind source, IReadOnlyList<string> invalidSettings)
|
||||
: base(
|
||||
source,
|
||||
$"{source} 데이터베이스 연결 정보가 설정되지 않았습니다. " +
|
||||
$"확인할 항목: {string.Join(", ", invalidSettings)}.",
|
||||
isTransient: false)
|
||||
{
|
||||
InvalidSettings = invalidSettings.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> InvalidSettings { get; }
|
||||
}
|
||||
|
||||
public sealed class DatabaseQueryException : DatabaseOperationException
|
||||
{
|
||||
public DatabaseQueryException(
|
||||
DataSourceKind source,
|
||||
bool isTransient,
|
||||
int? providerErrorCode = null)
|
||||
: base(
|
||||
source,
|
||||
"데이터베이스 요청을 처리할 수 없습니다. 연결 설정과 네트워크 상태를 확인하세요.",
|
||||
isTransient,
|
||||
providerErrorCode)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DatabaseQueryTimeoutException : DatabaseOperationException
|
||||
{
|
||||
public DatabaseQueryTimeoutException(DataSourceKind source)
|
||||
: base(
|
||||
source,
|
||||
"데이터베이스 응답 시간이 초과되었습니다. 잠시 후 다시 시도하세요.",
|
||||
isTransient: true)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
|
||||
{
|
||||
private readonly IDatabaseConnectionFactory _connectionFactory;
|
||||
private readonly ITransientDatabaseErrorDetector _errorDetector;
|
||||
private readonly TimeSpan _operationTimeout;
|
||||
private readonly int _maximumRetryCount;
|
||||
private readonly TimeSpan _initialRetryDelay;
|
||||
|
||||
public ResilientDataQueryExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
DatabaseResilienceOptions resilienceOptions,
|
||||
ITransientDatabaseErrorDetector? errorDetector = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
ArgumentNullException.ThrowIfNull(resilienceOptions);
|
||||
|
||||
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
|
||||
validationOptions.ValidateRuntimeSettings();
|
||||
|
||||
_errorDetector = errorDetector ?? new TransientDatabaseErrorDetector();
|
||||
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
|
||||
_maximumRetryCount = resilienceOptions.MaximumRetryCount;
|
||||
_initialRetryDelay = TimeSpan.FromMilliseconds(resilienceOptions.InitialRetryDelayMilliseconds);
|
||||
}
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
|
||||
ExecuteAsync(source, tableName, query, CancellationToken.None)
|
||||
.ConfigureAwait(false)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
public async Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(_operationTimeout);
|
||||
var operationToken = timeoutSource.Token;
|
||||
var retryableQuery = IsIdempotentReadQuery(query);
|
||||
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ExecuteAttemptAsync(source, tableName, query, operationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw new DatabaseQueryTimeoutException(source);
|
||||
}
|
||||
catch (DatabaseNotConfiguredException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (DatabaseConfigurationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (_errorDetector.IsTimeout(source, exception))
|
||||
{
|
||||
throw new DatabaseQueryTimeoutException(source);
|
||||
}
|
||||
|
||||
var isTransient = _errorDetector.IsTransient(source, exception);
|
||||
if (retryableQuery && isTransient && attempt < _maximumRetryCount)
|
||||
{
|
||||
await DelayBeforeRetryAsync(source, attempt, operationToken, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new DatabaseQueryException(
|
||||
source,
|
||||
isTransient,
|
||||
_errorDetector.GetProviderErrorCode(source, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DataTable> ExecuteAttemptAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = _connectionFactory.Create(source);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = query;
|
||||
command.CommandType = CommandType.Text;
|
||||
command.CommandTimeout = _connectionFactory.GetCommandTimeoutSeconds(source);
|
||||
|
||||
await using var reader = await command
|
||||
.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ReadDataTableAsync(reader, tableName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DelayBeforeRetryAsync(
|
||||
DataSourceKind source,
|
||||
int completedAttempt,
|
||||
CancellationToken operationToken,
|
||||
CancellationToken callerToken)
|
||||
{
|
||||
if (_initialRetryDelay <= TimeSpan.Zero)
|
||||
{
|
||||
operationToken.ThrowIfCancellationRequested();
|
||||
return;
|
||||
}
|
||||
|
||||
var multiplier = 1L << Math.Min(completedAttempt, 10);
|
||||
var delayMilliseconds = Math.Min(
|
||||
_initialRetryDelay.TotalMilliseconds * multiplier,
|
||||
30_000d);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds), operationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (callerToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw new DatabaseQueryTimeoutException(source);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DataTable> ReadDataTableAsync(
|
||||
DbDataReader reader,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var table = new DataTable(string.IsNullOrWhiteSpace(tableName) ? "Result" : tableName)
|
||||
{
|
||||
Locale = System.Globalization.CultureInfo.InvariantCulture
|
||||
};
|
||||
|
||||
var columnNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var ordinal = 0; ordinal < reader.FieldCount; ordinal++)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(reader.GetName(ordinal))
|
||||
? $"Column{ordinal + 1}"
|
||||
: reader.GetName(ordinal);
|
||||
var uniqueName = baseName;
|
||||
for (var suffix = 2; !columnNames.Add(uniqueName); suffix++)
|
||||
{
|
||||
uniqueName = $"{baseName}_{suffix}";
|
||||
}
|
||||
|
||||
var fieldType = reader.GetFieldType(ordinal);
|
||||
table.Columns.Add(uniqueName, fieldType);
|
||||
}
|
||||
|
||||
var values = new object[reader.FieldCount];
|
||||
table.BeginLoadData();
|
||||
try
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
reader.GetValues(values);
|
||||
table.LoadDataRow(values, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
table.EndLoadData();
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
internal static bool IsIdempotentReadQuery(string query)
|
||||
{
|
||||
var span = query.AsSpan().TrimStart();
|
||||
while (span.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var lineEnd = span.IndexOfAny('\r', '\n');
|
||||
if (lineEnd < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
span = span[(lineEnd + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
if (!StartsWithToken(span, "SELECT") && !StartsWithToken(span, "WITH"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is deliberately conservative. False negatives only disable a retry;
|
||||
// a false positive could repeat a state-changing statement.
|
||||
ReadOnlySpan<string> stateChangingTokens =
|
||||
[
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"MERGE",
|
||||
"CALL",
|
||||
"EXEC",
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"DROP",
|
||||
"TRUNCATE",
|
||||
"GRANT",
|
||||
"REVOKE"
|
||||
];
|
||||
|
||||
foreach (var token in stateChangingTokens)
|
||||
{
|
||||
if (ContainsToken(span, token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool StartsWithToken(ReadOnlySpan<char> sql, ReadOnlySpan<char> token) =>
|
||||
sql.StartsWith(token, StringComparison.OrdinalIgnoreCase)
|
||||
&& (sql.Length == token.Length || !IsIdentifierCharacter(sql[token.Length]));
|
||||
|
||||
private static bool ContainsToken(ReadOnlySpan<char> sql, ReadOnlySpan<char> token)
|
||||
{
|
||||
for (var index = 0; index <= sql.Length - token.Length; index++)
|
||||
{
|
||||
if (!sql[index..].StartsWith(token, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasLeftBoundary = index == 0 || !IsIdentifierCharacter(sql[index - 1]);
|
||||
var rightIndex = index + token.Length;
|
||||
var hasRightBoundary = rightIndex == sql.Length || !IsIdentifierCharacter(sql[rightIndex]);
|
||||
if (hasLeftBoundary && hasRightBoundary)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsIdentifierCharacter(char value) =>
|
||||
char.IsLetterOrDigit(value) || value is '_' or '$' or '#';
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Data.Common;
|
||||
using System.Net.Sockets;
|
||||
using MMoneyCoderSharp.Data;
|
||||
using MySqlConnector;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public interface ITransientDatabaseErrorDetector
|
||||
{
|
||||
bool IsTransient(DataSourceKind source, Exception exception);
|
||||
|
||||
bool IsTimeout(DataSourceKind source, Exception exception);
|
||||
|
||||
int? GetProviderErrorCode(DataSourceKind source, Exception exception);
|
||||
}
|
||||
|
||||
public sealed class TransientDatabaseErrorDetector : ITransientDatabaseErrorDetector
|
||||
{
|
||||
private static readonly HashSet<int> OracleTransientErrors =
|
||||
[
|
||||
28,
|
||||
1033,
|
||||
1034,
|
||||
1089,
|
||||
1090,
|
||||
3113,
|
||||
3114,
|
||||
12153,
|
||||
12154,
|
||||
12170,
|
||||
12514,
|
||||
12516,
|
||||
12518,
|
||||
12520,
|
||||
12528,
|
||||
12537,
|
||||
12541,
|
||||
12543,
|
||||
12545,
|
||||
12547,
|
||||
12560,
|
||||
12571
|
||||
];
|
||||
|
||||
private static readonly HashSet<int> MariaDbTransientErrors =
|
||||
[
|
||||
1040,
|
||||
1042,
|
||||
1043,
|
||||
1047,
|
||||
1081,
|
||||
1129,
|
||||
1130,
|
||||
1152,
|
||||
1153,
|
||||
1154,
|
||||
1155,
|
||||
1156,
|
||||
1157,
|
||||
1158,
|
||||
1159,
|
||||
1160,
|
||||
1161,
|
||||
1205,
|
||||
1213,
|
||||
1927,
|
||||
2002,
|
||||
2003,
|
||||
2004,
|
||||
2005,
|
||||
2006,
|
||||
2012,
|
||||
2013,
|
||||
2014,
|
||||
2047,
|
||||
2055
|
||||
];
|
||||
|
||||
public bool IsTransient(DataSourceKind source, Exception exception)
|
||||
{
|
||||
if (exception is SocketException or IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return source switch
|
||||
{
|
||||
DataSourceKind.Oracle when exception is OracleException oracleException =>
|
||||
OracleTransientErrors.Contains(oracleException.Number),
|
||||
DataSourceKind.MariaDb when exception is MySqlException mySqlException =>
|
||||
mySqlException.IsTransient
|
||||
|| MariaDbTransientErrors.Contains(mySqlException.Number),
|
||||
_ when exception is DbException dbException => dbException.IsTransient,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsTimeout(DataSourceKind source, Exception exception)
|
||||
{
|
||||
if (exception is TimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source == DataSourceKind.Oracle
|
||||
&& exception is OracleException oracleException
|
||||
&& oracleException.Number == 1013)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source == DataSourceKind.MariaDb
|
||||
&& exception is MySqlException mySqlException
|
||||
&& mySqlException.ErrorCode == MySqlErrorCode.CommandTimeoutExpired)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
for (var inner = exception.InnerException; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is TimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public int? GetProviderErrorCode(DataSourceKind source, Exception exception) => source switch
|
||||
{
|
||||
DataSourceKind.Oracle when exception is OracleException oracleException => oracleException.Number,
|
||||
DataSourceKind.MariaDb when exception is MySqlException mySqlException => mySqlException.Number,
|
||||
_ when exception is DbException dbException => dbException.ErrorCode,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Diagnostics;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
|
||||
public enum DatabaseHealthState
|
||||
{
|
||||
NotConfigured,
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
TimedOut
|
||||
}
|
||||
|
||||
public sealed record DatabaseHealthStatus(
|
||||
DataSourceKind Source,
|
||||
DatabaseHealthState State,
|
||||
DateTimeOffset CheckedAt,
|
||||
TimeSpan Elapsed,
|
||||
string UserMessage);
|
||||
|
||||
public interface IDatabaseHealthService
|
||||
{
|
||||
Task<DatabaseHealthStatus> CheckAsync(
|
||||
DataSourceKind source,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<DatabaseHealthStatus>> CheckAllAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class DatabaseHealthService : IDatabaseHealthService
|
||||
{
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
private readonly DatabaseOptions _options;
|
||||
|
||||
public DatabaseHealthService(IDataQueryExecutor executor, DatabaseOptions options)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async Task<DatabaseHealthStatus> CheckAsync(
|
||||
DataSourceKind source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checkedAt = DateTimeOffset.Now;
|
||||
if (!_options.IsConfigured(source))
|
||||
{
|
||||
return new DatabaseHealthStatus(
|
||||
source,
|
||||
DatabaseHealthState.NotConfigured,
|
||||
checkedAt,
|
||||
TimeSpan.Zero,
|
||||
"연결 정보가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var query = source switch
|
||||
{
|
||||
DataSourceKind.Oracle => "SELECT 1 FROM DUAL",
|
||||
DataSourceKind.MariaDb => "SELECT 1",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), source, null)
|
||||
};
|
||||
|
||||
await _executor
|
||||
.ExecuteAsync(source, $"{source}Health", query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new DatabaseHealthStatus(
|
||||
source,
|
||||
DatabaseHealthState.Healthy,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
"연결 정상");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (DatabaseQueryTimeoutException exception)
|
||||
{
|
||||
return new DatabaseHealthStatus(
|
||||
source,
|
||||
DatabaseHealthState.TimedOut,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
exception.Message);
|
||||
}
|
||||
catch (DatabaseInfrastructureException exception)
|
||||
{
|
||||
return new DatabaseHealthStatus(
|
||||
source,
|
||||
DatabaseHealthState.Unhealthy,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
exception.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DatabaseHealthStatus(
|
||||
source,
|
||||
DatabaseHealthState.Unhealthy,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
"데이터베이스 연결 상태를 확인할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DatabaseHealthStatus>> CheckAllAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checks = Enum
|
||||
.GetValues<DataSourceKind>()
|
||||
.Select(source => CheckAsync(source, cancellationToken));
|
||||
return await Task.WhenAll(checks).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>MBN_STOCK_WEBVIEW.Infrastructure</RootNamespace>
|
||||
<AssemblyName>MBN_STOCK_WEBVIEW.Infrastructure</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.200" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user