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.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using System.Text;
|
||||
using System.Collections.Concurrent;
|
||||
using ISession = Opc.Ua.Client.ISession;
|
||||
using StatusCodes = Opc.Ua.StatusCodes;
|
||||
|
||||
@@ -16,9 +17,17 @@ namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
/// </summary>
|
||||
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)
|
||||
@@ -67,11 +76,10 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
{
|
||||
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||
ApplicationConfiguration appConfig, string endpointUrl,
|
||||
CancellationToken ct = default)
|
||||
@@ -126,7 +134,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
try
|
||||
{
|
||||
_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);
|
||||
|
||||
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
||||
@@ -162,7 +170,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
||||
|
||||
@@ -215,7 +223,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
||||
|
||||
@@ -279,16 +287,55 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var nodeIdStr = r.NodeId.ToString();
|
||||
if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지
|
||||
if (!visited.Add(nodeIdStr)) continue;
|
||||
|
||||
var dataType = r.NodeClass == NodeClass.Variable
|
||||
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
|
||||
: "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(
|
||||
currentLevel,
|
||||
r.NodeClass.ToString(),
|
||||
r.DisplayName.Text,
|
||||
displayNameStr, // null 방지된 값 사용
|
||||
nodeIdStr,
|
||||
dataType));
|
||||
|
||||
@@ -365,7 +412,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
ISession? session = null;
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var appConfig = await _configProvider.GetConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
|
||||
|
||||
@@ -373,22 +420,70 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
? new NodeId(startNodeId)
|
||||
: ObjectIds.ObjectsFolder;
|
||||
|
||||
// NodeClassMask 확장: ObjectType, VariableTypes 등 포함하여 더 많은 노드 탐색
|
||||
var browser = new Browser(session)
|
||||
{
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
||||
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable | NodeClass.ObjectType | NodeClass.VariableType),
|
||||
ResultMask = (uint)BrowseResultMask.All
|
||||
};
|
||||
|
||||
var refs = await browser.BrowseAsync(startNode);
|
||||
var nodes = refs.Select(r => new ExperionNodeInfo(
|
||||
r.NodeId.ToString(),
|
||||
r.DisplayName.Text,
|
||||
r.NodeClass.ToString(),
|
||||
r.NodeClass == NodeClass.Object
|
||||
)).ToList();
|
||||
int okCount = 0;
|
||||
int noNameCount = 0;
|
||||
|
||||
var nodes = refs.Select(r => {
|
||||
// ─────────────────────────────────────────
|
||||
// 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);
|
||||
}
|
||||
@@ -400,11 +495,67 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
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)
|
||||
{
|
||||
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