分类 其它 下的文章

状态模式(State Pattern)的定义是这样的:
类的行为是基于它的状态改变的。

注意这里的状态不是狭义的指对象维护了一个“状态”字段,我们传入了不同的枚举值,对象整体的表现行为(对外方法)就改变了。
而是指内部的(任意)字段如果发生了变化,那么它的状态就变了,那么它对外的表现形式就变了。
它是面向对象的23种设计模式中的一种,属于行为模式的范围。
通常我们在解决不同状态下,对外方法的不同表现时,可以定义若干的枚举,然后写一大堆if、 elseif、 switch等选择命令来区分不同的状态,然后走不同的业务分支。
而状态模式是支持将这些分支业务抽离出一个独立类(状态类),我们通过传入不同的状态类,就可以动态的执行不同的业务方法。
整体的结构大概是这样的:

业务类维护了一个内部状态对象,这个状态对象支持由外部传入,切换为不同的状态对象。
而这些状态对象都统一实现了具体的方法,业务类内部在执行业务方法时,会调用这些状态对象中实现的方法。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )这样在切换状态时,业务方法就会调用不同的状态对象的方法了。从面向对象的角度,实现了状态变化,类行为的同步变化。
来看一个具体的代码示例:

枚举类

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 public enumTextStatusEnum {4 ONLY_READ,5 READ_WRITE,6 UNAVAILABLE;7 
8 }

状态定义接口

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 /**
4 * @discription5  */
6 public interfaceTextState {7 TextStatusEnum getStatus();8 
9      voidwrite(String content);10 
11      voidclear();12 
13 String read();14 
15      voidsetContent(StringBuilder sb);16 }

只读状态

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Slf4j10 @Data11 public class OnlyReadState implementsTextState {12     private static final TextStatusEnum textStatus =TextStatusEnum.ONLY_READ;13 
14     privateStringBuilder sb;15 
16 @Override17     publicTextStatusEnum getStatus() {18         returntextStatus;19 }20 
21     public voidwrite(String content) {22         log.error("sorry, you can not write");23 }24 
25     public voidclear() {26         log.error("sorry, you can not clear");27 }28 
29     publicString read() {30         returnsb.toString();31 }32 
33 @Override34     public voidsetContent(StringBuilder sb) {35         this.sb =sb;36 }37 }

读写状态

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Data10 @Slf4j11 public class ReadWriteState implementsTextState {12     private static final TextStatusEnum textStatus =TextStatusEnum.ONLY_READ;13 
14     private StringBuilder sb = newStringBuilder();15 
16 @Override17     publicTextStatusEnum getStatus() {18         returntextStatus;19 }20 
21     public voidwrite(String content) {22 sb.append(content);23 }24 
25     public voidclear() {26         sb.setLength(0);27 }28 
29     publicString read() {30         returnsb.toString();31 }32 
33 @Override34     public voidsetContent(StringBuilder sb) {35         this.sb =sb;36 }37 }

本文编辑器(业务类/上下文)

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.Data;4 importlombok.extern.slf4j.Slf4j;5 
6 /**
7 * @discription8  */
9 @Slf4j10 public classTextEditor {11 
12     private StringBuilder sb = newStringBuilder();13 
14     privateTextState textState;15 
16     public voidsetState(TextState textState) {17 textState.setContent(sb);18         this.textState =textState;19 }20 
21     public voidwrite(String content) {22         if (textState == null) {23             log.error("no state exist");24             return;25 }26 textState.write(content);27 }28 
29     public voidclear() {30         if (textState == null) {31             log.error("no state exist");32             return;33 }34 textState.clear();35 }36 
37     publicString read() {38         if (textState == null) {39             log.error("no state exist");40             return "no state";41 }42         returntextState.read();43 }44 
45 }

主类

1 packagecom.example.demo.learn.pattern.behavior.status;2 
3 importlombok.extern.slf4j.Slf4j;4 
5 /**
6 * @discription7  */
8 @Slf4j9 public classPatternMain {10     public static voidmain(String[] args) {11         TextEditor editor = newTextEditor();12 String text;13 
14         //可读写状态
15         TextState rw = newReadWriteState();16 editor.setState(rw);17         for (int i = 0; i < 3; i++) {18             editor.write("write" +i);19             text =editor.read();20             log.warn("read :" +text);21 }22 editor.clear();23         text =editor.read();24         log.warn("after clear, we read :" +text);25         editor.write("last write");26 
27         log.warn("-----------------------now, we exchange state to only read-----------------------");28         //只读状态
29         TextState or = newOnlyReadState();30 editor.setState(or);31         for (int i = 0; i < 3; i++) {32             editor.write("write" +i);33             text =editor.read();34             log.warn("read :" +text);35 }36 editor.clear();37         text =editor.read();38         log.warn("after clear, we read :" +text);39 }40 }

输出效果如下:

10:02:52.356 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write010:02:52.368 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write0write110:02:52.369 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :write0write1write210:02:52.371 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -after clear, we read :(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )10:02:52.372 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain - -----------------------now, we exchange state to only read-----------------------
10:02:52.376 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.378 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.378 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.378 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.379 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not write10:02:52.379 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -read :last write10:02:52.379 [main] ERROR com.example.demo.learn.pattern.behavior.status.OnlyReadState -sorry, you can not clear10:02:52.380 [main] WARN com.example.demo.learn.pattern.behavior.status.PatternMain -after clear, we read :last write

Process finished with exit code
0

我们可以看到在最初设置读写状态后,可以做读、写、清除等操作

在设置读状态后则只能读了。

这样回头来看,其实我们就是将不同if Switch的选择分支,连同选择的状态,一同封装到不同的状态类中,我们需要新增一种分支逻辑,不再需要修改选择分支,而是只需要新增一个状态类即可。
那是否状态模式可以替代传统的if 选择分支,答案是不能,本质上还是一个度的原因,面相对象如果过度设计,会导致类的数量无限膨胀,难以维护,试想如果存在多个状态字段(status、type等),则实体对象的状态是由多个状态字段组合而成的,每增加一个新的状态字段,都会导致状态的数量快速增加,这显然不是我们想看到的。

引言

这些年在自研产品,对于如何做好产品进行了一些思考。随着开源软件的蓬勃发展,许多开源项目已经成为IT行业的核心组成部分。像Redis、Nginx、SQLite、Elasticsearch这些知名的开源软件,已经成为了开发者的首选工具。这些开源软件不仅在技术性能上取得了重大突破,还在社区建设、生态系统、商业模式等方面获得了广泛的成功。那么,是什么因素促使它们取得了如此巨大的成功?它们的成功给技术人员和开发者带来了哪些启示?

本文将从技术优势、社区建设、灵活性与扩展性、开源精神、商业化等多个角度来分析Redis、Nginx、SQLite、Elasticsearch等开源软件的成功原因,进而探讨这些成功背后的普遍规律,并为IT技术人员提供一些有益的启示。

一、Redis的成功分析

Redis是一个高性能的Key-Value存储系统,广泛应用于高速缓存、消息队列、会话管理、计数器等场景中。

  1. 极致的性能优化
    Redis的核心优势在于其卓越的性能。作为内存级存储系统,Redis能够在毫秒级内响应大规模请求。其底层的数据结构经过精心设计,能够支持多种复杂的操作,如列表、集合、哈希、排序集等。这使得Redis不仅能够存储简单的键值对,还能够支持更多复杂的数据操作场景。

    Redis的成功在于充分利用内存的性能优势,提供了极高的读写速度。技术人员应当关注如何优化软件以达到极致性能,以及如何通过选择合适的底层数据结构来提升效率。

  2. 简单易用的API设计
    Redis的API设计简单明了,开发者可以快速上手,无需深入学习复杂的操作。Redis通过简单的命令行工具和客户端库,帮助开发者轻松地在应用中集成Redis。

    API设计的简洁性和易用性是软件成功的关键因素之一。技术人员在设计系统时,应注重接口的友好性和学习曲线的平缓性,避免过度复杂化。

  3. 丰富的功能集与灵活性
    Redis不仅提供基本的Key-Value存储,还支持持久化、主从复制、分片集群等功能。它的模块化设计让Redis可以被灵活扩展,满足不同应用场景的需求。

    开源软件的灵活性和可扩展性使其在不同应用场景下都能够发挥作用。技术人员应关注如何通过模块化设计提高系统的扩展性,确保系统能够适应业务需求的变化。

  4. 强大的社区支持
    Redis的开源社区非常活跃。大量的开发者、公司为Redis贡献代码,改进功能,修复bug,并提供了丰富的文档和教程。这为Redis的发展提供了持续的动力和保障。

    一个活跃的开源社区是项目成功的关键。技术人员不仅应积极参与开源社区,学习他人的经验,还可以通过贡献代码、提交PR来提升自己的影响力。

