Scala特质初始化触发空指针异常,测试配额逻辑遇阻求助
问题描述
我在创建Scala特质时遇到了值初始化为null的问题。特质定义如下:
private[name] trait HttpIssuer extends HttpIssuerImpl with JsonApi with Logging { def credentials: ApiCredentials def maxAllowedQuota: Int def minHour: Option[Int] def maxHour: Option[Int] implicit def session: Session val throttling = new Throttling(maxPerSecond = 5, maxPerMinute = 300) val log = LoggerFactory.getLogger(getClass) val hasTimeRange = minHour.isDefined || maxHour.isDefined var response: Option[MarketoIdentityResponse] = None var authorization: Option[String] = response.map(_.accessToken) var previouslyUsedApiQuota: Int = 0 var numberOfQueries: Int = 0 override def executeRequest(request: RestRequest): ApiResponse = { executeRequest(request, 0) } def executeRequest(request: RestRequest, retries: Int = 0): ApiResponse = { if (numberOfQueries + previouslyUsedApiQuota > maxAllowedQuota) { warn("Exceeded max allowed API quota of " + maxAllowedQuota + " based on previously used quota of " + previouslyUsedApiQuota + " and new API usage of " + numberOfQueries) System.exit(0) } } }
我尝试测试executeRequest方法,验证当numberOfQueries + previouslyUsedApiQuota > maxAllowedQuota时触发退出逻辑,设置numberOfQueries=0、previouslyUsedApiQuota=0、maxAllowedQuota=-1来触发该场景,测试代码如下:
@Test def test_executeRequestExits: Unit = { val creds = ApiCredentials ( instanceId = "dummy-instance-id", clientId = "dummy-client-id", clientSecret = "String", soapUserId = "String", encryptionKey = "String" ) val mockRequest = mock(classOf[RestRequest]) val issuer = new HttpIssuer { override val credentials: ApiCredentials = creds override implicit val session: Session = new Session { override def isInTransaction: Boolean = false override def tenant: String = "test-tenant" override def jdbcSession(): JdbcSession = throw new UnsupportedOperationException("Not implemented for testing") } // 设为负配额以触发错误处理 override val maxAllowedQuota: Int = -1 override val minHour: Option[Int] = Some(1) override val maxHour: Option[Int] = Some(2) } val mockApiFailure = mock(classOf[ApiFailure]) when(mockApiFailure.code).thenReturn("601") // Access token expired when(MarketoHttpIssuer.failureGauge(any())(any())).thenReturn(any()) val executeRequestReturn = issuer.executeRequest(mockRequest) class NoExitSecurityManager extends SecurityManager { var exitCalled = false var exitStatus: Option[Int] = None override def checkExit(status: Int): Unit = { exitCalled = true exitStatus = Some(status) } } val securityManager = new NoExitSecurityManager // 验证System.exit是否以0为参数被调用 assert(securityManager.exitCalled) assert(securityManager.exitStatus.contains(0)) // 恢复原始安全管理器 System.setSecurityManager(null) }
问题在于初始化该特质时出现空指针异常,调试发现问题出在val hasTimeRange = minHour.isDefined || maxHour.isDefined这一行。我已经在特质实现中给minHour和maxHour设置了Some值,却仍出现null的情况,这是哪里出错了?另外,在val issuer = new MarketoHttpIssuer行打断点时,发现初始化多次,是不是我对Scala特质的初始化方式理解有误?
解决方案
1. 空指针异常的核心原因:初始化顺序问题
Scala中,特质的初始化逻辑会先于实现该特质的匿名类的成员初始化。也就是说,当特质里的hasTimeRange被初始化时,匿名类中你重写的minHour和maxHour还没完成初始化,此时它们的值是null(Scala对未初始化的引用类型默认值为null),调用isDefined自然会抛出空指针。
2. 解决方法:将hasTimeRange改为惰性值或方法
方案一:使用
lazy val延迟初始化
把hasTimeRange声明为惰性值,这样它的初始化会推迟到第一次被使用的时候,此时匿名类的minHour和maxHour已经完成初始化:lazy val hasTimeRange = minHour.isDefined || maxHour.isDefined方案二:改为无参方法
既然hasTimeRange只是对两个属性的判断,直接定义成方法更合适,每次调用时都会实时获取最新的minHour和maxHour值:def hasTimeRange: Boolean = minHour.isDefined || maxHour.isDefined
3. 关于初始化多次的问题
特质本身不会被单独初始化,你看到的多次初始化其实是:
- 首先初始化特质继承链上的所有父特质(比如
HttpIssuerImpl、JsonApi、Logging) - 然后初始化当前特质
HttpIssuer的成员 - 最后初始化匿名类的重写成员
这是Scala特质初始化的正常流程,并非异常。
额外优化建议
测试代码中,你需要先设置自定义的SecurityManager再调用executeRequest,否则System.exit(0)会直接终止测试进程,导致后续断言无法执行。调整后的测试代码片段:
// 先设置自定义安全管理器 val securityManager = new NoExitSecurityManager System.setSecurityManager(securityManager) // 再执行方法 val executeRequestReturn = issuer.executeRequest(mockRequest) // 执行断言 assert(securityManager.exitCalled) assert(securityManager.exitStatus.contains(0)) // 恢复原始安全管理器 System.setSecurityManager(null)
内容的提问来源于stack exchange,提问作者Friendly Programmer

