JJServiceManagerEnt/Core/LogTailer.cs

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;
}
}
}