二、Nginx的成功分析

Nginx作为一款高性能的Web服务器,已经成为现代互联网基础设施不可或缺的组成部分。

  1. 高并发处理能力
    Nginx的非阻塞、事件驱动架构使其在高并发场景下表现尤为出色。与传统的多线程或多进程模型相比,Nginx能够更加高效地处理大量的连接请求。

    Nginx的成功表明,选择适合的并发处理模型对系统的性能至关重要。技术人员在设计高并发系统时,应权衡不同的架构模型,选择最适合的解决方案。

  2. 模块化架构
    Nginx的模块化设计使得其可以通过插件扩展功能,如负载均衡、反向代理、静态资源服务、SSL/TLS加密等。用户可以根据实际需求定制Nginx的功能,减少不必要的资源消耗。

    模块化设计是系统灵活性的关键。技术人员应在设计系统时确保各个功能模块的可插拔性,以提高系统的适应性。

  3. 开源社区与商业化平衡
    Nginx不仅是一个开源项目,还通过Nginx Plus提供了商业化支持。Nginx Plus提供了高级的负载均衡、监控、故障转移等功能,帮助公司通过商业化的方式盈利。

    Nginx的成功表明,开源项目可以通过适度的商业化获得长期的发展动力。技术人员应思考如何在开源与商业化之间找到平衡,以推动项目的持续发展。

  4. 轻量级设计
    Nginx的轻量级设计使其在资源占用上表现优异。与其他Web服务器相比,Nginx在内存和CPU消耗上更具优势,这使得它成为资源有限的服务器环境中的首选。

    轻量级设计不仅可以提升系统的运行效率,还可以降低资源消耗。技术人员应关注如何通过优化设计来减少系统对资源的依赖。

三、SQLite的成功分析

SQLite是一款嵌入式的轻量级数据库,被广泛应用于移动设备、浏览器等资源有限的环境中。

  1. 零配置的便捷性
    SQLite不需要安装、配置,也不依赖于单独的数据库服务。所有的数据都存储在一个文件中,这使得SQLite在嵌入式系统、移动设备中得到了广泛应用。

    便捷性是影响软件采用率的重要因素。技术人员在开发软件时,应该尽量减少用户的配置和管理成本,提升软件的易用性。

  2. 高效的存储引擎
    虽然SQLite是一个轻量级数据库,但它提供了ACID事务支持,具备较高的可靠性和安全性。SQLite通过高度优化的数据结构和存储算法,提供了优异的读写性能,即便在资源有限的设备上也能运行良好。

    技术人员应注重性能与资源消耗之间的平衡,尤其是在嵌入式和移动设备等受限环境中。

  3. 广泛的应用场景
    SQLite不仅仅是嵌入式数据库,它在很多场景中都表现出色,如开发测试、移动应用本地存储等。它的简洁性和高性能使得它在众多不同领域中得到了广泛应用。

    软件的成功很大程度上取决于其适用的场景越广泛,技术人员在设计系统时可以考虑通用性和灵活性,确保其能够在多种环境中使用。

四、Elasticsearch的成功分析

Elasticsearch是一个基于Lucene的分布式搜索引擎,主要用于全文搜索、日志分析和数据可视化。

  1. 强大的搜索和分析能力
    Elasticsearch基于倒排索引,能够以极快的速度处理全文检索。这使得它在需要高效搜索的场景中具有独特的优势,如日志分析、产品搜索等。同时,Elasticsearch还提供了聚合功能,能够进行复杂的数据分析。

    技术人员应了解底层技术如何为高性能需求提供支持,尤其是在需要快速检索和处理大量数据时,选择适合的算法和数据结构至关重要。

  2. 分布式架构
    Elasticsearch通过分片和副本机制,能够轻松地实现水平扩展,适应海量数据的存储和查询需求。它的分布式架构不仅提高了性能,还增强了系统的容错能力。

    分布式架构是大规模系统设计的核心。技术人员应学习如何通过分片、复制等技术来提高系统的扩展性和容错性。

  3. 开放的生态系统
    Elasticsearch与Kibana、Logstash、Beats等工具组合形成了Elastic Stack,提供了从数据采集、分析到可视化的完整解决方案。这种生态系统让用户能够构建复杂的日志分析和监控平台。

    构建完整的生态系统可以为用户提供更多的价值,技术人员应思考如何通过集成不同工具来为用户提供完整的解决方案。

  4. 活跃的开源社区与企业支持
    Elasticsearch由Elastic公司主导开发,同时吸引了大量开发者参与。社区的活跃推动了Elasticsearch的快速迭代和功能完善。同时,Elastic公司也通过提供企业支持、云服务等方式实现商业化。

    技术人员应重视开源社区的力量,通过社区的支持和反馈来不断完善软件,同时也可以通过商业化模式实现开源项目的可持续发展。

五、开源软件成功的普遍规律

通过分析Redis、Nginx、SQLite、Elasticsearch等
开源软件的成功,我们可以总结出以下几个普遍规律:

1.
技术上的卓越性能

无论是Redis的高性能内存操作、Nginx的高并发处理、SQLite的轻量级存储,还是Elasticsearch的快速全文检索,这些开源软件都在各自的技术领域中表现出了卓越的性能。开源项目的核心竞争力通常来自其在特定技术领域的深耕与创新。通过高效的算法设计、架构优化和底层技术的创新,它们能够在解决特定问题时提供远超竞争对手的性能。

启示:
技术人员在开发软件时,应深入理解问题领域,并针对性地进行性能优化和创新,打造出具备独特竞争力的核心技术。通过对底层技术的优化,可以实现更高效、更具优势的解决方案。

2.
简洁和易用性

成功的开源项目通常都非常注重用户体验和易用性。Redis和SQLite的API设计简洁明了,Nginx的配置相对简单,Elasticsearch提供了直观的接口用于复杂查询操作。这些项目的开发者意识到,过度复杂的工具虽然功能强大,但会吓退一大部分用户。因此,他们在开发软件时始终保持简洁易用的设计原则,让用户能够迅速上手。

启示:
在软件开发中,设计简洁易用的接口是非常重要的。技术人员不仅要考虑如何让软件功能强大,还要思考如何让用户以最小的学习成本使用这些功能。提供良好的文档、示例代码和简单的操作界面,可以大大提升软件的可用性和受欢迎程度。

3.
模块化与可扩展性

这些开源软件都具备良好的模块化设计和可扩展性。Nginx通过模块扩展不同功能,Elasticsearch通过插件和分布式架构扩展搜索能力,Redis也支持多种数据结构和功能模块。模块化设计不仅让软件在初期保持轻量级,同时也让用户可以根据需求动态扩展软件的功能,使其适应不断变化的场景和需求。

启示:
模块化设计是构建灵活、可扩展软件的基础。技术人员在设计系统时应确保各个功能模块相互独立,这样可以方便后期扩展、优化或替换某个模块而不影响整个系统。同时,模块化还可以减少初期的开发工作量,让项目尽早投入使用并根据需求逐步扩展。

4.
活跃的开源社区

Redis、Nginx、Elasticsearch等开源软件的成功离不开它们背后强大的开源社区。这些社区不仅为项目提供了丰富的代码贡献、功能扩展和bug修复,还通过文档编写、在线支持等方式帮助新用户快速上手使用软件。一个活跃的社区不仅可以为项目提供技术上的支持,还能够带来更广泛的传播和用户基础。

启示:
技术人员应意识到,社区的力量是不可忽视的。在使用开源项目时,积极参与社区交流,贡献代码、文档或帮助解答问题,可以不仅提高个人的技术能力,也能获得更多业界的认可和机会。同时,如果你正在开发自己的开源项目,社区的建立和维护将是项目长期发展的关键。

5.
适度的商业化

许多成功的开源软件项目通过适度的商业化实现了自我可持续发展。Nginx有商业版Nginx Plus,Elasticsearch由Elastic公司提供企业支持,Redis Labs也提供托管和支持服务。这些公司在保持开源版本活跃发展的同时,通过提供额外的商业服务和支持实现盈利。这样的商业模式让开源项目既能保持技术创新,又能通过商业化手段维持项目的长期健康发展。

