مستوى تسجيل منفصل لكل طلب

أثناء قراءة رادار تقنية ThoughtWorks ، عثرت على تقنية " فصل تسجيل الدخول لكل طلب ". نحن في Confirmit نستخدم التسجيل على نطاق واسع ، وتساءلت كيف يمكن تنفيذ هذه الوظيفة.

وصف المشكلة


دعونا نرى ما هو على المحك. لنفترض أن لدينا خدمة ويب. في مرحلة ما ، يبدأ التعطل على خوادم الإنتاج ، ولكن فقط لبعض الطلبات. على سبيل المثال ، تحدث الأعطال لمستخدم معين فقط. أو فقط في نقطة وصول منفصلة ... نحن بحاجة إلى إيجاد سبب. في هذه الحالة ، يأتي التسجيل لمساعدتنا.

يمكننا إدراج تعليمات تسجيل كافية في الكود الخاص بنا لفهم سبب المشكلة. تتطابق هذه الإرشادات عادة مع رسالتك بمستوى سجل معين (تصحيح ، معلومات ، تحذير ، ...). بالإضافة إلى ذلك ، يحتوي السجل نفسه أيضًا على مستواه الخاص. سيتم تسجيل جميع الرسائل التي تحتوي على مستويات أعلى من مستوى السجل في تخزين السجل (ملف ، قاعدة بيانات ، ...). إذا كان مستوى الرسالة أقل من مستوى السجل ، فسيتم ببساطة تجاهل الرسالة.

عندما يعمل تطبيقنا بشكل جيد ، نريد أن نرى أقل عدد ممكن من إدخالات السجل بحيث يظل حجمها صغيرًا. في الوقت نفسه ، إذا تعطل التطبيق ، نريد أن يكون للسجل إدخالات كافية حتى نتمكن من فهم سبب المشكلة. تكمن الصعوبة هنا في أننا عادة ما نقوم بتعيين مستوى تسجيل واحد لجميع قطع الأشجار في طلبنا. إذا كان كل شيء على ما يرام ، فإننا نحافظ على هذا المستوى مرتفعًا (على سبيل المثال ، تحذير). إذا كنا بحاجة إلى البحث عن سبب الفشل ، فسنقوم بتثبيته أدناه (على سبيل المثال ، Debug).

مستوى واحد من التسجيل في التطبيق


عندما نضع مستوى التسجيل في التطبيق منخفضًا ، يتم تعبئة مخازن السجل بكمية كبيرة من الرسائل. تأتي هذه الرسائل من طلبات مختلفة ويتم خلطها فيما بينها ، حيث يمكن تقديم الطلبات في وقت واحد. هذا يؤدي إلى عدد من المشاكل المحتملة:

  • كيفية فصل رسائل طلبات الإشكالية عن رسائل الطلبات الأخرى؟
  • الطلبات التي لا تؤدي إلى تعطل ما زالت تقضي وقتها في الكتابة إلى مستودع السجل ، على الرغم من أن هذه الرسائل لن يتم استخدامها أبدًا.
  • ستشغل الرسائل من الطلبات الناجحة مساحة في مستودعات السجل ، على الرغم من أن هذه الرسائل لن يتم استخدامها أبدًا.

بصراحة ، هذه الصعوبات ليست مستعصية على الحل. لفصل الرسائل ذات الطلبات "الجيدة" عن رسائل الطلبات "السيئة" ، يمكنك استخدام معرف الارتباط . تسمح جميع أنظمة معالجة السجل الحديثة بتصفية السجلات وفقًا لمعايير مختلفة ، بما في ذلك هذا.

الأداء هو أيضا عادة ليست قضية رئيسية. تدعم أنظمة التسجيل التسجيل غير المتزامن ، وبالتالي فإن تأثير التسجيل الشامل لا يؤدي إلى تعطيل.

ومساحة القرص الآن رخيصة نسبيًا ، لذا فإن تخزين الكثير من السجلات ليس مشكلة. خاصة إذا كنا نستطيع حذف السجلات القديمة من وقت لآخر.

ومع ذلك ، هل يمكننا تحسين هذا النظام؟ هل يمكننا تعيين مستوى تسجيل منفصل لكل طلب ، وفقًا لشروط معينة؟ أود أن أعتبر هذه المشكلة هنا.

مستوى تسجيل منفصل لكل طلب


