计算机程序的思维逻辑 (90) - 正则表达式 (下 - 剖析常见表达式)

?88节介绍了正则表达式的语法,上节介绍了正则表达式相关的Java API,本节来讨论和分析一些常用的正则表达式,具体包括:

  • 邮编
  • 电话号码,包括手机号码和固定电话号码
  • 日期和时间
  • 身份证
  • IP地址
  • URL
  • Email地址
  • 中文字符

对于同一个目的,正则表达式往往有多种写法,大多没有唯一正确的写法,本节的写法主要是示例。此外,写一个正则表达式,匹配希望匹配的内容往往比较容易,但让它不匹配不希望匹配的内容,则往往比较困难,也就是说,保证精确性经常是很难的,不过,很多时候,我们也没有必要写完全精确的表达式,需要写到多精确与你需要处理的文本和需求有关,另外,正则表达式难以表达的,可以通过写程序进一步处理。这么描述可能比较抽象,下面,我们会具体讨论分析。

邮编

邮编比较简单,就是6位数字,首位不能是0,所以表达式可以为:

[1-9][0-9]{5}

这个表达式可以用于验证输入是否为邮编,比如:

public static Pattern ZIP_CODE_PATTERN = Pattern.compile(
        "[1-9][0-9]{5}");

public static boolean isZipCode(String text) {
    return ZIP_CODE_PATTERN.matcher(text).matches();
}

但如果用于查找,这个表达式是不够的,看个例子:

public static void findZipCode(String text) {
    Matcher matcher = ZIP_CODE_PATTERN.matcher(text);
    while (matcher.find()) {
        System.out.println(matcher.group());
    }
}

public static void main(String[] args) {
    findZipCode("邮编 100013,电话18612345678");
}

文本中只有一个邮编,但输出却为:

100013
186123

这怎么办呢?可以使用88节介绍的环视边界匹配,对于左边界,它前面的字符不能是数字,环视表达式为:

(?<![0-9])

对于右边界,它右边的字符不能是数字,环视表达式为:

(?![0-9])

所以,完整的表达式可以为:

(?<![0-9])[1-9][0-9]{5}(?![0-9])

使用这个表达式,也就是说,将ZIP_CODE_PATTERN改为:

public static Pattern ZIP_CODE_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "[1-9][0-9]{5}"
        + "(?![0-9])"); // 右边不能有数字

就可以输出期望的结果了。

非0开头的6位数字就一定是邮编吗?答案当然是否定的,所以,这个表达式也不是精确的,如果需要更精确的验证,可以写程序进一步检查。

手机号码

中国的手机号码都是11位数字,所以,最简单的表达式就是:

[0-9]{11}

不过,目前手机号第1位都是1,第2位取值为3、4、5、7、8之一,所以,更精确的表达式是:

1[3|4|5|7|8|][0-9]{9}

为方便表达手机号,手机号中间经常有连字符(即减号‘-‘),形如:

186-1234-5678

为表达这种可选的连字符,表达式可以改为:

1[3|4|5|7|8|][0-9]-?[0-9]{4}-?[0-9]{4}

在手机号前面,可能还有0、+86或0086,和手机号码之间可能还有一个空格,比如:

018612345678
+86 18612345678
0086 18612345678

为表达这种形式,可以在号码前加如下表达式:

((0|\+86|0086)\s?)?

和邮编类似,如果为了抽取,也要在左右加环视边界匹配,左右不能是数字。所以,完整的表达式为:

(?<![0-9])((0|\+86|0086)\s?)?1[3|4|5|7|8|][0-9]-?[0-9]{4}-?[0-9]{4}(?![0-9])

用Java表示的代码为:

public static Pattern MOBILE_PHONE_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "((0|\\+86|0086)\\s?)?" // 0 +86 0086
        + "1[3|4|5|7|8|][0-9]-?[0-9]{4}-?[0-9]{4}" // 186-1234-5678
        + "(?![0-9])"); // 右边不能有数字