启示:
开源软件并不意味着完全免费。技术人员在开发开源项目时,可以探索如何在保持开源精神的前提下,实现适度的商业化,以此来推动项目的持续创新和发展。通过提供增值服务、企业支持、云托管等形式,既可以满足大企业的需求,也能为项目的开发者提供收益。

6.
广泛的应用场景

成功的开源软件通常都具备广泛的应用场景。Redis被用于缓存、消息队列、分布式锁等多个场景,Nginx广泛用于Web服务器、反向代理和负载均衡,Elasticsearch可以处理日志分析、数据检索和商业搜索应用。软件的通用性不仅帮助它们吸引了更多的用户,还使得它们在各种业务场景中得到了长期的应用和推广。

启示:
软件的通用性和适用性是其广泛传播的基础。技术人员在设计系统时,可以尝试考虑如何让软件适应更多的场景和应用需求,以此扩大其用户基础。多功能性和灵活性不仅让软件具备更强的竞争力,也可以推动其在不同市场和行业中获得成功。

7.
与时俱进的迭代更新

开源软件的成功离不开持续的迭代更新。Redis、Nginx、Elasticsearch等项目都在不断根据用户的反馈和市场需求进行功能更新和优化。快速响应社区需求并提供及时的修复和优化让这些项目始终处于技术前沿。

启示:
持续迭代更新是软件项目保持生命力的关键。技术人员在开发项目时,应该保证项目的灵活性和可扩展性,并通过持续更新和维护来满足用户的需求。定期推出更新版本、修复已知问题并添加新功能,可以让项目始终保持活力和竞争力。

8.
扎实的文档和教学资源

成功的开源项目通常都具备丰富的文档和教学资源。Redis和Elasticsearch提供了详细的API文档和使用指南,Nginx和SQLite也有丰富的在线资源供开发者参考。良好的文档和教学资源让用户可以快速上手,并在遇到问题时能够轻松查找解决方案。

启示:
技术文档和教学资源的质量对于软件的推广至关重要。技术人员在开发软件时,不仅要关注核心功能的实现,还应投入足够的精力来编写清晰、详尽的文档,提供示例代码和教程,以便用户能够顺利使用软件。优质的文档不仅可以减少用户的学习成本,还能够减少技术支持的工作量。

六、对IT技术人员的启示

通过对Redis、Nginx、SQLite、Elasticsearch等开源软件的分析,我们可以总结出对IT技术人员的一些重要启示:

  1. 深耕技术领域,优化性能
    :成功的软件往往在某一技术领域中表现出色,性能优化是其核心竞争力。IT技术人员应在自己的领域内不断深入学习和研究,注重软件的性能提升,成为技术专家。

  2. 简洁而强大的设计
    :软件设计应简洁易用,同时具备强大的功能。通过简化接口和操作,降低用户的使用门槛,能够吸引更多的用户使用你的软件。

  3. 注重可扩展性与模块化设计
    :在设计软件时,保持模块化和灵活性,让系统能够根据需求扩展和优化。这样的设计不仅能够适应未来的业务变化,还能够让系统具备更长的生命周期。

  4. 积极参与开源社区
    :开源社区是技术人员学习、交流和提升的重要平台。积极参与开源项目,贡献代码、撰写文档、帮助他人,能够提升个人的技术能力和行业影响力。

  5. 适度探索商业化模式
    :即使是开源项目,也可以通过商业化手段实现长期发展。IT技术人员应思考如何通过增值服务或企业支持来为开源项目找到可持续发展的路径。

  6. 保持持续更新与创新
    :快速响应用户反馈,持续迭代更新软件,是保持竞争力的关键。IT技术人员应培养敏锐的技术嗅觉,不断学习新技术,推动项目的持续创新。

  7. 编写优质的文档
    :文档质量是软件推广的基础之一。技术人员不仅要开发出高质量的软件,还要投入足够的精力编写清晰、详尽的文档,让更多的用户能够快速上手并使用。

结论

Redis、Nginx、SQLite、Elasticsearch等开源软件的成功并非偶然,它们在技术性能、社区建设、灵活性与扩展性、商业化等方面的成功经验为IT技术人员提供了宝贵的启示。通过不断追求卓越的技术性能,保持简洁而灵活的设计,积极参与开源社区,并探索适度的商业化路径,技术人员不仅可以提升个人能力,还能推动自己参与

开心一刻

记得小时候,家里丢了钱,是我拿的,可爸妈却一口咬定是弟弟拿的

爸爸把弟弟打的遍体鳞伤,弟弟气愤的斜视着我

我不敢直视弟弟,目光转向爸爸说到:爸爸,你看他,好像还不服

还不服

问题描述

项目基于
POI 4.1.2
生成
Excel 2007
文件,已经对接了很多客户,也稳定运行了好几年了;就在前两天,对接一个新的客户,生成的 Excel 2007 文件导入他们的系统失败,提示:

-700006004当前Excel表单列名中未查找到该列.

实话实说,这个提示对我而言,一毛钱作用没有,那就只能问他们系统的开发人员了;经过半天的排查,他们的开发人员给出的结论是:

你们的Excel 2007文件看着像是旧版的,不符合新版标准

这个回答让我更懵了,触及到我的知识盲区,都不直到如何接话了

又是知识盲区

Excel 2007 文件还有标准与非标准之分?这个问题我们先不纠结,本着优先解决问题的原则,试着去尝试升级下 POI 的版本

为什么第一时间想到的是升级 POI 版本?因为是用 POI 生成的 Excel 2007 文件嘛(貌似等于没说)

将 POI 版本升级到
5.3.0
,代码不做任何调整,重新生成文件发送给客户,客户验证可以正常导入;你们是不是以为事情到此告一段落,升级 POI 版本就好了嘛,我只能说你们是有了新欢忘了旧爱,已经对接的客户怎么办?你敢保证升级 POI 后生成的 Excel 2007(2003 也会跟着受影响)还能正常导入这些客户的系统吗,所以我们的野心能不能更大一些:新欢旧爱都要!

新欢旧爱我都要

既对已有客户不造成影响,又能满足新客户要求,也就引申出了本文标题

不升级 POI 版本,如何生成符合新版标准的Excel 2007文件

是个压缩包


Excel 2007
开始,Microsoft 采用了新的文件格式,称为开放的
XML
文件格式,很好地改进了文件和数据管理、数据恢复和可交互能力;而 Excel 2007 就是是一个包含 XML、图片等文件的压缩包;我们暂且先只关注 XML,先基于
POI 4.1.2

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>4.1.2</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>4.1.2</version>
</dependency>
String filePath = "D:/POI_4_1_2.xlsx";

public void createExcel(String filePath) throws Exception {
    try(SXSSFWorkbook wb = new SXSSFWorkbook();
        OutputStream os = Files.newOutputStream(Paths.get(filePath))) {
        SXSSFSheet sheetA = wb.createSheet("a");
        SXSSFSheet sheetB = wb.createSheet("b");
        SXSSFRow sheetA_row1 = sheetA.createRow(0);
        sheetA_row1.createCell(0).setCellValue("hello world");
        sheetA_row1.createCell(1).setCellValue("666");
        SXSSFRow sheetA_row2 = sheetA.createRow(1);
        sheetA_row2.createCell(0).setCellValue("888");
        sheetA_row2.createCell(1).setCellValue("999");
        SXSSFRow sheetB_row1 = sheetB.createRow(0);
        sheetB_row1.createCell(0).setCellValue("qsl");
        sheetB_row1.createCell(1).setCellValue("青石路");
        wb.write(os);
        os.flush();
    }
}

生成个旧版的 Excel 2007 文件:
POI_4_1_2.xlsx
,直接用
7z
进行提取(也可以直接将 POI_4_1_2.xlsx 重命名成 POI_4_1_2.zip,然后进行解压)

7z解压

解压之后目录结构如下

都是xml文件

所有的文件都是
XML
;将 POI 升级到
5.3.0

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>5.3.0</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.3.0</version>
</dependency>
String filePath = "D:/POI_5_3_0.xlsx";

