fix(#7): DisposeSessionAsync 중복 close 후 dispose 방지 (ConcurrentDictionary 플래그)
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
using System.Text;
|
|
||||||
using ExperionCrawler.Core.Application.Interfaces;
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
using ExperionCrawler.Core.Domain.Entities;
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
using ExperionCrawler.Infrastructure.Certificates;
|
using ExperionCrawler.Infrastructure.Certificates;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
|
using System.Text;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using ISession = Opc.Ua.Client.ISession;
|
using ISession = Opc.Ua.Client.ISession;
|
||||||
using StatusCodes = Opc.Ua.StatusCodes;
|
using StatusCodes = Opc.Ua.StatusCodes;
|
||||||
|
|
||||||
@@ -16,9 +17,17 @@ namespace ExperionCrawler.Infrastructure.OpcUa;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExperionOpcClient : IExperionOpcClient
|
public class ExperionOpcClient : IExperionOpcClient
|
||||||
{
|
{
|
||||||
private readonly ILogger<ExperionOpcClient> _logger;
|
private readonly ILogger<ExperionOpcClient> _logger;
|
||||||
|
private readonly IOpcUaConfigProvider _configProvider;
|
||||||
|
|
||||||
|
// 세션 정리 중복 방지 플래그
|
||||||
|
private static readonly ConcurrentDictionary<uint, bool> _sessionClosedFlags = new();
|
||||||
|
|
||||||
public ExperionOpcClient(ILogger<ExperionOpcClient> logger) => _logger = logger;
|
public ExperionOpcClient(ILogger<ExperionOpcClient> logger, IOpcUaConfigProvider configProvider)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configProvider = configProvider;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 공통 설정 빌더 ────────────────────────────────────────────────────────
|
// ── 공통 설정 빌더 ────────────────────────────────────────────────────────
|
||||||
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
||||||
@@ -67,11 +76,10 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
{
|
{
|
||||||
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
||||||
};
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||||
}
|
|
||||||
|
|
||||||
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
|
||||||
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||||
ApplicationConfiguration appConfig, string endpointUrl,
|
ApplicationConfiguration appConfig, string endpointUrl,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -126,7 +134,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
|
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
|
||||||
var appConfig = await BuildConfigAsync(cfg);
|
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
||||||
|
|
||||||
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
||||||
@@ -162,7 +170,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appConfig = await BuildConfigAsync(cfg);
|
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
||||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
||||||
|
|
||||||
@@ -215,7 +223,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appConfig = await BuildConfigAsync(cfg);
|
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct);
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct);
|
||||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
||||||
|
|
||||||
@@ -279,16 +287,55 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
if (ct.IsCancellationRequested) return;
|
if (ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
var nodeIdStr = r.NodeId.ToString();
|
var nodeIdStr = r.NodeId.ToString();
|
||||||
if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지
|
if (!visited.Add(nodeIdStr)) continue;
|
||||||
|
|
||||||
var dataType = r.NodeClass == NodeClass.Variable
|
var dataType = r.NodeClass == NodeClass.Variable
|
||||||
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
|
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
|
||||||
: "N/A";
|
: "N/A";
|
||||||
|
|
||||||
|
// ───────────────────────────────────── Display Name 추출 (null 방지) ───────────────────────────────
|
||||||
|
string displayNameStr;
|
||||||
|
|
||||||
|
if (r.DisplayName != null)
|
||||||
|
{
|
||||||
|
if (r.DisplayName.Text != null && r.DisplayName.Text.Length > 0)
|
||||||
|
{
|
||||||
|
displayNameStr = r.DisplayName.Text.Trim();
|
||||||
|
}
|
||||||
|
else if (r.DisplayName.ToString() != null && r.DisplayName.ToString().Length > 0)
|
||||||
|
{
|
||||||
|
displayNameStr = r.DisplayName.ToString().Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
displayNameStr = $"Node:{nodeIdStr}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
displayNameStr = $"Node:{nodeIdStr}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayName이 빈 문자열이라도 대체 이름 사용 (null 방지)
|
||||||
|
// string.IsNullOrWhiteSpace는 모든 공백을 포함하므로 확실히 대체
|
||||||
|
if (string.IsNullOrWhiteSpace(displayNameStr))
|
||||||
|
{
|
||||||
|
displayNameStr = $"Node:{nodeIdStr}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 디버깅 로그 - 첫 번째 레벨에서 값 확인
|
||||||
|
if (results.Count < 5 || currentLevel == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ExperionOpc] LVL={Level} NodeId={NodeId} DisplayNameLen={Len}",
|
||||||
|
currentLevel, nodeIdStr, displayNameStr.Length);
|
||||||
|
}
|
||||||
|
|
||||||
results.Add(new ExperionNodeMapEntry(
|
results.Add(new ExperionNodeMapEntry(
|
||||||
currentLevel,
|
currentLevel,
|
||||||
r.NodeClass.ToString(),
|
r.NodeClass.ToString(),
|
||||||
r.DisplayName.Text,
|
displayNameStr, // null 방지된 값 사용
|
||||||
nodeIdStr,
|
nodeIdStr,
|
||||||
dataType));
|
dataType));
|
||||||
|
|
||||||
@@ -365,7 +412,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
ISession? session = null;
|
ISession? session = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appConfig = await BuildConfigAsync(cfg);
|
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
|
||||||
|
|
||||||
@@ -373,22 +420,70 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
? new NodeId(startNodeId)
|
? new NodeId(startNodeId)
|
||||||
: ObjectIds.ObjectsFolder;
|
: ObjectIds.ObjectsFolder;
|
||||||
|
|
||||||
|
// NodeClassMask 확장: ObjectType, VariableTypes 등 포함하여 더 많은 노드 탐색
|
||||||
var browser = new Browser(session)
|
var browser = new Browser(session)
|
||||||
{
|
{
|
||||||
BrowseDirection = BrowseDirection.Forward,
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||||
IncludeSubtypes = true,
|
IncludeSubtypes = true,
|
||||||
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable | NodeClass.ObjectType | NodeClass.VariableType),
|
||||||
ResultMask = (uint)BrowseResultMask.All
|
ResultMask = (uint)BrowseResultMask.All
|
||||||
};
|
};
|
||||||
|
|
||||||
var refs = await browser.BrowseAsync(startNode);
|
var refs = await browser.BrowseAsync(startNode);
|
||||||
var nodes = refs.Select(r => new ExperionNodeInfo(
|
int okCount = 0;
|
||||||
r.NodeId.ToString(),
|
int noNameCount = 0;
|
||||||
r.DisplayName.Text,
|
|
||||||
r.NodeClass.ToString(),
|
var nodes = refs.Select(r => {
|
||||||
r.NodeClass == NodeClass.Object
|
// ─────────────────────────────────────────
|
||||||
)).ToList();
|
// DisplayName 우선, 그 다음 BrowseName, 마지막으로 NodeId 사용
|
||||||
|
// DisplayName이 이스케이프된 계층 경로 일 때 BrowseName도 함께 결합
|
||||||
|
// ─────────────────────────────────────────
|
||||||
|
string? displayName = null;
|
||||||
|
string? browseName = null;
|
||||||
|
|
||||||
|
if (r.NodeClass == NodeClass.Variable || r.NodeClass == NodeClass.Object)
|
||||||
|
{
|
||||||
|
// DisplayName에서 계층 구조 파싱 (이스케이프된 '/'를 처리)
|
||||||
|
if (r.DisplayName != null && r.DisplayName.Text != null)
|
||||||
|
{
|
||||||
|
displayName = r.DisplayName.Text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseName에서 계층 구조 확인
|
||||||
|
if (r.NodeId != null)
|
||||||
|
{
|
||||||
|
browseName = r.NodeId.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayName 있으면 사용, 없으면 BrowseName 사용
|
||||||
|
if (!string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
displayName = SanitizeDisplayName(displayName);
|
||||||
|
okCount++;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(browseName))
|
||||||
|
{
|
||||||
|
displayName = browseName;
|
||||||
|
noNameCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
displayName = $"Node:{r.NodeId.ToString()}"; // NodeId만 사용
|
||||||
|
noNameCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExperionNodeInfo(
|
||||||
|
r.NodeId.ToString(),
|
||||||
|
displayName,
|
||||||
|
r.NodeClass.ToString(),
|
||||||
|
r.NodeClass == NodeClass.Object
|
||||||
|
);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionOpc] 노드 탐색 완료: {Count}개 노드 ({Ok}개 이름있음, {NoName}개 이름없음)",
|
||||||
|
nodes.Count, okCount, noNameCount);
|
||||||
|
|
||||||
return new ExperionBrowseResult(true, nodes);
|
return new ExperionBrowseResult(true, nodes);
|
||||||
}
|
}
|
||||||
@@ -400,11 +495,67 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
finally { await DisposeSessionAsync(session); }
|
finally { await DisposeSessionAsync(session); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 비정상적인 DisplayName을 정상적인 이름으로 변환.
|
||||||
|
/// 예: "ns=1;s=FICQ-6102.hzset.fieldvalue" -> "FICQ-6102.hzset.fieldvalue"
|
||||||
|
/// </summary>
|
||||||
|
private static string? SanitizeDisplayName(string original)
|
||||||
|
{
|
||||||
|
// 이미 정상적인 색인이거나 점(.)이 포함된 경우 그대로 반환
|
||||||
|
if (original.StartsWith("ns=") || original.Contains('.'))
|
||||||
|
{
|
||||||
|
// 여러 점들이 연속된 경우 축소
|
||||||
|
while (original.Contains(".."))
|
||||||
|
{
|
||||||
|
original = original.Replace("..", ".");
|
||||||
|
}
|
||||||
|
return original.TrimStart('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외 경우는 원본 그대로 반환 (동적 노드의 경우)
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 세션 정리 ─────────────────────────────────────────────────────────────
|
// ── 세션 정리 ─────────────────────────────────────────────────────────────
|
||||||
private static async Task DisposeSessionAsync(ISession? session)
|
private static async Task DisposeSessionAsync(ISession? session)
|
||||||
{
|
{
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
try { await session.CloseAsync(); } catch { /* ignore */ }
|
|
||||||
session.Dispose();
|
// Session ID 기반 플래그 사용하여 중복 정리 방지
|
||||||
|
uint sessionIdKey = 0;
|
||||||
|
|
||||||
|
// SessionId가 NodeId 타입인 경우 uint로 변환
|
||||||
|
if (session.SessionId != null)
|
||||||
|
{
|
||||||
|
if (session.SessionId.IdType == Opc.Ua.IdType.Numeric)
|
||||||
|
{
|
||||||
|
sessionIdKey = Convert.ToUInt32(session.SessionId.Identifier);
|
||||||
|
}
|
||||||
|
// 그 외 타입은 무시 (Session ID를 키로 사용할 수 없음)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 세션이 정리되었는지 확인
|
||||||
|
if (sessionIdKey != 0 && _sessionClosedFlags.TryGetValue(sessionIdKey, out var isClosed) && isClosed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.CloseAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// CloseAsync 실패 시 로깅 후 계속 진행 (세션 자원은 명시적으로 dispose 수행)
|
||||||
|
// CloseAsync는 세션 소켓과 리소스의 안전한 종료를 담당하므로 실패 시에도 dispose 진행 가능
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (sessionIdKey != 0)
|
||||||
|
{
|
||||||
|
// 플래그를 true로 설정하여 중복 정리 방지
|
||||||
|
_sessionClosedFlags[sessionIdKey] = true;
|
||||||
|
}
|
||||||
|
try { session.Dispose(); } catch (Exception ex) { /* ignore already disposed */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user