A benchmark is a test of the performance of a computer system.

​ 基准测试是对计算机系统的性能的测试

计时器

性能的指标就是时间,在c++11后计时十分方便,因为有<chrono>神器

在性能测试中,一般依赖堆栈上的生命周期来进行计时

计时器的实现全貌

  1. class InstrumentationTimer {
  2. private:
  3. chrono::time_point<chrono::steady_clock> start;
  4. const char *m_hint;
  5. public:
  6. explicit InstrumentationTimer(const char *hint) : m_hint(hint) {
  7. start = chrono::steady_clock::now();
  8. }
  9. ~InstrumentationTimer() {
  10. auto end = chrono::steady_clock::now();
  11. cout << m_hint << ':' << static_cast<double>((end - start).count()) / 1e6 << "ms\n";
  12. long long llst = chrono::time_point_cast<chrono::microseconds>(start).time_since_epoch().count();
  13. long long lled = chrono::time_point_cast<chrono::microseconds>(end).time_since_epoch().count();
  14. //Instrumentor::Get().WriteProfile({m_hint, llst, lled});
  15. }
  16. };

非常简单的原理 就是应用作用域自动调用析构函数来停止计时

唯一难搞的就是chrono的层层包装

本文非常功利 不深究底层 ~

time_pointer

  1. chrono::time_point<chrono::steady_clock> start;

在chrono命名空间下(std下层) 有个神奇的类型 叫时间点time_point

在不同的操作环境下 有不同的实现 所以这是一个模板