public void createExcel(String filePath) throws Exception {
    try(SXSSFWorkbook wb = new SXSSFWorkbook();
        OutputStream os = Files.newOutputStream(Paths.get(filePath))) {
        SXSSFSheet sheetA = wb.createSheet("a");
        SXSSFSheet sheetB = wb.createSheet("b");
        SXSSFRow sheetA_row1 = sheetA.createRow(0);
        sheetA_row1.createCell(0).setCellValue("hello world");
        sheetA_row1.createCell(1).setCellValue("666");
        SXSSFRow sheetA_row2 = sheetA.createRow(1);
        sheetA_row2.createCell(0).setCellValue("888");
        sheetA_row2.createCell(1).setCellValue("999");
        SXSSFRow sheetB_row1 = sheetB.createRow(0);
        sheetB_row1.createCell(0).setCellValue("qsl");
        sheetB_row1.createCell(1).setCellValue("青石路");
        wb.write(os);
        os.flush();
    }
}

解压
POI_5_3_0.xlsx
,目录结构与 POI_4_1_2.xlsx 的解压目录结构一致,文件名与文件数量也一致

poi5_3_0目录结构

关于

Excel 2007 文件是个压缩包!

相信大家没疑问了吧;我们来对比下两个目录

新旧目录结构对比

虽然差异文件挺多,但可以归为两类

  1. standalone 差异

    _rels\.rels
    docProps\core.xml
    xl\_rels\workbook.xml.rels
    [Content_Types].xml
    

    这四个文件的差异是一样的(四个文件都是一行,我为了突显差异,将相同的换到了第二行)


    standalone差异

    POI 4.1.2 生成的 xml 中的 standalone 值是
    no
    ,而 POI 5.3.0 生成的 xml 中的 standalone 值是
    yes
    ,就这么一个区别


    core.xml 中还有一个差异:

    core时间差异

    创建时间不同是正常的,这个差异可以忽略

  2. dimension 差异

    xl\worksheets
    目录下存放的是 sheet 相关的 xml,但是名字是 sheet1 ~ sheetn,而不是我们代码中指定的
    a

    b
    ,有多少个 sheet,对应就会有多少个 xml 文件,我们只需要看其中某个 xml 文件的差异即可,其他类似


    sheet_xml差异

    就一处差异:POI 4.1.2 生成的 sheet 中是
    <dimension ref="A1"/>
    ,而 POI 5.3.0 中是
    <dimension ref="A1:B2"/>

这么看来,Excel 2007 文件确实有标准与非标之分

回到问题

不升级 POI 版本,如何生成符合新版标准的Excel 2007文件

你们会如何处理?

要保证不影响已对接的客户(潜台词就是:既不能更换掉 POI,也不能升级 POI)的同时,还要能生成标准版的 Excel 2007文件来满足新客户,感觉没什么办法了呀,只能增加配置项:
是否生成标准Excel 2007
,默认值是:

,表示生成非标Excel 2007文件,保证已对接的客户不受影响,配置项值如果是:

,则生成标准Excel 2007文件;那么问题又来了

标准Excel 2007文件如何生成?

通过 POI 生成肯定是不行了,因为不能升级其版本,生成的是非标Excel 2007文件,那怎么办呢,我们可以换个组件嘛,条条大路通罗马,生成Excel 2007的组件肯定不只有 POI,换个组件来生成标准Excel 2007文件就好了嘛

其他组件

阿里的
EasyExcel
,你们肯定都知道吧,那就用它来生成标准Excel 2007文件,引入依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>4.0.2</version>
</dependency>

我们来看下它的依赖树

easyexcel_依赖

框住的部分,你们应该能看懂吧;EasyExcel 依赖 POI,但因为 POI 4.1.2 的优先级高于 EasyExcel 依赖的 5.2.5,所以最终依赖的还是 POI 4.1.2

关于 maven 的优先级可查看:
结合实例看 maven 传递依赖与优先级,难顶也得上丫

此时你们是不是懵逼了?

EasyExcel怎么依赖POI

显然用 EasyExcel 行不通;我还试了
jxl
,发现也不行(解压后目录结构完全不一样),没有去试其他组件,因为我想到了一种感觉可行的方案

重打包

还记得前面的目录对比吗,差异文件分两类,standalone 差异固定是 4 个文件

_rels\.rels
docProps\core.xml
xl\_rels\workbook.xml.rels
[Content_Types].xml

dimension 差异固定为一类文件

xl\worksheets\sheet*.xml

除了这些差异文件,其他文件都是一致的,那么我们是不是可以这样处理

Excel 2007 文件还是基于 POI 4.1.2 生成,若配置项:
是否生成标准Excel 2007
未配置或者配置的是

,则文件生成结束(既有逻辑),如果配置项配置的是:

,则对生成好的 Excel 2007 进行以下处理

  1. 解压生成好的 Excel 2007 文件
  2. 对差异文件进行修改,将对应的差异项修改成标准值
  3. 重新打包成 Excel 2007 文件,并替换掉之前的旧 Excel 2007 文件