固定电话

不考虑分机,中国的固定电话一般由两部分组成:区号和市内号码,区号是3到4位,市内号码是7到8位。区号以0开头,表达式可以为:

0[0-9]{2,3}

市内号码表达式为:

[0-9]{7,8}

区号可能用括号包含,区号与市内号码之间可能有连字符,如以下形式:

010-62265678
(010)62265678

整个区号是可选的,所以整个表达式为:

(\(?0[0-9]{2,3}\)?-?)?[0-9]{7,8}

再加上左右边界环视,完整的Java表示为:

public static Pattern FIXED_PHONE_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "(\\(?0[0-9]{2,3}\\)?-?)?" // 区号
        + "[0-9]{7,8}"// 市内号码
        + "(?![0-9])"); // 右边不能有数字

日期

日期的表示方式有很多种,我们只看一种,形如:

2017-06-21
2016-11-1

年月日之间用连字符分隔,月和日可能只有一位。

最简单的正则表达式可以为:

\d{4}-\d{1,2}-\d{1,2}

年一般没有限制,但月只能取值1到12,日只能取值1到31,怎么表达这种限制呢?

对于月,有两种情况,1月到9月,表达式可以为:

0?[1-9]

10月到12月,表达式可以为:

1[0-2]

所以,月的表达式为:

(0?[1-9]|1[0-2])

对于日,有三种情况:

  • 1到9号,表达式为:0?[1-9]
  • 10号到29号,表达式为:[1-2][0-9]
  • 30号和31号,表达式为:3[01]

所以,整个表达式为:

\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|3[01])

加上左右边界环视,完整的Java表示为:

public static Pattern DATE_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "\\d{4}-" // 年
        + "(0?[1-9]|1[0-2])-" // 月
        + "(0?[1-9]|[1-2][0-9]|3[01])"// 日
        + "(?![0-9])"); // 右边不能有数字

时间

考虑24小时制,只考虑小时和分钟,小时和分钟都用固定两位表示,格式如下:

10:57

基本表达式为:

\d{2}:\d{2}

小时取值范围为0到23,更精确的表达式为:

([0-1][0-9]|2[0-3])

分钟取值范围为0到59,更精确的表达式为:

[0-5][0-9]

所以,整个表达式为:

([0-1][0-9]|2[0-3]):[0-5][0-9]

加上左右边界环视,完整的Java表示为:

public static Pattern TIME_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "([0-1][0-9]|2[0-3])" // 小时
        + ":" + "[0-5][0-9]"// 分钟
        + "(?![0-9])"); // 右边不能有数字

身份证

身份证有一代和二代之分,一代是15位数字,二代是18位,都不能以0开头,对于二代身份证,最后一位可能为x或X,其他是数字。

一代身份证表达式可以为:

[1-9][0-9]{14}

二代身份证可以为:

[1-9][0-9]{16}[0-9xX]

这两个表达式的前面部分是相同的,二代身份证多了如下内容:

[0-9]{2}[0-9xX]

所以,它们可以合并为一个表达式,即:

[1-9][0-9]{14}([0-9]{2}[0-9xX])?

加上左右边界环视,完整的Java表示为:

public static Pattern ID_CARD_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "[1-9][0-9]{14}" // 一代身份证
        + "([0-9]{2}[0-9xX])?" // 二代身份证多出的部分
        + "(?![0-9])"); // 右边不能有数字

符合这个要求的就一定是身份证号码吗?当然不是,身份证还有一些更为具体的要求,本文就不探讨了。

IP地址

IP地址格式如下:

192.168.3.5 

点号分隔,4段数字,每个数字范围是0到255。最简单的表达式为:

(\d{1,3}\.){3}\d{1-3}

\d{1,3}太简单,没有满足0到255之间的约束,要满足这个约束,就要分多种情况考虑。

