Scala快速入门(一篇就够了)
Scala快速入门
文章目录
1. Scala
Scala 是一种高级语言,其将【面向对象】和【函数式编程】相结合。
scala 代码和 java 代码不一样:编译器做了很多事情
-
java 中变量类型需要明文给出
-
scala 中可以对变量类型进行推断(防止运行期报错)
Scala的六大特性:
特性 | 描述 |
---|---|
SEANLESS JAVA INTEROP | Scala 运行在 JVM 上,因此 Java 代码和 Scala 代码可以相互混合,实现完全无缝集成 |
TYPE INFERENCE | Don’t work for the type system,Let the type system work for you |
CONCURRENCY & DISTRIBUTION | 对集合进行并行操作,使用 actors 进行并发和分发,以及 futures 的异步编程 |
TRAITS | 将 JAVA 风格接口的灵活性和类的强大功能结合起来 |
PATTERN MATCHING | 类似于"Switch",实现类的结构、序列、常量匹配 |
HIGER-ORDER FUNCTIONS | 函数是第一类值,函数可以是变量,也可以传递给其他函数 |
2. Scala 基础概念
2.1 开发和运行环境
- 开发环境:JDK + SDK(编译器)
- 运行环境:JDK + JRE
2.2 与Java相同之处
注释、命名(驼峰标识)、输出(简化为:println、print)等
2.3 变量
只有两种变量类型:
- val:表示一种不可变变量,类似于 Java 中的 final,在 scala 中推荐使用该变量
- var:表示一种可变变量,建议在有特定需要的情况下使用
// Scala 编译器会自动推断变量的类型,也可以显示声明:val a: Int = 1
val a = 1
var b = "bbxx"
2.3.1 内置类型
Scala 中的数据类型都是对象,而不是基本类型,内置的数据类型有(Boolean
、Byte
、Short
、Int
、Long
、Float
、Double
、Char
、String
,其中 Int 和 Double 是默认的类型),以下是特殊类型的介绍。
2.3.1.1 BigInt和BigDecimal
对于大数字,可以使用 BigInt 和 BigDecimal 类型
举例:
var b = BigInt(12345667790)
var b = BigDecimal(123456.789)
2.3.1.2 String和Char
使用双引号括住字符串,使用单引号括住字符:
val name = "Bill"
val c = 'a'
字符串合并(连接符号+
、字符串插值):
val firstName = "John"
val mi = 'C'
val lastName = "Doe"
// 连接符号 +
val name = firstName + " " + mi + " " + lastName
// 字符串插值,符串前加上字母's',然后在字符串内的变量名前面方式一个 '$' 符号
val name = s"$firstName $mi $lastName"
println(s"Name: $firstName $mi $lastName")
// 可以将变量名用花括号包围
println(s"Name: ${firstName} ${mi} ${lastName}")
//表达式也可以放入大括号中
println(s"1+1= ${1+1}")
//使用三个双引号,可以创建多行字符串('|' 和 stripMargin 的作用是避免第一行后的行式缩进的。)
val speech = """
FOUR score and
| seven years ago
| our fathers...
""".stripMargin
其它:
- 在字符串前加上字母 ‘f’,就可以在字符串内部使用 printf 样式的格式设置
- raw 插值不会执行字符串中的转义字符
- 可以自定义字符串插值…
2.4 流程控制
【面向表达式编程】当编写的每个表达式都返回一个值时,这种风格被称为面向表达式的编程或 EOP。相反,不返回值的代码被称为语句,用于产生其他效果。
Scala 的if/else
控制结构类似于 Java 中的 if/else
。if 结构总是会返回一个结果,这个结果可以选择忽略,也可以将结果赋给一个变量,写作三元运算符:
val x = if (a < b) a else b
2.5 模式匹配
Scala 中有 match 表达式,类似于Java
中的 switch
语句。不过scala更强,支持任何数据类型、表达式可以有返回值以及强大的模式匹配(感觉有点类似于自然语言了)。
val result = i match {
case 1 => "one"
case 2 => "two"
case _ => "not 1 or 2"
}
// match 表达式可以用于任何数据类型:
def getClassAsString(x: Any): String = x match {
case s: String => s + " is a String"
case i: Int => "Int"
case f: Float => "Float"
case l: List[_] => "List"
case _ => "Unknown"
}
//match 表达式也可以返回值,可以将字符串结果赋给一个新值:
val monthName = i match {
case 1 => "January"
case 2 => "February"
case 3 => "March"
case 4 => "April"
case 5 => "May"
case _ => "Invalid month"
}
// match 表达式支持在一个 case 语句中处理多个 case,下面的代码演示了将 0 或空字符串计算为 false
// 可以看到这里输入参数 a 被定义为 Any 类型,这是所有 Scala 类的根类,就像 Java 中的 Object。
def isTrue(a: Any) = a match {
case 0 | "" => false
case _ => true
}
// 在 case 语句中使用 if 表达式可以表达强大的模式匹配
count match {
case 1 => println("one, a lonely number")
case x if x == 2 || x == 3 => println("two's company, three's a crowd")
case x if x > 3 => println("4+, that's a party")
case _ => println("i'm guessing your number is zero or less")
}
i match {
case a if 0 to 9 contains a => println("0-9 range: " + a)
case b if 10 to 19 contains b => println("10-19 range: " + b)
case _ => println("Hmmm...")
}
2.6 异常捕获
Scala 的异常捕获由 try/catch/finally
结构完成:
try {
writeToFile(text)
} catch {
case fnfe: FileNotFoundException => println(fnfe)
case ioe: IOException => println(ioe)
}
//finally 子句通常在需要关闭资源时使用
try {
} catch {
case foo: FooException => handleFooException(foo)
} finally {
}
2.7 循环
2.7.1 for 循环
Scala 中的for
循环用来迭代集合中的元素,通常这样写:
for (arg <- args) println(arg)
for (i <- 0 to 5) println(i)
for (i <- 0 to 10 by 2) println(i)
// 在 for 循环中还可以使用 【yield】 关键字,从现有集合创建新集合
val x = for (i <- 1 to 5) yield i * 2
// 在 yield 关键字之后使用代码块可以解决复杂的创建问题
val capNames = for (name <- names) yield {
val nameWithoutUnderscore = name.drop(1)
val capName = nameWithoutUnderscore.capitalize
capName
}
// 简约格式
val capNames = for (name <- names) yield name.drop(1).capitalize
//还可以向 for 循环中添加守卫代码,实现元素的过滤操作:
val fruit = for{
f <- fruits if f.length > 4
} yield f.length
2.7.2 while 循环
// while
while (condition) {
}
// do while
do {
} while (condition)
2.8 函数
函数的定义方式:
- def 是用来定义函数的关键字
- sum 是函数名
- 输入参数 a 的类型是 Int
- 输入参数 b 的类型是 Int
- Int 是函数的返回值类型
- = 左侧是函数名和函数签名
- = 右侧是函数体
def sum(a: Int, b: Int): Int = a + b
// 不声明函数的返回类型
def sum(a: Int, b: Int) = a + b
// 函数的调用如下
val x = sum(1, 2)
2.8.1 匿名方法
- 匿名函数可以作为代码段编写
- 匿名函数经常和集合中的 map、filter 等方法一起使用
// 创建一个 List
val ints = List(1, 2, 3)
val doubledInts = ints.map(_ * 2)
val doubledInts = ints.map((i: Int) => i * 2)
val doubledInts = ints.map(i => i * 2)
val x = ints.filter(_ > 5)
val x = ints.filter(_ % 2 == 0)
2.9 集合类型
Map 和 Set 都有可变和不可变两种版本
Class | 描述 |
---|---|
ArrayBuffer | 有索引的、可变序列 |
List | 线性链表、不可变序列 |
Vector | 有索引的、不可变序列 |
Map | 键值对 |
Set | 无序的、去重集合 |
2.9.1 List
List 是一个线性的、不可变的序列,即一个无法修改的链表,想要添加或删除 List 元素时,都要从现有 List 中创建一个新 List。
创建方式:
val ints = List(1, 2, 3)
val nums = List.range(0, 10)
val nums = (1 to 10 by 2).toList
val letters = ('a' to 'f').toList
val letters = ('a' to 'f' by 2).toList
因为 List 是不可变的,只能通过创建一个新列表来向现有 List 中添加或追加元素:
val a = List(1, 2, 3)
// 在 List 前面添加元素
val b = 0 +: a
val b = List(-1, 0) ++: a
2.9.2 Map
Map 是由键和值组成的可迭代序列。Scala 中的 Map 类似于 Java 的 HashMap。
val ratings = Map (
"Lady in the water" -> 3.0,
"Snakes on a plane" -> 4.0,
"You, Me and Dupree" -> 3.5
)
可变的Map:
// 导入
import scala.collection.mutable.Map
var states = collection.mutable.Map("AK" -> "Alaska")
// 添加单个元素
states += ("AL" -> "Alabama")
// 添加多个元素
states += ("AR" -> "Arkansas", "AZ" -> "Arizona")
// 从另一个Map添加元素
states ++= Map("CA" -> "California", "CO" -> "Colorado")
// 删除元素
states -= "AR"
states -= ("AL", "AZ")
states --= List("AL", "AZ")
// 更新元素
states("AK") = "Alaska, A Really Big State"
// 迭代元素
for((k, v) <- ratings) println(s"key: $k, value: $v")
ratings.foreach {
case(movie, rating) => println(s"key: $movie, value: $rating")
}
// 获取 Map 所有的 key
states.keys
// 获取 Map 所有的 Value
states.values
// 判断 Map 是否包含某个 Key
states.contains(3)
// 对 Map 的 value 进行转换操作
states.transform((k, v) => v.toUpperCase)
// 对 Map 的 key 进行过滤
states.view.filterKeys(Set("AR", "AL")).toMap
// 获取一个 Map 的前两个元素
states.take(2)
2.9.3 ArrayBuffer
ArrayBuffer 是一个可变序列,可以使用它的方法来修改它的内容:
// 导入
import scala.collection.mutable.ArrayBuffer
val ints = ArrayBuffer[Int]()
val names = ArrayBuffer[String]()
ints += 1
ints += 2
// 创建带有初试元素的 ArrayBuffer
val nums = ArrayBuffer(1, 2, 3)
nums += 5 += 6
nums ++= List(7, 8, 9)
// 删除元素
nums -= 9
nums -= 7 -= 8
nums --= Array(5, 6)
ArrayBuffer 具有一些它自己的方法:
val a = ArrayBuffer(1, 2, 3) // 1, 2, 3
a.append(4) // 1, 2, 3, 4
a.appendAll(Seq(5, 6)) // 1, 2, 3, 4, 5, 6
a.clear() //
val a = ArrayBuffer(9, 10) // 9, 10
a.insert(0, 8) // 8, 9, 10
a.insertAll(0, Vector(4, 5, 6, 7)) // 4, 5, 6, 7, 8, 9, 10
a.prepend(3) // 3, 4, 5, 6, 7, 8, 9, 10
a.prependAll(Array(0, 1, 2)) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
val a = ArrayBuffer.range('a', 'h') // a, b, c, d, e, f, g
a.remove(0) // b, c, d, e, f, g
a.remove(2, 3) // b, c, g
a.dropInPlace(2) // g
a.dropRightInPlace(2)
2.9.4 Vector
Vector 是一个索引的、不可变序列,索引意味着可以通过索引值快速访问,其他方面与 List 类似:
val nums = Vector(1, 2, 3, 4, 5)
val strings = Vector("one", "two")
val peeps = Vector(
Person("Bert"),
Person("Ernie"),
Person("Grover")
)
// 由于 Vector 是不可变的,只能通过创建新序列的方式来向现有 Vector 追加元素:
val b = a :+ 4
val b = a ++ Vector(4, 5)
val b = 0 +: a
val b = Vector(-1, 0) ++: a
2.9.5 Set
Set 是一个没有重复元素的可迭代集合。同时拥有可变和不可变的 Set 类,默认是不可变的。
可变Set:
import scala.collection.mutable.Set
var set = scala.collection.mutabl.Set[Int]()
set += 1
set +=2 +=3
set ++= Vector(4, 5)
// add函数,元素添加成功返回true,否则false
set.add(6)
set.add(5)
set -= 1
set -= (2, 3)
set --= Array(4, 5)
// 清空
set.clear()
// 删除
set.remove()
2.9.6 Tuples
元组中可以放入不同类型的元素(同一容器中存储异构数据),可以包含2个到22个值,所有值可以具有不同的类型:
val t = (11, 11.0, "Eleven")
对于元组变量,可以通过"_数字"来访问它的值:
t._1
t._2
t._3
也可以用模式匹配来将元组中的元素分配给各个变量:
val (symbol, price, volume) = ("AAPL", 123.45, 1101011)
当需要多次使用相同的元组时,可以声明一个专用的 case 类:
case class StockInfo(symbol: String, price: BigDecimal, volume: Long)
2.9.7 常用的集合函数
下面是一些常用的函数(这些函数都是返回一个新的集合,不改变原有集合)。
foreach:该函数对列表进行遍历操作,同时可以传入函数参数,对每个元素执行该函数
nums.foreach(println)
filter:该函数用于对列表元素进行过滤
nums.filter(_ < 4).foreach(println)
map:将传入的函数作用于列表中的每个元素,为每个元素返回一个新的、转换后的值
val doubles = nums.map(_ * 2)
head:返回第一个元素
nums.head
“foo”.head
tail:返回 head 元素之后的每一个元素
nums.tail
"foo".tail
take:将元素从集合中提取出来
nums.take(1)
nums.take(2)
takeWhile:将符合条件的元素从集合中提取出来
nums.takeWhile(_ < 5)
drop:将指定元素之外的元素从集合中提取出来
nums.drop(1)
nums.drop(5)
dropWhile:将条件之外的元素从集合中提取出
nums.dropWhile(_ < 5)
reduce:接收一个函数,并将函数作用于集合的后续元素
def add(x: Int, y: Int): Int = {
val theSum = x + y
println(s"received $x and $y, their sum is $theSum")
theSum
}
val a = List(1, 2, 3, 4)
a.reduce(add)
a.reduce(_ + _)
foldLeft:支持传入一个种子值,然后在其基础上进行计算操作
nums.foldLeft(0)(_ + _)
nums.foldLeft(1)(_ * _)
2.10 Class(类)
注意:不需要创建"get"和"set"方法来访问类中的字符
// 类定义
class Person(var firstName: String, var lastName: String) {
def printFullName() = println(s"$firstName $lastName")
}
// 类调用
val person = new Person("zhang", "san")
println(person.firstName)
person.lastName = "si"
person.printFullName()
2.10.1 构造函数
在 Scala 中,一个类的主构造函数是以下的组合:
- 构造函数参数
- 类主体中调用的函数
- 类主体中执行的语句和表达式
Scala 在定义类时,可以定义参数,这样编译器会产生默认构造函数:
class Person(var firstName: String, var lastName: String)
类构造函数中定义的参数会自动在类中创建字段,可以通过"."来访问:
- 上面代码中两个字段都定义为 var 字段,所以是可变的,如果没有手动指定,那么默认是 val 字段,且默认是 private;
- 有类名构造函数中的参数可以设置为 var,其他函数中的参数都是 val 类型。
Scala 允许为构造函数提供默认值:
class Socket(var timeout: Int = 2000, var linger: Int = 3000) {
override def toString = s"timeout: $timeout, linger: $linger"
}
// 默认值构造函数为参数提供了首选的默认值,但也可以根据自己的需要重写这些值,同时在调用构造函数时可用指定参数的名称
val s = new Socket(timeout = 1000, linger = 3000)
类主体中声明的字段类似于 Java 静态代码块,在类首次实例化时分配:
class Person(var firstName: String, var lastName: String) {
println("the constructor begins")
// 默认是 public
var age = 0
private val HOME = System.getProperty("user.home")
override def toString(): String = s"$firstName $lastName is $age years old"
def printHome(): Unit = println(s"HOME = $HOME")
def printFullName(): Unit = println(this)
printHome()
printFullName()
println("you've reached the end of the constructor")
}
上述代码的执行结果如下:
the constructor begins
HOME = “”
$firstName $lastName is $age years old
you've reached the end of the constructor
2.10.2 this
通过 this 关键字可以定义辅助构造函数:
- 每个辅助构造函数必须具有不同的函数签名(不同的参数列表)
- 每个构造函数必须调用之前定义的构造函数之一
val DefaultCrustSize = 12
val DefaultCrustType = "THIN"
class Oizza (var crustSize: Int, var crustType: String) {
def this(crustSize: Int) = {
this(crustSize, DefaultCrustType)
}
def this(crustType: String) = {
this(DefaultCrustSize, crustType)
}
def this() = {
this(DefaultCrustSize, DefaultCrustType)
}
override def toString = s"A $crustSize inch pizza with a $crustType crust"
}
2.10.3 枚举类(trait)
枚举是创建小型常量组的工具,如一周中的几天、一年中的几个月等。
// 声明一个基本的 trait,然后根据需要使用 case 对象来扩展该 trait。
sealed trait DayOfWeek
case object Sunday extends DayOfWeek
case object Monday extends DayOfWeek
case object Tuesday extends DayOfWeek
case object Wednesday extends DayOfWeek
case object Thursday extends DayOfWeek
case object Friday extends DayOfWeek
case object Saturday extends DayOfWeek
2.10.4 伴生对象(object)
Scala 中的伴生对象即用 object 关键字声明的对象,并且与 class 有相同的名称。
- 伴生对象及其类可以访问彼此的私有成员(字段和函数)
- 当在伴生对象中定义一个 apply 函数后,可以无需使用 new 关键字即可创建类的实例,实际是 apply 方法充当了工厂方法。
classs Person {
var name = ""
}
object Person {
def apply(name: String): Person = {
var p = new Person
p.name = name
p
}
}
val p = Person.apply("Fred Flinstone")
// 在调用时,实际过程如下:
// 1. 输入 val p = Person("Fred")
// 2. Scala 编译器发现在 Person 之前没有 new 关键字
// 3. 编译器在 Person 类的伴生对象中查找与输入的函数签名匹配的 apply 方法
// 4. 找到了就调用 apply 方法,否则返回编译器错误
val p = Person("Fred Flinstone")
// 当然,在伴生对象中可以创建多个 apply 方法,从而提供多个构造函数
class Person {
var name: Option[String] = None
var age: Option[Int] = None
override def toString = s"$name, $age"
}
object Person {
def apply(name: Option[String]): Person = {
var p = new Person
p.name = name
p
}
def apply(name: Option[String], age: Option[Int]): Person = {
var p = new Person
p.name = name
p.age = age
p
}
}
Scala 中还提供了反构造方法,可以从一个对象实例中返回传入的参数:
class Person(var name: String, var age: Int)
object Person {
def unapply(p: Person): String = s"${p.name}, ${p.age}"
}
val p = new Person("Lori", 29)
val result = Person.unapply(p)
2.10.5 样例类(case)
样例类使用 case 关键字定义,它具有常规类的所有功能:
- 默认情况下,样例类的构造参数是公共 val 字段,每个参数会生成访问方法;
- apply 方法是在类的伴生对象中创建的,所以不需要使用 new 关键字创建实例
- unapply 方法对实例进行解耦
- 类中会生成一个 copy 方法,在克隆对象或克隆过程中更新字段时非常有用
case class BaseballTeam(name: String, lastWorldSeriesWin: Int)
val cubs1908 = BaseballTeam("Chicago Cubs", 1908)
// 类中会生成 equals 和 hashcode 方法,用于比较对象
// 生成默认的 toString 方法
val cubs2016 = cubs1908.copy(lastWorldSeriesWin = 2016)
2.10.6 样例对象
样例对象使用 case object 关键字定义:
- 可以序列化
- 有一个默认的 hashcode 实现
- 有一个 toString 实现
用途1:创建枚举
sealed trait Topping
case object Cheese extends Topping
case object Pepperoni extends Topping
case object Sausage extends Topping
case object Mushrooms extends Topping
case object Onions extends Topping
sealed trait CrustSize
case object SmallCrustSize extends CrustSize
case object MediumCrustSize extends CrustSize
case object LargeCrustSize extends CrustSize
sealed trait CrustType
case object RegularCrustType extends CrustType
case object ThinCrustType extends CrustType
case object ThickCrustType extends CrustType
case class Pizza {
crustSize: CrustSize,
crustType: CrustType,
toppings: Seq[Topping]
用途2:为在其他对象之间传递的"消息"创建容器时使用(如 Akka actor 库)
case class StartSpeakingMessage(textToSpeak: String)
case object StopSpeakingMessage
case object PauseSpeakingMessage
case object ResumeSpeakingMessage
class Spark extends Actor {
def receive = {
case StartSpeakingMessage(textToSpeak) =>
// code to speak the text
case StopSpeakingMessage =>
// code to stop speaking
case PauseSpeakingMessage =>
// code to pause speaking
case ResumeSpeakingMessage =>
// code to resume speaking
}
}
2.11 Traits
Scala 中 Traits 类似于 Java 中的接口,它可以将代码分解成小的模块化单元,实现对类的扩展。
trait Speaker {
def speak(): String // 这是一个抽象方法
}
trait TailWagger {
def startTail(): Unit = println("tail is wagging")
def stopTail(): Unit = println("tail is stopped")
}
//使用 extends 关键字和 with 关键字为 Dog 类扩展了 speak 方法
class Dog(name: String) extends Speaker with TailWagger {
def speak(): String = "Woof!"
}
2.11.1 trait
定义一个 trait,它具有一个具体方法和一个抽象方法:
trait Pet {
def speak = println("Yo")
def comToMaster(): Unit
}
当一个类扩展一个 trait 时,每个抽象方法都必须实现:
class Dog(name: String) extends Pet {
def comToMaster(): Unit = println("Woo-hoo, I'm coming!")
}
对于具有具体方法的 trait,可以在创建实例时将其混合:
val d = new Dog("Fido") with TailWagger with Runner
类可以覆盖在 trait 中已定义的方法:
class Cat extends Pet {
override def speak(): Unit = println("meow")
def comToMaster(): Unit = println("That's not happen.")
}
2.11.2 抽象类
Scala 中的抽象类使用较少,一般只在以下情况使用:
- 希望创建一个需要构造函数参数的基类
- Scala 代码将被 Java 代码调用
由于 Scala 的 trait 不支持构造函数参数,所以以下写法会报错:
// 报错
trait Animal(name: String)
当一些基类需要具有构造函数参数时,使用抽象类(注意:一个类只能扩展一个抽象类):
abstract class Animal(name: String)
// 一个类只能扩展一个抽象类
abstract class Pet(name: String) {
def speak(): Unit = println("Yo")
def comeToMaster(): Unit
}
class Dog(name: String) extends Pet(name) {
override def speak() = println("Woof")
def comToMaster() = println("Here I come!")
}
2.12 函数式编程
函数式编程:是一种强调只使用纯函数和不可变值编写应用程序的编程风格。
2.12.1 纯函数
纯函数应该有以下特点:
- 函数的输出只取决于它的输入变量
- 函数不会改变任何隐藏状态
- 函数没有任何“后门”:不从外部世界(控制台、Web服务、数据库、文件等)读取数据,也不向外部世界写出数据
Scala 中 "scala.math._package"中的如下方法都是纯函数:
- abs
- ceil
- max
- min
当然,Scala 中有很多非纯函数,如 foreach:
- 集合类的 foreach 函数是不纯粹的,它具有其副作用,如打印到 STDOUT
- 它的方法签名声明了返回 Unit 类型,类似的,任何返回 Unit 的函数都是一个不纯函数
非纯函数通常有以下特点:
- 读取隐藏的输入,即访问没有显示传递到函数中作为输入参数的变量和数据
- 写出隐藏输出
- 修改给定的参数
- 对外部世界执行某种I/O
2.12.2 传递函数
函数可以作为变量进行传递,允许将函数作为参数传递给其他函数。
val doubles = nums.map(_ * 2)
2.12.3 没有NULL值
在 Scala 中,没有 null 的存在,使用 Option/Some/Nothing 这样的结构进行处理。
- Some 和 Nothing 是 Option 的子类
- 通常声明一个函数返回一个 Option 类型
- 接收到可以处理的参数则返回 Some
- 接收到无法处理的参数则返回 Nothing
//当三个字符串都转换为整数时,则返回一个包装在 Some 中的整数
// 当三个字符串中任何一个不能转换为整数时,则返回一个 None
def toInt(s: String): Option[Int] = {
try{
Some(Integer.parseInt(s.trim))
} catch {
case e: Exception => None
}
}
toInt(x) match {
case Some(i) => println(i)
case None => println("That didn't work.")
}
val y = for {
a <- toInt(stringA)
b <- toInt(stringB)
c <- toInt(stringC)
} yield a + b + c
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)