大家好,又见面了,我是全栈君。
【《重构 改善既有代码的设计》学习笔记】重构:第一个案例
本篇文章的内容来自《重构 改善既有代码的设计》一书学习笔记整理并且加上自己的浅显的思考总结!
一、简单的例子
一个影片出租店用的程序,计算每一位顾客的消费金额,并打印详单。
详单打印 顾客租了哪些影片、租期多长,影片类型 、单个影片费用、总费用 。 除了费用外,还要计算顾客的积分,不同种类租片积分不同。
注:影片为为三类:普通片、儿童片、新片!
Think:如果是你做这样一个功能,用java编写,你会怎么去写代码?可以简单思考一下整个逻辑在往下面看!
简单点设计三个表我,一个顾客表,一个影片表,还有一个是租用记录表。那么在java中顾客这个类是要有的 Customer 、然后影片这个类要有 Movie ,还有一个租期类 Rental。
1、三个类组合实现一个打印详单的功能
摘录原书 的代码 ,稍做字段调整 。Customer
类如下,有一个 statement()
打印详单!
/** * 顾客 * * @author:dufy * @version:1.0.0 * @date 2019/1/21 */
public class Customer {
private String _name;
private Vector rentals = new Vector();
public Coustomer(String _name) {
this._name = _name;
}
public void addRental(Rental rental) {
rentals.addElement(rental);
}
public String getName() {
return _name;
}
public String statement() {
//总费用
double totalAmount = 0;
// 积分数
int integralNum = 0;
Enumeration erts = rentals.elements();
String result = "租用的顾客名字为: " + getName() + "\n";
result += "=========================================\n";
while (erts.hasMoreElements()) {
// 每个影片的费用
double thisAmount = 0;
Rental rental = (Rental) erts.nextElement();
int daysRented = rental.getDaysRented();
Integer type = rental.getMovie().getType();
switch (type) {
case Movie.REGULAR:
thisAmount += 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
thisAmount += daysRented * 3;
break;
case Movie.CHILDRENS:
thisAmount += 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
break;
}
integralNum++;
// 如果是租用新片则累积多加1积分
if (type == Movie.NEW_RELEASE && daysRented > 1) {
integralNum++;
}
//打印租用影片详情
result += "影片名称:" + rental.getMovie().getTitle() + "\t 影片类型:" + rental.getMovie().getMovieTypeName(type)
+ "\n";
result += "租期:" + daysRented + "天\t 费用:" + thisAmount + "元\n";
result += "----------------------------------------------------\n";
totalAmount += thisAmount;
}
result += ">>>>>>>>>>>>>>>>>>总费用:" + totalAmount + "元<<<<<<<<<<<<<<<<<<<< \n";
result += ">>>>>>>>>>>>>>>>>>本次积分:" + integralNum + "<<<<<<<<<<<<<<<<<<<<";
return result;
}
}
Movie
类的代码
public class Movie {
/** * 普通片 */
public static final int REGULAR = 0;
/** *新片 */
public static final int NEW_RELEASE = 1;
/** * 儿童片 */
public static final int CHILDRENS = 2;
/** * 影片的名称 */
private String title;
/** * 影片的类型 */
private Integer type;
public Movie() {
}
public Movie(String title, Integer type) {
this.title = title;
this.type = type;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Integer getType() {
return type;
}
public void setType(Integer type) {
this.type = type;
}
public String getMovieTypeName(int type){
String name = "";
if(REGULAR == type){
name = "普通片";
}else if(NEW_RELEASE == type){
name = "新片";
}else if(CHILDRENS == type){
name = "儿童片";
}else{
name = "未知";
}
return name;
}
}
Rental
类代码:
public class Rental {
/** * 影片 */
private Movie movie;
/** * 租用的天数 */
private int daysRented;
public Rental() {
}
public Rental(Movie movie, int daysRented) {
this.movie = movie;
this.daysRented = daysRented;
}
public Movie getMovie() {
return movie;
}
public void setMovie(Movie movie) {
this.movie = movie;
}
public int getDaysRented() {
return daysRented;
}
public void setDaysRented(int daysRented) {
this.daysRented = daysRented;
}
}
验证功能:
public class AppMain {
public static void main(String[] args) {
Customer c = new Customer("admin");
Movie m1 = new Movie("功夫",Movie.REGULAR);
Movie m2 = new Movie("功夫熊猫",Movie.CHILDRENS);
Movie m3 = new Movie("功夫之王",Movie.NEW_RELEASE);
Rental r1 = new Rental(m1,4);
Rental r2 = new Rental(m2,2);
Rental r3 = new Rental(m3,5);
c.addRental(r1);
c.addRental(r2);
c.addRental(r3);
String statement = c.statement();
System.out.println(statement);
}
}
租用的顾客名字为: admin
=========================================
影片名称:功夫 影片类型:普通片
租期:4天 费用:5.0元
----------------------------------------------------
影片名称:功夫熊猫 影片类型:儿童片
租期:2天 费用:1.5元
----------------------------------------------------
影片名称:功夫之王 影片类型:新片
租期:5天 费用:15.0元
----------------------------------------------------
>>>>>>>>>>>>>>>>>>总费用:21.5元<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>>>>>>>>>>本次积分:4<<<<<<<<<<<<<<<<<<<<
2、简单分析此功能代码
上面的这一些代码实现了需求功能,但是你有没有觉得那个地方看起来很不舒服。【 代码重构还是需要有一定的编写代码的经验和编写过一定的代码】如果看起来没有任何感觉的话,那说明还需要在多多历练,多写一些代码多思考。
其实对于这个简单的需求来说,上面的程序已经完全可以正常的工作,但是有没有觉得 Customer
中 statement
方法做的事情太多了,很多事情原本应该有其他类完成的,却都放到statement
中完成了。并且当有新需求加入的时候,修改代码也是很麻烦的,而且容易出错。
(1)、如果我们打印详单 要换一种输出的风格?对于上面的代码要怎么进行新功能的添加?
- 对于这个问题,我们可以快速的处理方式为: 拷贝一份
statement
进行修改就ok了。
(2)、在有多种详单打印输出风格的前提下, 如果我们影片的计费方式发生变化,又或者是积分计算方式方式变化,又会如何?
- 此时我们需要将所有
statement
方法同时进行修改,并确保各处修改的一致性。【这样的代码后期维护起来成本很高,并且容易出错】
对于上面的一些需求,我们都可以根据已有的代码【拷贝粘贴】进行功能的快速开发完成。但是随着需求的越来越复杂,在statement 中 能够用于 适当修改的点越来越难找,不犯错的机会也越来越少 。
》》》【书籍小结】《《《
如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便的达成目标,那就先重构那个程序,使得特性的添加比较容易进行,然后再添加特性。
》》》【我的思考小结】《《《
对于很多人,加入一家公司或者加入一个项目,并不是所有的功能代码都是新开发的,很大一部分时间在维护之前开发好的代码/功能,因为开发者的水平或者需求的变更,导致在原有代码上在开发新的需求变得异常的复杂和艰难,此时就一定要考虑要重构掉这一块的代码了,不要想着绕过,勇敢面对。重构,真的是可以锻炼自己思维和代码的编写能力。
二、重构的第一步
【书籍总结】
重构的第一步永远相同: 那就是为即将修改的代码建立一组可靠的测试环境。
这样做的目的是防止在重构的过程引入新的bug, 好的测试是重构的根本,花时间建立一个优良的测试机制完全值得。测试机制是要让程序能够自我检验,否则在重构后花大把时间进行比对,这样会降低开发速度。【程序员在修改bug的过程中又可能在创建新的bug】
三、分解并重组 statement()
当一个方法的长度长的离谱,看到这样的函数,就应该想着能否进行拆解。代码块越小,代码的功能越容易管理。也方便后面的维护!
代码重构目标:希望将长长的函数切开,把较小的块移动到更合适的类中,最终能够降低代码重复和扩展。
上面的statement()
方法中,switch看起来就是一个明显的逻辑泥团。把它提炼到单独的函数中似乎比较好。
然后在分析函数内的局部变量和参数,其中statement()
while循环中有两个: thisAmount
、daysRented
和type
, thisAmount
会被修改,后面两个不会被修改。任何不会被修改的变量都可以被当成参数传入新的函数,至于被修改的变量就要格外小心。
1、第一次重构—方法抽离
第一次修改的代码:
public String statement() {
// 省略
while (erts.hasMoreElements()) {
// 每个影片的费用
double thisAmount = 0;
Rental rental = (Rental) erts.nextElement();
int daysRented = rental.getDaysRented();
int type = rental.getMovie().getType();
thisAmount = amountFor(type,daysRented);
}
// 省略
return result;
}
/** * 计算影片租费 * @param type 影片类型 * @param daysRented 租期 * @return */
private double amountFor(int type,int daysRented){
double thisAmount = 0;
switch (type) {
case Movie.REGULAR:
thisAmount += 2;
if (daysRented > 2) {
thisAmount += (daysRented - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
thisAmount += daysRented * 3;
break;
case Movie.CHILDRENS:
thisAmount += 1.5;
if (daysRented > 3) {
thisAmount += (daysRented - 3) * 1.5;
}
break;
}
return thisAmount;
}
看起来怎么改了这么一点点东西啊! 需要注意的是 : 重构步骤的本质,每次修改的幅度都要很小,这样任何错误都很容易发现。【说白了就是步子要迈的小一点,否则容易扯着蛋】
》》》【书籍小结】《《《
重构技术就是以 **微小 **的步伐修改程序,如果你犯下错误,很容易发现它。
》》》【我的思考小结】《《《
就目前而言,上面的这个重构,相比大家在正在编写代码的时候也会操作,现在的工具IDEA,对于重构的支持也很好,使用好就是提供效率和提升代码质量。
2、第二次重构—变量名的修改
这次相对简单,是一种思想的转变,上面的 type
局部变量,在命名上相对比较好了,但是还是能够进一步优化! 可以改为 movieType
! 具体的修改代码不粘贴了。
更改变量名称 是值得的行为吗? 作者说,绝对值得,我认为也绝对值得。要不你写的变量是 a 、b、c 或者 i、j、k,这样的代码真的好吗? 好的代码应该清楚的表达出自己的功能,变量名称是代码清晰的关键【代码要表现自己的目的】。
》》》【书籍小结】《《《
任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是真正优秀的程序员。
》》》【我的思考小结】《《《
我遇到过一些同事吐槽说目前接受的这个项目的代码写的就是一坨,注释没有,变量定义也不知是做什么!然而 当他去开发一个功能的时候,他写的代码也是 一坨xiang! 当你看到项目中之前别人代码写的不好的,可以抱怨代码,指责之前开发这个项目的人,但是请不要继续学习/效仿他们,继续写那种像一坨xiang的代码。
3、第三次重构—代码搬家
第一次重构的时候抽出一个方法 amountFor
,但是amountFor
是用来计算 影片租期的费用的,似乎 此方法放到Customer
类中有些不妥。 绝大多数情况下,函数应该放到它所使用的数据的所属对象内。这样我们就应该将amountFor
放入到 Rental
类中。
如下图所示:
但是“搬家” 之后应该去掉参数。并且在“搬家”后 修改函数名称!具体的修改代码如下:
public class Rental {
/** * 计算 影片租费 * @return 租费金额 */
public double getCharge(){
double resultAmount = 0;
int rentalDays = getDaysRented();
switch (getMovie().getType()) {
case Movie.REGULAR:
resultAmount += 2;
if (rentalDays > 2) {
resultAmount += (rentalDays - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
resultAmount += rentalDays * 3;
break;
case Movie.CHILDRENS:
resultAmount += 1.5;
if (rentalDays > 3) {
resultAmount += (rentalDays - 3) * 1.5;
}
break;
}
return resultAmount;
}
}
// 其他代码省略...
public class Customer {
public String statement() {
while (erts.hasMoreElements()) {
thisAmount = amountFor(rental);
}
}
/** * 计算影片租费 * @param rental * @return */
private double amountFor(Rental rental){
return rental.getCharge();
}
}
改完代码后,可以使用运行 AppMain
进行验证! 改完后需要验证通过!
提炼 积分计算代码
由于有了上面上面的基础,积分提炼这一块的代码,我们也单独抽离成一个函数,并且将函数进行搬家到Rental
类中。
修改前后对比图:
修改后的代码如下:
public class Rental {
//其他代码省略
/** *计算积分 */
public int getIntegralPoints(){
if (getMovie().getType() == Movie.NEW_RELEASE && getDaysRented() > 1) {
return 2;
}
return 1;
}
}
public class Customer {
public String statement() {
//其他省略...
integralNum += rental.getIntegralPoints();
//其他省略...
}
}
还是再次强调一下:重构的时候最好小步前进, 做一次搬移,在编译、再测试。这样才能使出错的几率最小。
4、第四次重构—去除临时变量
在 statement()
中还有临时变量,有时候,临时变量确实能让程序的开发变的简单和快速!但是 临时变量也可能是个问题,它们只有在自己的函数中才有效,如果临时变量太多,导致函数冗长复杂。
下面就将计算 总费用 totalAmount
和 积分 integralNum
临时变量 进行去除操作。
public class Customer {
public String statement() {
//省略...
while (erts.hasMoreElements()) {
Rental rental = (Rental) erts.nextElement();
int movieType = rental.getMovie().getType();
//省略...
}
result += ">>>>>>>>>>>>>>>>>>总费用:" + getTotalCharge() + "元<<<<<<<<<<<<<<<<<<<< \n";
result += ">>>>>>>>>>>>>>>>>>本次积分:" + getIntegralNum() + "<<<<<<<<<<<<<<<<<<<<";
return result;
}
/** * 计算影片租费 * @return */
private double getTotalCharge(){
double result = 0;
Enumeration erts = rentals.elements();
while (erts.hasMoreElements()) {
Rental rental = (Rental) erts.nextElement();
result += rental.getCharge();
}
return result;
}
/** * 计算积分 * @return */
private double getIntegralNum(){
double result = 0;
Enumeration erts = rentals.elements();
while (erts.hasMoreElements()) {
Rental rental = (Rental) erts.nextElement();
result += rental.getIntegralPoints();
}
return result;
}
}
看了这次的重构,会发现一下问题,那就是性能。原本只需要执行一次的while循环,现在需要执行三次了。 如果while 循环耗时很多,那就可能大大降低程序的性能。 但请注意 前面这句话的加粗词,只是如果 、可能。 除非进行评测,否则无法确定循环 的执行时间,也无法知道这个循环是否被经常使用以至于影响系统的整体性能。 重构的时候不必担心这些,优化时你才需要担心他们。到优化的时候你已处于一个比较有利的位置,可以有更多选择完成有效优化。
5、 实现用另一种方式打印详单
通过上面的代码重构,现在用想使用另一种方式打印租用详单,整个代码就简单很多了。此时 脱下“重构”的帽子,带上“添加功能”的帽子。实现一个打印htmlStatement()
的方法。
public String htmlStatement() {
Enumeration erts = rentals.elements();
String result = "<title>租用的顾客名字为: " + getName() + "</title>\n";
result += "<p>================================</p>\n";
while (erts.hasMoreElements()) {
// 每个影片的费用
Rental rental = (Rental) erts.nextElement();
int movieType = rental.getMovie().getType();
//打印租用影片详情
result += "影片名称:" + rental.getMovie().getTitle() + "\t 影片类型:" + rental.getMovie().getMovieTypeName(movieType)
+ "\n";
result += "租期:" + rental.getDaysRented() + "天\t 费用:" + rental.getCharge() + "元\n";
result += "<p>---------------------------</p>\n";
}
result += "<span>总费用:" + getTotalCharge() + "元<span>\n";
result += "<span>本次积分:" + getIntegralNum() + "<span>";
return result;
}
<title>租用的顾客名字为: admin</title>
<p>================================</p>
影片名称:功夫 影片类型:普通片
租期:4天 费用:5.0元
<p>---------------------------</p>
影片名称:功夫熊猫 影片类型:儿童片
租期:2天 费用:1.5元
<p>---------------------------</p>
影片名称:功夫之王 影片类型:新片
租期:5天 费用:15.0元
<p>---------------------------</p>
<span>总费用:21.5元<span>
<span>本次积分:4.0<span>
通过计算逻辑的提炼,可以很快完成一个htmlStatement()
甚至更多的打印详单的方式。 并且最要的是如果租费或者积分的计算发生任何变化,我只需要在一个地方进行修改就可以了,而不需要在每一个打印详单的方法中进行修改然后复制粘贴到其他方法中。 这样的简单重构就发生在你我每天开发的项目之中,相信我,多花费的这些时间绝对是值得的。
6、第五次重构-运用多态取代与价格相关的条件逻辑
在实际的开发过程中,用户的需求一直不断,在上面目前的重构的代码中,他们准备修改影片分类规则。我们尚不清楚他们想怎么做,但似乎新分类法很快就要引入,影片分类发生变化,则费用计算和积分计算也可能会发生变化。所以必须进入费用计算和积分计算中,把因条件而已的代码(switch语句内case的字句)替换掉,让我们重新开始重构之路。
看一下之前我们重构之后,计算租费的方法:
public class Rental {
/** * 影片 */
private Movie movie;
// 省略...
/** * 计算 影片租费 * @return 租费金额 */
public double getCharge(){
double resultAmount = 0;
int rentalDays = getDaysRented();
switch (getMovie().getType()) {
case Movie.REGULAR:
resultAmount += 2;
if (rentalDays > 2) {
resultAmount += (rentalDays - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
resultAmount += rentalDays * 3;
break;
case Movie.CHILDRENS:
resultAmount += 1.5;
if (rentalDays > 3) {
resultAmount += (rentalDays - 3) * 1.5;
}
break;
}
return resultAmount;
}
}
使用switch 语句最好是在对象自己的数据上使用,而不是在别人的数据上使用,此时getCharge
应该移动到Movie
类中。 移过去后,我们需要将租期作为参数传递进去,因为租期长度来自Rental
对象。
思考:为什么是选择将租期长度传给Movie对象,而不是将影片类型传递给Rental对象呢?
因为在本系统中可能发生的变化是加入新的影片类型,这种变化带有不稳定倾向,如果影片类型有变化,我们希望尽量控制它造成的影响,所以选择在Movie对象中计算费用。
积分的计算和租费思考类似,修改后代码:
public class Rental {
/** * 影片 */
private Movie movie;
/** * 租用的天数 */
private int daysRented;
// 省略...
/** * 计算 影片租费 * @return 租费金额 */
public double getCharge(){
return movie.getCharge(daysRented);
}
/** *计算积分 */
public int getIntegralPoints(){
return movie.getIntegralPoints(daysRented);
}
}
public class Movie {
/** * 计算 影片租费 * @return 租费金额 */
public double getCharge(int rentalDays){
double resultAmount = 0;
switch (getType()) {
case Movie.REGULAR:
resultAmount += 2;
if (rentalDays > 2) {
resultAmount += (rentalDays - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
resultAmount += rentalDays * 3;
break;
case Movie.CHILDRENS:
resultAmount += 1.5;
if (rentalDays > 3) {
resultAmount += (rentalDays - 3) * 1.5;
}
break;
}
return resultAmount;
}
/** *计算积分 */
public int getIntegralPoints(int rentalDays){
if (getType() == Movie.NEW_RELEASE && rentalDays > 1) {
return 2;
}
return 1;
}
}
修改完后,一定记得测试! 测试通过在继续开干。
如果我们有数种影片种类,不同的影片有自己计费法。
Movie (科幻片、爱情片、岛国动作片…),这么一来,每一种类的片子有自己的一套计费方法,那么 此时我们可以使用策略模式。(积分也是一样的)
此段稍微和书中有不同,如想看到详细细节请看书**《重构 改善既有代码的设计》。**
整个修改:
新增
- Price
public abstract class Price {
abstract int getMovieType();
abstract double getCharge(int daysRental);
/** * 如果是大型项目积分的计算建议还是不要和租费放一起 * 这里因为是演示demo就放一起了 * @return */
abstract int getIntegralPoints(int daysRental);
}
- ChildrensMoviePrice
public class ChildrensMoviePrice extends Price {
@Override
int getMovieType() {
return Movie.CHILDRENS;
}
@Override
double getCharge(int daysRental) {
double resultAmount = 0;
resultAmount += 1.5;
if (daysRental > 3) {
resultAmount += (daysRental - 3) * 1.5;
}
return resultAmount;
}
@Override
int getIntegralPoints(int daysRental) {
return 1;
}
}
- NewReleaseMoviePrice
public class NewReleaseMoviePrice extends Price {
@Override
int getMovieType() {
return Movie.NEW_RELEASE;
}
@Override
double getCharge(int daysRental) {
return daysRental * 3;
}
@Override
int getIntegralPoints(int daysRental) {
if (getMovieType() == Movie.NEW_RELEASE && daysRental > 1) {
return 2;
}
return 1;
}
}
- RegularMoviePrice
public class RegularMoviePrice extends Price {
@Override
int getMovieType() {
return Movie.REGULAR;
}
@Override
double getCharge(int daysRental) {
double resultAmount = 0;
resultAmount += 2;
if (daysRental > 2) {
resultAmount += (daysRental - 2) * 1.5;
}
return resultAmount;
}
@Override
int getIntegralPoints(int daysRental) {
return 1;
}
}
修改
public class Movie {
// 省略其他....
public Movie(String title, Integer type) {
this.title = title;
setType(type);
}
public Integer getType() {
return price.getMovieType();
}
public void setType(Integer type) {
switch (type) {
case REGULAR:
price = new RegularMoviePrice();
break;
case NEW_RELEASE:
price = new NewReleaseMoviePrice();
break;
case CHILDRENS:
price = new ChildrensMoviePrice();
break;
}
}
/** * 计算 影片租费 * @return 租费金额 */
public double getCharge(int rentalDays){
return price.getCharge(rentalDays);
}
/** *计算积分 */
public int getIntegralPoints(int rentalDays){
return price.getIntegralPoints(rentalDays);
}
}
修改到这里,如果你是一步步按照步骤操作,那么最后重构完的代码你要有了,我这里就不在贴一次了。再次强调一下,还是测试。
通过上面的这一次简单重构,后面在来多个种类的影片我们也可以很从容的应对,整个代码的可维护性和可扩展性得到了很大的提升。
四、总结
通过一个简单的例子,你有没有对“重构改怎么做”有那么一点点感觉,重构的整个过程会有很多的技巧,重构行为使得代码的责任分配更合理,代码的维护更轻松。希望我们在编写代码的开始就有这种思维,有好的代码风格,能够写出好的代码。
重构的节奏: 测试,小修改、测试、小修改…,以这种节奏重构,能够快速安全的前进。
》》》【我的思考小结】《《《
看了第一章,我觉得这本书值得好好品读,作为开发人员,我们或多或少会在自己项目的开发中留下一些技术债,这里面就有 为了方便 复制重复的方法,但是一遇到需要修改,很多地方都要进行修改。当初是快了,后面却慢了! 快到最后变成了慢,最终导致要来还这个债务。 希望看文章的所有伙伴,可以提前就有这种思维方式,那就可以避免后期的很多问题了。
PS:排除 一些 我就是写代码的,后期的维护和我没有关系,也不愿自己代码能越写越好的人。 因为我就曾遇到这样的人,他的想法就是我开发完这个功能,说不定我那天走了,反正后面也不是我维护。 对于这类型的人,我们不能改变他们,但是我希望如果你遇到了,请不要学他。不管我们在这家公司呆多久,对自己的代码负责,对自己的人生负责。
如果您觉得这篇博文对你有帮助,请点赞或者喜欢,让更多的人看到,谢谢!
如果帅气(美丽)、睿智(聪颖),和我一样简单善良的你看到本篇博文中存在问题,请指出,我虚心接受你让我成长的批评,谢谢阅读!
祝你今天开心愉快!
欢迎访问我的csdn博客和关注的个人微信公众号!
愿你我在人生的路上能都变成最好的自己,能够成为一个独挡一面的人。
不管做什么,只要坚持下去就会看到不一样!在路上,不卑不亢!
博客首页 : http://blog.csdn.net/u010648555
© 阿飞(dufyun)
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/121056.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...