LOGO OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 开发文档 其他文档  
 
网站管理员

C#调试Debug/Release入门篇

admin
2021年3月8日 15:3 本文热度 4254

DotNet程序的调试,是DotNet程序员必备的技能之一,开发出稳定的程序、解决程序的疑难杂症都需要很强大的调试能力。DotNet调试有很多方法和技巧。现在本文就介绍一下借助DebugView工具进行调试的方法,以及由DebugView引申出来的知识点。

DebugView

DebugView是一个查看调试信息的非常棒的工具,支持Debug、Release模式编译的程序,甚至支持内核程序,而且能够定制各种过滤条件,让你只看到关心的输出信息,而且可以定制高亮显示的内容等等,非常方便。


捕捉Release模式的Win32程序输出的调试信息,需要选中Capture Global Win32选项:


过滤与高亮功能


可以通过include、exclude设置过滤条件,包含指定字符串的输出信息将会被过滤。还可以通过exclude条件过滤掉对应进程ID的调试信息。多个条件使用“;”分隔,而且支持“*”通配符。

远程调试

DebugView支持远程捕捉调试信息。首先在远程机器上通过如下命令启动DebugView:

DebugView.exe /a /t /g /s

这样,DebugView就会以服务的方式运行,如下图:


然后在本地机器上启动DebugView,并通过Connect连接到远程机器的DebugView,当远程机器中有调试信息输出时,本地就会捕获到,并展示出来:


输出信息到DebugView的几种方式

DebugView的一些功能是不是让你心动了呢。俗话说心动不如行动,但是在行动之前,首先要知道C#如何将调试信息输出到DebugView中。

通过编程输出一些调试信息到DebugView中,一共有三种方式:

  • Debug.WriteLine
  • Debugger.Log
  • Kernal32.dll中的OutputDebugString方法

一、Debug.WriteLine

通过Debug.WriteLine可以将调试信息写入到DebugView中,如下:

Debug.WriteLine("这是调试信息");

效果如下:


不过此方式只能在Debug模式下有效。具体原因下面会详细介绍。

二、Debugger.Log

Debug.WriteLine已经很好用了,唯一的缺点就是在Release模式下无效。那么在Release模式下就可以使用Debugger.Log方法,示例如下:

Debugger.Log(0, null, "这是Debugger.Log输出的调试信息");

三、Kernel32.dll中的OutputDebugString方法

做C++开发的应该知道可以通过OutputDebugString这个API开实现输出调试信息到DebugView中吧。那么C++能做的,C#也能做。可以通过PInvoke的方式引入此方法,这个API属于Kernel32.dll,如下声明:

[DllImport("kernel32.dll", CharSet=CharSet.Auto)]
public static extern void OutputDebugString(string message);

然后就可以通过调用此方法,将调试信息输出到DebugView中。

DebugView与日志框架比较

可能有人会说,DebugView能做的事情,我用log4Net,NLog等日志框架也能做,为什么要用DebugView呢?

问的好,那么我就根据平时使用DebugView的经验来给你一个用DebugView的理由:

  • DebugView使用非常方便。相比于日志框架庞大的体系,DebugView的使用可谓是十分的简单方便。DebugView只有几百K的大小,占用空间几乎可以忽略不计。从官网下载后,直接运行exe,几乎不需要任何配置就可以正常使用。而相比于DebugView,日志框架可以算的上庞然大物。而当你从官网获得log4Net后,需要进行各种繁杂的配置。甚至你要花上几天时间专门学习一下这套框架。由此可以看出DebugView的使用实在是方便的不能再方便。
  • DebugView是可视化工具,支持各种过滤和高亮。DebugView可以通过过滤条件来过滤不关心的信息,只显示相关的调试信息。而日志框架输出的是文件等文本信息,这些信息会包含程序运行过程中的所有信息,虽然可以通过配置文件来指定只输出哪一类信息,但是不如DebugView来的方便简单。
  • DebugView可以实时监视。DebugView中有“自动滚动”的功能,程序中输出的调试信息,基本上瞬间就会在DebugView中展示出来,当由于大量信息导致DebugView中的文本框满了后,DebugView可以通过自动滚动滚动条,让你随时都可以看到最新的一条信息,达到类似监视的效果。而日志框架由于其写文本的特性,很难达到这种效果,即使能达到,相信也是需要对日志框架相当清楚了解,才能完成这个效果。

