71

阅读“你的/一个好的圈复杂度限制是多少? ”后,我意识到我的许多同事对我们项目的这个新的QA政策感到非常恼火:每个函数不再有 10 个圈复杂度。

含义:不超过10个'if'、'else'、'try'、'catch'等代码工作流分支语句。对。正如我在'你测试私有方法吗?',这样的政策有很多好的副作用。

但是:在我们(200 人 - 7 年之久)项目开始时,我们很高兴记录日志(不,我们不能轻易将其委托给某种“面向方面编程”的日志方法)。

myLogger.info("A String");
myLogger.fine("A more complicated String");
...

当我们的系统的第一个版本上线时,我们遇到了巨大的内存问题,不是因为日志记录(曾经关闭过),而是因为日志参数(字符串),它们总是被计算出来,然后传递给'info()' 或 'fine()' 函数,却发现日志记录的级别是 'OFF',并且没有发生日志记录!

所以 QA 回来并敦促我们的程序员进行条件日志记录。总是。

if(myLogger.isLoggable(Level.INFO) { myLogger.info("A String");
if(myLogger.isLoggable(Level.FINE) { myLogger.fine("A more complicated String");
...

但是现在,由于每个函数限制的“不能移动”10 个圈复杂度级别,他们认为他们放入函数中的各种日志被认为是一种负担,因为每个“if(isLoggable())”都是算作+1圈复杂度!

因此,如果一个函数具有 8 个“if”、“else”等,在一个紧密耦合的不易共享算法中,以及 3 个关键日志操作......即使条件日志可能不是真的,它们也会超出限制该功能的所述复杂性的一部分......

您将如何解决这种情况?
我在我的项目中看到了一些有趣的编码演变(由于那个“冲突”),但我只想先得到你的想法。


谢谢你的所有答案。
我必须坚持认为问题与“格式”无关,而是与“参数评估”相关(在调用一个什么都不做的方法之前进行评估可能非常昂贵)
所以当一个在“A String”上面写的时候,我实际上意味着 aFunction(),aFunction() 返回一个字符串,并调用一个复杂的方法来收集和计算要由记录器显示的所有类型的日志数据......使用条件日志,因此人为增加“圈复杂度”的实际问题......)

我现在得到了你们中的一些人提出的“可变参数函数”点(谢谢约翰)。
注意:java6 中的快速测试表明,我的varargs 函数在被调用之前确实评估了它的参数,因此它不能应用于函数调用,而是用于“日志检索器对象”(或“函数包装器”),其中 toString( ) 只会在需要时调用。知道了。

我现在已经发布了我在这个主题上的经验。
我会把它留在那里直到下周二投票,然后我会选择你的答案之一。
再次感谢您的所有建议:)

4

12 回答 12

64

对于当前的日志框架,这个问题没有实际意义

当前的日志框架,如 slf4j 或 log4j 2 在大多数情况下不需要保护语句。他们使用参数化的日志语句,以便可以无条件地记录事件,但只有在启用事件时才会发生消息格式化。消息构造由记录器根据需要执行,而不是由应用程序先发制人。

如果您必须使用古董日志库,您可以继续阅读以获取更多背景信息以及使用参数化消息改造旧库的方法。

守卫语句真的增加了复杂性吗?

考虑从圈复杂度计算中排除日志保护语句。

可以说,由于其可预测的形式,条件日志检查确实不会增加代码的复杂性。

不灵活的指标会使原本优秀的程序员变坏。当心!

假设您的计算复杂性的工具无法针对该程度进行定制,以下方法可能会提供一种解决方法。

需要条件记录

我假设你的保护语句被引入是因为你有这样的代码:

private static final Logger log = Logger.getLogger(MyClass.class);

Connection connect(Widget w, Dongle d, Dongle alt) 
  throws ConnectionException
{
  log.debug("Attempting connection of dongle " + d + " to widget " + w);
  Connection c;
  try {
    c = w.connect(d);
  } catch(ConnectionException ex) {
    log.warn("Connection failed; attempting alternate dongle " + d, ex);
    c = w.connect(alt);
  }
  log.debug("Connection succeeded: " + c);
  return c;
}

在 Java 中,每个 log 语句都会创建一个 new ,并在连接到字符串的每个对象上StringBuilder调用该方法。反过来,toString()这些方法可能会创建自己的实例,并调用其成员的方法,等等,跨越一个潜在的大型对象图。(在 Java 5 之前,它更加昂贵,因为使用过,并且它的所有操作都是同步的。)toString()StringBuildertoString()StringBuffer

这可能相对昂贵,特别是如果日志语句位于某些执行量很大的代码路径中。而且,如上所述,即使记录器由于日志级别太高而必然丢弃结果,也会发生昂贵的消息格式化。

这导致了以下形式的保护语句的引入:

  if (log.isDebugEnabled())
    log.debug("Attempting connection of dongle " + d + " to widget " + w);

d使用此保护,仅在必要时执行参数和w字符串连接的评估。

简单、高效的日志记录解决方案

但是,如果记录器(或您围绕所选日志记录包编写的包装器)采用格式化程序和格式化程序的参数,则可以延迟消息构造,直到确定将使用它,同时消除保护语句及其圈复杂度。

public final class FormatLogger
{

  private final Logger log;

  public FormatLogger(Logger log)
  {
    this.log = log;
  }

  public void debug(String formatter, Object... args)
  {
    log(Level.DEBUG, formatter, args);
  }

  … &c. for info, warn; also add overloads to log an exception …

  public void log(Level level, String formatter, Object... args)
  {
    if (log.isEnabled(level)) {
      /* 
       * Only now is the message constructed, and each "arg"
       * evaluated by having its toString() method invoked.
       */
      log.log(level, String.format(formatter, args));
    }
  }

}

class MyClass 
{

  private static final FormatLogger log = 
     new FormatLogger(Logger.getLogger(MyClass.class));

  Connection connect(Widget w, Dongle d, Dongle alt) 
    throws ConnectionException
  {
    log.debug("Attempting connection of dongle %s to widget %s.", d, w);
    Connection c;
    try {
      c = w.connect(d);
    } catch(ConnectionException ex) {
      log.warn("Connection failed; attempting alternate dongle %s.", d);
      c = w.connect(alt);
    }
    log.debug("Connection succeeded: %s", c);
    return c;
  }

}

现在,除非必要,否则不会发生带有缓冲区分配的级联调用!toString()这有效地消除了导致防护语句的性能损失。在 Java 中,一个小的惩罚是对您传递给记录器的任何原始类型参数进行自动装箱。

可以说,进行日志记录的代码比以往任何时候都更干净,因为不整洁的字符串连接已经消失了。如果格式字符串被外部化(使用 a ResourceBundle),它会更干净,这也有助于软件的维护或本地化。

进一步的增强

另请注意,在 Java 中,MessageFormat可以使用对象来代替“格式” String,这为您提供了额外的功能,例如可以更巧妙地处理基数的选择格式。另一种选择是实现您自己的格式化功能,该功能调用您为“评估”定义的某些接口,而不是基本toString()方法。

于 2008-09-19T21:46:24.293 回答
33

在 Python 中,您将格式化的值作为参数传递给日志记录函数。仅当启用日志记录时才应用字符串格式。函数调用的开销仍然存在,但与格式化相比,这是微不足道的。

log.info ("a = %s, b = %s", a, b)

您可以对任何具有可变参数的语言(C/C++、C#/Java 等)执行类似的操作。


这并不是真正适用于难以检索参数的情况,而是适用于将它们格式化为字符串的成本很高的情况。例如,如果您的代码中已经有一个数字列表,您可能希望记录该列表以进行调试。执行mylist.toString()将需要一段时间而没有任何好处,因为结果将被丢弃。因此,您将mylist作为参数传递给日志记录函数,并让它处理字符串格式。这样,只有在需要时才会执行格式化。


由于 OP 的问题特别提到了 Java,以下是如何使用上述内容:

我必须坚持认为问题与“格式化”无关,而是与“参数评估”相关(在调用一个什么都不做的方法之前进行评估可能非常昂贵)

诀窍是让对象在绝对需要之前不会执行昂贵的计算。这在支持 lambdas 和闭包的 Smalltalk 或 Python 等语言中很容易,但在 Java 中仍然可以实现,只要有一点想象力。

假设你有一个函数get_everything()。它会将数据库中的每个对象检索到一个列表中。显然,如果结果将被丢弃,您不想调用它。因此,您无需直接调用该函数,而是定义了一个名为 的内部类LazyGetEverything

public class MainClass {
    private class LazyGetEverything { 
        @Override
        public String toString() { 
            return getEverything().toString(); 
        }
    }

    private Object getEverything() {
        /* returns what you want to .toString() in the inner class */
    }

    public void logEverything() {
        log.info(new LazyGetEverything());
    }
}

在此代码中,对 的调用getEverything()被包装,因此在需要之前不会实际执行它。仅当启用调试时,日志记录功能才会toString()对其参数执行。这样,您的代码将只承受函数调用而不是完整getEverything()调用的开销。

于 2008-09-19T22:06:12.153 回答
6

在支持 lambda 表达式或代码块作为参数的语言中,一种解决方案是将其提供给日志记录方法。可以评估配置,并且仅在需要时实际调用/执行提供的 lambda/代码块。不过还没试过。

理论上这是可能的。我不想在生产中使用它,因为我期望大量使用 lamdas/代码块进行日志记录时会出现性能问题。

但一如既往:如果有疑问,请对其进行测试并测量对 CPU 负载和内存的影响。

于 2008-09-19T21:50:02.863 回答
4

感谢您的所有回答!你们真棒 :)

现在我的反馈不像你的那么直截了当:

是的,对于一个项目(如“在单个生产平台上单独部署和运行的一个程序”),我想您可以对我进行所有技术操作:

  • 专用的“Log Retriever”对象,可以传递给 Logger 包装器,只需要调用 toString()
  • 与记录可变参数函数(或普通 Object[] 数组!)结合使用

正如@John Millikin 和@erickson 所解释的那样,你已经有了它。

然而,这个问题迫使我们思考“为什么我们首先要登录?”
我们的项目实际上是部署在各种生产平台上的 30 个不同的项目(每个 5 到 10 人),具有异步通信需求和中央总线架构。问题中描述的简单日志记录在开始时(5 年前)
对每个项目都很好,但从那时起,我们必须加强。输入关键绩效指标

我们不要求记录器记录任何内容,而是要求自动创建的对象(称为 KPI)注册事件。这是一个简单的调用(myKPI.I_am_signaling_myself_to_you()),不需要有条件(解决了“人为增加圈复杂度”的问题)。

该 KPI 对象知道谁调用了它,并且由于他从应用程序开始运行,因此他能够检索我们之前在记录时当场计算的大量数据。
此外,可以独立监控 KPI 对象,并在单个单独的发布总线上按需计算/发布其信息。
这样,每个客户都可以询问他真正想要的信息(例如,'我的流程是否开始了,如果是,从什么时候开始?'),而不是寻找正确的日志文件并寻找一个神秘的字符串......

事实上,“为什么我们首先要登录?”这个问题确实存在。让我们意识到我们不仅仅是为程序员和他的单元或集成测试而记录,而是为一个更广泛的社区,包括一些最终客户本身。我们的“报告”机制必须是集中的、异步的、24/7 的。

该 KPI 机制的具体内容超出了此问题的范围。可以说它的正确校准是迄今为止我们面临的最复杂的非功能性问题。它仍然不时使系统屈服!但是,经过适当校准,它可以挽救生命。

再次感谢您的所有建议。当简单的日志记录仍然存在时,我们将在系统的某些部分考虑它们。
但是这个问题的另一点是在一个更大、更复杂的背景下向你说明一个特定的问题。
希望你喜欢它。下周晚些时候,我可能会问一个关于 KPI 的问题(信不信由你,到目前为止,这在 SOF 上没有任何问题!)。

我将把这个答案留到下周二投票,然后我会选择一个答案(显然不是这个;))

于 2008-09-20T07:30:20.910 回答
4

也许这太简单了,但是围绕保护子句使用“提取方法”重构呢?您的示例代码:

public void Example()
{
  if(myLogger.isLoggable(Level.INFO))
      myLogger.info("A String");
  if(myLogger.isLoggable(Level.FINE))
      myLogger.fine("A more complicated String");
  // +1 for each test and log message
}

变成这样:

public void Example()
{
   _LogInfo();
   _LogFine();
   // +0 for each test and log message
}

private void _LogInfo()
{
   if(!myLogger.isLoggable(Level.INFO))
      return;

   // Do your complex argument calculations/evaluations only when needed.
}

private void _LogFine(){ /* Ditto ... */ }
于 2008-09-28T01:07:10.427 回答
3

在 C 或 C++ 中,我会使用预处理器而不是 if 语句来进行条件日志记录。

于 2008-09-19T21:42:18.157 回答
3

将日志级别传递给记录器,让它决定是否写日志语句:

//if(myLogger.isLoggable(Level.INFO) {myLogger.info("A String");
myLogger.info(Level.INFO,"A String");

更新:啊,我看到你想有条件地创建没有条件语句的日志字符串。大概是在运行时而不是编译时。

我只想说我们解决这个问题的方法是将格式化代码放在记录器类中,这样只有在关卡通过时才会进行格式化。非常类似于内置的 sprintf。例如:

myLogger.info(Level.INFO,"A String %d",some_number);   

那应该符合你的标准。

于 2008-09-19T21:45:56.650 回答
2

替代文字
(来源:scala-lang.org

Scala有一个注解@elidable(),它允许你删除带有编译器标志的方法。

使用 scala REPL:

C:>斯卡拉

欢迎使用 Scala 版本 2.8.0.final(Java HotSpot(TM) 64 位服务器 VM,Java 1. 6.0_16)。输入表达式以对其进行评估。键入 :help 以获取更多信息。

scala> 导入 scala.annotation.elidable 导入 scala.annotation.elidable

scala> 导入 scala.annotation.elidable._ 导入 scala.annotation.elidable._

scala> @elidable(FINE) def logDebug(arg :String) = println(arg)

日志调试:(arg:字符串)单位

scala> logDebug(“测试”)

斯卡拉>

与 elide-beloset

C:>scala -Xelide-below 0

欢迎使用 Scala 版本 2.8.0.final(Java HotSpot(TM) 64 位服务器 VM,Java 1. 6.0_16)。输入表达式以对其进行评估。键入 :help 以获取更多信息。

scala> 导入 scala.annotation.elidable 导入 scala.annotation.elidable

scala> 导入 scala.annotation.elidable._ 导入 scala.annotation.elidable._

scala> @elidable(FINE) def logDebug(arg :String) = println(arg)

日志调试:(arg:字符串)单位

scala> logDebug(“测试”)

测试

斯卡拉>

另见Scala 断言定义

于 2010-07-20T17:22:33.927 回答
2

条件日志是邪恶的。它给你的代码增加了不必要的混乱。

您应该始终将您拥有的对象发送到记录器:

Logger logger = ...
logger.log(Level.DEBUG,"The foo is {0} and the bar is {1}",new Object[]{foo, bar});

然后有一个 java.util.logging.Formatter 使用 MessageFormat 将 foo 和 bar 展平为要输出的字符串。仅当记录器和处理程序将在该级别记录时才会调用它。

为了增加乐趣,您可以使用某种表达语言来更好地控制如何格式化记录的对象(toString 可能并不总是有用)。

于 2011-07-02T22:15:39.393 回答
1

尽管我讨厌 C/C++ 中的宏,但在工作中,我们为 if 部分设置了#defines,如果为假,则忽略(不评估)以下表达式,但如果为真,则返回一个流,可以使用 ' 将内容传送到该流中<<' 运算符。像这样:

LOGGER(LEVEL_INFO) << "A String";

我认为这将消除您的工具看到的额外“复杂性”,并且还消除了对字符串的任何计算,或者在未达到级别时要记录的任何表达式。

于 2008-09-19T21:47:03.777 回答
1

这是一个使用三元表达式的优雅解决方案

logger.info(logger.isInfoEnabled() ? "日志语句在这里..." : null);

于 2009-09-16T05:38:59.683 回答
1

考虑一个日志记录工具函数......

void debugUtil(String s, Object… args) {
   if (LOG.isDebugEnabled())
       LOG.debug(s, args);
   }
);

然后在您想要避免的昂贵评估周围使用“关闭”进行调用。

debugUtil(“We got a %s”, new Object() {
       @Override String toString() { 
       // only evaluated if the debug statement is executed
           return expensiveCallToGetSomeValue().toString;
       }
    }
);
于 2011-11-22T13:48:50.837 回答