值是1位数,前面可能有0到2个0,表达式为:

0{0,2}[0-9]

值是两位数,前面可能有一个0,表达式为:

0?[0-9]{2}

值是三位数,又要分为多种情况。以1开头的,后两位没有限制,表达式为:

1[0-9]{2}

以2开头的,如果第二位是0到4,则第三位没有限制,表达式为:

2[0-4][0-9]

如果第二位是5,则第三位取值为0到5,表达式为:

25[0-5] 

所以,\d{1,3}更为精确的表示为:

(0{0,2}[0-9]|0?[0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])

所以,加上左右边界环视,IP地址的完整Java表示为:

public static Pattern IP_PATTERN = Pattern.compile(
        "(?<![0-9])" // 左边不能有数字
        + "((0{0,2}[0-9]|0?[0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}"
        + "(0{0,2}[0-9]|0?[0-9]{2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])"
        + "(?![0-9])"); // 右边不能有数字     

URL

URL的格式比较复杂,其规范定义在https://tools.ietf.org/html/rfc1738,我们只考虑http协议,其通用格式是:

http://<host>:<port>/<path>?<searchpart>

开始是http://,接着是主机名,主机名之后是可选的端口,再之后是可选的路径,路径后是可选的查询字符串,以?开头。

一些例子:

http://www.example.com
http://www.example.com/ab/c/def.html
http://www.example.com:8080/ab/c/def?q1=abc&q2=def

主机名中的字符可以是字母、数字、减号和点号,所以表达式可以为:

[-0-9a-zA-Z.]+

端口部分可以写为:

(:\d+)?

路径由多个子路径组成,每个子路径以/开头,后跟零个或多个非/的字符,简单的说,表达式可以为:

(/[^/]*)*

更精确的说,把所有允许的字符列出来,表达式为:

(/[-\w$.+!*‘(),%;:@&=]*)*

对于查询字符串,简单的说,由非空字符串组成,表达式为:

\?[\S]*

更精确的,把所有允许的字符列出来,表达式为:

\?[-\w$.+!*‘(),%;:@&=]*

路径和查询字符串是可选的,且查询字符串只有在至少存在一个路径的情况下才能出现,其模式为:

(/<sub_path>(/<sub_path>)*(\?<search>)?)?

所以,路径和查询部分的简单表达式为:

(/[^/]*(/[^/]*)*(\?[\S]*)?)?

精确表达式为:

(/[-\w$.+!*‘(),%;:@&=]*(/[-\w$.+!*‘(),%;:@&=]*)*(\?[-\w$.+!*‘(),%;:@&=]*)?)?

HTTP的完整Java表达式为:

public static Pattern HTTP_PATTERN = Pattern.compile(
        "http://" + "[-0-9a-zA-Z.]+" // 主机名
        + "(:\\d+)?" // 端口
        + "(" // 可选的路径和查询 - 开始
            + "/[-\\w$.+!*‘(),%;:@&=]*" // 第一层路径
            + "(/[-\\w$.+!*‘(),%;:@&=]*)*" // 可选的其他层路径
            + "(\\?[-\\w$.+!*‘(),%;:@&=]*)?" // 可选的查询字符串
        + ")?"); // 可选的路径和查询 - 结束 

Email地址

完整的Email规范比较复杂,定义在https://tools.ietf.org/html/rfc822,我们先看一些实际中常用的。

比如新浪邮箱,它的格式如:

[email protected]

对于用户名部分,它的要求是:4-16个字符,可使用英文小写、数字、下划线,但下划线不能在首尾。

怎么验证用户名呢?可以为:

[a-z0-9][a-z0-9_]{2,14}[a-z0-9]

新浪邮箱的完整Java表达式为:

public static Pattern SINA_EMAIL_PATTERN = Pattern.compile(
        "[a-z0-9]"
        + "[a-z0-9_]{2,14}"
        + "[a-z0-9]@sina\\.com");        