اسمحوا لي أن أذكر متطلبات الحل الذي سوف أطبقه. يجب أن يكون هناك طريقة لتأسيس مستوى تسجيل منفصل لكل طلب. يجب أن يكون هناك طريقة مرنة لتحديد الشروط التي تحدد اختيار هذا المستوى لطلب معين. ويجب أن يكون من الممكن تغيير هذه الشروط أثناء تنفيذ البرنامج دون الحاجة إلى إعادة تشغيله.

لذلك ، تم تعيين المهمة. لنبدأ.

سوف أقوم بإنشاء خدمة ويب بسيطة تستند إلى .NET Core. سيكون لديه وحدة تحكم واحدة:

[Route("api/[controller]")] [ApiController] public class ValuesController : ControllerBase { ... // GET api/values [HttpGet] public ActionResult<IEnumerable<string>> Get() { Logger.Info("Executing Get all"); return new[] { "value1", "value2" }; } // GET api/values/5 [HttpGet("{id}")] public ActionResult<string> Get(int id) { Logger.Info($"Executing Get {id}"); return "value"; } } 

سنناقش تنفيذ خاصية Logger لاحقًا. بالنسبة لهذا التطبيق ، سأستخدم مكتبة log4net للتسجيل. توفر هذه المكتبة فرصة مثيرة للاهتمام. أنا أتحدث عن مستوى الميراث . باختصار ، إذا قلت في تكوين هذه المكتبة أن السجل الذي يحمل الاسم X يجب أن يكون بمستوى تسجيل المعلومات ، فهذا يعني أن جميع السجلات التي تحتوي على أسماء تبدأ بـ X. (على سبيل المثال ، XY ، XZ ، XAB ) سيرث هذا المستوى نفسه.

من هنا تأتي الفكرة الأصلية. لكل طلب ، سأحدد بطريقة ما المستوى المطلوب للتسجيل. ثم سوف أقوم بإنشاء سجل مسمى جديد في تكوين log4net . سيكون لهذا السجل مستوى التسجيل المطلوب. بعد ذلك ، يجب أن تحتوي جميع كائنات المسجل التي تم إنشاؤها كجزء من الطلب الحالي على أسماء تبدأ باسم هذا السجل الجديد الذي أنشأته بواسطتي. المشكلة الوحيدة هنا هي أن log4net لا يزيل السجلات أبدًا. بمجرد إنشائها ، فهي موجودة طوال حياة التطبيق. لهذا السبب ، أقوم في البداية بإنشاء سجل واحد لكل مستوى ممكن من التسجيل:

 <?xml version="1.0" encoding="utf-8" ?> <log4net> <appender name="Console" type="log4net.Appender.ConsoleAppender"> <layout type="log4net.Layout.PatternLayout"> <!-- Pattern to output the caller's file name and line number --> <conversionPattern value="%5level [%thread] (%file:%line) - %message%newline" /> </layout> </appender> <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> <file value="RequestLoggingLog.log" /> <appendToFile value="true" /> <maximumFileSize value="100KB" /> <maxSizeRollBackups value="2" /> <layout type="log4net.Layout.PatternLayout"> <conversionPattern value="%level %thread %logger - %message%newline" /> </layout> </appender> <root> <level value="WARN" /> <appender-ref ref="Console" /> <appender-ref ref="RollingFile" /> </root> <logger name="EdlinSoftware.Log.Error"> <level value="ERROR" /> </logger> <logger name="EdlinSoftware.Log.Warning"> <level value="WARN" /> </logger> <logger name="EdlinSoftware.Log.Info"> <level value="INFO" /> </logger> <logger name="EdlinSoftware.Log.Debug"> <level value="DEBUG" /> </logger> </log4net> 

الآن لدي عدة سجلات بأسماء EdlinSoftware.Log.XXXX . ستكون هذه الأسماء بمثابة بادئات لأسماء السجل المستخدمة في الاستعلامات. لتجنب الاصطدامات بين الطلبات ، سأقوم بتخزين البادئة المحسوبة لهذا الطلب في مثيل AsyncLocal . سيتم تعيين قيمة هذا الكائن في الوسيطة OWIN الجديدة:

 app.Use(async (context, next) => { try { LogSupport.LogNamePrefix.Value = await LogSupport.GetLogNamePrefix(context); await next(); } finally { LogSupport.LogNamePrefix.Value = null; } }); 

عند تعيين هذه القيمة ، يكون من السهل جدًا إنشاء أدوات قطع الأشجار باستخدام بادئة الاسم المطلوبة:

 public static class LogSupport { public static readonly AsyncLocal<string> LogNamePrefix = new AsyncLocal<string>(); public static ILog GetLogger(string name) { return GetLoggerWithPrefixedName(name); } public static ILog GetLogger(Type type) { return GetLoggerWithPrefixedName(type.FullName); } private static ILog GetLoggerWithPrefixedName(string name) { if (!string.IsNullOrWhiteSpace(LogNamePrefix.Value)) { name = $"{LogNamePrefix.Value}.{name}"; } return LogManager.GetLogger(typeof(LogSupport).Assembly, name); } .... } 

أصبح من الواضح الآن كيفية الحصول على مثيل مسجل في وحدة التحكم لدينا:

 [Route("api/[controller]")] [ApiController] public class ValuesController : ControllerBase { private ILog _logger; private ILog Logger { get => _logger ?? (_logger = LogSupport.GetLogger(typeof(ValuesController))); } .... } 

يبقى أن نفعل شيئًا واحدًا فقط: حدد بطريقة ما القواعد التي نختار بها مستوى التسجيل لكل طلب. يجب أن تكون هذه آلية مرنة إلى حد ما. الفكرة الأساسية هي استخدام البرمجة النصية C #. سوف أقوم بإنشاء ملف LogLevelRules.json ، حيث سأحدد مجموعة من أزواج "مستوى تسجيل القاعدة":

 [ { "logLevel": "Debug", "ruleCode": "context.Request.Path.Value == \"/api/values/1\"" }, { "logLevel": "Debug", "ruleCode": "context.Request.Path.Value == \"/api/values/3\"" } ] 

logLevel هنا هو المستوى المطلوب لتسجيل الدخول ، و ruleCode هو رمز C # الذي يعرض قيمة منطقية للطلب المحدد. سيتم تشغيل التطبيق رموز القاعدة واحدا تلو الآخر. سيتم استخدام القاعدة الأولى ، التي تُرجع الكود إليها ، لتعيين المستوى المناسب من التسجيل. إذا كانت كل القواعد خاطئة ، فسيتم استخدام المستوى الافتراضي.

لإنشاء مفوضين من تمثيل سلسلة للقواعد ، يتم استخدام فئة CSharpScript :

 public class Globals { public HttpContext context; } internal class LogLevelRulesCompiler { public IReadOnlyList<LogLevelRule> Compile(IReadOnlyList<LogLevelRuleDescription> levelRuleDescriptions) { var result = new List<LogLevelRule>(); foreach (var levelRuleDescription in levelRuleDescriptions ?? new LogLevelRuleDescription[0]) { var script = CSharpScript.Create<bool>(levelRuleDescription.RuleCode, globalsType: typeof(Globals)); ScriptRunner<bool> runner = script.CreateDelegate(); result.Add(new LogLevelRule(levelRuleDescription.LogLevel, runner)); } return result; } } internal sealed class LogLevelRule { public string LogLevel { get; } public ScriptRunner<bool> Rule { get; } public LogLevelRule(string logLevel, ScriptRunner<bool> rule) { LogLevel = logLevel ?? throw new ArgumentNullException(nameof(logLevel)); Rule = rule ?? throw new ArgumentNullException(nameof(rule)); } } 

هنا ، الأسلوب Compile باسترداد قائمة الكائنات قراءة من ملف LogLevelRules.json . يخلق مفوض عداء لكل قاعدة.

يمكن حفظ قائمة المفوضين هذه:

 LogSupport.LogLevelSetters = new LogLevelRulesCompiler().Compile( new LogLevelRulesFileReader().ReadFile("LogLevelRules.json") ); 

وتستخدم في المستقبل:

 public static class LogSupport { internal static IReadOnlyList<LogLevelRule> LogLevelSetters = new LogLevelRule[0]; ... public static async Task<string> GetLogNamePrefix(HttpContext context) { var globals = new Globals { context = context }; string result = null; foreach (var logLevelSetter in LogLevelSetters) { if (await logLevelSetter.Rule(globals)) { result = $"EdlinSoftware.Log.{logLevelSetter.LogLevel}"; break; } } return result; } } 

وبالتالي ، عندما يبدأ التطبيق ، نقرأ LogLevelRules.json ، ونحول محتوياته إلى قائمة المفوضين باستخدام فئة CSharpScript ، ونحفظ هذه القائمة في حقل LogSupport.LogLevelSetters . ثم ، لكل طلب ، نقوم بتشغيل مندوبين من هذه القائمة للحصول على مستوى التسجيل.

الشيء الوحيد المتبقي هو تتبع التغييرات في ملف LogLevelRules.json . إذا كنا نريد تعيين مستوى التسجيل لبعض الاستعلامات ، فسنضيف قاعدة جديدة إلى هذا الملف. لإجبار تطبيقنا على تطبيق هذه التغييرات دون إعادة التشغيل ، من الضروري مراقبة الملف:

 var watcher = new FileSystemWatcher { Path = Directory.GetCurrentDirectory(), Filter = "*.json", NotifyFilter = NotifyFilters.LastWrite }; watcher.Changed += (sender, eventArgs) => { // ,  ,  ,   . Thread.Sleep(1000); LogSupport.LogLevelSetters = new LogLevelRulesCompiler().Compile( new LogLevelRulesFileReader().ReadFile("LogLevelRules.json") ); }; watcher.EnableRaisingEvents = true; 

تجدر الإشارة إلى أنه من أجل الإيجاز ، حذفت رمز مزامنة الدفق عند العمل مع حقل LogSupport.LogLevelSetters . ولكن في الممارسة العملية ، مثل هذا التزامن مطلوب.

يمكن العثور على رمز التطبيق الكامل على جيثب .

العيوب


يتيح لك الرمز الموضح أعلاه حل مشكلة تحديد مستوى تسجيل منفصل لكل طلب. لكنه لديه عدد من أوجه القصور. لنناقشهم.

1. هذا النهج يغير أسماء السجلات. لذلك في مستودع السجل بدلاً من " MyClassLogger " ، سيتم تخزين شيء مثل " Edlinsoft.Log.Debug.MyClassLogger ". يمكنك العيش معها ، لكنها ليست مريحة للغاية. ربما يمكن التغلب على المشكلة عن طريق تغيير تخطيط السجل (تخطيط السجل).

2. الآن أصبح من المستحيل استخدام المثيلات الثابتة لفئات المسجل ، حيث يجب إنشاؤها بشكل منفصل لكل طلب. المشكلة الأكثر خطورة هنا ، في رأيي ، هي أن المطورين يجب أن يضعوا هذا في الاعتبار دائمًا. قد يقوم شخص ما بطريق الخطأ بإنشاء حقل ثابت باستخدام مسجل والحصول على نتائج غريبة. يمكنك التغلب على هذا الموقف عن طريق إنشاء فئة مجمّع للمسجلين بدلاً من استخدام فئات log4net مباشرةً. يمكن لمثل هذا المجمع أن ينشئ دائمًا مثيلات جديدة من أدوات تسجيل log4net لكل عملية. في هذه الحالة ، يمكن استخدامه بحرية في الحقول الثابتة.

3. النهج الموصوف يخلق العديد من حالات قطع الاشجار. هذا يستهلك دورات الذاكرة والمعالج أثناء الإنشاء وأثناء تجميع البيانات المهملة. بناءً على طلبك ، قد تكون هذه أو لا تمثل مشكلة مهمة.

4. عندما نغير ملف JSON بالقواعد ، قد يحتوي رمز القاعدة على أخطاء. من السهل جدًا استخدام كتل try-catch بحيث لا تدمر هذه الأخطاء تطبيقنا الرئيسي. ومع ذلك ، نحن بحاجة بطريقة ما إلى معرفة أن هناك خطأ ما. هناك نوعان من الأخطاء:

  • أخطاء وقت التجميع لرمز القاعدة في المفوضين.
  • أخطاء وقت التشغيل من هؤلاء المفوضين.

بطريقة ما نحتاج إلى معرفة هذه الأخطاء ، وإلا لن يعمل تسجيلنا ببساطة ، ولن نعرف حتى عن ذلك.

5. يمكن أن يحتوي كود القاعدة في ملف JSON ، من حيث المبدأ ، على أي تعليمات. هذا يمكن أن يؤدي إلى مشاكل أمنية. من الضروري الحد من قدرات هذا الرمز بطريقة أو بأخرى. من ناحية أخرى ، إذا كان المهاجم قادرًا على الكتابة مباشرة إلى ملفات التطبيق ، فستكون مشكلة الأمان واضحة.

الخاتمة


بشكل عام ، لم يكن لدي انطباع بأن مثل هذا الحل يجب أن يحل محل النهج الحالي للتسجيل. أداة جيدة مع القدرة على تصفية إدخالات السجل يمكن أن تساعد هنا حتى عند استخدام مستوى واحد من التسجيل للتطبيق بأكمله. ومع ذلك ، آمل أن يعطيك تحليل هذه المشكلة طعامًا للتفكير.

Source: https://habr.com/ru/post/ar436340/


All Articles