这些理由应该足以让你使用DebugView了吧。使用DebugView的理由肯定还不止这些,如果你有更好的理由,还请分享出来。

当然,DebugView与日志框架,每个都有每个的用途。通过DebugView的方式,只适合短暂的调试,而正式发布的网站或者软件,需要一套记录程序长期以来的运行状态的工具,那么就非日志框架莫属了。所以DebugView与日志框架,要在合适的地方,发挥他们最大的功效。

声明

Log4Net等日志框架,功能足够强大,也足够丰富,相信上面说到的DebugView的功能,也可以通过日志框架来实现。但是和DebugView比较起来,会相对复杂一些。所以上面说到的使用DebugView的理由是基于方便性的比较,DebugView有足够的方便性来让你选择使用他。

ConditionalAttribute详解与条件编译

说到调试,那么肯定有开发人员遇到这种情况,开发产品的时候,遇到一些问题,就在代码中加入了大量的调试输出信息,比如通过Console.WriteLine、MessageBox.Show或者通过Ilog.Log记录日志,甚至临时改变逻辑来验证逻辑的正确性等。经过这些调试信息的帮助,终于解决了产品的问题。但此时又遇到了新的问题,产品最终发布的时候,肯定是不能有这些调试信息的,可是已经加了这么多调试信息,难道要全部删除掉吗。这显然不是一个好办法,这么多代码,手一抖,很容易就删除了不相关的代码,造成不可预估的后果。

做过C/C++开发的,可以从各种跨平台的开源库中看到,一堆一堆的#if....#else....#endif,这就是条件编译,这也是C/C++跨平台所依赖的最基本的东西,在Linux系统,编译这段代码,在Windows系统又编译那段代码,最终实现了代码级别的跨平台。

那么C#中有没有类似的功能呢,答案当然是有,而且有两种:

  • 通过给方法加上ConditionalAttribute特性
  • 使用#if..#else..#endif,来控制代码的编译

ConditionalAttribute特性

下面是ConditionalAttribute的构造函数:

public ConditionalAttribute(
    string conditionString
)

构造函数中的参数conditionString,是一个区分大小写的条件编译符号的字符串。

上面提到Debug.WriteLine时,说到这个功能只在Debug模式下才有用,Release下就不起作用了。

我们从MSDN中看一下Debug.WriteLine的说明:

[ConditionalAttribute("DEBUG")]
public static void WriteLine(
    string message
)

由此也就明白了Debug.WriteLine只能在Debug模式下使用,而在Release模式下无效的原因了。

条件编译#if..#else..#endif

C/C++中有#if..#else..#endif,C#中也有这些,他们都被称为预处理器。通过预定义的条件编译符号,来控制编译时编译哪部分代码。如下:

public bool HasPermission(string userName)
{
#if DEBUG
    //Debug模式下,不需要做权限判断,直接返回true
    return true;
#else
    //Release模式下,只有sa用户才有权限
    if (!string.IsNullOrEmpty(userName) && userName == "sa")
    {
        return true;
    }
    else
    {
        return false;
    }
#endif
}

预定义的Debug宏在什么地方

说到条件编译,是不是只有DEBUG 和 RELEASE两种情况呢,如果是这种情况的话,那也就是说DEBUG和RELEASE两种情况是定义好了的,俗话说就是“做死了”,这是作死的节奏啊。不作死就不会死,至少VS在这点上还没有作死。

让我们来一步步揭开DEBUG的面纱。

既然是条件编译,那么就应该和编译选项有关。我们知道C#项目,有一个属性页,可以设置很多编译的选项,如下:


从图中看到,条件编译是用的DEBUG常量,或者称为DEBUG条件编译符号,是在这个编译生成选项中定义的,如果去掉这个定义,那么编译后的HasPermission方法就会根据用户名进行权限检查,程序中通过Debug.WriteLine输出的调试信息也会输出到DebugView中,也就相当于Release模式下的效果。