我们再来看QQ邮箱,它对于用户名的要求为:

  • 3-18字符,可使用英文、数字、减号、点或下划线
  • 必须以英文字母开头,必须以英文字母或数字结尾
  • 点、减号、下划线不能连续出现两次或两次以上

如果只有第一条,可以为:

[-0-9a-zA-Z._]{3,18}

为满足第二条,可以改为:

[a-zA-Z][-0-9a-zA-Z._]{1,16}[a-zA-Z0-9]

怎么满足第三条呢?可以使用边界环视,左边加如下表达式:

(?![-0-9a-zA-Z._]*(--|\.\.|__))

完整表达式可以为:

(?![-0-9a-zA-Z._]*(--|\.\.|__))[a-zA-Z][-0-9a-zA-Z._]{1,16}[a-zA-Z0-9]

QQ邮箱的完整Java表达式为:

public static Pattern QQ_EMAIL_PATTERN = Pattern.compile(
        "(?![-0-9a-zA-Z._]*(--|\\.\\.|__))" // 点、减号、下划线不能连续出现两次或两次以上
        + "[a-zA-Z]" // 必须以英文字母开头
        + "[-0-9a-zA-Z._]{1,16}" // 3-18位 英文、数字、减号、点、下划线组成
        + "[a-zA-Z0-9]@qq\\.com"); // 由英文字母、数字结尾        

以上都是特定邮箱服务商的要求,一般的邮箱是什么规则呢?一般而言,以@作为分隔符,前面是用户名,后面是域名。

用户名的一般规则是:

  • 由英文字母、数字、下划线、减号、点号组成
  • 至少1位,不超过64位
  • 开头不能是减号、点号和下划线

比如:

[email protected]

这个表达式可以为:

[0-9a-zA-Z][-._0-9a-zA-Z]{0,63}

域名部分以点号分隔为多个部分,至少有两个部分。最后一部分是顶级域名,由2到3个英文字母组成,表达式可以为:

[a-zA-Z]{2,3}

对于域名的其他点号分隔的部分,每个部分一般由字母、数字、减号组成,但减号不能在开头,长度不能超过63个字符,表达式可以为:

[0-9a-zA-Z][-0-9a-zA-Z]{0,62}

所以,域名部分的表达式为:

([0-9a-zA-Z][-0-9a-zA-Z]{0,62}\.)+[a-zA-Z]{2,3}

完整的Java表示为:

public static Pattern GENERAL_EMAIL_PATTERN = Pattern.compile(
        "[0-9a-zA-Z][-._0-9a-zA-Z]{0,63}" // 用户名
        + "@"
        + "([0-9a-zA-Z][-0-9a-zA-Z]{0,62}\\.)+" // 域名部分
        + "[a-zA-Z]{2,3}"); // 顶级域名      

中文字符

中文字符的Unicode编号一般位于\u4e00和\u9fff之间,所以匹配任意一个中文字符的表达式可以为:

[\u4e00-\u9fff]

Java表达式为:

public static Pattern CHINESE_PATTERN = Pattern.compile(
        "[\\u4e00-\\u9fff]");

小结

本节详细讨论和分析了一些常见的正则表达式,在实际开发中,有些可以直接使用,有些需要根据具体文本和需求进行调整。

至此,关于正则表达式,我们就介绍完了,相信你对正则表达式一定有了一个更为清晰透彻的理解!

在之前的章节中,我们都是基于Java 7讨论的,从下节开始,我们探讨Java 8的一些特性,尤其是函数式编程。

(与其他章节一样,本节所有代码位于 https://github.com/swiftma/program-logic,位于包shuo.laoma.regex.c90下)

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

时间: 2024-11-05 13:17:53

计算机程序的思维逻辑 (90) - 正则表达式 (下 - 剖析常见表达式)的相关文章

计算机程序的思维逻辑 (25) - 异常 (下)

上节我们介绍了异常的基本概念和异常类,本节我们进一步介绍对异常的处理,我们先来看Java语言对异常处理的支持,然后探讨在实际中到底应该如何处理异常. 异常处理 catch匹配 上节简单介绍了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一个异常类型,比如说: try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); }