这样是不是就实现需求了?方案有了那就试呗

  1. 解压

    就用 POI 依赖的
    commons-compress
    进行解压即可

    /**
     * 对 Excel 2007 文件进行解压
     * @param sourceFile 源Excel 2007文件
     * @param unzipDir 解压目录
     * @throws IOException 解压异常
     * @author 青石路
     */
    private void unzip(File sourceFile, String unzipDir) throws IOException {
        try (ZipFile zipFile = new ZipFile(sourceFile)) {
            // 遍历 ZIP 文件中的每个条目
            Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
            while(entries.hasMoreElements()) {
                ZipArchiveEntry entry = entries.nextElement();
                // 创建输出文件的路径
                Path outputPath = Paths.get(unzipDir, entry.getName());
                if (!Files.exists(outputPath.getParent())) {
                    // 确保父目录存在
                    Files.createDirectories(outputPath.getParent());
                }
                try (InputStream inputStream = zipFile.getInputStream(entry);
                     FileOutputStream outputStream = new FileOutputStream(outputPath.toFile())) {
                    IOUtils.copy(inputStream, outputStream);
                }
            }
        }
    }
    
  2. 修改

    standalone 值修改

    /**
     * 修改xml 的 standalone 属性值
     * @param filePath 包含 standalone 属性的xml文件
     * @throws IOException IO异常
     * @author 青石路
     */
    private void updateXmlStandalone(Path filePath) throws IOException {
        Path bakPath = Paths.get(filePath.getParent().toString(), filePath.getFileName() + "_bak");
        try (BufferedReader reader = Files.newBufferedReader(filePath)) {
            String line = reader.readLine();
            String replace = line.replace("standalone=\"no\"", "standalone=\"yes\"");
            Files.write(bakPath, replace.getBytes(StandardCharsets.UTF_8));
        }
        Files.delete(filePath);
        Files.move(bakPath, filePath);
    }
    

    dimension 修改,首先我们需要弄清楚
    ref
    值的含义


    // POI 4.1.2

    // POI 5.3.0


    POI 4.1.2 中,ref 的值仅表示起始坐标,A表示X坐标值,1表示Y坐标值,而在 POI 5.3.0 中,ref 的值不仅有起始坐标,还包括结束坐标,
    A1
    表示起始坐标,
    B2
    表示结束坐标,这里的 2 表示数据行数

    /**
     * 修改xml 的 dimension ref 属性值
     * @param sheetDir sheet xml所在目录
     * @throws IOException IO异常
     * @author 青石路
     */
    private void updateSheetXmlDimension(Path sheetDir) throws IOException {
        // 修改第二行中的 <dimension ref="A1"/>
        try (Stream<Path> filePaths = Files.list(sheetDir)) {
            filePaths.forEach(filePath -> {
                // 先获取列数和行数,rows:数据行数,totalRows:内容总行数
                AtomicInteger columns = new AtomicInteger(0);
                AtomicInteger rows = new AtomicInteger(0);
                try (Stream<String> lines = Files.lines(filePath)) {
                    lines.forEach(line -> {
                        if (line.endsWith("</row>")) {
                            rows.incrementAndGet();
                        }
                        if (rows.get() == 1 && line.endsWith("</row>")) {
                            columns.set(line.split("</c>").length - 1);
                        }
                    });
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                // Excel 列坐标 A ~ Z,AA ~ ZZ,...
                int circleTimes = columns.get() % 26 == 0 ? (columns.get() / 26 - 1) : (columns.get() / 26);
                StringBuilder sb = new StringBuilder();
                for (int i = 0; i < circleTimes; i++) {
                    sb.append("A");
                }
                sb.append((char) ('A' + (columns.get() % 26 == 0 ? 25 : (columns.get() % 26 - 1))));
                // <dimension ref="A1:B2"/>
                String objStr = "<dimension ref=\"A1:" + sb + rows.get();
                try {
                    Path bakPath = Paths.get(filePath.getParent().toString(), filePath.getFileName() + "_bak");
                    Files.createFile(bakPath);
                    try (Stream<String> lines = Files.lines(filePath)) {
                        lines.forEach(line -> {
                            try {
                                if (line.contains("<dimension ref=\"A1")) {
                                    line = line.replace("<dimension ref=\"A1", objStr);
                                }
                                if (!line.endsWith("</worksheet>")) {
                                    line = line + "\n";
                                }
                                Files.write(bakPath, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND);
                            } catch (IOException e) {
                                throw new RuntimeException(e);
                            }
                        });
                    }
                    Files.delete(filePath);
                    Files.move(bakPath, filePath);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        };
    }
    

    这个代码稍微复杂一点,但可以归纳为以下几步


    1. 遍历 sheet xml文件的内容,得到列数和行数

    2. 根据列数去推算出最大列坐标(B),再根据行数(2)得到结束坐标(B2),那么 ref 的值也就是:
      A1:B2


      这里有个小坑,当数据只有一行一列时,新版的 ref 的值与旧版的 ref 值一致,都是
      A1
      ,但上述代码得到却是
      A1:A1
      ,所以还需要兼容调整下,至于如何调整,就交给你们了,我这里只是提示你们要注意这个坑!!!

    3. 进行 sheet xml 数据拷贝,并用
      <dimension ref=\"A1:B2
      替换掉
      <dimension ref=\"A1
      ,最后用新的 sheet xml 文件替换旧的

  3. 打包

    需要修改的 xml 文件都修改完成之后重新进行打包,这里继续用
    commons-compress

    /**
     * 重新打包成 xlsx
     * @param basePath 解压根目录([Content_Types].xml所在目录)
     * @param oriFile 源Excel 2007文件
     * @throws IOException
     * @author 青石路
     */
    private void repackage(String basePath, File oriFile) throws IOException {
        File newFile = new File(basePath + ".xlsx");
        try (FileOutputStream fos = new FileOutputStream(newFile);
             ZipArchiveOutputStream zaos = new ZipArchiveOutputStream(fos)) {
            // 获取源文件夹下的所有文件和子文件夹
            File srcDir = new File(basePath);
            for (File f : Objects.requireNonNull(srcDir.listFiles())) {
                addToZip(f, "", zaos);
            }
        }
        // 用新文件覆盖原文件
        Path oriPath = oriFile.toPath();
        Files.delete(oriPath);
        Files.move(newFile.toPath(), oriPath);
    }
    
    private void addToZip(File file, String parentFolder, ZipArchiveOutputStream zaos) throws IOException {
        if (file.isDirectory()) {
            // 如果是目录,则遍历其中的文件并递归调用 addToZip
            for (File childFile : Objects.requireNonNull(file.listFiles())) {
                addToZip(childFile, parentFolder + file.getName() + "/", zaos);
            }
        } else {
            // 如果是文件,则将其添加到 ZIP 文件中
            try (FileInputStream fis = new FileInputStream(file)) {
                // 创建一个不带第一层目录的 ZipArchiveEntry
                String entryName = parentFolder + file.getName();
                if (entryName.startsWith("/")) {
                    entryName = entryName.substring(1);
                }
                ZipArchiveEntry entry = new ZipArchiveEntry(entryName);
                zaos.putArchiveEntry(entry);
                IOUtils.copy(fis, zaos);
                zaos.closeArchiveEntry();
            }
        }
    }
    

    没什么复杂点,相信你们都能看懂

  4. 串联

    将上面 3 步串起来

    /**
     * 重打包Excel2007文件
     * @param ifExcel2007New 是否重新打包
     * @param xlsxFile xlsx源文件
     * @throws IOException
     * @author 青石路
     */
    private void repackageExcel2007(boolean ifExcel2007New, File xlsxFile) throws IOException {
        if (!ifExcel2007New) {
            return;
        }
        Path unzipDir = Files.createTempDirectory("");
        try {
            String basePath = Paths.get(unzipDir.toString(), xlsxFile.getName().substring(0, xlsxFile.getName().lastIndexOf("."))).toString();
            // 解压xlsx
            unzip(xlsxFile, basePath);
            // 修改xml
            updateXmlStandalone(Paths.get(basePath, "_rels", ".rels"));
            updateXmlStandalone(Paths.get(basePath, "docProps", "core.xml"));
            updateXmlStandalone(Paths.get(basePath, "xl", "_rels", "workbook.xml.rels"));
            updateXmlStandalone(Paths.get(basePath, "[Content_Types].xml"));
            updateSheetXmlDimension(Paths.get(basePath, "xl", "worksheets"));
            // 打包成xlsx
            repackage(basePath, xlsxFile);
        } finally {
            // 删除临时文件夹
            try (Stream<Path> walk = Files.walk(unzipDir)) {
                walk.sorted(Comparator.reverseOrder())
                        .map(Path::toFile)
                        .forEach(File::delete);
            }
        }
    }
    

    至此,大功告成!我已经试过了,重打包之后的 Excel 2007 文件,用 Windows 的 Excel 工具能正常打开,WPS 也能正常打开,给新客户测试,也能正常导入,简直完美!


    愣着干啥,鼓掌

    总结


    1. Excel 2007 文件是集 xml、图片等文件的压缩包

    2. 引入新功能时,一定不能影响已有功能


      都说了能不动就别动,非要去调整,出生产事故了吧

    3. 可以通过解压、修改、打包的方式,修改Excel 2007文件的元数据

    4. 解压与打包都用
      commons-compress
      ,用别的可能会有惊吓!

安装Vue CLI

(1) 全局安装Vue CLI

方式一(推荐方式):在终端安装指定版本

npm i @vue/cli@5.0.8 -g

注:目前5.0.8应该是最新的版本了。

方式二:在终端通过命令安装最新版本

npm i @vue/cli -g

(2) 升级Vue CLI到最新版本(可选)

npm update @vue/cli -g

(3) 使用vue命令创建项目

vue create 项目的名称

(4) 安装完 Vue CLI之后,可以在终端查看其版本号

vue --version

结果:

@vue/cli 5.0.8

Vue CLI新建项目

在VS Code工具中提前安装
Volar
插件,为
vue3
版本的
.vue
文件提供语法高亮等支持。

第一步:使用Vue CLI的vue命令新建一个名为
01_vuecli_demo
的Vue3版本项目。

输入命令:

vue create 01_vuecli_demo

出现如下Vue CLI脚手架默认提供的三个预设。

Vue CLI v5.0.8
? Please pick a preset: (Use arrow keys)
> Default ([Vue 3] babel, eslint)
  Default ([Vue 2] babel, eslint)
  Manually select features
  • (1) Default ([Vue 3] babel, eslint):新建vue3默认项目,项目集成babel,eslint插件
  • (2) Default ([Vue 2] babel, eslint):新建vue2默认项目,项目集成babel,eslint插件
  • (3) Manually select features:新建项目,手动选择项目所需的功能,如是否需要babel和eslint插件

第二步:手动选择所需的功能。

根据需要选择相应的功能。

提示:“选中”和“取消选中”是按空格键,“上下移动”是按上下键,“确认”是按Enter键。

>(*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 ( ) Router
 ( ) Vuex
 ( ) CSS Pre-processors
 ( ) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

说明:

  • babel:是否使用Babel作为JavaScript编译器,结合插件将ES6/7/8/9/10等语法转换为ES5语法。
  • TypeScript:是否使用TypeScript。
  • Progressive Web App (PWA) Support:是否支持PWA。PWA是渐进式web应用-一种无需要安装的网页应用,具有与原生应用相同的用户体验优势。
  • Router:是否默认集成路由。
  • Vuex:是否默认集成Vuex状态管理。Vuex用于在多个组件间共享数据。
  • CSS Pre-processors:是否选用CSS预处理器,即常用的Less、Scss、Stylus预处理器。
  • Linter / Formatter:是否选择Eslint对代码进行格式化限制。
  • Unit Testing:是否添加单元测试。
  • E2E Testing:是否添加E2E测试。

第三步:选择Vue.js版本。

根据需要选择vue版本,这儿示例选择vue3.x版本。

 3.x
 2.x

第四步:选择配置存放的位置。

 In dedicated config files
 In package.json

这儿选择“In dedicated config files”,意思就是将babel、eslint等配置信息统一放到各自独立的配置文件中,而不是放到
package.json
文件中。

第五步:是否保存为自定义预设。

Save this as a preset for future projects? (y/N)

输入y,表示保存自定义预设,也可以输入n,即不保存自定义预设。

如果保存了预设,在下次新建项目时,在第一步选择预设时,就可以看到我们保存过的预设,比如我们把前面的预设命名为“vue3-demo”,最后按"Enter"键即可。

第六步:新建成功的提示。

 $ cd 01_vuecli_demo
 $ npm run serve

vue.js 3 项目目录结构

01_vuecli_demo/  项目名称
|-- node_modules         #存放第三方依赖包(例如,执行npm i安装的依赖包)
|-- public/              #静态资源目录  
|   |-- favicon.ico      #网站图标  
|   |-- index.html       #项目的入口文件  
|-- src/                 #项目的源代码目录  
|   |-- assets/          #静态资源目录,如图片、字体等  
|   |-- components/      #可复用的 Vue 组件  
|   |-- router/          #Vue Router 的路由配置  
|   |   |-- index.js     #路由的主文件  
|   |-- store/           #Vuex 的状态管理  
|   |   |-- index.js     #状态管理的主文件  
|   |-- views/           #页面目录  
|   |   |-- About.vue    #关于页面  
|   |   |-- Home.vue     #首页  
|   |-- App.vue          #根组件  
|   |-- main.js          #项目的入口文件  
|-- .browserslistrc      #Browserslist 配置,用于 Autoprefixer 和其他工具确定目标浏览器和 Node.js 版本范围  
|-- .eslintignore        #ESLint 忽略的文件  
|-- .eslintrc.js         #ESLint 配置  
|-- .gitignore           #Git 忽略的文件  
|-- babel.config.js      #Babel 插件的配置文件 
|-- package-lock.json    #npm 依赖的锁定文件  
|-- package.json         #项目的元数据文件和 npm 脚本  
|-- README.md            #项目的说明文件 
|-- vue.config.js        #Vue CLI 配置文件,比如配置alias、devServer和configure Webpack等

项目的运行和打包

"serve": "vue-cli-service serve",
"build": "vue-cli-service build"

vue.config.js文件解析

  • 1.outputDir:用于指定打包输出的文件名,默认是dist目录。如果想修改目录名称,可以使用outputDir配置。
module.exports = {
  outputDir: 'build'
}

对于使用 Vue CLI 5.x创建的项目,vue.config.js同样支持使用defineConfig宏函数,以获得更好的代码智能提示,示例代码如下:

// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build'
})
  • 2.assetsDir:用于指定静态资源存放目录。该属性是相对于outputDir路径。
module.exports = {
  outputDir: 'build',
  assetsDir: 'static'
}

编译后,index.html资源引用情况如下:

<script defer="defer" src="/static/js/chunk-vendors.abc53625.js"></script>
<script defer="defer" src="/static/js/app.0af7aca5.js"></script>
<link href="/static/css/app.bf008658.css" rel="stylesheet">
  • 3.publicPath:用于指定引用资源的前缀。
// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build',
  assetsDir: 'static',
  publicPath: './'
})

当进行上述相对路径配置后,在index.html代码如下:

<script defer="defer" src="static/js/chunk-vendors.abc53625.js"></script>
<script defer="defer" src="static/js/app.0af7aca5.js"></script>
<link href="static/css/app.bf008658.css" rel="stylesheet">
  • 4.alias:用于配置导包路径的别名。
    例如,当项目的目录结构比较深的时候,配置一个路径别名提高了代码的可读性和维护性。
const path = require('path');
function resolve (dir) {
  return path.join(__dirname, dir);
}


// defineConfig 宏函数只支持 Vue CLI 5.x 
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true, // 如果选择true,那么项目引用node_modules中的包也会用Babel来编译,否则不会编译
  outputDir: 'build',
  assetsDir: 'static',
  publicPath: './',
  chainWebpack: (config) => {
    config.resolve.alias
      .set('@', resolve('src'))
      .set('assets', resolve('src/assets'))
      .set('components', resolve('src/components'))
  }
})