模板类型可以有

  • chrono::high_resolution_clock 高解析度类型 不建议使用 因为这个可能有移植的问题 但好像进度最高?
  • chrono::steady_clock 稳得一批的钟 我超爱这个 因为这个不仅进度不低 而且调用的时间短,影响极小 (300ns
  • chrono::system_clock 系统带的钟 不大行 精度因系统而定? windows是100ns

所以 你懂的 用steady就好了(也不用太纠结几纳秒

给时间点一个当前时间 注意类型一致

  1. start = chrono::steady_clock::now();

duration

  1. auto dur = end - start;

为啥用auto 因为方便昂(duration 模板具体化写到头皮发麻

时间点运算得到的是时间段 因为默认的时间点单位时间是纳秒(steady_clock),所以得到的时间是内部以longlong存储的多少纳秒

如何调出时间?

  1. (end - start).count()

得到的是longlong ns

如何更改单位时间?

一个是转换时间段的格式

  1. chrono::duration_cast<chrono::microseconds>(end - start).count())

一个是转换时间点的格式

  1. chrono::time_point_cast<chrono::microseconds>(start)

如何调出一个时间戳?(系统从我也不知道哪开始算起的时间段 1970.1.1大概? 相当于帮你减了一下

  1. start.time_since_epoch().count()

可选格式:

  • chrono::nanoseconds

  • chrono::microseconds

  • chrono::milliseconds

  • chrono::seconds

  • chrono::minutes

  • chrono::hours

回到实现

构造函数没啥好讲的 就是开始计时

重点是析构函数

  1. ~InstrumentationTimer() {
  2. auto end = chrono::steady_clock::now();
  3. cout << m_hint << ':' << static_cast<double>((end - start).count()) / 1e6 << "ms\n";
  4. long long llst = chrono::time_point_cast<chrono::microseconds>(start).time_since_epoch().count();
  5. long long lled = chrono::time_point_cast<chrono::microseconds>(end).time_since_epoch().count();
  6. Instrumentor::Get().WriteProfile({m_hint, llst, lled});
  7. }

思路:

  • 首先!!!一定先停止计时 (你不会还想增大误差吧) 用auto接住 省一个成员

  • 然后 输出的是你要计时的位置的注释(hint) 接一个时间段

    因为时间段输出的是longlong 我看多了几点几ms觉得非常亲切 所以用纳秒算时间段(默认)后再除1e6得到毫秒

  • 留两个时间戳后面有用

  • 然后是后面的调用记录某一段程序运行时间的函数啦 这里传进去的有hint 开始和结束的时间戳 有了这些 你就能算出经过的时间

整理输出部分

Chrome大法好

chromo 自带了个可视化分析软件 在地址栏上输入chrome://tracing/就可以看到

它接受的是json文件 所以我们要把我们记录下来的东西打包成json拖到界面上 就可以看到精美(并不) 的可视化界面

这是打包器+记录器的全貌

  1. class Instrumentor {
  2. private:
  3. ofstream m_OutputStream;
  4. bool m_Fir;
  5. public:
  6. Instrumentor() : m_Fir(true) {}
  7. void BeginSession(const string &filepath = "results.json") {
  8. m_OutputStream.open(filepath);
  9. WriteHeader();
  10. }
  11. void EndSession() {
  12. WriteFooter();
  13. m_OutputStream.close();
  14. m_Fir = true;
  15. }
  16. void WriteProfile(const ProfileResult &result) {
  17. if (!m_Fir) { //not add ',' when first time
  18. m_OutputStream << ',';
  19. } else m_Fir = false;
  20. string name(result.Name);
  21. replace(name.begin(), name.end(), '"', '\'');
  22. m_OutputStream << R"({)";
  23. m_OutputStream << R"("cat":"function",)";
  24. m_OutputStream << R"("dur":)" << result.end - result.start << ",";
  25. m_OutputStream << R"("name":")" << name << "\",";
  26. m_OutputStream << R"("ph":"X",)";
  27. m_OutputStream << R"("pid":0,)";
  28. m_OutputStream << R"("tid":0,)";
  29. m_OutputStream << R"("ts":)" << result.start;
  30. m_OutputStream << R"(})";
  31. m_OutputStream.flush();
  32. }
  33. void WriteHeader() {
  34. m_OutputStream << R"({"otherData":{},"traceEvents":[)";
  35. m_OutputStream.flush();
  36. }
  37. void WriteFooter() {
  38. m_OutputStream << "]}";
  39. m_OutputStream.flush();
  40. }
  41. static Instrumentor &Get() {
  42. static auto instance = new Instrumentor();
  43. return *instance;
  44. }
  45. };

以及我们的目标 Chrome能识别的json文件

  1. {
  2. "otherData": {},
  3. "traceEvents": [
  4. {
  5. "cat": "function",
  6. "dur": 2166411,
  7. "name": "void core1(int)",
  8. "ph": "X",
  9. "pid": 0,
  10. "tid": 0,
  11. "ts": 19699253339
  12. },
  13. {
  14. "cat": "function",
  15. "dur": 1649285,
  16. "name": "void core2()",
  17. "ph": "X",
  18. "pid": 0,
  19. "tid": 0,
  20. "ts": 19701420118
  21. },
  22. {
  23. "cat": "function",
  24. "dur": 3816266,
  25. "name": "void benchMark()",
  26. "ph": "X",
  27. "pid": 0,
  28. "tid": 0,
  29. "ts": 19699253338
  30. }
  31. ]
  32. }

Get( )

首先看到最后的Get( )

  1. static Instrumentor &Get() {
  2. static auto instance = new Instrumentor();
  3. return *instance;
  4. }

这个能提供给我们一个单例,就是仅存在一个与我们运行时的对象

static 显式的指出Get得到的东西是和我们exe文件存在时间一样长的 而且这个定义只执行一次

如果你没有听懂 就只要记住它返回的永远是同一个对象 要用这个对象的时候就用Get

该这么用:

  1. Instrumentor::Get().balabala();

初始化

  1. private:
  2. ofstream m_OutputStream;
  3. bool m_Fir;
  4. public:
  5. Instrumentor() : m_Fir(true) {}
  6. void BeginSession(const string &filepath = "results.json") {
  7. m_OutputStream.open(filepath);
  8. WriteHeader();
  9. }
  10. void EndSession() {
  11. WriteFooter();
  12. m_OutputStream.close();
  13. m_Fir = true;
  14. }

ofsteam文件输出流用于输出到文件默认是results.json

不要忘记列表中的逗号的处理 我们用m_Fir检测是不是第一个

然后是注意到json开头和结尾是固定的

  1. void WriteHeader() {
  2. m_OutputStream << R"({"otherData":{},"traceEvents":[)";
  3. m_OutputStream.flush();
  4. }
  5. void WriteFooter() {
  6. m_OutputStream << "]}";
  7. m_OutputStream.flush();
  8. }

R"( string )"即原始字符串 可以输出字符串里面的原本的字符 感兴趣的可以自行拓展更多有关知识 这里用了之后就不用打转义的双引号了

每次输出到文件时记得及时刷新 m_OutputStream.flush();防止之后的线程出现毛病

ok 现在我们可以这么用了

  1. int main() {
  2. Instrumentor::Get().BeginSession();
  3. benchMark(); //测试的函数放这里
  4. Instrumentor::Get().EndSession();
  5. }

中间列表的填写

但是?最最最重要的中间列表的填写呢?

在这里

  1. void WriteProfile(const ProfileResult &result) {
  2. if (!m_Fir) { //not add ',' when first time
  3. m_OutputStream << ',';
  4. } else m_Fir = false;
  5. string name(result.Name);
  6. replace(name.begin(), name.end(), '"', '\'');
  7. m_OutputStream << R"({)";
  8. m_OutputStream << R"("cat":"function",)";
  9. m_OutputStream << R"("dur":)" << result.end - result.start << ",";
  10. m_OutputStream << R"("name":")" << name << "\",";
  11. m_OutputStream << R"("ph":"X",)";
  12. m_OutputStream << R"("pid":0,)";
  13. m_OutputStream << R"("tid":0,)";
  14. m_OutputStream << R"("ts":)" << result.start;
  15. m_OutputStream << R"(})";
  16. m_OutputStream.flush();
  17. }

在InstrumentationTimer中的调用:

  1. //m_hint 是计时器注释 llst 开始时间戳 lled 结束时间戳
  2. Instrumentor::Get().WriteProfile({m_hint, llst, lled});

定义传进来的参数 可以扩展

  1. struct ProfileResult {
  2. string Name;
  3. long long start, end;
  4. };

就是简单的往里面塞东西啦

值得注意的是 chrome 的tracing 默认时间戳的单位时间是microseconds 即毫秒 所以要记得转换格式哦

  1. long long llst = chrono::time_point_cast<chrono::microseconds>(start).time_since_epoch().count();
  2. long long lled = chrono::time_point_cast<chrono::microseconds>(end).time_since_epoch().count();

考虑到传进来的函数名字可能会带有" " 让json出错 所以退而求其次 把它转成 ' ' (其实在前面加一个转义字符更好 但是实现起来太麻烦了)

  1. string name(result.Name);
  2. replace(name.begin(), name.end(), '"', '\'');

好啦 包装弄好了 下一步开始高效插桩

打桩

神说:“我怕麻烦。”

于是就有了宏

低级打桩

先看

  1. void core1() {
  2. InstrumentationTimer tt("halo world 0 to 9999");
  3. for (int i = 0; i < 10000; ++i) {
  4. cout << "Hello world #" << i << endl;
  5. }
  6. }
  7. void benchMark() {
  8. InstrumentationTimer tt("shart benchMark");
  9. core1();
  10. }

在一个函数的开头放上计时器 计时器就会自动记录这个作用域自它定义开始到结束所经过的时间和有关的信息

在计时器销毁前几微秒 它会将它所看到的的东西传给Instrumentor来记录所发生的事情

但是!!这未免也太傻了

为什么还要我手动给一个名字

让它自动生成独一无二的名字就行了嘛

中级打桩

有那么个宏 是所有编辑器都能自动展开的 叫 __FUNCTION__ 它会变成它所在的函数的名字的字符串

于是就有了

  1. #define PROFILE_SCOPE(name) InstrumentationTimer tt(name)
  2. #define PROFILE_FUNCTION() PROFILE_SCOPE(__FUNCTION__)
  1. void core1() {
  2. PROFILE_FUNCTION();
  3. for (int i = 0; i < 10000; ++i) {
  4. cout << "Hello world #" << i << endl;
  5. }
  6. }
  7. void benchMark() {
  8. PROFILE_FUNCTION();
  9. core1();
  10. }

好 但还不够好

所有的计时器都是一个名称 万一不小心重名了 那事情就不好整了

又有一个宏 叫 __LINE__ 它会变成所在行号(数字)

而宏能用神奇的 #将东西黏在一起

就有了

  1. #define PROFILE_SCOPE(name) InstrumentationTimer tt##__LINE__(name)

好 但还不够好

万一我的函数是重载的 输出的是一样的函数名字 我咋知道调用的是哪个版本的函数

又有一个宏 叫 __PRETTY_FUNCTION__ MSVC是 __FUNCSIG__它能变成完整的函数签名的字符串 就像 "void core1(int)"

  1. #define PROFILE_FUNCTION() PROFILE_SCOPE(__PRETTY_FUNCTION__)

好 但还不够好

这个我可不想把它保留在release下 让用户也帮我测测时间 怎么才能方便的关掉呢

对 还是宏

高级打桩

  1. #define PROFILING 1
  2. #if PROFILING
  3. #define PROFILE_SCOPE(name) InstrumentationTimer tt##__LINE__(name)
  4. #define PROFILE_FUNCTION() PROFILE_SCOPE(__PRETTY_FUNCTION__)
  5. #else
  6. #define PROFILE_SCOPE(name)
  7. #define PROFILE_FUNCTION()
  8. #endif
  9. void core(int useless) {
  10. PROFILE_FUNCTION();
  11. for (int i = 0; i < 10000; ++i) {
  12. cout << "Hello world #" << i << endl;
  13. }
  14. }
  15. void core() {
  16. PROFILE_FUNCTION();
  17. for (int i = 0; i < 10000; ++i) {
  18. cout << "Hello world #" << sqrt(i) << endl;
  19. }
  20. }
  21. void benchMark() {
  22. PROFILE_FUNCTION();
  23. core(23333);
  24. core();
  25. }

这就是了 如果我想关掉测试 就把profiling设为1 这是所有测试都只是空行 而release对于没有使用的函数则自动删去了 丝毫不影响性能

多线程

扩展

拓展ProfileResult

  1. struct ProfileResult {
  2. string Name;
  3. long long start, end;
  4. uint32_t TheadID;
  5. };

更改输出

  1. m_OutputStream << R"("tid":)" << result.TheadID << ",";

在Timer中捕获该线程的id 并用自带hash转换成uint32方便输出

  1. uint32_t threadID = hash<std::thread::id>{}(std::this_thread::get_id());

传递id

  1. Instrumentor::Get().WriteProfile({m_hint, llst, lled,threadID});

最后变成了这样

  1. ~InstrumentationTimer() {
  2. auto end = chrono::steady_clock::now();
  3. cout << m_hint << ':' << static_cast<double>((end - start).count()) / 1e6 << "ms\n";
  4. long long llst = chrono::time_point_cast<chrono::microseconds>(start).time_since_epoch().count();
  5. long long lled = chrono::time_point_cast<chrono::microseconds>(end).time_since_epoch().count();
  6. uint32_t threadID = hash<std::thread::id>{}(std::this_thread::get_id());
  7. Instrumentor::Get().WriteProfile({m_hint, llst, lled,threadID});
  8. }

测试

搞一个多线程出来

  1. void benchMark() {
  2. PROFILE_FUNCTION();
  3. cout << "Running BenchMarks...\n";
  4. thread a([]() { core(23333); });
  5. thread b([]() { core(); });
  6. a.join();
  7. b.join();
  8. }

用lamda可以非常简洁的开多线程重载函数

最后加入2个join函数 这样在这两个线程都完成它们的工作之前 我们不会真正退出这个benchmark函数

完成

好啦 我们的工作完成了 欣赏一下代码吧

  1. #include <bits/stdc++.h>
  2. #include <sstream>
  3. using namespace std;
  4. struct ProfileResult {
  5. string Name;
  6. long long start, end;
  7. uint32_t TheadID;
  8. };
  9. class Instrumentor {
  10. private:
  11. ofstream m_OutputStream;
  12. bool m_Fir;
  13. public:
  14. Instrumentor() : m_Fir(true) {}
  15. void BeginSession(const string &filepath = "results.json") {
  16. m_OutputStream.open(filepath);
  17. WriteHeader();
  18. }
  19. void EndSession() {
  20. WriteFooter();
  21. m_OutputStream.close();
  22. m_Fir = true;
  23. }
  24. void WriteProfile(const ProfileResult &result) {
  25. if (!m_Fir) { //not add ',' when first time
  26. m_OutputStream << ',';
  27. } else m_Fir = false;
  28. string name(result.Name);
  29. replace(name.begin(), name.end(), '"', '\'');
  30. m_OutputStream << R"({)";
  31. m_OutputStream << R"("cat":"function",)";
  32. m_OutputStream << R"("dur":)" << result.end - result.start << ",";
  33. m_OutputStream << R"("name":")" << name << "\",";
  34. m_OutputStream << R"("ph":"X",)";
  35. m_OutputStream << R"("pid":0,)";
  36. m_OutputStream << R"("tid":)" << result.TheadID << ",";
  37. m_OutputStream << R"("ts":)" << result.start;
  38. m_OutputStream << R"(})";
  39. m_OutputStream.flush();
  40. }
  41. void WriteHeader() {
  42. m_OutputStream << R"({"otherData":{},"traceEvents":[)";
  43. m_OutputStream.flush();
  44. }
  45. void WriteFooter() {
  46. m_OutputStream << "]}";
  47. m_OutputStream.flush();
  48. }
  49. static Instrumentor &Get() {
  50. static auto instance = new Instrumentor();
  51. return *instance;
  52. }
  53. };
  54. class InstrumentationTimer {
  55. private:
  56. chrono::time_point<chrono::steady_clock> start;
  57. const char *m_hint;
  58. public:
  59. explicit InstrumentationTimer(const char *hint) : m_hint(hint) {
  60. start = chrono::steady_clock::now();
  61. }
  62. ~InstrumentationTimer() {
  63. auto end = chrono::steady_clock::now();
  64. cout << m_hint << ':' << static_cast<double>((end - start).count()) / 1e6 << "ms\n";
  65. long long llst = chrono::time_point_cast<chrono::microseconds>(start).time_since_epoch().count();
  66. long long lled = chrono::time_point_cast<chrono::microseconds>(end).time_since_epoch().count();
  67. uint32_t threadID = hash<std::thread::id>{}(std::this_thread::get_id());
  68. Instrumentor::Get().WriteProfile({m_hint, llst, lled,threadID});
  69. }
  70. };
  71. #define PROFILING 1
  72. #if PROFILING
  73. #define PROFILE_SCOPE(name) InstrumentationTimer tt##__LINE__(name)
  74. #define PROFILE_FUNCTION() PROFILE_SCOPE(__PRETTY_FUNCTION__)
  75. #else
  76. #define PROFILE_SCOPE(name)
  77. #define PROFILE_FUNCTION()
  78. #endif
  79. void core(int useless) {
  80. PROFILE_FUNCTION();
  81. for (int i = 0; i < 10000; ++i) {
  82. cout << "Hello world #" << i << endl;
  83. }
  84. }
  85. void core() {
  86. PROFILE_FUNCTION();
  87. for (int i = 0; i < 10000; ++i) {
  88. cout << "Hello world #" << sqrt(i) << endl;
  89. }
  90. }
  91. void benchMark() {
  92. PROFILE_FUNCTION();
  93. cout << "Running BenchMarks...\n";
  94. thread a([]() { core(23333); });
  95. thread b([]() { core(); });
  96. a.join();
  97. b.join();
  98. }
  99. int main() {
  100. Instrumentor::Get().BeginSession();
  101. benchMark();
  102. Instrumentor::Get().EndSession();
  103. }