其实DEBUG常量与Debug、Release模式并无太大的关系,唯一的关系就是,VS生成的项目中,Debug模式下,默认会选中“定义DEBUG常量”,而Release模式下,默认不会选中。也就是说,Debug模式,默认会定义DEBUG常量,而Release不会,如下图:


既然DEBUG常量与Debug模式无本质上的关联,那么为什么说到Debug,就认为DEBUG呢。道理其实很简单,世上本无路,走的人多了,便成了路。本来这个DEBUG常量只是Debug模式下的默认预定义的常量,只是因为大家习惯了,并且对它的这种预定义还比较认可,时间久了,就自然而然认为DEBUG就代表Debug模式。

虽然我们可以通过去掉DEBUG常量,来使条件编译在Debug模式下达到Release模式的效果,但是建议最好不要这样做,因为这就像是大家普遍都认可的一个约定,如果你一反常态,不遵守这个约定,对于程序,编译没有问题,但是后续维护肯定会相当麻烦,所以还请大侠手下留情。

使用自定义的编译常量

DEBUG常量作为一种普遍的约定,最好不要打破。如果有除DEBUG外的条件编译需要,可以使用自定义的编译常量。

自定义编译常量有两种方法:

  • 通过编译生成属性页中的条件编译输入框,定义自己的编译常量。
  • 在代码中使用#define预处理,来定义编译常量。

我们可以在条件编译的输入框中,定义自己的编译常量,多个常量之间用分号“;”隔开,而且支持中文常量,如下:


当然,我们也可以在代码中通过#define预处理来定义,比如:

#define 缘生梦
#define hbccdf

但是有一点需要注意,define定义常量必须在using 命名空间之前,否则会造成编译错误。

通过VS和Resharper改变颜色来查看哪些代码真正会被编译

引入条件编译后,我们可以通过VS很快知道哪些代码会被编译:


虽然条件编译的代码可以很直观的看出来,但是Conditional修饰的方法就看不出来了,这时就要借助神器Resharper了,如下图:


从图中看出,release模式,由于没有定义DEBUG、缘生梦两个常量,所以,调用Test、Debug.WriteLine方法的地方就会变暗,这样很直观就知道这些代码不会被编译。再次说明一个道理,神器对于开发有很大的帮助作用。

通过反编译查看生成后的代码

上面总是说,有些代码会被编译,有的则不会,那么真正编译后的效果是怎样的,我们不妨使用另外一个比较强大的工具,反编译工具Reflector,来查看一下反编译后的代码:


从图中的反编译后的代码可以看出,满足条件的代码会真正编译到生成的程序集里,而不满足的代码则不会生成到程序集里。

Conditional修饰的方法,会有方法的实现,但是没有方法的调用。不适合在方法里做一些变量的修改。

总结Conditional和条件编译

  • Conditional为方法级别的条件编译,只能修饰方法;#if..#else..#endif为行级别的条件编译,可以指定任意代码。
  • Conditional只能修饰无返回值的方法,而且不适合在方法中处理一些变量的值。#if..#else..#endif没有任何限制。
  • 可以通过条件编译符号输入框,或者代码中的#define定义条件编译常量,这些常量对于Conditional、#if..#else..#endif均有效。

TRACE常量

从上面多幅图中,可以看到,在Debug和Release模式下都会定义一个TRACE常量。

现在我们知道DEBUG常量是用来控制调用Debug.WriteLine的语句是否被编译。那么TRACE常量呢。

相信很多人也用过System.Diagnostics.Trace类,由DEBUG常量与Debug有关,可以想到TRACE常量与Trace有关,我们来看一下MSDN对于Trace的定义。

从MSDN中,看到Trace类的定义以及可以调用的方法与Debug类似,都有WriteLine方法。下面是Trace的WriteLine方法定义:

[ConditionalAttribute("TRACE")]
public static void WriteLine(
    string message
)

由此可以知道,TRACE常量是用来控制Trace类中WriteLine等方法是否被编译的作用。

Debug类与Trace类的区别

到现在为止,我们渐渐的了解了Debug类与Trace类,他们都可以通过WriteLine方法输出调试或跟踪信息,从他们的定义和暴露的方法中,可以看出他们非常相似,那么他们有什么区别呢。

有些人会根据使用的效果总结为:Debug类只能在Debug模式下执行,在Release模式下无效果,而Trace类在Debug和Release模式下都可以执行。

