Skip to content

Commit dbe3c1f

Browse files
Merge pull request #335 from ua-parser/some-performance-improvements
Some performance improvements
2 parents 072afe5 + 51f4ba9 commit dbe3c1f

File tree

10 files changed

+363
-83
lines changed

10 files changed

+363
-83
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ val scalac2Flags = Seq(
1818
)
1919

2020
lazy val commonSettings = Seq(
21-
scalaVersion := "2.13.14",
21+
scalaVersion := "2.13.18",
2222
crossScalaVersions := Seq("2.12.21", "2.13.18", "3.3.7"),
2323
scalacOptions := {
2424
CrossVersion.partialVersion(scalaVersion.value) match {
Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
package org.uaparser.scala
22

33
case class DeviceParser(patterns: List[DevicePattern]) {
4-
def parse(agent: String): Device = patterns
5-
.foldLeft[Option[Device]](None) {
6-
case (None, pattern) => pattern.process(agent)
7-
case (result, _) => result
4+
private val patternsArray = patterns.toArray
5+
private val length = patternsArray.length
6+
def parse(agent: String): Device = {
7+
var i = 0
8+
while (i < length) {
9+
patternsArray(i).process(agent) match {
10+
case Some(d) => return d
11+
case None => ()
12+
}
13+
i += 1
814
}
9-
.getOrElse(Device("Other"))
15+
DeviceParser.Other
16+
}
1017
}
1118

1219
object DeviceParser {
20+
private val Other = Device("Other")
21+
1322
def fromList(config: List[Map[String, String]]): DeviceParser =
1423
DeviceParser(config.flatMap(DevicePattern.fromMap))
1524
}
Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package org.uaparser.scala
22

3-
import java.util.regex.{Matcher, Pattern}
3+
import java.util.regex.Pattern
44

55
import org.uaparser.scala.MatcherOps.MatcherImprovements
66

@@ -10,31 +10,29 @@ private[scala] case class DevicePattern(
1010
brandReplacement: Option[String],
1111
modelReplacement: Option[String]
1212
) {
13+
1314
def process(agent: String): Option[Device] = {
1415
val matcher = pattern.matcher(agent)
1516
if (!matcher.find()) None
1617
else {
17-
val family = familyReplacement.map(r => replace(r, matcher)).orElse(matcher.groupAt(1))
18-
val brand = brandReplacement.map(r => replace(r, matcher)).filterNot(s => s.isEmpty)
19-
val model = modelReplacement.map(r => replace(r, matcher)).orElse(matcher.groupAt(1)).filterNot(s => s.isEmpty)
18+
val family = familyReplacement
19+
.map(r => Util.patternReplacementWithMatcherGroups(r, matcher, agent))
20+
.orElse(matcher.groupAt(1))
21+
val brand =
22+
brandReplacement.map(r => Util.patternReplacementWithMatcherGroups(r, matcher, agent)).filterNot(s => s.isEmpty)
23+
val model = modelReplacement
24+
.map(r => Util.patternReplacementWithMatcherGroups(r, matcher, agent))
25+
.orElse(matcher.groupAt(1))
26+
.filterNot(s => s.isEmpty)
2027
family.map(Device(_, brand, model))
2128
}
2229
}
23-
24-
private def replace(replacement: String, matcher: Matcher): String = {
25-
(if (replacement.contains("$") && matcher.groupCount() >= 1) {
26-
(1 to matcher.groupCount()).foldLeft(replacement) { (rep, i) =>
27-
val toInsert = if (matcher.group(i) ne null) matcher.group(i) else ""
28-
rep.replaceFirst("\\$" + i, Matcher.quoteReplacement(toInsert))
29-
}
30-
} else replacement).trim
31-
}
3230
}
3331

3432
private object DevicePattern {
3533
def fromMap(m: Map[String, String]): Option[DevicePattern] = m.get("regex").map { r =>
3634
val pattern =
37-
m.get("regex_flag").map(flag => Pattern.compile(r, Pattern.CASE_INSENSITIVE)).getOrElse(Pattern.compile(r))
35+
m.get("regex_flag").map(_ => Pattern.compile(r, Pattern.CASE_INSENSITIVE)).getOrElse(Pattern.compile(r))
3836
DevicePattern(pattern, m.get("device_replacement"), m.get("brand_replacement"), m.get("model_replacement"))
3937
}
4038
}

modules/lib/src/main/scala/org/uaparser/scala/MatcherOps.scala

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ object MatcherOps {
77
// Tries to safely return the matching group at index i wrapped in an Option.
88
// We also take care of converting empty strings to a None, because it seems possible in uap-core to define matching
99
// groups that capture empty strings. At the time, the semantics of None and empty strings seemed to match.
10-
def groupAt(i: Int): Option[String] = {
11-
try {
10+
@inline def groupAt(i: Int): Option[String] = {
11+
if (i <= m.groupCount()) {
1212
val matched = m.group(i)
13-
if (matched == null || matched.isEmpty) None
14-
else Some(matched)
15-
} catch {
16-
case _: IndexOutOfBoundsException => None
17-
}
13+
if (matched == null || matched.isEmpty) None else Some(matched)
14+
} else None
1815
}
1916
}
2017
}
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
package org.uaparser.scala
22

33
case class OSParser(patterns: List[OSPattern]) {
4-
def parse(agent: String): OS = patterns
5-
.foldLeft[Option[OS]](None) {
6-
case (None, pattern) => pattern.process(agent)
7-
case (result, _) => result
4+
private val patternsArray = patterns.toArray
5+
private val length = patternsArray.length
6+
def parse(agent: String): OS = {
7+
var i = 0
8+
while (i < length) {
9+
patternsArray(i).process(agent) match {
10+
case Some(d) => return d
11+
case None => ()
12+
}
13+
i += 1
814
}
9-
.getOrElse(OS("Other"))
15+
OSParser.Other
16+
}
1017
}
1118

1219
object OSParser {
20+
private val Other = OS("Other")
1321
def fromList(config: List[Map[String, String]]): OSParser = OSParser(config.flatMap(OSPattern.fromMap))
1422
}

modules/lib/src/main/scala/org/uaparser/scala/OSPattern.scala

Lines changed: 124 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,144 @@ package org.uaparser.scala
22

33
import java.util.regex.{Matcher, Pattern}
44

5-
import scala.util.control.Exception.allCatch
5+
import scala.collection.mutable.ListBuffer
66

77
import org.uaparser.scala.MatcherOps.MatcherImprovements
8-
import org.uaparser.scala.OSPattern.{replaceBackreference, replacementBack1}
98

10-
private[scala] case class OSPattern(
9+
private[scala] final case class OSPattern(
1110
pattern: Pattern,
12-
osReplacement: Option[String],
13-
v1Replacement: Option[String],
14-
v2Replacement: Option[String],
15-
v3Replacement: Option[String],
16-
v4Replacement: Option[String]
11+
osReplacement: Option[OSPattern.FamilyReplacement],
12+
v1Replacement: Option[OSPattern.VersionReplacement],
13+
v2Replacement: Option[OSPattern.VersionReplacement],
14+
v3Replacement: Option[OSPattern.VersionReplacement],
15+
v4Replacement: Option[OSPattern.VersionReplacement]
1716
) {
17+
1818
def process(agent: String): Option[OS] = {
19-
val matcher = pattern.matcher(agent)
20-
if (!matcher.find()) None
19+
val m = pattern.matcher(agent)
20+
if (!m.find()) None
2121
else {
22-
osReplacement
23-
.map(replacementBack1(matcher))
24-
.orElse(matcher.groupAt(1))
25-
.map { family =>
26-
val major = v1Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(2))
27-
val minor = v2Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(3))
28-
val patch = v3Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(4))
29-
val patchMinor = v4Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(5))
30-
OS(family, major, minor, patch, patchMinor)
22+
val familyOpt: Option[String] =
23+
osReplacement match {
24+
case Some(rep) => Some(rep.render(agent, m))
25+
case None => m.groupAt(1)
3126
}
27+
28+
familyOpt.map { family =>
29+
val major = OSPattern.resolveVersion(v1Replacement, m, fallbackGroup = 2)
30+
val minor = OSPattern.resolveVersion(v2Replacement, m, fallbackGroup = 3)
31+
val patch = OSPattern.resolveVersion(v3Replacement, m, fallbackGroup = 4)
32+
val patchMinor = OSPattern.resolveVersion(v4Replacement, m, fallbackGroup = 5)
33+
OS(family, major, minor, patch, patchMinor)
34+
}
3235
}
3336
}
3437
}
3538

3639
private object OSPattern {
37-
private[this] val quotedBack1: Pattern = Pattern.compile(s"(${Pattern.quote("$1")})")
38-
39-
private[this] def getBackreferenceGroup(replacement: String): Option[Int] =
40-
for {
41-
ref <- Option(replacement).filter(_.contains("$"))
42-
groupOpt = allCatch.opt(ref.substring(1).toInt)
43-
group <- groupOpt
44-
} yield group
45-
46-
private def replacementBack1(matcher: Matcher)(replacement: String): String =
47-
if (matcher.groupCount() >= 1) {
48-
quotedBack1.matcher(replacement).replaceAll(matcher.group(1))
49-
} else replacement
50-
51-
private def replaceBackreference(matcher: Matcher)(replacement: String): Option[String] =
52-
getBackreferenceGroup(replacement) match {
53-
case Some(group) => matcher.groupAt(group)
54-
case None => Some(replacement)
40+
41+
private val Dollar1 = "$1"
42+
43+
sealed trait FamilyReplacement {
44+
def render(agent: String, m: Matcher): String
45+
}
46+
47+
private final case class FamilyLiteral(value: String) extends FamilyReplacement {
48+
override def render(agent: String, m: Matcher): String = value
49+
}
50+
private final case class FamilyWithGroup1(parts: Array[String]) extends FamilyReplacement {
51+
52+
// We insert group 1 between each part.
53+
override def render(agent: String, m: Matcher): String = {
54+
val stringBuilder = new java.lang.StringBuilder()
55+
val hasGroup1 = m.groupCount() >= 1 && m.start(1) >= 0
56+
val partsLength = parts.length
57+
58+
var i = 0
59+
parts.foreach { part =>
60+
stringBuilder.append(part)
61+
if (i < partsLength - 1) {
62+
if (hasGroup1) stringBuilder.append(agent, m.start(1), m.end(1))
63+
}
64+
i += 1
65+
}
66+
stringBuilder.toString
67+
}
68+
}
69+
70+
private def compileFamilyReplacement(replacementDef: String): FamilyReplacement = {
71+
val first = replacementDef.indexOf(Dollar1)
72+
if (first < 0) FamilyLiteral(replacementDef)
73+
else {
74+
// Split by "$1". Keep empty segments.
75+
val buf = ListBuffer.empty[String]
76+
var from = 0
77+
var idx = first
78+
while (idx >= 0) {
79+
buf += replacementDef.substring(from, idx)
80+
from = idx + Dollar1.length
81+
idx = replacementDef.indexOf(Dollar1, from)
82+
}
83+
buf += replacementDef.substring(from)
84+
FamilyWithGroup1(buf.toArray)
5585
}
86+
}
5687

57-
def fromMap(m: Map[String, String]): Option[OSPattern] = m.get("regex").map { r =>
58-
OSPattern(
59-
Pattern.compile(r),
60-
m.get("os_replacement"),
61-
m.get("os_v1_replacement"),
62-
m.get("os_v2_replacement"),
63-
m.get("os_v3_replacement"),
64-
m.get("os_v4_replacement")
65-
)
88+
sealed trait VersionReplacement
89+
private final case class VersionLiteral(value: String) extends VersionReplacement
90+
private final case class VersionGroupRef(group: Int) extends VersionReplacement
91+
92+
private def compileVersionReplacement(replacementDef: String): VersionReplacement = {
93+
// Treat only the whole string "$<digits>" as a group reference, otherwise it will be treated as a literal.
94+
if (replacementDef != null && replacementDef.length >= 2 && replacementDef.charAt(0) == '$') {
95+
var i = 1
96+
var n = 0
97+
var hasDigits = false
98+
while (i < replacementDef.length) {
99+
val ch = replacementDef.charAt(i)
100+
if (ch >= '0' && ch <= '9') {
101+
hasDigits = true
102+
n = n * 10 + (ch - '0')
103+
i += 1
104+
} else {
105+
return VersionLiteral(replacementDef)
106+
}
107+
}
108+
if (hasDigits && n > 0) VersionGroupRef(n) else VersionLiteral(replacementDef)
109+
} else VersionLiteral(replacementDef)
66110
}
111+
112+
private def evalVersion(rep: VersionReplacement, m: Matcher): Option[String] =
113+
rep match {
114+
case VersionLiteral(v) => if (v == null || v.isEmpty) None else Some(v)
115+
case VersionGroupRef(gr) => m.groupAt(gr)
116+
}
117+
118+
// - replacement is a backref and missing, fall back to the default captured group
119+
// - replacement is a literal, use it (unless empty)
120+
// - no replacement provided, use fallback captured group
121+
private def resolveVersion(repOpt: Option[VersionReplacement], matcher: Matcher, fallbackGroup: Int): Option[String] =
122+
repOpt match {
123+
case Some(VersionGroupRef(gr)) => matcher.groupAt(gr).orElse(matcher.groupAt(fallbackGroup))
124+
case Some(litOrOther) => evalVersion(litOrOther, matcher).orElse(matcher.groupAt(fallbackGroup))
125+
case None => matcher.groupAt(fallbackGroup)
126+
}
127+
128+
def fromMap(m: Map[String, String]): Option[OSPattern] =
129+
m.get("regex").map { r =>
130+
val osRep = m.get("os_replacement").map(compileFamilyReplacement)
131+
val v1 = m.get("os_v1_replacement").map(compileVersionReplacement)
132+
val v2 = m.get("os_v2_replacement").map(compileVersionReplacement)
133+
val v3 = m.get("os_v3_replacement").map(compileVersionReplacement)
134+
val v4 = m.get("os_v4_replacement").map(compileVersionReplacement)
135+
136+
OSPattern(
137+
pattern = Pattern.compile(r),
138+
osReplacement = osRep,
139+
v1Replacement = v1,
140+
v2Replacement = v2,
141+
v3Replacement = v3,
142+
v4Replacement = v4
143+
)
144+
}
67145
}
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
package org.uaparser.scala
22

33
case class UserAgentParser(patterns: List[UserAgentPattern]) {
4-
def parse(agent: String): UserAgent = patterns
5-
.foldLeft[Option[UserAgent]](None) {
6-
case (None, pattern) => pattern.process(agent)
7-
case (result, _) => result
4+
private val patternsArray = patterns.toArray
5+
private val length = patternsArray.length
6+
def parse(agent: String): UserAgent = {
7+
var i = 0
8+
while (i < length) {
9+
patternsArray(i).process(agent) match {
10+
case Some(d) => return d
11+
case None => ()
12+
}
13+
i += 1
814
}
9-
.getOrElse(UserAgent("Other"))
15+
UserAgentParser.Other
16+
}
1017
}
1118

1219
object UserAgentParser {
20+
private val Other = UserAgent("Other")
1321
def fromList(config: List[Map[String, String]]): UserAgentParser =
1422
UserAgentParser(config.flatMap(UserAgentPattern.fromMap))
1523
}

0 commit comments

Comments
 (0)