16 KiB
16 KiB
STEP 7 — ExperionFastService 신규 파일 생성
사전 확인 (작업 전 반드시 수행)
src/Infrastructure/OpcUa/디렉토리 목록을 확인한다.- 아래 항목을 확인하고 기록한다:
- STEP 5, 6이 완료되어 인터페이스와 DB 메서드가 존재하는가?
ExperionFastService.cs파일이 이미 존재하는가? → 존재하면 내용 비교 후 필요한 부분만 수정 (신규 생성)IExperionOpcClient에IsConnectedAsync,CreateSessionAsync가 구현되어 있는가?IOpcUaConfigProvider인터페이스가 존재하는가? (주입 경로 확인)Opc.Ua.ClientNuGet 패키지가 Infrastructure 프로젝트에 있는가?
작업 내용
파일: src/Infrastructure/OpcUa/ExperionFastService.cs (신규 생성)
using System.Collections.Concurrent;
using System.Text.Json;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT.
/// IHostedService로 등록하여 앱 시작/종료 시 자동 관리.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly IOpcUaConfigProvider _configProvider;
private readonly IExperionOpcClient _opcClient;
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
private CancellationTokenSource? _cts;
private Task? _monitorTask;
private const int MaxConcurrentSessions = 3;
private const int MaxRowsPerSession = 5_000_000;
private const int FlushIntervalMs = 2_000;
public ExperionFastService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionFastService> logger,
IOpcUaConfigProvider configProvider,
IExperionOpcClient opcClient)
{
_scopeFactory = scopeFactory;
_logger = logger;
_configProvider = configProvider;
_opcClient = opcClient;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
// 앱 시작 시 Running 상태 세션 → Failed 마킹
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
foreach (var s in sessions.Where(s => s.Status == "Running"))
{
_logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id);
await db.UpdateFastSessionStatusAsync(s.Id, "Failed");
}
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
if (_monitorTask != null)
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
foreach (var kvp in _sessions)
kvp.Value.Cancel = true;
await Task.Delay(2000).ConfigureAwait(false); // 마지막 flush 대기
}
public void Dispose()
{
_cts?.Dispose();
}
// ── IExperionFastService ──────────────────────────────────────────────────
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
{
if (request.TagList.Length == 0 || request.TagList.Length > 8)
throw new ArgumentException("태그는 1~8개까지 가능합니다.");
if (request.SamplingMs is not (100 or 250 or 500 or 1000))
throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running");
if (runningCount >= MaxConcurrentSessions)
throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다.");
var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig());
if (string.IsNullOrEmpty(cfg?.EndpointUrl))
throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다.");
if (!await _opcClient.IsConnectedAsync(cfg))
throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다.");
// 노드 유효성 사전 검증
foreach (var tagName in request.TagList)
{
var nodeId = await db.GetNodeIdByTagNameAsync(tagName);
if (string.IsNullOrEmpty(nodeId))
throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다.");
var readResult = await _opcClient.ReadTagAsync(cfg, nodeId);
if (!readResult.Success)
throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}");
}
var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest(
Name: request.Name,
SamplingMs: request.SamplingMs,
DurationSec: request.DurationSec,
TagList: request.TagList,
RetentionDays: request.RetentionDays));
var ctx = new FastSessionContext
{
SessionId = session.Id,
TagList = request.TagList,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
StartedAt = DateTime.UtcNow,
Buffer = new ConcurrentQueue<FastRecord>()
};
_sessions[session.Id] = ctx;
await StartSubscriptionAsync(ctx, cfg);
await db.UpdateFastSessionStatusAsync(session.Id, "Running");
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s",
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
return MapToInfo(session);
}
public async Task StopSessionAsync(int sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var ctx))
throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다.");
ctx.Cancel = true;
await FlushBufferAsync(ctx).ConfigureAwait(false);
await StopSubscriptionAsync(ctx).ConfigureAwait(false);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
_sessions.TryRemove(sessionId, out _);
_logger.LogInformation("[Fast] 세션 {Id} 중지 — 총 {Count}행", sessionId, ctx.TotalRows);
}
public async Task DeleteSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.DeleteFastSessionAsync(sessionId);
_sessions.TryRemove(sessionId, out _);
}
public async Task PinSessionAsync(int sessionId, bool pinned)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return await db.GetFastRecordsAsync(sessionId, from, to);
}
public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
}
// ── Private ────────────────────────────────────────────────────────────────
private async Task StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg)
{
var session = await _opcClient.CreateSessionAsync(cfg);
var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = ctx.SamplingMs,
KeepAliveCount = 10
};
foreach (var tagName in ctx.TagList)
{
var nodeId = await GetNodeIdAsync(tagName);
var item = new MonitoredItem(subscription)
{
StartNodeId = nodeId,
SamplingInterval = ctx.SamplingMs,
DisplayName = tagName
};
item.Notification += (_, e) => OnNotification(ctx, e, tagName);
subscription.AddItem(item);
}
await session.AddSubscriptionAsync(subscription);
subscription.Create();
ctx.Subscription = subscription;
ctx.Session = session;
}
private async Task StopSubscriptionAsync(FastSessionContext ctx)
{
if (ctx.Subscription != null)
{
ctx.Subscription.Delete(false);
ctx.Subscription = null;
}
if (ctx.Session != null)
{
await ctx.Session.CloseAsync();
await ctx.Session.DisposeAsync();
ctx.Session = null;
}
}
private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName)
{
if (ctx.Cancel) return;
if (e.NotificationValue is MonitoredItemNotification notification)
{
ctx.Buffer.Enqueue(new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = DateTime.UtcNow,
TagName = tagName,
Value = notification.Value.Value?.ToString()
});
ctx.TotalRows++;
}
}
private async Task FlushBufferAsync(FastSessionContext ctx)
{
var buffer = new List<FastRecord>();
while (ctx.Buffer.TryDequeue(out var record))
buffer.Add(record);
if (buffer.Count == 0) return;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.BatchInsertFastRecordsAsync(buffer);
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
if (ctx.TotalRows >= MaxRowsPerSession)
{
ctx.Cancel = true;
await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
_sessions.TryRemove(ctx.SessionId, out _);
_logger.LogWarning("[Fast] 세션 {Id} RowLimitReached ({Max}행)", ctx.SessionId, MaxRowsPerSession);
}
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(FlushIntervalMs, ct);
foreach (var kvp in _sessions.ToList())
{
var ctx = kvp.Value;
if (ctx.Cancel) continue;
if ((DateTime.UtcNow - ctx.StartedAt).TotalSeconds >= ctx.DurationSec)
{
ctx.Cancel = true;
await StopSessionAsync(ctx.SessionId);
continue;
}
await FlushBufferAsync(ctx);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
}
}
}
private async Task<string> GetNodeIdAsync(string tagName)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty;
}
private static FastSessionInfo MapToInfo(FastSession s) => new(
Id: s.Id,
Name: s.Name,
StartedAt: s.StartedAt,
EndedAt: s.EndedAt,
Status: s.Status,
SamplingMs: s.SamplingMs,
DurationSec: s.DurationSec,
TagList: JsonSerializer.Deserialize<string[]>(s.TagList) ?? [],
RowCount: s.RowCount,
RetentionDays: s.RetentionDays,
Pinned: s.Pinned);
// ── Inner Class ────────────────────────────────────────────────────────────
private sealed class FastSessionContext
{
public int SessionId { get; set; }
public string[] TagList { get; set; } = [];
public int SamplingMs { get; set; }
public int DurationSec { get; set; }
public DateTime StartedAt { get; set; }
public ConcurrentQueue<FastRecord> Buffer { get; set; } = new();
public int TotalRows { get; set; } // 누적 행 수
public bool Cancel { get; set; }
public ISession? Session { get; set; }
public Subscription? Subscription { get; set; }
}
}
사후 확인 (작업 후 반드시 수행)
ExperionFastService.cs파일을 읽어 전체 구조를 확인한다.- 아래 항목을 하나씩 확인한다:
- 클래스가
IExperionFastService,IHostedService,IDisposable모두 구현하는가? StartAsync— Running 세션 Failed 마킹 로직이 있는가?OnNotification—MonitoredItemNotification타입 체크를 하는가? (e.NotificationValue is MonitoredItemNotification)FlushBufferAsync—ctx.TotalRows >= MaxRowsPerSession체크가 있는가?MapToInfo—JsonSerializer.Deserialize<string[]>사용하는가?FastSessionContext.TotalRows필드가 있는가?- 파일 상단에
using System.Text.Json;이 있는가? using Opc.Ua;,using Opc.Ua.Client;가 있는가?
- 클래스가
dotnet build src/Web실행 → 에러 0, 경고 14개 (기존 경고 포함) 확인- 문제가 있으면 수정 후 다시 빌드 확인
완료 조건
dotnet build src/Web결과: 에러 0, 경고 14개 (기존 경고 포함)ExperionFastService.cs파일 존재 및 빌드 통과