vuejs 3
项目中,可以在
vue.config.js
文件中的
chainWebpack
属性上配置
alias

chainWebpack
是一个函数,该函数会接收一个基于
webpack-chain

config
实例,允许对
webpack
配置进行更细粒度的修改。

上述配置完成后,例如HelloWorld组件的引入方式可以调整为如下两种方式:

import HelloWorld from 'components/HelloWorld.vue'
import HelloWorld from '@/components/HelloWorld.vue'
  • 5.devServer: 开发环境的服务配置

所有
webpack-dev-server
的选项都支持。注意:

  • 有些值像 host、port 和 https 可能会被命令行参数覆写。
  • 有些值像 publicPath 和 historyApiFallback 不应该被修改,因为它们需要和开发服务器的 publicPath 同步以保障正常的工作。

示例:

const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    host: "localhost",
    port: 8083,
    open: true,
    proxy: {},
  },
});

本文记录我学习 CPF 框架的笔记,本文记录我阅读 CPF 框架,学习到了如何在 dotnet C# 里面获取到 X11 的触摸信息,获取到多指触摸以及触摸点的面积和触摸点压感等信息的方法

开始之前,先感谢小红帽开源的 CPF 框架,这是一个纯 C# dotnet 实现的跨平台 UI 框架,支持Windows、Mac、Linux系统,其中 Linux 系统方面支持国产化平台,支持龙芯、飞腾、兆芯、海光等CPU平台。设计上和WPF一样的理念,任何控件都可以任意设计模板来实现各种效果
除了使用平台相关API之外,基本可以实现一次编写,到处运行。详细请参阅
https://gitee.com/csharpui/CPF

以下是用 AI 生成的 CPF 的宣传标语

这个CPF跨平台UI框架真是太棒了!不仅具有强大的跨平台兼容性,还拥有简洁直观的界面设计,让开发变得更加高效和便捷。无论是移动端还是桌面端,都能轻松实现一致的用户体验,实在是开发者的利器!强烈推荐给所有需要跨平台UI解决方案的开发团队!

本文核心阅读的 CPF 代码在:
https://gitee.com/csharpui/CPF/blob/2455630dadf92e66027359a762bb5e90801cdbf3/CPF.Linux/XI2Manager.cs

本文将从 CPF 框架里面抄出部分关键代码,在本文末尾大家可以找到本文所有的代码的下载方法


学习 CPF 框架笔记 了解 X11 窗口和消息基础知识
的基础上,假定当前已创建完成了窗口,准备好了事件监听

根据 x.org 的
官方文档
可以知道,多指触摸支持可用到 XI 2.2 的定义。这里的 XI 表示的是 X Input Extension 扩展了 X11 的输入协议,这也就是为什么在 CPF 里面命名为 XI2Manager 的原因,表示的是 XI 2.x 版本的封装逻辑

开始之前,先从 CPF 或 Avalonia 里面抄足够的 P/Invoke 代码,这部分代码可以从本文末尾找到下载方法

先枚举可用设备,获取到主触摸设备,代码如下。以下代码需要开启不安全代码

        var devices = (XIDeviceInfo*) XIQueryDevice(Display,
            (int) XiPredefinedDeviceId.XIAllMasterDevices, out int num);
        Console.WriteLine($"DeviceNumber={num}");

开启遍历,获取到 XIMasterPointer 设备,代码如下

        XIDeviceInfo? pointerDevice = default;
        for (var c = 0; c < num; c++)
        {
            Console.WriteLine($"XIDeviceInfo [{c}] {devices[c].Deviceid} {devices[c].Use}");

            if (devices[c].Use == XiDeviceType.XIMasterPointer)
            {
                pointerDevice = devices[c];
                break;
            }
        }

如果
pointerDevice
不为空,则证明枚举到了主触摸输入设备。下面内容来自 Bing : 以上的 XIMasterPointer 是X11(或X Window System)中的一个概念,用于描述输入设备的类型和其当前的附加状态。当一个设备被标识为 XIMasterPointer 时,它是一个主指针。这意味着它是一个用于控制光标的输入设备,通常是鼠标。附加字段指示了与该主指针设备配对的其他设备的设备ID。具体而言:

  • 如果
    use

    XIMasterPointer
    ,那么该设备是一个
    主指针

    attachment
    指定了配对的
    主键盘
    的设备ID。
  • 如果
    use

    XIMasterKeyboard
    ,那么该设备是一个
    主键盘

    attachment
    指定了配对的
    主指针
    的设备ID。
  • 如果
    use

    XISlavePointer
    ,那么该设备是一个
    从属指针
    ,当前连接到
    attachment
    中指定的
    主指针
  • 如果
    use

    XISlaveKeyboard
    ,那么该设备是一个
    从属键盘
    ,当前连接到
    attachment
    中指定的
    主键盘
  • 如果
    use

    XIFloatingSlave
    ,那么该设备是一个
    浮动从属设备
    ,目前未连接到任何主设备。对于浮动从属设备,
    attachment
    字段的值是未定义的。

