Files
ExperionCrawler/src/Infrastructure/OpcUa/ExperionRealtimeService.cs
windpacer f71ec310e4 feat: P&ID 그래프 파이프라인 및 MCP 서버 개선
- P&ID 그래프 파이프라인 구현 (py)
  - pid_geometric_extractor.py: 기하학적 특징 추출
  - pid_intelligent_mapper.py: 태그 매핑
  - pid_topology_builder.py: 위상 구축
  - test_pipeline_phase2.py, test_pipeline_phase3.py: 테스트

- MCP 서버 개선
  - server.py: 멀티프로세싱 지원
  - pipeline/: 분석, 추출, 매핑, 위상 모듈 추가

- C# P&ID 그래프 서비스
  - PidGraphDtos.cs: DTO 정의
  - PidGraphService.cs: 비즈니스 로직
  - PidGraphController.cs: API 컨트롤러

- OPC UA 서비스 개선
  - ExperionOpcServerService.cs
  - ExperionRealtimeService.cs
  - ExperionFastService.cs

- MCP 클라이언트 및 호스팅 서비스 개선
  - McpClient.cs
  - McpServerHostedService.cs

- 웹 UI 개선
  - pid_graph_view.html: P&ID 그래프 뷰어
  - pid-viewer.js: 뷰어 로직
  - app.js: 메인 앱
  - pid_graph.css: 스타일

- 프로젝트 설정 업데이트
  - ExperionCrawler.csproj
  - Program.cs
2026-05-03 03:50:20 +09:00