确实,在VS中新建一个项目,分别调用Debug.WriteLine和Trace.WriteLine方法,只有Trace.WriteLine输出的信息,在Release模式下有效。这也看似验证了上面的区别。

但这是他们两个真正的区别吗。我们一点一点来分析。

首先看这两个方法的定义:


由图看到,每个方法都通过ConditionalAttribute关联了一个条件编译常量,Debug关联DEBUG常量,Trace关联TRACE常量。再来看一下这两个常量的定义:


从图中看到,TRACE在Debug和Release模式下都会定义,这样在Debug和Release模式下都会执行,而DEBUG只在Debug模式下才会定义,Debug.WriteLine只在Debug模式下执行。从而也验证了上面的结论。

但DEBUG与TRACE只是在默认的情况下的定义。当改变这些定义后,上面的结论就不再正确。

我们来做这样的实验:Debug模式下只定义TRACE常量,Release模式只定义DEBUG常量


然后在Debug和Release模式下分别执行Debug.WriteLine方法和Trace.WriteLine方法,Debug.WriteLine方法只在Release模式下有效,而Trace.WriteLine方法只在Debug模式下有效。上面的结论不再成立。

为了更好的验证一下我们的结论,对他们进行反编译:


由图中的反编译代码看到,除了关联的条件编译常量不同外,内部调用的方法均为TraceInternal.WriteLine,实现完全一样。

那么下面来总结一下Debug与Trace的区别:

  • Debug类关联DEBUG常量,Trace类关联TRACE常量。
  • 默认情况下,VS创建的项目Debug模式会定义DEBUG与TRACE,而Release模式只定义TRACE,使Debug只在Debug模式有效,而Trace在Debug和Release模式均有效。
  • 可以通过修改DEBUG、TRACE的默认定义,来改变Debug、Trace的默认行为。但是建议最好不要这样做,因为改变了这种默认的约定可能会出现意想不到的问题。

Debug、Debugger、Kernel32的联系

每个人大脑的空间都是有限的,零散的知识很容易忘掉,输出调试信息到DebugView中的三种方法,由于关联性不是很强,很容易会忘掉其中的一两种,那么既然实现相同的功能,他们之间有什么关联吗。

这就要从Debug的MSDN文档说起。

我们知道Debug编译的程序,运行的时候可以通过Debug.WriteLine方法输出调试i信息到DebugView,而MSDN中的解释

将后跟行结束符的消息写入 Listeners 集合中的跟踪侦听器。

并没有说是输出到DebugView中,而是写入到Listeners集合中的跟踪侦听器。为了弄明白原理,有必要深入的研究一下。这时就要依赖反编译工具Reflector了。


从整体看上去,Debug类的每一个方法都通过Conditional与DEBUG常量管理,也就是默认情况下,Debug类的所有方法在Debug模式下均不会编译。

我们再来具体看一下Debug.WriteLine方法:

[Conditional("DEBUG"), __DynamicallyInvokable]
public static void WriteLine(string message)
{
    TraceInternal.WriteLine(message);
}

再来看一下TraceInternal方法:

public static void WriteLine(string message)
{
    foreach (TraceListener listener in Listeners)
    {
        listener.WriteLine(message);
        if (AutoFlush)
        {
            listener.Flush();
        }
    }
    return;
}

上面的代码是精简后的代码。从代码中看到会调用集合中的每一个listener的WriteLine方法。

那么这些listener的WriteLine又做了什么呢:

public abstract class TraceListener : MarshalByRefObject, IDisposable
{
    public abstract void WriteLine(string message);
}

原来TraceListener的WriteLine是抽象类的抽象方法,那么我们得到的listener是具体类的一个抽象,相当于接口,这是微软的一贯做法,再继续下去,就需要知道是哪些具体的TraceListener了。

继续上面的线索,我们是从Listeners属性中获取到的TraceListener,那么就去看Listeners的get实现:

