本文目錄
- 一、序列化是什麼
- 二、為什麼需要序列化
- 三、序列化怎麼用
- 四、序列化深度探秘
- 4.1 為什麼必須實現Serializable接口
- 4.2 被序列化對象的字段是引用時該怎麼辦
- 4.3 同一個對象會被序列化多次嗎
- 4.4 只想序列化對象的部分字段該怎麼辦
- 4.5 被序列化對象具有繼承關係該怎麼辦
- 五、serialVersionUID的作用及自動生成
- 六、序列化的缺點
- 七、參考文獻
前言
Oracle 公司計劃廢除 Java 中的古董:序列化技術,因為它帶來了許多嚴重的安全問題(如序列化存儲安全、反序列化安全、傳輸安全等),據統計,至少有3分之1的漏洞是序列化帶來的,這也是 1997 年誕生序列化技術的一個巨大錯誤。但是,序列化技術現在在 Java 應用中無處不在,特別是現在的持久化框架和分佈式技術中,都需要利用序列化來傳輸對象,如:Hibernate、Mybatis、Java RMI、Dubbo等,即對象要存儲或者傳輸都不可避免要用到序列化技術,所以刪除序列化技術將是一個長期的計劃。
你在實際工作中可能會很難有機會真正用到Java自帶的序列化技術了,工業界一般也會選擇一些更安全的對象編解碼方案例如Google的Protobuf等。所以,對於Java序列化,我們不必再投入過多的精力學習,你花20分鐘讀完本文所掌握的知識,對於應付日常源碼閱讀中遇到的遺留的Java序列化技術應該是足夠了。
一、序列化是什麼
序列化機制允許將實現序列化的Java對象轉換成字節序列,這些字節序列可以保存在磁盤上,或通過網絡傳輸,以備以後重新恢復成原來的對象。序列化機制使得對象可以脫離程序的運行而獨立存在。
- 序列化:將一個Java對象寫入IO流中
- 反序列化:從IO流中恢復該Java對象
本文中用序列化來簡稱整個序列化和反序列化機制。
二、為什麼需要序列化
所有可能在網絡上傳輸的對象的類都應該是可序列化的,否則程序將會出現異常,比如RMI(Remote Method Invoke,即遠程方法調用,是JavaEE的基礎)過程中的參數和返回值;所有需要保存到磁盤裡的對象的類都必須可序列化,比如Web應用中需要保存到HttpSession或ServletContext屬性的Java對象。
因為序列化是RMI過程的參數和返回值都必須實現的機制,而RMI又是Java EE技術的基礎——所有的分佈式應用常常需要跨平台、跨網絡,所以要求所有傳遞的參數、返回值必須實現序列化。因此序列化機制是Java EE平台的基礎。通常建議:程序創建的每個JavaBean類都實現Serializable。
三、序列化怎麼用
如果一個類的對象需要序列化,那麼在Java語法層面,這個類需要:
- 實現Serializable接口
- 使用ObjectOutputStream將對象輸出到流,實現對象的序列化;使用ObjectInputStream從流中讀取對象,實現對象的反序列化
下面我們通過代碼示例來看看序列化最基本的用法。我們創建了Person類,其擁有兩個基本類型的屬性,並實現了Serializable接口。testSerialize方法用來測試序列化,testDeserialize方法用來測試反序列化。
1 import org.junit.Test;
2
3 import java.io.*;
4
5 public class SerializableTest {
6
7 @Test
8 public void testSerialize() {
9 Person one = new Person(12, 148.2);
10 Person two = new Person(35, 177.8);
11
12 try (ObjectOutputStream output =
13 new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14 output.writeObject(one);
15 output.writeObject(two);
16 } catch (IOException e) {
17 e.printStackTrace();
18 }
19 }
20
21 @Test
22 public void testDeserialize() {
23
24 try (ObjectInputStream input =
25 new ObjectInputStream(new FileInputStream("Person.txt"))) {
26 Person one = (Person) input.readObject();
27 Person two = (Person) input.readObject();
28
29 System.out.println(one);
30 System.out.println(two);
31 } catch (IOException e) {
32 e.printStackTrace();
33 } catch (ClassNotFoundException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 class Person implements Serializable {
40 int age;
41 double height;
42
43 public Person(int age, double height) {
44 this.age = age;
45 this.height = height;
46 }
47
48 @Override
49 public String toString() {
50 return "Person{" +
51 "age=" + age +
52 ", height=" + height +
53 '}';
54 }
55 }
四、序列化深度探秘
4.1 為什麼必須實現Serializable接口
如果某個類需要支持序列化功能,那麼它必須實現Serializable接口,否則會報 java.io.NotSerializableException。Serializable接口是一個標誌性接口(Marker Interface),也就是說,該接口並不包含任何具體的方法,是一個空接口,僅僅用來判斷該類是否能夠序列化。JDK8中Serializable接口的源碼如下:
1 package java.io;
2
3 public interface Serializable {
4 }
在 ObjectOutputStream.java 的 writeObject0 方法中,我們確實可以看到對對象是否實現了 Serializable接口進行了驗證(第15行),否則會拋出 NotSerializableException 異常(第22行)。
1 private void writeObject0(Object obj, boolean unshared)
2 throws IOException
3 {
4 boolean oldMode = bout.setBlockDataMode(false);
5 depth++;
6 try {
7 ...
8 // remaining cases
9 if (obj instanceof String) {
10 writeString((String) obj, unshared);
11 } else if (cl.isArray()) {
12 writeArray(obj, desc, unshared);
13 } else if (obj instanceof Enum) {
14 writeEnum((Enum<?>) obj, desc, unshared);
15 } else if (obj instanceof Serializable) {
16 writeOrdinaryObject(obj, desc, unshared);
17 } else {
18 if (extendedDebugInfo) {
19 throw new NotSerializableException(
20 cl.getName() + "\n" + debugInfoStack.toString());
21 } else {
22 throw new NotSerializableException(cl.getName());
23 }
24 }
25 } finally {
26 depth--;
27 bout.setBlockDataMode(oldMode);
28 }
29 }
4.2 被序列化對象的字段是引用時該怎麼辦
在第三部分“序列化怎麼用”部分的示例中,Person類的字段全都是基本類型,我們知道基本類型其地址中直接存放的就是它的值,那如果是引用類型呢?引用類型其地址中存放的是指向堆內存中的一個地址,難道序列化時就是將這個地址進行了保存嗎?顯然,這是說不通的,因為對象的內存地址是可變的,在同一系統的不同運行時刻或者是不同系統中,對象的地址肯定是不同的,因此,序列化內存地址沒有意義。
如果被序列化對象的字段是引用,那麼要求該引用的類型也是可序列化實現了Serializable接口的,否則無法序列化。當對某個對象進行序列化時,系統會自動把該對象的所有Field依次進行序列化,如果某個Field引用到另一個對象,則被引用的對象也會被序列化;如果被引用的對象的Field也引用了其他對象,則被引用的對象也會被序列化,這種情況被稱為遞歸序列化。
4.3 同一個對象會被序列化多次嗎
如果對象A和對象B同時引用了對象C,那麼,當序列化對象A和對象B時,對象C會被序列化兩次嗎?答案顯然是不會。
要解釋這個問題,就不得不說一下Java序列化的基本算法了:
- 所有序列化到二進制流的對象都有一個序列化編號
- 當程序試圖序列化一個對象時,程序將先檢查該對象是否已經被序列化過,只有該對象從未(在本次虛擬機中)被序列化過,系統才會將該對象轉換成字節序列並賦予一個唯一的編號
- 如果某個對象已經序列化過,程序將只是直接輸出其序列化編號,而不是再次重新序列化該對象
4.4 只想序列化對象的部分字段該怎麼辦
在一些特殊的場景下,如果一個類里包含的某些Field值是敏感信息,例如銀行賬戶信息等,這時不希望系統將該Field值進行序列化;或者某個Field的類型是不可序列化的,因此不希望對該Field進行遞歸序列化,以避免引發java.io.NotSerializableException異常。
此時,我們就需要自定義序列化了。自定義序列化的常用方式有兩種:
- 使用transient關鍵字
- 重寫writeObject與readObject方法
我們先看第一種方式,使用transient關鍵字。transient關鍵字只能用於修飾Field,不可修飾Java程序中的其他成分。使用transient修飾的屬性,java序列化時,會忽略掉此字段,所以反序列化出的對象,被transient修飾的屬性是默認值。對於引用類型,值是null;基本類型,值是0;boolean類型,值是false。
下列代碼中,我們把People的height字段設置為transient,在反序列化時,可觀察到輸出為默認值0.0。
1 import org.junit.Test;
2
3 import java.io.*;
4
5 public class SerializableTest {
6
7 @Test
8 public void testSerialize() {
9 Person one = new Person(12, 156.6);
10 Person two = new Person(16, 177.7);
11
12 try (ObjectOutputStream output =
13 new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14 output.writeObject(one);
15 output.writeObject(two);
16 } catch (IOException e) {
17 e.printStackTrace();
18 }
19 }
20
21 @Test
22 public void testDeserialize() {
23
24 try (ObjectInputStream input =
25 new ObjectInputStream(new FileInputStream("Person.txt"))) {
26 Person one = (Person) input.readObject();
27 Person two = (Person) input.readObject();
28
29 System.out.println(one);
30 System.out.println(two);
31 } catch (IOException e) {
32 e.printStackTrace();
33 } catch (ClassNotFoundException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 class Person implements Serializable{
40 protected int age;
41 protected transient double height;
42
43 public Person() {
44 }
45
46 public Person(int age, double height) {
47 this.age = age;
48 this.height = height;
49 }
50
51 @Override
52 public String toString() {
53 return "Person{" +
54 "age=" + age +
55 ", height=" + height +
56 '}';
57 }
58 }
程序輸出:
Person{age=12, height=0.0}
Person{age=16, height=0.0}
Process finished with exit code 0
使用transient關鍵字修飾Field雖然簡單、方便,但被transient修飾的Field將被完全隔離在序列化機制之外,這樣導致在反序列化恢復Java對象時無法取得該Field值。Java還提供了一種自定義序列化機制,通過這種自定義序列化機制可以讓程序控制如何序列化各Field,甚至完全不序列化某些Field(與使用transient關鍵字的效果相同)。在序列化和反序列化過程中需要特殊處理的類應該提供如下特殊簽名的方法,這些特殊的方法用以實現自定義序列化。
private void writeObject(java.io.ObjectOutputStream out)
throws IOException
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException;
private void readObjectNoData()
throws ObjectStreamException;
- writeObject()方法負責寫入特定類的實例狀態,以便相應的readObject()方法可以恢復它。通過重寫該方法,程序員可以完全獲得對序列化機制的控制,可以自主決定哪些Field需要序列化,需要怎樣序列化。在默認情況下,該方法會調用out.defaultWriteObject來保存Java對象的各Field,從而可以實現序列化Java對象狀態的目的。
- readObject()方法負責從流中讀取並恢復對象Field,通過重寫該方法,程序員可以完全獲得對反序列化機制的控制,可以自主決定需要反序列化哪些Field,以及如何進行反序列化。在默認情況下,該方法會調用in.defaultReadObject來恢復Java對象的非靜態和非瞬態Field。在通常情況下,readObject()方法與writeObject()方法對應,如果writeObject()方法中對Java對象的Field進行了一些處理,則應該在readObject()方法中對其Field進行相應的反處理,以便正確恢復該對象。
- 當序列化流不完整時,readObjectNoData()方法可以用來正確地初始化反序列化的對象。例如,接收方使用的反序列化類的版本不同於發送方,或者接收方版本擴展的類不是發送方版本擴展的類,或者序列化流被篡改時,系統都會調用readObjectNoData()方法來初始化反序列化的對象。
下面的示例代碼中,我們在writeObject方法中對Person的字段進行了簡單的加密處理,在readObject方法中對其進行了相應的解密。
1 import org.junit.Test;
2
3 import java.io.*;
4
5 public class SerializableTest {
6
7 @Test
8 public void testSerialize() {
9 Person one = new Person(12, 156.6);
10 Person two = new Person(16, 177.7);
11
12 try (ObjectOutputStream output =
13 new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14 output.writeObject(one);
15 output.writeObject(two);
16 } catch (IOException e) {
17 e.printStackTrace();
18 }
19 }
20
21 @Test
22 public void testDeserialize() {
23
24 try (ObjectInputStream input =
25 new ObjectInputStream(new FileInputStream("Person.txt"))) {
26 Person one = (Person) input.readObject();
27 Person two = (Person) input.readObject();
28
29 System.out.println(one);
30 System.out.println(two);
31 } catch (IOException e) {
32 e.printStackTrace();
33 } catch (ClassNotFoundException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 class Person implements Serializable{
40 protected int age;
41 protected double height;
42
43 public Person() {
44 }
45
46 public Person(int age, double height) {
47 this.age = age;
48 this.height = height;
49 }
50
51 private void writeObject(java.io.ObjectOutputStream out)
52 throws IOException {
53 System.out.println("Encryption!");
54 out.writeInt(age + 1);
55 out.writeDouble(height - 1);
56 }
57 private void readObject(java.io.ObjectInputStream in)
58 throws IOException, ClassNotFoundException {
59 System.out.println("Decryption!");
60 this.age = in.readInt() - 1;
61 this.height = in.readDouble() + 1;
62 }
63
64 @Override
65 public String toString() {
66 return "Person{" +
67 "age=" + age +
68 ", height=" + height +
69 '}';
70 }
71 }
4.5 被序列化對象具有繼承關係該怎麼辦
被序列化對象具有繼承關係時無非就兩種情況,第一,該類具有子類,第二,該類具有父類。
當該類實現了Serializable接口且具有子類時,根據官方文檔中的說明,其子類天然具有可被序列化的屬性,不需要顯式實現Serializable接口;。
All subtypes of a serializable class are themselves serializable.
當該類實現了Serializable接口且具有父類時,,該類的父類需要實現Serializable接口嗎?在JDK8中Serializable接口的官方文檔中有這樣一段話:
1 /**
2 * ......
3 *
4 * To allow subtypes of non-serializable classes to be serialized, the
5 * subtype may assume responsibility for saving and restoring the
6 * state of the supertype's public, protected, and (if accessible)
7 * package fields. The subtype may assume this responsibility only if
8 * the class it extends has an accessible no-arg constructor to
9 * initialize the class's state. It is an error to declare a class
10 * Serializable if this is not the case. The error will be detected at
11 * runtime.
12 *
13 * During deserialization, the fields of non-serializable classes will
14 * be initialized using the public or protected no-arg constructor of
15 * the class. A no-arg constructor must be accessible to the subclass
16 * that is serializable. The fields of serializable subclasses will
17 * be restored from the stream.
18 */
閱讀文檔我們得知,為了使得不可序列化類的子類能夠序列化,其子類必須擔負起保存和恢復其超類的public、protected 和 package(if accessible)實例域的責任,且要求其父類必須有一個可訪問的無參構造函數以使得在反序列化時能夠初始化實例域。
我們寫代碼驗證一下,如果父類中沒有可訪問的無參構造函數會發生什麼,注意Person類中沒有無參構造函數。
1 import org.junit.Test;
2
3 import java.io.*;
4
5 public class SerializableTest {
6
7 @Test
8 public void testSerialize() {
9 Student one = new Student(12, 156.6, "1234");
10 Student two = new Student(16, 177.7, "5678");
11
12 try (ObjectOutputStream output =
13 new ObjectOutputStream(new FileOutputStream("Student.txt"))) {
14 output.writeObject(one);
15 output.writeObject(two);
16 } catch (IOException e) {
17 e.printStackTrace();
18 }
19 }
20
21 @Test
22 public void testDeserialize() {
23
24 try (ObjectInputStream input =
25 new ObjectInputStream(new FileInputStream("Student.txt"))) {
26 Student one = (Student) input.readObject();
27 Student two = (Student) input.readObject();
28
29 System.out.println(one);
30 System.out.println(two);
31 } catch (IOException e) {
32 e.printStackTrace();
33 } catch (ClassNotFoundException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 class Person{
40 protected int age;
41 protected double height;
42
43 public Person(int age, double height) {
44 this.age = age;
45 this.height = height;
46 }
47
48 @Override
49 public String toString() {
50 return "Person{" +
51 "age=" + age +
52 ", height=" + height +
53 '}';
54 }
55 }
56
57 class Student extends Person implements Serializable{
58 private String id;
59
60 public Student(int age, double height, String id) {
61 super(age, height);
62 this.id = id;
63 }
64
65 @Override
66 public String toString() {
67 return "Student{" +
68 "age=" + age +
69 ", height=" + height +
70 ", id='" + id + '\'' +
71 '}';
72 }
73 }
程序輸出產生異常:
java.io.InvalidClassException: Student; no valid constructor
at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(ObjectStreamClass.java:150)
at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:768)
at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1775)
at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1351)
at java.io.ObjectInputStream.readObject(ObjectInputStream.java:371)
at SerializableTest.testDeserialize(SerializableTest.java:26)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
...
Process finished with exit code 0
當我們為Person類添加默認構造函數時:
1 class Person{
2 protected int age;
3 protected double height;
4
5 public Person() {
6 }
7
8 public Person(int age, double height) {
9 this.age = age;
10 this.height = height;
11 }
12
13 @Override
14 public String toString() {
15 return "Person{" +
16 "age=" + age +
17 ", height=" + height +
18 '}';
19 }
20 }
程序輸出如下,我們可觀察到,父類中的字段都是默認值,只有子類中的字段得到了正確的序列化。出現這種情況的原因是子類並沒有擔負起序列化父類中字段的責任。
Student{age=0, height=0.0, id='1234'}
Student{age=0, height=0.0, id='5678'}
Process finished with exit code 0
為了解決上述問題,我們需要藉助上一節中學到的知識,使用自定義的序列化方法writeObject和readObject來主動將父類中的字段進行序列化。
1 import org.junit.Test;
2
3 import java.io.*;
4
5 public class SerializableTest {
6
7 @Test
8 public void testSerialize() {
9 Student one = new Student(12, 156.6, "1234");
10 Student two = new Student(16, 177.7, "5678");
11
12 try (ObjectOutputStream output =
13 new ObjectOutputStream(new FileOutputStream("Studnet.txt"))) {
14 output.writeObject(one);
15 output.writeObject(two);
16 } catch (IOException e) {
17 e.printStackTrace();
18 }
19 }
20
21 @Test
22 public void testDeserialize() {
23
24 try (ObjectInputStream input =
25 new ObjectInputStream(new FileInputStream("Studnet.txt"))) {
26 Student one = (Student) input.readObject();
27 Student two = (Student) input.readObject();
28
29 System.out.println(one);
30 System.out.println(two);
31 } catch (IOException e) {
32 e.printStackTrace();
33 } catch (ClassNotFoundException e) {
34 e.printStackTrace();
35 }
36 }
37 }
38
39 class Person{
40 protected int age;
41 protected double height;
42
43 public Person() {
44 }
45
46 public Person(int age, double height) {
47 this.age = age;
48 this.height = height;
49 }
50
51 @Override
52 public String toString() {
53 return "Person{" +
54 "age=" + age +
55 ", height=" + height +
56 '}';
57 }
58 }
59
60 class Student extends Person implements Serializable{
61 private String id;
62
63 public Student(int age, double height, String id) {
64 super(age, height);
65 this.id = id;
66 }
67
68 private void writeObject(java.io.ObjectOutputStream out)
69 throws IOException {
70 out.defaultWriteObject();
71 out.writeInt(age);
72 out.writeDouble(height);
73 }
74
75 private void readObject(java.io.ObjectInputStream in)
76 throws IOException, ClassNotFoundException {
77 in.defaultReadObject();
78 this.age = in.readInt();
79 this.height = in.readDouble();
80 }
81
82 @Override
83 public String toString() {
84 return "Student{" +
85 "age=" + age +
86 ", height=" + height +
87 ", id='" + id + '\'' +
88 '}';
89 }
90 }
程序輸出如下,可以看到完全正確。
Student{age=12, height=156.6, id='1234'}
Student{age=16, height=177.7, id='5678'}
Process finished with exit code 0
五、serialVersionUID的作用及自動生成
我們知道,反序列化必須擁有class文件,但隨着項目的升級,class文件也會升級,序列化怎麼保證升級前後的兼容性呢?
java序列化提供了一個private static final long serialVersionUID 的序列化版本號,只有版本號相同,即使更改了序列化屬性,對象也可以正確被反序列化回來。如果反序列化使用的class的版本號與序列化時使用的不一致,反序列化會報InvalidClassException異常。下面是JDK 8中ArrayList的源碼中的serialVersionUID。
1 public class ArrayList<E> extends AbstractList<E>
2 implements List<E>, RandomAccess, Cloneable, java.io.Serializable
3 {
4 private static final long serialVersionUID = 8683452581122892189L;
5
6 /**
7 * Default initial capacity.
8 */
9 private static final int DEFAULT_CAPACITY = 10;
10 ...
11 }
序列化版本號可自由指定,如果不指定,JVM會根據類信息自己計算一個版本號,這樣隨着class的升級,就無法正確反序列化;不指定版本號另一個明顯隱患是,不利於jvm間的移植,可能class文件沒有更改,但不同jvm可能計算的規則不一樣,這樣也會導致無法反序列化。
什麼情況下需要修改serialVersionUID呢?分三種情況。
- 如果只是修改了方法,反序列化不容影響,則無需修改版本號
- 如果只是修改了靜態Field或瞬態Field,則反序列化不受任何影響
- 如果修改類時修改了非靜態Field、非瞬態Field,則可能導致序列化版本不兼容。如果對象流中的對象和新類中包含同名的Field,而Field類型不同,則反序列化失敗,類定義應該更新serialVersionUID Field值。如果只是新增了實例變量,則反序列化回來新增的是默認值;如果減少了實例變量,反序列化時會忽略掉減少的實例變量。
我們在日常編程實踐中,一般會選擇使用IDE來自動生成serialVersionUID,這樣可以最大化地減少重複的可能性。對於IntelliJ IDEA,自動生成serialVersionUID有三步:
- 修改IDEA配置:File->Setting->Editor->Inspections->Serialization issues->Serializable class without ’serialVersionUID’
- 類實現Serializable接口
- 在類名上執行Alt+Enter,然後選擇生成serialVersionUID即可
六、序列化的缺點
Java序列化存在四個致命缺點,導致其不適用於網絡傳輸:
- 無法跨語言:在網絡傳輸中,經常會有異構語言的進程的交互,但Java序列化技術是Java語言內部的私有協議,其他語言無法進行反序列化。目前所有流行的RPC框架都沒有使用Java序列化作為編解碼框架。
- 潛在風險高:不可信流的反序列化可能導致遠程代碼執行(RCE)、拒絕服務(DoS)和一系列其他攻擊。
- 序列化后的碼流太大
- 序列化的性能較低
在真正的生產環境中,一般會選擇其它編解碼框架,領先的跨平台結構化數據表示是 JSON 和 Protocol Buffers,也稱為 protobuf。JSON 由 Douglas Crockford 設計用於瀏覽器與服務器通信,Protocol Buffers 由谷歌設計用於在其服務器之間存儲和交換結構化數據。JSON 和 protobuf 之間最顯著的區別是 JSON 是基於文本的,並且是人類可讀的,而 protobuf 是二進制的,但效率更高。
七、參考文獻
- 《瘋狂Java講義》第2版,李剛著,电子工業出版社
- 《Java核心技術》第10版,霍斯特曼等著,机械工業出版本
- 《Netty權威指南》第2版,李林鋒著,电子工業出版社
- 《Effective Java》第2版,Joshua Bloch著,机械工業出版社
本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理
【其他文章推薦】
※教你寫出一流的銷售文案?
※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益
※回頭車貨運收費標準
※別再煩惱如何寫文案,掌握八大原則!
※超省錢租車方案
※產品缺大量曝光嗎?你需要的是一流包裝設計!
※推薦台中搬家公司優質服務,可到府估價