计算机程序的思维逻辑 (89) - 正则表达式 (中)

上节介绍了正则表达式的语法,本节介绍相关的Java API. 正则表达式相关的类位于包java.util.regex下,有两个主要的类,一个是Pattern,另一个是Matcher.Pattern表示正则表达式对象,它与要处理的具体字符串无关.Matcher表示一个匹配,它将正则表达式应用于一个具体字符串,通过它对字符串进行处理. 字符串类String也是一个重要的类,我们在29节专门介绍过String,其中提到,它有一些方法,接受的参数不是普通的字符串,而是正则表达式.此外,正则表达式在Jav

计算机程序的思维逻辑 (88) - 正则表达式 (上)

?上节我们提到了正则表达式,它提升了文本处理的表达能力,本节就来讨论正则表达式,它是什么?有什么用?各种特殊字符都是什么含义?如何用Java借助正则表达式处理文本?都有哪些常用正则表达式?由于内容较多,我们分为三节进行探讨,本节先简要探讨正则表达式的语法. 正则表达式是一串字符,它描述了一个文本模式,利用它可以方便的处理文本,包括文本的查找.替换.验证.切分等. 正则表达式中的字符有两类,一类是普通字符,就是匹配字符本身,另一类是元字符,这些字符有特殊含义,这些元字符及其特殊含义就构成了正则表达

计算机程序的思维逻辑 (37) - 泛型 (下) - 细节和局限性

35节介绍了泛型的基本概念和原理,上节介绍了泛型中的通配符,本节来介绍泛型中的一些细节和局限性. 这些局限性主要与Java的实现机制有关,Java中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为Object,运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的. 一项技术,往往只有理解了其局限性,我们才算是真正理解了它,才能更好的应用它. 下面,我们将从以下几个方面来介绍这些细节和局限性: 使用泛型类.方法和接口 定义泛型类.方

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

本节探讨Character类,它的基本用法我们在包装类第一节已经介绍了,本节不再赘述.Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法以及相关的Unicode知识. 在介绍这些方法之前,我们需要回顾一下字符在Java中的表示方法,我们在第六节.第七节.第八节介绍过编码.Unicode.char等知识,我们先简要回顾一

计算机程序的思维逻辑 (29) - 剖析String

上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比较简单直接的,我们来看下. 基本用法 可以通过常量定义String变量 String name = "老马说编程"; 也可以通过new创建String String name = new String("老马说编程"); String可以直接使用+和+=运算符,如: S

计算机程序的思维逻辑 (22) - 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码,具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译连接为一个完整的程序? 本节就来讨论Java中的解决机制,具体包括包.jar包.程序的编译与连接,从包开始. 包的概念 使用任何语言进行编程都有一个相同的问题,就是命名冲突,程序一般不全是一个人写的,会调用系统提供的代码.第三方库中的代码.项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java中解决这个问题的方法就是包

计算机程序的思维逻辑 (23) - 枚举的本质

前面系列,我们介绍了Java中表示和操作数据的基本数据类型.类和接口,本节探讨Java中的枚举类型. 所谓枚举,是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季.一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁.安全和方便. 下面我们就来介绍枚举的使用,同时介绍其实现原理. 基础 基本用法 定义和使用基本的枚举是比较简单的,我们来看个例子,为表示衣服的尺寸,我们定义一个枚举类型Size,包括三个尺寸,小/中/大,代码如下: public enum Size {

计算机程序的思维逻辑 (21) - 内部类的本质

内部类 之前我们所说的类都对应于一个独立的Java源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类. 为什么要放到别的类内部呢?一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁. 不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的, 每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件. 也就是说,每个内部