400 lines
16 KiB
Markdown
400 lines
16 KiB
Markdown
# STEP 7 — ExperionFastService 신규 파일 생성
|
|
|
|
## 사전 확인 (작업 전 반드시 수행)
|
|
|
|
1. `src/Infrastructure/OpcUa/` 디렉토리 목록을 확인한다.
|
|
2. 아래 항목을 확인하고 기록한다:
|
|
- [x] STEP 5, 6이 완료되어 인터페이스와 DB 메서드가 존재하는가?
|
|
- [x] `ExperionFastService.cs` 파일이 이미 존재하는가? → 존재하면 내용 비교 후 필요한 부분만 수정 (신규 생성)
|
|
- [x] `IExperionOpcClient`에 `IsConnectedAsync`, `CreateSessionAsync`가 구현되어 있는가?
|
|
- [x] `IOpcUaConfigProvider` 인터페이스가 존재하는가? (주입 경로 확인)
|
|
- [x] `Opc.Ua.Client` NuGet 패키지가 Infrastructure 프로젝트에 있는가?
|
|
|
|
---
|
|
|
|
## 작업 내용
|
|
|
|
**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` (신규 생성)
|
|
|
|
```csharp
|
|
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; }
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 사후 확인 (작업 후 반드시 수행)
|
|
|
|
1. `ExperionFastService.cs` 파일을 읽어 전체 구조를 확인한다.
|
|
2. 아래 항목을 하나씩 확인한다:
|
|
- [x] 클래스가 `IExperionFastService`, `IHostedService`, `IDisposable` 모두 구현하는가?
|
|
- [x] `StartAsync` — Running 세션 Failed 마킹 로직이 있는가?
|
|
- [x] `OnNotification` — `MonitoredItemNotification` 타입 체크를 하는가? (`e.NotificationValue is MonitoredItemNotification`)
|
|
- [x] `FlushBufferAsync` — `ctx.TotalRows >= MaxRowsPerSession` 체크가 있는가?
|
|
- [x] `MapToInfo` — `JsonSerializer.Deserialize<string[]>` 사용하는가?
|
|
- [x] `FastSessionContext.TotalRows` 필드가 있는가?
|
|
- [x] 파일 상단에 `using System.Text.Json;` 이 있는가?
|
|
- [x] `using Opc.Ua;`, `using Opc.Ua.Client;` 가 있는가?
|
|
3. `dotnet build src/Web` 실행 → 에러 0, 경고 14개 (기존 경고 포함) 확인
|
|
4. 문제가 있으면 수정 후 다시 빌드 확인
|
|
|
|
---
|
|
|
|
## 완료 조건
|
|
- `dotnet build src/Web` 결과: 에러 0, 경고 14개 (기존 경고 포함)
|
|
- `ExperionFastService.cs` 파일 존재 및 빌드 통과 |