504 lines
19 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Certificates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using ISession = Opc.Ua.Client.ISession;
using StatusCodes = Opc.Ua.StatusCodes;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// OPC UA Subscription 기반 실시간 livevalue 업데이트 서비스.
/// 값이 변경될 때만 콜백을 받아 realtime_table 을 갱신합니다.
/// </summary>
public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionRealtimeService> _logger;
private readonly IServiceProvider _sp;
private readonly IOpcUaConfigProvider _configProvider;
private ISession? _session;
private Subscription? _subscription;
private CancellationTokenSource? _cts;
private Task? _monitorTask;
private Task? _flushTask;
// 콜백에서 최신 값만 기록 (노드당 1개 유지) → 500ms 배치 flush
private readonly ConcurrentDictionary<string, (string? value, DateTime timestamp)>
_pendingUpdates = new();
// nodeId → RealtimePoint 매핑 (FlushLoop에서 tagname을 찾기 위해 사용)
private Dictionary<string, Core.Domain.Entities.RealtimePoint> _pointCache = new();
// OPC UA 서버 서비스 (순환 참조 방지를 위해 lazy resolve)
private IExperionOpcServerService? _opcServer;
private volatile bool _running;
private int _subscribedCount;
private string _statusMsg = "중지됨";
private ExperionServerConfig? _currentCfg;
private volatile bool _restarting = false; // 재진입 방지 플래그
// 자동 재시작 플래그 파일 경로
private static readonly string FlagPath =
Path.GetFullPath("realtime_autostart.json");
public ExperionRealtimeService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionRealtimeService> logger,
IServiceProvider sp,
IOpcUaConfigProvider configProvider)
{
_scopeFactory = scopeFactory;
_logger = logger;
_sp = sp;
_configProvider = configProvider;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
// 앱 기동 시 플래그 파일이 있으면 자동 구독 시작
if (!File.Exists(FlagPath)) return;
try
{
var json = await File.ReadAllTextAsync(FlagPath, cancellationToken);
var cfg = JsonSerializer.Deserialize<ExperionServerConfig>(json);
if (cfg != null)
{
_logger.LogInformation("[Realtime] 자동 재시작 플래그 감지 — 구독 자동 시작");
await StartAsync(cfg);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 자동 재시작 플래그 읽기 실패 — 무시");
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
// 앱 종료(Ctrl+C 등) 시: 플래그 파일은 유지 → 재기동 시 자동 재시작
_cts?.Cancel();
var tasks = new[] { _monitorTask, _flushTask }
.Where(t => t != null).Select(t => t!).ToArray();
if (tasks.Length > 0)
{
try
{
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Realtime] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
}
_running = false;
_logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
}
// ── IExperionRealtimeService ──────────────────────────────────────────────
public async Task StartAsync(ExperionServerConfig cfg)
{
if (_running || _restarting)
{
_logger.LogWarning("[Realtime] 이미 실행 중 또는 재시작 중. 무시합니다.");
return;
}
_restarting = true;
try
{
if (_running)
{
_logger.LogWarning("[Realtime] 이미 실행 중. 재시작합니다.");
await StopAsync();
}
}
finally
{
_restarting = false;
}
// 플래그 파일 저장 (앱 재기동 시 자동 재시작용)
try
{
var json = JsonSerializer.Serialize(cfg);
await File.WriteAllTextAsync(FlagPath, json);
_logger.LogInformation("[Realtime] 자동 재시작 플래그 저장: {Path}", FlagPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)");
}
_currentCfg = cfg;
_cts = new CancellationTokenSource();
_monitorTask = Task.Run(() => RunLoopAsync(_cts.Token));
_logger.LogInformation("[Realtime] 구독 시작 요청: {Url}", cfg.EndpointUrl);
}
public async Task StopAsync()
{
if (_restarting)
{
_logger.LogWarning("[Realtime] 재시작 중이므로 StopAsync 무시 (restarting 플래그 취소)");
return;
}
// 플래그 파일 삭제 (자동 재시작 비활성화)
try
{
if (File.Exists(FlagPath)) File.Delete(FlagPath);
_logger.LogInformation("[Realtime] 자동 재시작 플래그 삭제");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)");
}
_cts?.Cancel();
var tasks = new List<Task>();
if (_monitorTask != null) tasks.Add(_monitorTask);
if (_flushTask != null) tasks.Add(_flushTask);
if (tasks.Count > 0)
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
await CleanupSessionAsync();
_pendingUpdates.Clear();
_running = false;
_subscribedCount = 0;
_statusMsg = "중지됨";
_logger.LogInformation("[Realtime] 구독 중지 완료");
}
public RealtimeServiceStatus GetStatus()
=> new(_running, _subscribedCount, _statusMsg);
public async Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId)
{
// 구독 중이 아니면 DB에만 저장된 상태 — 다음 구독 시작 시 자동 포함
if (!_running || _subscription == null)
return (true, "구독 중 아님 — 다음 구독 시작 시 자동 포함됩니다.");
await Task.CompletedTask;
var item = new MonitoredItem(_subscription.DefaultItem)
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = 500,
QueueSize = 1,
DiscardOldest = true,
DisplayName = nodeId
};
item.Notification += OnNotification;
_subscription.AddItem(item);
try
{
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
_subscription.ApplyChanges();
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
// 서버 응답 상태 확인 (Error가 null이면 정상)
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
{
// 유효하지 않은 node_id → subscription에서 제거
_subscription.RemoveItem(item);
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
_subscription.ApplyChanges();
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
var code = item.Status.Error.StatusCode;
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
return (false, $"OPC UA 서버가 노드를 거부했습니다: {code}");
}
_subscribedCount++;
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
_logger.LogInformation("[Realtime] 핫 추가 성공: {NodeId}", nodeId);
return (true, "구독에 즉시 추가되었습니다.");
}
catch (Exception ex)
{
_subscription.RemoveItem(item);
_logger.LogError(ex, "[Realtime] MonitoredItem 추가 실패: {NodeId}", nodeId);
return (false, $"MonitoredItem 추가 중 오류: {ex.Message}");
}
}
// ── 내부 루프 ─────────────────────────────────────────────────────────────
private async Task RunLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await ConnectAndSubscribeAsync(ct);
// 세션이 살아있는 동안 KeepAlive 대기
while (!ct.IsCancellationRequested &&
_session != null && _session.Connected)
{
await Task.Delay(5_000, ct);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_running = false;
_statusMsg = $"재연결 대기 중: {ex.Message}";
_logger.LogWarning(ex, "[Realtime] 연결 오류, 30초 후 재시도");
await CleanupSessionAsync();
try { await Task.Delay(30_000, ct); }
catch (OperationCanceledException) { break; }
}
}
_running = false;
_statusMsg = "중지됨";
}
private async Task ConnectAndSubscribeAsync(CancellationToken ct)
{
if (_currentCfg == null) return;
_statusMsg = "연결 중...";
_logger.LogInformation("[Realtime] OPC UA 접속 시도: {Url}", _currentCfg.EndpointUrl);
var appConfig = await BuildConfigAsync(_currentCfg);
var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct);
_session = await CreateSessionAsync(appConfig, endpoint, _currentCfg);
_logger.LogInformation("[Realtime] 세션 생성 완료");
// realtime_table 의 node_id 목록 조회
List<RealtimePoint> points;
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
points = (await db.GetRealtimePointsAsync()).ToList();
}
if (points.Count == 0)
{
_statusMsg = "포인트 없음 (포인트빌더에서 먼저 빌드하세요)";
_logger.LogWarning("[Realtime] realtime_table 이 비어 있습니다.");
return;
}
// Subscription 생성
_subscription = new Subscription(_session.DefaultSubscription)
{
PublishingInterval = 1_000,
KeepAliveCount = 10,
LifetimeCount = 100,
MaxNotificationsPerPublish = 1000,
PublishingEnabled = true,
Priority = 0
};
// MonitoredItem 등록
foreach (var pt in points)
{
var item = new MonitoredItem(_subscription.DefaultItem)
{
StartNodeId = new NodeId(pt.NodeId),
AttributeId = Attributes.Value,
SamplingInterval = 500,
QueueSize = 1,
DiscardOldest = true,
DisplayName = pt.NodeId
};
item.Notification += OnNotification;
_subscription.AddItem(item);
}
_session.AddSubscription(_subscription);
#pragma warning disable CS0618 // 'Create()' is obsolete
_subscription.Create();
#pragma warning restore CS0618 // 'Create()' is obsolete
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
_subscribedCount = points.Count;
_running = true;
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
_logger.LogInformation("[Realtime] 구독 완료: {Count}개 포인트", _subscribedCount);
// 배치 flush 태스크 시작 (콜백 → dictionary → 500ms 단위 배치 DB 업데이트)
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
}
// 콜백: Task.Run 없이 dictionary에만 기록 (최신 값 덮어쓰기)
private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
{
foreach (var val in item.DequeueValues())
{
var nodeId = item.DisplayName;
var value = val.Value?.ToString();
var timestamp = val.SourceTimestamp == DateTime.MinValue ? DateTime.UtcNow : val.SourceTimestamp;
_pendingUpdates[nodeId] = (value, timestamp);
}
}
// 배치 flush 루프 — 500ms 주기, 단일 DbContext로 일괄 업데이트
private async Task FlushLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(500, ct); }
catch (OperationCanceledException) { break; }
await FlushPendingAsync();
}
// 종료 시 남은 항목 최종 flush
await FlushPendingAsync();
}
private async Task FlushPendingAsync()
{
if (_pendingUpdates.IsEmpty) return;
// 스냅샷 후 제거 (새 콜백은 계속 dictionary에 추가 가능)
var snapshot = _pendingUpdates.ToArray();
foreach (var kv in snapshot)
_pendingUpdates.TryRemove(kv.Key, out _);
var updates = snapshot
.Select(kv => new LiveValueUpdate(kv.Key, kv.Value.value, kv.Value.timestamp))
.ToList();
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var count = await db.BatchUpdateLiveValuesAsync(updates);
_logger.LogDebug("[Realtime] 배치 업데이트: {Count}/{Total}건",
count, updates.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패");
}
// OPC UA 서버 노드 값 갱신 (lazy resolve — 순환 참조 방지)
try
{
_opcServer ??= _sp.GetService<IExperionOpcServerService>();
if (_opcServer?.GetStatus().Running == true)
{
foreach (var u in updates)
{
if (_pointCache.TryGetValue(u.NodeId, out var pt))
_opcServer.UpdateNodeValue(pt.TagName, u.Value, u.Timestamp);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "[Realtime] OPC 서버 노드 값 갱신 실패 (무시)");
}
}
private async Task CleanupSessionAsync()
{
try
{
if (_subscription != null)
{
#pragma warning disable CS0618 // 'Delete()' is obsolete
_subscription.Delete(true);
#pragma warning restore CS0618 // 'Delete()' is obsolete
_subscription = null;
}
if (_session != null)
{
if (_session.Connected)
await _session.CloseAsync();
_session.Dispose();
_session = null;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Realtime] 세션 정리 중 오류 (무시)");
}
}
// ── OPC UA 헬퍼 ─────────────────────────────────────────────────────────────
private async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
{
return await _configProvider.GetConfigAsync(cfg);
}
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
ApplicationConfiguration appConfig, string endpointUrl,
CancellationToken ct = default)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
var endpointConfig = EndpointConfiguration.Create(appConfig);
using var discovery = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, timeoutCts.Token);
var endpoints = await discovery.GetEndpointsAsync(null);
var selected = endpoints
.OrderByDescending(e => e.SecurityLevel)
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
?? endpoints[0];
return new ConfiguredEndpoint(null, selected, endpointConfig);
}
// OPC UA Session 생성 (비동기)
private static async Task<ISession> CreateSessionAsync(
ApplicationConfiguration appConfig,
ConfiguredEndpoint endpoint,
ExperionServerConfig cfg)
{
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
return await new DefaultSessionFactory(null).CreateAsync(
appConfig,
endpoint,
false,
"ExperionRealtimeSession",
60_000,
identity,
null,
CancellationToken.None);
}
private volatile bool _disposed = false;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts?.Cancel();
// StopAsync에서 이미 Task.WhenAll로 대기하므로, Dispose에서는 await 없이 정리만 수행
// CleanupSessionAsync는 이미 완료된 상태를 가정
try
{
CleanupSessionAsync().GetAwaiter().GetResult();
}
catch
{
// Ignore exceptions during disposal
}
_cts?.Dispose();
}
}