public static TraceListenerCollection Listeners
{
    get
    {
        InitializeSettings();
        if (listeners == null)
        {
            lock (critSec)
            {
                if (listeners == null)
                {
                    SystemDiagnosticsSection systemDiagnosticsSection = DiagnosticsConfiguration.SystemDiagnosticsSection;
                    if (systemDiagnosticsSection != null)
                    {
                        //从配置文件获取listener,但是由于此处没有设置配置文件,所以先不研究这个地方
                            listeners = systemDiagnosticsSection.Trace.Listeners.GetRuntimeObject();
                    }
                    else
                    {
                        //这里new了一个TraceListener的集合
                            listeners = new TraceListenerCollection();
                        //这里我们看到了TraceListener的具体实现类DefaultTraceListener
                        TraceListener listener = new DefaultTraceListener {
                            IndentLevel = indentLevel,
                            IndentSize = indentSize
                        };
                        listeners.Add(listener);
                    }
                }
            }
        }
        return listeners;
    }
}

从代码中,找到了具体的实现类DefaultTraceListener,那么就快点看看他的WriteLine方法吧,有点迫不及待了。

实际上,WriteLine方法会调用内部的Write方法:

private void Write(string message, bool useLogFile)
{
    if (base.NeedIndent)
    {
         //写缩进,实际是空格
          this.WriteIndent();
    }
    if ((message == null) || (message.Length <= 16384))
    {
         //输出消息
          this.internalWrite(message);
    }
    else
    {
        //当消息很长时,会通过internalWrite方法将消息多次输出
         int startIndex = 0;
        while (startIndex < (message.Length - 16384))
        {
            this.internalWrite(message.Substring(startIndex, 16384));
            startIndex += 16384;
        }
        this.internalWrite(message.Substring(startIndex));
    }
    if (useLogFile && (this.LogFileName.Length != 0))
    {
       //输出到日志文件中
        this.WriteToLogFile(message, false);
    }
}

与输出信息有关的有两个地方,一个是调用internalWrite,另外一个是WriteToLogFile。从WriteToLogFile是有执行条件的。感兴趣的可以研究一下。重点来看一下internalWrite方法。

private void internalWrite(string message)
{
    if (Debugger.IsLogging())
    {
       //调用Debugger.Log方法,这个方法可以输出信息到DebugView
        Debugger.Log(0, null, message);
    }
    else if (message == null)
    {
        SafeNativeMethods.OutputDebugString(string.Empty);
    }
    else
    {
       //调用Native方法
        SafeNativeMethods.OutputDebugString(message);
    }
}

internalWrite中调用了两个比较重要的方法。其中Debugger.Log方法是不是很熟悉呢。我们刚刚在上面总结了三种输出调试信息到DebugView的方法,其中就包含了Debugger.Log,而Debug.WriteLine方法中,又会调用到Debugger.Log方法,这样,这两个方法就建立起了联系。

再来看SafeNativeMethods.OutputDebugString,看到这个类的命名,以及对Win32API的了解,就能想到,这肯定是通过PInvoke等方法对Win32API的封装,看其定义:

[DllImport("kernel32.dll", CharSet=CharSet.Auto)]
public static extern void OutputDebugString(string message);

果然,这就是对kernel32.dll中的OutputDebugString的封装调用,而这又是上面三种方法其中的一种。好了,这样Debug、Debugger、OutputDebugString就全都联系到了一起,他们之间有了联系,是不是就更容易记忆了。

配置TraceListener

从上面对Debug.WriteLine的一步步跟踪分析的过程中,我们看到了对于TraceListener的获取,一种方式是DefaultTraceListener,另外一种就是如下:

SystemDiagnosticsSection systemDiagnosticsSection = DiagnosticsConfiguration.SystemDiagnosticsSection;
if (systemDiagnosticsSection != null)
{
     //从配置文件获取listener,但是由于此处没有设置配置文件,所以先不研究这个地方
    listeners = systemDiagnosticsSection.Trace.Listeners.GetRuntimeObject();
}

这几行代码就是从配置文件的system.diagnostics中获取TraceListener。

关于配置文件具体的读取和解析过程,本文就不再详细介绍了,感兴趣的朋友可以自行研究,或者等到我的后面博文详细介绍。

那么现在主要说一下如何通过配置文件来设置TraceListener,如下:

<configuration>
   <system.diagnostics>
      <trace autoflush="true" indentsize="2">
         <listeners>
            <add name="myListener" type="System.Diagnostics.ConsoleTraceListener" />
         </listeners>
      </trace>
   </system.diagnostics>