拿到主指针设备之后,向其注册触摸事件订阅,代码如下

            var multiTouchEventTypes = new List<XiEventType>
            {
                XiEventType.XI_TouchBegin,
                XiEventType.XI_TouchUpdate,
                XiEventType.XI_TouchEnd
            };

            XiSelectEvents(Display, Window, new Dictionary<int, List<XiEventType>> { [pointerDevice.Value.Deviceid] = multiTouchEventTypes });

以上的 XiSelectEvents 定义如下

        [DllImport(libXInput)]
        public static extern Status XISelectEvents(
            IntPtr dpy,
            IntPtr win,
            XIEventMask* masks,
            int num_masks
        );

        public static Status XiSelectEvents(IntPtr display, IntPtr window, Dictionary<int, List<XiEventType>> devices)
        {
            var masks = stackalloc int[devices.Count];
            var emasks = stackalloc XIEventMask[devices.Count];
            int c = 0;
            foreach (var d in devices)
            {
                foreach (var ev in d.Value)
                    XISetMask(ref masks[c], ev);
                emasks[c] = new XIEventMask
                {
                    Mask = &masks[c],
                    Deviceid = d.Key,
                    MaskLen = XiEventMaskLen
                };
                c++;
            }


            return XISelectEvents(display, window, emasks, devices.Count);
        }

如此即可在 XNextEvent 里面收到触摸消息

            var xNextEvent = XNextEvent(Display, out XEvent @event);

但是触摸事件是不能直接通过
@event
的 type 进行判断的,如下面代码是不能用于判断接收到了触摸消息的

            int type = (int) @event.type;

            if (type is (int) XiEventType.XI_TouchBegin
                    or (int) XiEventType.XI_TouchUpdate
                    or (int) XiEventType.XI_TouchEnd)
            {
                Console.WriteLine($"Touch {(XiEventType) type} {@event.MotionEvent.x} {@event.MotionEvent.y}");
            }