最后的json

  1. {
  2. "otherData": {},
  3. "traceEvents": [
  4. {
  5. "cat": "function",
  6. "dur": 3844575,
  7. "name": "void core(int)",
  8. "ph": "X",
  9. "pid": 0,
  10. "tid": 1709724944,
  11. "ts": 24887197644
  12. },
  13. {
  14. "cat": "function",
  15. "dur": 4039317,
  16. "name": "void core()",
  17. "ph": "X",
  18. "pid": 0,
  19. "tid": 2740856708,
  20. "ts": 24887197714
  21. },
  22. {
  23. "cat": "function",
  24. "dur": 4040539,
  25. "name": "void benchMark()",
  26. "ph": "X",
  27. "pid": 0,
  28. "tid": 2850328247,
  29. "ts": 24887196811
  30. }
  31. ]
  32. }

细心的小伙伴可以推一推运行这段代码时间是什么时候呢~

关于BenchMark/c++11计时器/Chrome:tracing 的一些笔记的更多相关文章

  1. Chrome development tools学习笔记(5)

    调试JavaScript 随着如今JavaScript应用的越来越广泛,在面对前端工作的时候,开发人员须要强大的调试工具来高速有效地解决这个问题.我们文章的主角,Chrome DevTools就提供了 ...

  2. Chrome development tools学习笔记(3)

    (上次DOM的部分做了些补充,欢迎查看Chrome development tools学习笔记(2)) 利用DevTools Elements工具来调试页面样式 CSS(Cascading Style ...

  3. 微软专家推荐11个Chrome 插件

    Web开发人员,需要长时间使用浏览器,尽管Windows10 Edge浏览器启动非常快速,且支持110多种设备,Edge支持基于JS 扩展,但也删除了很多旧功能像Active-X等插件.多数情况下,插 ...

  4. 11月份 chrome 标签整理

    Spring MVC框架相关 Java Web开发 和 linux下开发 汇总 项目源码 优秀的音视频开源框架 常用软件的下载 学习资源或网站 最后分享一些以前收藏的优秀博客 这两天经过3次面试,很幸 ...

  5. 11.在Chrome谷歌浏览器中安装插件XPath Helper的方法

    1.首先在以下链接下载XPath Helper插件,链接:https://pan.baidu.com/s/1Ng7HAGgsVfOyqy6dn094Jg 提取码:a1dv 2.插件下载完成后解压,然后 ...

  6. 【读书笔记】深入应用C++11代码优化与工业级应用 读书笔记01

    第一章 使用C++11让程序更简洁.更现代 1.1  类型推导 1.1.1  auto类型推导 1.auto关键字的新意义 不同于python等动态类型语言的运行时进行变量类型的推导,隐式类型定义的类 ...

  7. 11月15日jquery学习笔记

    1.属性 jQuery对象是类数组,拥有length属性和介于0~length-1之间的数值属性,可以用toArray()方法将jQuery对象转化为真实数组. selector属性是创建jQuery ...

  8. Chrome浏览器启动参数大全(命令行参数)

    前言 在开发Web项目当中,浏览器必不可少,而浏览器的启动参数可以帮我们实现很多功能. 常用参数 常用参数请参考下表. 序号 参数 说明 1 --allow-outdated-plugins 不停用过 ...

  9. 那些你不知道的chrome URLs

    Xee:我用的是七星浏览器,因为我看了很多的浏览器,它们的版本都停滞不前了: 360安全浏览器的重度用户肯定不会对 se:last (上次未关闭页面)这个页面感到陌生,即使您没有见过这个,但也一定很熟 ...

随机推荐

  1. vue的seo问题?

    seo关系到网站排名, vue搭建spa做前后端分离不好做seo, 可通过其他方法解决: SSR服务端渲染: 将同一个组件渲染为服务器端的 HTML 字符串.利于seo且更快. vue-meta-in ...

  2. requests库获取响应流进行转发

    遇到了一个问题,使用requests进行转发 requests响应流的时候,出现各种问题,问题的描述没有记录,不过Debug以下终于解决了问题.......下面简单的描述解决方案 response = ...

  3. consumer提交offset原理

    1 数据结构 消费者的消费状态是保存在SubscriptionState类中的,而SubscriptionState有个重要的属性那就是assignment保存了消费者消费的partition及其pa ...

  4. 指出在 spring aop 中 concern 和 cross-cutting concern 的不同之处?

    concern 是我们想要在应用程序的特定模块中定义的行为.它可以定义为我们想 要实现的功能. cross-cutting concern 是一个适用于整个应用的行为,这会影响整个应用程序. 例如,日 ...

  5. 自启动Servlet

    自启动servlet也叫自动实例化servlet 特点 该Servlet的实例化过程不依赖于请求,而依赖于容器的启动,当Tomcat启动时就会实例化该Servlet 普通Servlet是在浏览器第一次 ...

  6. 如何解决用response输出字符流数据时的乱码问题

    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOE ...

  7. 单例模式 | C++ | Singleton模式

    Singleton 模式 单例模式(Singleton Pattern)是 C++/Java等语言中最简单的设计模式之一.这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式. 这种模式实 ...

  8. Spring Security OAuth 笔记

    1  单点登录 关于单点登录的原理,我觉得下面这位老哥讲的比较清楚,有兴趣可以看一下,下面我把其中的重点在此做个笔记总结 https://juejin.cn/post/6844904079274197 ...

  9. 总结一下各种0.5px的线

    在PC端用1px的边框线,看起来还好,但在手机端看起来就很难看了,而0.5px的分割线会有种精致的感觉.用普通写法border:solid 0.5px red;iPhone可以正常显示,android ...

  10. 创建新的servlet一定要记得修改web..xml文件!!!

    创建新的servlet一定要记得修改web..xml文件!!!