</configuration>

同样,有配置文件,那么就可以通过代码来实现同样的功能,如下:

Debug.Listeners.Add(new ConsoleTraceListener());
Debug.AutoFlush = true;
Debug.WriteLine("这是Debug.WriteLine输出的调试信息");

Debug编译与Release编译的区别

Debug与Release的区别,这个问题,相信很多人都会有疑问,也会有很多人有自己的答案,我听到过的答案有这些:

  • Release比Debug运行速度快,执行效率高。
  • Debug 是调试用的,可以打断点执行。Release是发布用的,不能打断点执行。
  • Debug编译的文件大,Release生成的程序集小。
  • Debug会生成pdb文件,Release不生成pdb文件。

这些答案看上去好像都对,但是为什么Debug与Release有这么大的区别呢,这就需要深入的思考一下。

在Debug类与Trace类的区别一节中,相信有些朋友已经明白,Debug编译与Release编译的区别其实是编译配置的不同,DEBUG、TRACE常量的定义就是其中不同的地方。那么Debug编译与Release编译还有什么不同呢。针对这个问题,我总结了一下,主要区别有下面几点:

  • 常量定义不同
  • 优化代码的不同
  • 包含的调试信息不同
  • 输出路径不同

接下来,我们详细的看一下。

常量定义不同

这个相信大家已经非常清楚了,Debug模式下会定义DEBUG、TRACE常量,而Release模式下只定义TRACE常量,如下图所示


优化代码不同


Debug模式下,默认不会进行代码的优化,而Release模式下,由于默认选中了“优化代码”选项,所以编译器会对生成的代码进行优化,以提高运行速度。

包含的调试信息不同

编译生成的属性页中有一个“高级”按钮,点击后会弹出一个对话框,然后可以对编译生成进行一些设置,如下


Debug与Release的高级选项对比:


从图中看到,Debug模式下默认会生成全部的调试信息,而Release模式下只生成pdb文件。

输出路径不同

这一点详细大家都很清楚了,Debug模式下会输出到Debug目录,Release模式下会输出到Release目录,如图:


Debug编译与Release编译的区别总结

说了这么多,我们再来思考本节开始时说到的关于Debug编译与Release编译的区别的答案,可以得出这样的结论:

    • Release比Debug运行速度快,执行效率高。由于Release模式编译会对生成的代码进行优化,所以会使生成的程序集在运行的时候比Debug编译的程序集运行速度快。
    • Debug 是调试用的,可以打断点执行。Release是发布用的,不能打断点执行。由于Release模式对代码进行优化,生成的调试信息只包含pdb文件,所以导致调试起来会比较困难。
    • Debug编译的文件大,Release生成的程序集小。依然是由于Release模式对生成的代码进行了优化,所以就导致生成的程序集相对来说比较小。
    • Debug会生成pdb文件,Release不生成pdb文件。这个要看VS的默认选项,在2012中,Release模式默认会生成pdb文件的,其他版本的VS可能会有不同的情况。

知道这些区别后,我们就可以通过修改编译配置来使Debug程序达到Release的效果,使Release程序达到Debug的效果。当然,还是那句话,虽然可以实现这样的效果,但是建议绝对不要这样做,不然程序维护起来,肯定会遇到很多问题。

    总结的重要性

    有人说,互联网这么发达,遇到什么问题直接谷歌百度,内事不决问百度,外事不决问谷歌。但是即使你通过网络解决了问题,如果不消化吸收,经过总结变成自己的东西,那么下次还会遇到同样的问题。书到用时方恨少,一回首,已白了少年头。如果不总结,任他虐我千百遍,我却视他如初见,从网络上找到的方法、代码、解决方案,那是别人的知识,只有经过实践去验证,通过总结,消化吸收,才能将这些知识“据为己有”,为我所用。

    那么至少从现在做起,把每天学到的知识,遇到的问题,得到的经验,总结下来,相信自己,这是大牛的节奏。


    该文章在 2021/3/8 15:03:17 编辑过
    关键字查询
    相关文章
    正在查询...
    点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
    点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
    点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
    点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
    Copyright 2010-2024 ClickSun All Rights Reserved