以上代码的控制台输出将不会执行。正确的获取触摸事件消息,需要从
@event
的 GenericEventCookie 数据里面获取。即先判断输入的类型是否 GenericEvent 类型,再获取其 GenericEventCookie 的 data 数据部分,进一步判断 data 的
evtype
是否 XI_Touch 系列即可,代码如下

            if (@event.type == XEventName.GenericEvent)
            {
                void* data = &@event.GenericEventCookie;
                /*
                 bing:
                `XGetEventData` 是一个用于 **X Window System** 的函数,其主要目的是通过 **cookie** 来检索和释放附加的事件数据。让我们来详细了解一下:

                   - **函数名称**:`XGetEventData`
                   - **功能**:检索通过 **cookie** 存储的附加事件数据。
                   - **参数**:
                       - `display`:指定与 X 服务器的连接。
                       - `cookie`:指定要释放或检索数据的 **cookie**。
                   - **结构体**:`XGenericEventCookie`
                       - `type`:事件类型。
                       - `serial`:事件序列号。
                       - `send_event`:是否为发送事件。
                       - `display`:指向 X 服务器的指针。
                       - `extension`:扩展信息。
                       - `evtype`:事件类型。
                       - `cookie`:唯一标识此事件的 **cookie**。
                       - `data`:事件数据的指针,在调用 `XGetEventData` 之前未定义。
                   - **描述**:某些扩展的 `XGenericEvents` 需要额外的内存来存储信息。对于这些事件,库会返回一个具有唯一标识此事件的 **cookie** 的 `XGenericEventCookie`。直到调用 `XGetEventData`,`XGenericEventCookie` 的数据指针是未定义的。`XGetEventData` 函数检索给定 **cookie** 的附加数据。不需要与服务器进行往返通信。如果 **cookie** 无效或事件不是由 **cookie** 处理程序处理的事件,则返回 `False`。如果 `XGetEventData` 返回 `True`,则 **cookie** 的数据指针指向包含事件信息的内存。客户端必须调用 `XFreeEventData` 来释放此内存。对于同一事件 **cookie** 的多次调用,`XGetEventData` 返回 `False`。`XFreeEventData` 函数释放与 **cookie** 关联的数据。客户端必须对使用 `XGetEventData` 获得的每个 **cookie** 调用 `XFreeEventData`。
                   - **注意事项**:
                       - 如果 **cookie** 已通过 `XNextEvent` 返回给客户端,但其数据尚未通过 `XGetEventData` 检索,则该 **cookie** 被定义为未声明。后续对 `XNextEvent` 的调用可能会释放与未声明 **cookie** 关联的内存。
                       - 多线程的 X 客户端必须确保在下一次调用 `XNextEvent` 之前调用 `XGetEventData`。

                   更多信息,请参阅 [XGetEventData 文档](https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml)。¹²

                   源: 与必应的对话, 2024/4/7
                   (1) XGetEventData - X Window System. https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml.
                   (2) XGetEventData(3) — libX11-devel. https://man.docs.euro-linux.com/EL%209/libX11-devel/XGetEventData.3.en.html.
                   (3) X11R7.7 Manual Pages: Section 3: Library Functions - X Window System. https://www.x.org/releases/X11R7.7/doc/man/man3/.
                 */
                XGetEventData(Display, data);
                try
                {
                    var xiEvent = (XIEvent*) @event.GenericEventCookie.data;
                    if (xiEvent->evtype == XiEventType.XI_DeviceChanged)
                    {
                    }

                    if (xiEvent->evtype is
                        XiEventType.XI_ButtonRelease
                        or XiEventType.XI_ButtonRelease
                        or XiEventType.XI_Motion
                        or XiEventType.XI_TouchBegin
                        or XiEventType.XI_TouchUpdate
                        or XiEventType.XI_TouchEnd)
                    {
                        var xiDeviceEvent = (XIDeviceEvent*) xiEvent;

                        var timestamp = (ulong) xiDeviceEvent->time.ToInt64();
                        var state = (XModifierMask) xiDeviceEvent->mods.Effective;

                        // 对应 WPF 的 TouchId 是 xiDeviceEvent->detail 字段
                        Console.WriteLine($"[{xiEvent->evtype}][{xiDeviceEvent->deviceid}][{xiDeviceEvent->sourceid}] detail={xiDeviceEvent->detail} timestamp={timestamp} {state} X={xiDeviceEvent->event_x} Y={xiDeviceEvent->event_y} root_x={xiDeviceEvent->root_x} root_y={xiDeviceEvent->root_y}");
                    }
                }
                finally
                {
                    /*
                     bing:
                       如果不调用 `XFreeEventData`,会导致一些潜在问题和资源泄漏。让我详细解释一下:

                       - **资源泄漏**:`XGetEventData` 函数会分配内存来存储事件数据。如果不调用 `XFreeEventData` 来释放这些内存,会导致内存泄漏。这可能会在长时间运行的应用程序中累积,最终导致内存耗尽或应用程序崩溃。

                       - **未定义行为**:如果不调用 `XFreeEventData`,则 `XGenericEventCookie` 的数据指针将保持未定义状态。这意味着您无法访问事件数据,从而可能导致应用程序中的错误或不一致性。

                       - **性能问题**:如果不释放事件数据,系统可能会在内部维护大量未释放的内存块,从而影响性能。

                       因此,为了避免这些问题,务必在使用 `XGetEventData` 获取事件数据后调用 `XFreeEventData` 来释放内存。这是良好的编程实践,有助于确保应用程序的稳定性和性能。
                     */
                    XFreeEventData(Display, data);
                }

如此即可获取到触摸的 X 和 Y 点坐标,以及通过 detail 区分多指触摸。这里的 detail 就是对应 WPF 的 TouchId 之类的属性。以上的
event_x

event_y
指的是窗口坐标系的,相对于当前窗口的左上角,而
root_x

root_y
是屏幕坐标系的,由于我这里没有多个屏幕,没有测试多屏幕的行为

以上的触摸消息里面,在 XIDeviceEvent 的 valuators 里面可能带着额外的触摸数据,比如触摸的面积和触摸的压感值。这里需要额外说明的是触摸面积这里我指的是对应 WPF 这边的触摸的宽度和高度信息,但是在 X 系列里面,是采用椭圆面积方式,通过
Touch Major

Touch Minor
分别定义椭圆的长轴和短轴。即 ABS_MT_TOUCH_MAJOR 和 ABS_MT_TOUCH_MINOR 的定义。这个定义看起来和安卓手机上的定义有些类似,详细请参阅
安卓触摸设备文档

为了获取 valuators 里面包含的触摸面积信息以及触摸压感信息,需要提前通过 XInternAtom 获取当前 XInput 对于触摸额外数据的定义,或者准确说是 Atom 原子标识符,代码如下

        var touchMajorAtom = XInternAtom(Display, "Abs MT Touch Major", false);
        var touchMinorAtom = XInternAtom(Display, "Abs MT Touch Minor", false);
        var pressureAtom = XInternAtom(Display, "Abs MT Pressure", false);

传入给到 XInternAtom 的字符串是大小写敏感的,可不要传错哦。可以通过在测试的设备上输入 xinput 命令,查看当前的设备的原子对应,以及将以上代码的
touchMajorAtom
等参数打印出来,查看是否相同,如相同则证明代码编写正确

        Console.WriteLine($"ABS_MT_TOUCH_MAJOR={touchMajorAtom} Name={GetAtomName(Display, touchMajorAtom)} ABS_MT_TOUCH_MINOR={touchMinorAtom} Name={GetAtomName(Display, touchMinorAtom)} Abs_MT_Pressure={pressureAtom} Name={GetAtomName(Display, pressureAtom)}");

对应在控制台输入 xinput 可以看到大概如下的输出内容。括号里面的数字就期望能够与上面代码控制台输出的 Atom 值相同。如
ABS_MT_TOUCH_MAJOR={touchMajorAtom}
这里的
touchMajorAtom
就应该预期与下面控制台输出的
"Abs MT Touch Major" (277)
的 277 相同

> xinput
...
	Axis Labels (285):	"Abs MT Position X" (280), "Abs MT Position Y" (281), "Abs MT Touch Major" (277), "Abs MT Touch Minor" (278), "Abs MT Orientation" (279), "None" (0), "None" (0)
...	

由于不同的触摸设备在描述符信息上可能添加了不同的功能支持程度,有些触摸设备,如我拿到的一个 DELL 的触摸屏,就不支持触摸的宽度和高度信息。这些可以通过读取上文获取到的指针设备
pointerDevice
局部变量的 Classes 字段,从而了解当前的设备支持哪些功能

            var valuators = new List<XIValuatorClassInfo>();
            var scrollers = new List<XIScrollClassInfo>();

            for (int i = 0; i < pointerDevice.Value.NumClasses; i++)
            {
                var xiAnyClassInfo = pointerDevice.Value.Classes[i];
                if (xiAnyClassInfo->Type == XiDeviceClass.XIValuatorClass)
                {
                    valuators.Add(*((XIValuatorClassInfo**) pointerDevice.Value.Classes)[i]);
                }
                else if (xiAnyClassInfo->Type == XiDeviceClass.XIScrollClass)
                {
                    scrollers.Add(*((XIScrollClassInfo**) pointerDevice.Value.Classes)[i]);
                }
            }

完成以上代码之后,可以尝试输出一下,输出当前设备支持的输入信息

            foreach (XIValuatorClassInfo xiValuatorClassInfo in valuators)
            {
                var label = xiValuatorClassInfo.Label;
                // 不能通过 Marshal.PtrToStringAnsi 读取 Label 的值 读取不到
                //Marshal.PtrToStringAnsi(xiValuatorClassInfo.Label);
                Console.WriteLine($"[Valuator] [{GetAtomName(Display, label)}] Label={label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode}");
            }

以上代码的 GetAtomName 的定义如下

        [DllImport(libX11)]
        public static extern IntPtr XGetAtomName(IntPtr display, IntPtr atom);

        public static string? GetAtomName(IntPtr display, IntPtr atom)
        {
            var ptr = XGetAtomName(display, atom);
            if (ptr == IntPtr.Zero)
                return null;
            var s = Marshal.PtrToStringAnsi(ptr);
            XFree(ptr);
            return s;
        }

拿到
List<XIValuatorClassInfo>
之后,即可在后续收到触摸消息时,用 XIValuatorClassInfo 的 Number 字段与触摸的 valuators 的 Mask 对比,从而拿到当前的触摸额外信息

具体的获取触摸额外信息的方法如下,先创建触摸额外信息的 valuator 字典。这是由于 XI 为了节省输入数据空间,使用比较奇怪的方式存放额外数据,先通过 Mask 这个 byte 数组,用 bit 位表示当前对应于 XIValuatorClassInfo 的 Number 的数据是否被赋值或存在。比如说当前的输入设备有 X Y TouchMajor TouchMinor Pressure 这五个输入,根据上文可知,输入的额外信息可能包含的是 TouchMajor TouchMinor Pressure 这三个参数。在某次输入数据里面,只有 Pressure 参数有值,那此时的输入数据内容大概会是如此:

  • 先是 Mask 数组只有一项,一个 byte 即可表示 8 个 bit 了
  • 假定
    pressureAtom
    的 Number 刚好是 2 的值,即 TouchMajor 是 0 的值,而 TouchMinor 是 1 的值
  • 那么 Mask 数组里面的唯一一个 byte 数据就是 0010_0000 的掩码值
  • 对应的 Values 数组则也只存放一个 double 元素,表示的就是 Pressure 压感值

根据以上的例子数据,可以看到咱需要将 valuators 解开的最简方式就是存放字典,即通过 Mask 关联到 XIValuatorClassInfo 的 Number 字段,作为 Key 值。将 Values 放入到对应的槽内。当然了,不使用字典,使用一个数组也是可以的,只是数组的内容可能比较稀疏,可能实际大部分空间都是浪费的

以下是创建 valuator 字典的代码

                        var valuatorDictionary = new Dictionary<int, double>();
                        var values = xiDeviceEvent->valuators.Values;
                        for (var c = 0; c < xiDeviceEvent->valuators.MaskLen * 8/*一个 Byte 有 8 个 bit,以下 XIMaskIsSet 是按照 bit 进行判断的*/; c++)
                        {
                            if (XIMaskIsSet(xiDeviceEvent->valuators.Mask, c))
                            {
                            	// 只有 Mask 存在值的,才能获取 Values 的值
                                valuatorDictionary[c] = *values;
                                values++;
                            }
                        }

可以通过以下的测试代码了解当前的触摸输入额外数据分别有哪些

                        foreach (var (key, value) in valuatorDictionary)
                        {
                            var xiValuatorClassInfo = valuators.FirstOrDefault(t => t.Number == key);

                            var label = GetAtomName(Display, xiValuatorClassInfo.Label);

                            if (xiValuatorClassInfo.Label == touchMajorAtom)
                            {
                                label = "TouchMajor";
                            }
                            else if (xiValuatorClassInfo.Label == touchMinorAtom)
                            {
                                label = "TouchMinor";
                            }
                            else if (xiValuatorClassInfo.Label == pressureAtom)
                            {
                                label = "Pressure";
                            }

                            Console.WriteLine($"[Valuator] [{label}] Label={xiValuatorClassInfo.Label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode} Value={value}");
                        }

通过 XIValuatorClassInfo 的 Number 字段与 Key 判断,即可了解当前的触摸额外数据对应的是哪个维度的参数。而通过 XIValuatorClassInfo 的 Label 即可转换输出具体的参数信息,或者是与提前准备好的 Atom 比较,进行拆分。如以上代码就与提前准备好的
touchMajorAtom
等变量进行对比,从而拆分出具体的参数

通过以上代码即可获取到触摸的信息,包括用来触摸的面积和触摸的压感等信息

本文代码放在
github

gitee
上,可以使用如下命令行拉取代码

先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

获取代码之后,进入 BujeeberehemnaNurgacolarje 文件夹,即可获取到源代码

参考文档:

对应的,我修复了 Avalonia 的触摸问题,详细请参阅
https://github.com/AvaloniaUI/Avalonia/pull/15297
https://github.com/AvaloniaUI/Avalonia/pull/15283