433 lines
16 KiB
C#
433 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
namespace JJServiceManager
|
|
{
|
|
/// <summary>로그 보기 창이 사용하는 로그 공급원 (내 PC 파일 / 원격)</summary>
|
|
public interface ILogSource : IDisposable
|
|
{
|
|
event Action<string> FileChanged;
|
|
string CurrentFile { get; }
|
|
void Start();
|
|
void Reload();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 서비스 로그 파일을 실시간으로 읽음 (tail).
|
|
/// - 파일 지정: 그 파일을 계속 추적
|
|
/// - 폴더 지정: 폴더 안에서 가장 최근에 수정된 파일을 자동으로 추적 (날짜별 로그에 유리)
|
|
/// - 읽기 전용으로만 열며, 파일을 수정·삭제하지 않음
|
|
/// </summary>
|
|
public sealed class LogTailer : ILogSource
|
|
{
|
|
private const int PollMs = 500;
|
|
private const int FolderScanEvery = 4; // 폴더 모드: 2초마다 최신 파일 확인
|
|
private static readonly TimeSpan SwitchIdle = TimeSpan.FromSeconds(5); // 지금 파일이 이만큼 멈춰 있어야 새 파일로 전환
|
|
private const int InitialBytes = 256 * 1024; // 처음 열 때 끝에서부터 읽을 양
|
|
private const int InitialMaxLines = 1000; // 처음 열 때 보여줄 최대 줄 수
|
|
private const int MaxReadPerTick = 4 * 1024 * 1024;
|
|
|
|
/// <summary>파일별 읽기 상태 (폴더 모드에서 파일을 오가도 이어서 읽음)</summary>
|
|
private sealed class FileState
|
|
{
|
|
public string Path;
|
|
public long Position;
|
|
public int BomLength;
|
|
public Encoding Encoding; // null = 아직 판단 전 (영문만 나온 상태)
|
|
public Decoder Decoder;
|
|
public string Partial = "";
|
|
public DateTime PartialSince;
|
|
}
|
|
|
|
private readonly ServiceEntry _entry;
|
|
private readonly Action<IList<string>, LogLevel> _output;
|
|
private readonly object _sync = new object();
|
|
private readonly Dictionary<string, FileState> _states = new Dictionary<string, FileState>(StringComparer.OrdinalIgnoreCase);
|
|
private Timer _timer;
|
|
private int _busy;
|
|
private int _tick;
|
|
private volatile bool _disposed;
|
|
|
|
private FileState _current;
|
|
private string _lastProblem;
|
|
|
|
public event Action<string> FileChanged;
|
|
|
|
public string CurrentFile => _current?.Path;
|
|
|
|
/// <summary>true면 처음 열 때 기존 내용은 건너뛰고 새로 추가되는 줄만 표시 (원격 재연결용)</summary>
|
|
public bool SkipInitial { get; set; }
|
|
|
|
public LogTailer(ServiceEntry entry, Action<IList<string>, LogLevel> output)
|
|
{
|
|
_entry = entry;
|
|
_output = output;
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_timer = new Timer(_ => Tick(), null, 0, PollMs);
|
|
}
|
|
|
|
/// <summary>처음부터 다시 읽음 (인코딩·경로 변경 시)</summary>
|
|
public void Reload()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_states.Clear();
|
|
_current = null;
|
|
_lastProblem = null;
|
|
}
|
|
}
|
|
|
|
private void Tick()
|
|
{
|
|
if (_disposed) return;
|
|
if (Interlocked.Exchange(ref _busy, 1) == 1) return;
|
|
try
|
|
{
|
|
lock (_sync) Poll();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Problem("로그를 읽는 중 오류: " + ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _busy, 0);
|
|
}
|
|
}
|
|
|
|
private void Poll()
|
|
{
|
|
if (_current == null || (_entry.LogIsFolder && (_tick++ % FolderScanEvery == 0)))
|
|
{
|
|
var path = ResolvePath();
|
|
if (path == null)
|
|
{
|
|
_current = null;
|
|
return;
|
|
}
|
|
if (_current == null || !string.Equals(path, _current.Path, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
SwitchTo(path);
|
|
return;
|
|
}
|
|
}
|
|
ReadNew(_current);
|
|
}
|
|
|
|
private void SwitchTo(string path)
|
|
{
|
|
// 이전 파일에 남은 내용을 마저 읽고 넘어감
|
|
if (_current != null && File.Exists(_current.Path)) ReadNew(_current);
|
|
|
|
_lastProblem = null;
|
|
FileChanged?.Invoke(path);
|
|
|
|
if (_states.TryGetValue(path, out var known))
|
|
{
|
|
_current = known;
|
|
Emit(new[] { $"──────── 파일 전환: {path} ────────" }, LogLevel.Notice);
|
|
ReadNew(known);
|
|
}
|
|
else
|
|
{
|
|
var st = new FileState { Path = path };
|
|
_states[path] = st;
|
|
_current = st;
|
|
Emit(new[] { $"──────── 파일: {path} ────────" }, LogLevel.Notice);
|
|
ReadInitial(st);
|
|
}
|
|
}
|
|
|
|
private string ResolvePath()
|
|
{
|
|
var p = _entry.LogPath;
|
|
if (string.IsNullOrWhiteSpace(p))
|
|
{
|
|
Problem("로그 경로가 지정되지 않았습니다.");
|
|
return null;
|
|
}
|
|
|
|
if (_entry.LogIsFolder)
|
|
{
|
|
if (!Directory.Exists(p))
|
|
{
|
|
Problem("로그 폴더를 찾을 수 없습니다: " + p);
|
|
return null;
|
|
}
|
|
var newest = new DirectoryInfo(p)
|
|
.EnumerateFiles()
|
|
.OrderByDescending(f => f.LastWriteTimeUtc)
|
|
.FirstOrDefault();
|
|
if (newest == null)
|
|
{
|
|
Problem("로그 폴더에 파일이 없습니다: " + p);
|
|
return null;
|
|
}
|
|
|
|
// 여러 파일에 동시에 기록되는 폴더에서 파일이 계속 바뀌지 않도록,
|
|
// 지금 보는 파일이 잠시 멈춰 있을 때만 새 파일로 넘어감
|
|
if (_current != null && !string.Equals(newest.FullName, _current.Path, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var cur = new FileInfo(_current.Path);
|
|
if (cur.Exists && DateTime.UtcNow - cur.LastWriteTimeUtc < SwitchIdle)
|
|
return _current.Path;
|
|
}
|
|
return newest.FullName;
|
|
}
|
|
|
|
if (!File.Exists(p))
|
|
{
|
|
Problem("로그 파일을 찾을 수 없습니다: " + p);
|
|
return null;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
private static FileStream OpenRead(string path)
|
|
=> new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
|
|
// ───────────────────────── 읽기 ─────────────────────────
|
|
|
|
private void ReadInitial(FileState st)
|
|
{
|
|
using var fs = OpenRead(st.Path);
|
|
PrepareEncoding(st, fs);
|
|
|
|
long len = fs.Length;
|
|
if (SkipInitial)
|
|
{
|
|
SkipInitial = false;
|
|
st.Position = len;
|
|
st.Partial = "";
|
|
return;
|
|
}
|
|
long start = Math.Max(st.BomLength, len - InitialBytes);
|
|
if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++;
|
|
bool fromMiddle = start > st.BomLength;
|
|
|
|
var text = ReadRange(st, fs, start, len);
|
|
var lines = SplitLines(text, out st.Partial);
|
|
st.PartialSince = DateTime.UtcNow;
|
|
|
|
// 중간부터 읽었으면 첫 줄은 잘렸을 수 있으므로 버림
|
|
if (fromMiddle && lines.Count > 0) lines.RemoveAt(0);
|
|
if (lines.Count > InitialMaxLines) lines = lines.GetRange(lines.Count - InitialMaxLines, InitialMaxLines);
|
|
Emit(lines, LogLevel.Info);
|
|
}
|
|
|
|
private void ReadNew(FileState st)
|
|
{
|
|
FileInfo fi;
|
|
try { fi = new FileInfo(st.Path); fi.Refresh(); }
|
|
catch { return; }
|
|
|
|
if (!fi.Exists)
|
|
{
|
|
if (!_entry.LogIsFolder) Problem("로그 파일이 사라졌습니다: " + st.Path);
|
|
_states.Remove(st.Path);
|
|
if (ReferenceEquals(st, _current)) _current = null;
|
|
return;
|
|
}
|
|
|
|
long len = fi.Length;
|
|
if (len < st.Position)
|
|
{
|
|
Emit(new[] { "──────── 파일 크기가 줄어들어 처음부터 다시 읽습니다 ────────" }, LogLevel.Notice);
|
|
st.Position = 0;
|
|
st.Partial = "";
|
|
st.Encoding = null;
|
|
st.Decoder = null;
|
|
st.BomLength = 0;
|
|
}
|
|
|
|
if (len == st.Position)
|
|
{
|
|
FlushStalePartial(st);
|
|
return;
|
|
}
|
|
|
|
using var fs = OpenRead(st.Path);
|
|
if (st.Position == 0) PrepareEncoding(st, fs);
|
|
|
|
long start = Math.Max(st.Position, st.BomLength);
|
|
if (len - start > MaxReadPerTick)
|
|
{
|
|
Emit(new[] { "──────── 로그가 너무 빠르게 늘어나 일부를 건너뜁니다 ────────" }, LogLevel.Notice);
|
|
start = len - MaxReadPerTick;
|
|
if (IsUtf16(st.Encoding) && ((start - st.BomLength) % 2 == 1)) start++;
|
|
st.Partial = "";
|
|
st.Decoder = st.Encoding?.GetDecoder();
|
|
}
|
|
|
|
var text = st.Partial + ReadRange(st, fs, start, len);
|
|
var lines = SplitLines(text, out var rest);
|
|
if (rest != st.Partial) st.PartialSince = DateTime.UtcNow;
|
|
st.Partial = rest;
|
|
Emit(lines, LogLevel.Info);
|
|
}
|
|
|
|
/// <summary>[start, end) 구간을 읽어 문자열로. 읽은 만큼 Position 이동</summary>
|
|
private string ReadRange(FileState st, FileStream fs, long start, long end)
|
|
{
|
|
if (end <= start)
|
|
{
|
|
st.Position = Math.Max(st.Position, start);
|
|
return "";
|
|
}
|
|
fs.Seek(start, SeekOrigin.Begin);
|
|
var buf = new byte[end - start];
|
|
int read = ReadFully(fs, buf);
|
|
st.Position = start + read;
|
|
if (read == 0) return "";
|
|
|
|
// 자동 인코딩: 한글 등 0x80 이상 바이트가 처음 나올 때 판단
|
|
if (st.Encoding == null && HasHighByte(buf, read))
|
|
{
|
|
st.Encoding = LooksLikeUtf8(buf, read) ? new UTF8Encoding(false) : NonUtf8Encoding(buf, read);
|
|
st.Decoder = st.Encoding.GetDecoder();
|
|
}
|
|
|
|
if (st.Encoding == null)
|
|
return Encoding.ASCII.GetString(buf, 0, read); // 아직 영문만 있음
|
|
|
|
st.Decoder ??= st.Encoding.GetDecoder();
|
|
var chars = new char[st.Decoder.GetCharCount(buf, 0, read)];
|
|
int n = st.Decoder.GetChars(buf, 0, read, chars, 0);
|
|
return new string(chars, 0, n);
|
|
}
|
|
|
|
/// <summary>BOM 확인 및 인코딩 설정</summary>
|
|
private void PrepareEncoding(FileState st, FileStream fs)
|
|
{
|
|
st.BomLength = 0;
|
|
st.Encoding = null;
|
|
st.Decoder = null;
|
|
|
|
var head = new byte[4];
|
|
fs.Seek(0, SeekOrigin.Begin);
|
|
int n = ReadFully(fs, head);
|
|
|
|
Encoding bomEnc = null;
|
|
if (n >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) { bomEnc = new UTF8Encoding(false); st.BomLength = 3; }
|
|
else if (n >= 2 && head[0] == 0xFF && head[1] == 0xFE) { bomEnc = new UnicodeEncoding(false, false); st.BomLength = 2; }
|
|
else if (n >= 2 && head[0] == 0xFE && head[1] == 0xFF) { bomEnc = new UnicodeEncoding(true, false); st.BomLength = 2; }
|
|
|
|
var setting = (_entry.LogEncoding ?? "auto").ToLowerInvariant();
|
|
if (bomEnc != null) st.Encoding = bomEnc; // BOM이 있으면 항상 BOM을 따름
|
|
else if (setting == "utf-8") st.Encoding = new UTF8Encoding(false);
|
|
else if (setting == "cp949") st.Encoding = Encoding.GetEncoding(949);
|
|
// 자동 + BOM 없음 → 내용을 보고 판단 (ReadRange)
|
|
|
|
st.Decoder = st.Encoding?.GetDecoder();
|
|
}
|
|
|
|
/// <summary>줄바꿈 없이 끝난 마지막 줄이 2초 넘게 그대로면 그대로 표시</summary>
|
|
private void FlushStalePartial(FileState st)
|
|
{
|
|
if (st.Partial.Length > 0 && (DateTime.UtcNow - st.PartialSince).TotalSeconds > 2)
|
|
{
|
|
Emit(new[] { st.Partial }, LogLevel.Info);
|
|
st.Partial = "";
|
|
}
|
|
}
|
|
|
|
private static List<string> SplitLines(string text, out string rest)
|
|
{
|
|
var parts = text.Split('\n');
|
|
rest = parts[^1];
|
|
var list = new List<string>(parts.Length);
|
|
for (int i = 0; i < parts.Length - 1; i++)
|
|
list.Add(parts[i].TrimEnd('\r', '\0'));
|
|
return list;
|
|
}
|
|
|
|
/// <summary>UTF-8이 아니면 CP949(한국어)로 올바르게 읽히는지 먼저 확인, 아니면 시스템 기본 코드페이지</summary>
|
|
private static Encoding NonUtf8Encoding(byte[] buf, int count)
|
|
{
|
|
try
|
|
{
|
|
var strict = Encoding.GetEncoding(949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
|
// 끝에서 잘린 2바이트 문자는 판단에서 제외
|
|
int n = count;
|
|
if (n > 0 && buf[n - 1] >= 0x80) n--;
|
|
strict.GetCharCount(buf, 0, n);
|
|
return Encoding.GetEncoding(949);
|
|
}
|
|
catch { }
|
|
try { return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.ANSICodePage); }
|
|
catch { return Encoding.GetEncoding(949); }
|
|
}
|
|
|
|
private static bool HasHighByte(byte[] b, int count)
|
|
{
|
|
for (int i = 0; i < count; i++) if (b[i] >= 0x80) return true;
|
|
return false;
|
|
}
|
|
|
|
private static bool LooksLikeUtf8(byte[] b, int count)
|
|
{
|
|
int i = 0;
|
|
// 앞부분이 잘린 멀티바이트 문자는 건너뜀
|
|
while (i < count && (b[i] & 0xC0) == 0x80) i++;
|
|
while (i < count)
|
|
{
|
|
byte c = b[i];
|
|
int need;
|
|
if (c < 0x80) { i++; continue; }
|
|
else if ((c & 0xE0) == 0xC0) need = 1;
|
|
else if ((c & 0xF0) == 0xE0) need = 2;
|
|
else if ((c & 0xF8) == 0xF0) need = 3;
|
|
else return false;
|
|
|
|
if (i + need >= count) return true; // 끝에서 잘린 문자는 판단 제외
|
|
for (int k = 1; k <= need; k++)
|
|
if ((b[i + k] & 0xC0) != 0x80) return false;
|
|
i += need + 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool IsUtf16(Encoding e) => e != null && (e.CodePage == 1200 || e.CodePage == 1201);
|
|
|
|
private static int ReadFully(Stream s, byte[] buf)
|
|
{
|
|
int total = 0;
|
|
while (total < buf.Length)
|
|
{
|
|
int n = s.Read(buf, total, buf.Length - total);
|
|
if (n <= 0) break;
|
|
total += n;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private void Problem(string message)
|
|
{
|
|
if (message == _lastProblem) return; // 같은 메시지는 한 번만
|
|
_lastProblem = message;
|
|
Emit(new[] { message }, LogLevel.Warning);
|
|
}
|
|
|
|
private void Emit(IList<string> lines, LogLevel level)
|
|
{
|
|
if (lines.Count == 0 || _disposed) return;
|
|
_output(lines, level);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_disposed = true;
|
|
_timer?.Dispose();
|
|
_timer = null;
|
|
}
|
|
}
|
|
}
|