【Android】数据持久化——数据存储

持久化技术简介

在你打开完成了一份PPT之后关闭程序,再次打开肯定是希望之前的内容还存在在电脑上,一打开PPT,之前的内容就自动出现了。数据持久化就是将那些内存中的瞬时数据保存到存储设备中,保证即使在手机或电脑关机的情况下,这些数据仍然不会丢失。保存在内存中的数据是处于瞬时状态的,而保存在存储设备中的数据是处于持久状态的,持久化技术则提供了一种机制可以让数据在瞬时状态和持久化状态之间进行转换。

Android系统主要提供了3种方式用于简单地实现数据持久化功能:

  • 文件存储
  • SharedPreferences存储
  • 数据库存储

文件存储

文件存储是Android中最基本的一种数据存储方式,它不对存储的内容进行任何的格式化处理,所有的数据都是原封不动地保存到文件当中,因而它比较适合用于存储一些简单的文本数据或者二进制数据。

将数据存储到文件中

Context类中提供了一个openFileOutput()方法,可以用于将数据存储到指定的文件当中。

  • 第一个参数为文件名,在文件创建的时候使用的就是这个名称(这里的文件名不可以包含路径,因为所有的文件都是默认存储到/data/data//files/目录下)
  • 第二个参数是文件的操作模式,主要有两种模式可以选,MODE_PRIVATE MODE_APPEND

二者的相同点:当文件不存在时,都会创建一个新文件来写入数据

MODE_PRIVATE:是默认的操作模式,会截断文件,即删除文件中的现有内容,并从文件开头开始写入新数据

MODE_APPEND:新写入的数据会被追加到文件的末尾,而不是覆盖现有内容

openFileOutput()方法返回的是一个FileOutputStream对象,得到了这个对象就可以使用Java流的方式将数据写到文件中了。我们先创建一个活动设置一个EditText用来使用户输入信息,在我们关闭程序的时候将数据进行存储,代码如下:

java 复制代码
public class MainActivity extends AppCompatActivity {

    private EditText editText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });
        editText = (EditText) findViewById(R.id.edit);
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        String input = editText.getText().toString();
        save(input);
    }

    public void save(String inputText) {
        FileOutputStream out = null;
        BufferedWriter writer = null;
        try {
            out = openFileOutput("data", Context.MODE_PRIVATE);
            writer = new BufferedWriter(new OutputStreamWriter(out));
            writer.write(inputText);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (writer != null) {
                    writer.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

从文件中读取数据

Context类中还提供一个openFileInput()方法,用于从文件里面读取数据,它只接收一个参数,即要读取的文件名,然后系统会自动到目录下去加载这个文件,并返回一个FileInputStream对象,得到这个对象之后再通过Java流的方式将数据读取出来。

读取文件代码如下:

java 复制代码
public String lode () {
    FileInputStream in = null;
    BufferedReader reader = null;
    StringBuilder content = new StringBuilder();
    try {
        in = openFileInput("data");
        reader = new BufferedReader(new InputStreamReader(in));
        String line = "";
        while ((line = reader.readLine()) != null) {
            content.append(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
    return content.toString();
}

首先通过openFileInput()方法获取到一个FileInputStream对象,然后借助它又构建一个InputStreamReader对象,接着再使用InputStreamReader构建成一个BufferedReader对象,这样就可以一行行地读取,把文件地内容都读取出来。学习了读取文件的代码,就对活动进行修改,使你第一次输入的数据在退出程序之后再次进入程序数据依然保留。修改代码如下:

先将上面读取文件的代码加入活动当中,再在onCreate()方法里面补充:

java 复制代码
String input = lode();
editText = (EditText) findViewById(R.id.edit);
if (!TextUtils.isEmpty(input)) {
    editText.setText(input);
    editText.setSelection(input.length());
    Toast.makeText(this,"Restoring succeeded", Toast.LENGTH_SHORT).show();
}

在Android开发中,setSelection方法用于设置文本输入框(如EditText)中光标的位置。当你调用editText.setSelection(input.length());时,你实际上是将光标移动到了文本的末尾。

现在运行程序,第一次运行没有输入任何的数据,因此打开程序的输入框是空白的,当我们输入一段文字,再次打开:

SharedPreferences存储

SharedPreferences是使用键值对的方式进行数据存储的,因此我们可以根据数据所对应的键将数据取出来。

将数据存储到SharedPreferences中

要想使用SharedPreferences来存储数据,首先需要获取到SharedPreferences对象,Android提供了三种方式用于得到SharedPreferences对象:

  • Context类中的getSharedPreferences()方法

    此方法接收两个参数,第一个参数用于指定SharedPreferences文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences文件都是放在/data/data//shared_prefs/目录下的。第二个参数用于指定操作模式,目前只有MODE_PRIVATE这一种模式可选,它是默认的操作模式,和直接传入0是效果相同的,表示只有当前的程序才可以对这个SharedPreferences文件进行读写

  • Activity类中的getPreferences()方法

    这个方法和上面的方法很类似,但它只接收一个操作模式参数,因为这个方法会自动获取当前活动的类名作为SharedPreferences的文件名

  • PreferenceManage类中的getDefaultSharedPreferences()方法

    这是一个静态方法,只接收一个Context参数,并自动使用当前应用程序的包名作为前缀名来命名SharedPreferences文件。

接下来就进行数据的存储:

  1. 调用SharedPreferences对象的edit()方法来获取一个SharedPreferences.Editor对象
  2. SharedPreferences.Editor对象中添加数据,例如添加String类型,就使用putString()方法
  3. 调用apply()方法将添加的数据提交,从而完成数据存储操作

接下来就根据实例来体验一下吧,新建一个项目,在主活动上面设置一个按钮用来触发存储数据,为按钮注册点击事件:

java 复制代码
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });

        Button buttonsava = (Button) findViewById(R.id.savaData);
        buttonsava.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                SharedPreferences.Editor editor =
                        getSharedPreferences("data", MODE_PRIVATE).edit();
                editor.putString("name", "tom");
                editor.putInt("age", 28);
                editor.putBoolean("married", false);
                editor.apply();
            }
        });
    }
}

当我们按下按钮就会触发点击事件,将这三个数据存储进去

从SharedPreferences中读取数据

上面提到了put方法将数据存储进去,相对应的有get方法从中读取数据。这些get方法都接收两个参数,第一个参数是键,传入存储数据时使用的键就可以得到相应的值了;第二个参数是默认值,即表示当前的键找不到对应的值时会以什么样的默认值进行返回。

仍然在上面进行修改,添加一个按钮用来触发读取数据的事件,并未按钮注册点击事件:

java 复制代码
Button buttonget = (Button) findViewById(R.id.getData);
buttonget.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        SharedPreferences preferences = getSharedPreferences("data", MODE_PRIVATE);
        String name = preferences.getString("name", "");
        int age = preferences.getInt("age", 0);
        boolean marr = preferences.getBoolean("married", false);
        Log.d("MainActivity", "name is " + name);
        Log.d("MainActivity", "age is " + age);
        Log.d("MainActivity", "married is " + marr);
    }
});

按下get data按钮就可以得到信息了:

案例

接下来就通过一个案例让大家体会数据存储吧。我们有时在登录的时候经常遇到一个选项:是否记住密码?当我们按下这个按钮在下一次登录的时候就会自动的为我们输入数据,此时我们只需要按下登录按钮即可。想要了解的话可以看上一篇广播的博客,在结尾我们实现了一个强制下线的功能,现在就对这个小项目加上记住密码功能。因此在登录界面我们需要使使用者自由选择是否让程序记住密码:

xml 复制代码
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">
    <CheckBox
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/remember_pass"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="18sp"
        android:text="remember password"/>
</LinearLayout>

修改登录界面的代码:

java 复制代码
private SharedPreferences preferences;
private SharedPreferences.Editor editor;
private CheckBox checkBox;

先根据前面的读取数据在onCreate()方法里面修改:

java 复制代码
preferences = PreferenceManager.getDefaultSharedPreferences(this);
checkBox = (CheckBox) findViewById(R.id.remember_pass);
boolean isremember = preferences.getBoolean("remember_password", false);
if (isremember) {
    String account = preferences.getString("account", "");
    String password = preferences.getString("password", "");
    accountEdit.setText(account);
    passwordEdit.setText(password);
    checkBox.setChecked(true);
}

先获取上一次存储的数据是否允许记得,如果是,就将上一次的数据读取出来,显示在文本框,接下来就是登录的点击按钮会根据我们此次是否选择记住密码而进行不同的操作:

java 复制代码
login.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        String account = accountEdit.getText().toString();
        String password = passwordEdit.getText().toString();
        if (account.equals("admin") && password.equals("123456")) {
            editor = preferences.edit();
            if (checkBox.isChecked()) {
                editor.putBoolean("remember_password", true);
                editor.putString("account", account);
                editor.putString("password", password);
            } else {
                editor.clear();
            }
            editor.apply();
            Intent intent = new Intent(LoginActivity.this, MainActivity.class);
            startActivity(intent);
            finish();
        } else {
            Toast.makeText(LoginActivity.this,
                    "account or password is invalid", Toast.LENGTH_SHORT).show();
        }
    }
});

在我们的账户与密码正确的时候会根据我们对于记住密码的选择进行数据存储的更新。

此时运行程序,当我们选择记住密码时,到了活动界面按下按钮,强制下线之后密码与账号已经输好了,只需要按下登录按钮即可。

SQLite数据库存储

Android系统内置了数据库------SQLite。SQLite是一款轻量级的关系型数据库,它的运算速度非常快,占用资源很少,通常只需要几百KB的内存就足够了,因而特别适合在移动设备上使用。SQLite不及支持标准的SQL语法,还遵循数据库的ACID事务。前面介绍的方法都只适用一些简单的数据存储,当存储量大的时候上面的方法就很难实现了。接下来就看看Android的SQLite数据库是如何使用的吧!

创建数据库

Android为了让我们能够更加方便地管理数据库,专门提供了一个 SQLiteOpenHelper帮助类,借助这个类就可以非常简单地对数据库进行创建和升级。下面我就对 SQLiteOpenHelper的基本用法进行介绍。

SQLiteOpenHelper是一个抽象类,这意味着如果我们想要使用它的话,就需要创建一个自己的帮助类去继承它。SQLiteOpenHelper中有两个抽象方法,分别是onCreate()onUpgrade(),我们必须在自己的帮助类里面重写这两个方法,然后分别在这两个方法中去实现创建、升级数据库的逻辑。

SQLiteOpenHelper中还有两个非常重要的实例方法:getReadableDatabase()getwritableDatabase()。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则创建一个新的数据库),并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候(如磁盘空间已满),getReadableDatabase()方法返回的对象将以只读的方式去打开数据库,而getwritableDatabase()方法则将出现异常。

SQLiteOpenHelper中有两个构造方法可供重写,一般使用参数少一点的那个构造方法即可。这个构造方法中接收4个参数:

  • 第一个参数是 Context,这个没什么好说的,必须要有它才能对数据库进行操作。

Cursor是一个从android.database.Cursor类继承而来的接口,用于访问SQLite数据库的内容。

  • 第二个参数是数据库名,创建数据库时使用的就是这里指定的名称。
  • 第三个参数允许我们在查询数据的时候返回一个自定义的Cursor,一般都是传入null。
  • 第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作。

构建出 SQLiteOpenHelper的实例之后,再调用它的getReadableDatabase()getwritableDatabase()方法就能够创建数据库了,数据库文件会存放在/data/data//databases/目录下。此时,重写的oncreate()方法也会得到执行,所以通常会在这里去处理一些创建表的逻辑。

SQL知识的补充:

一个数据库通常会包含一个或多个表,每个表由一个名字标识(例如价格、id)。表包含带有数据的记录,例:

ID name age
1 Tom 23
2 Jack 15
3 Aila 41

包含三条记录(每个记录对应一个人)

  1. 创建表:

create table 表名称 (

列名称1 数据类型,

列名称2 数据类型,

...

);

它的数据类型很简单:integer表示整型,real表示浮点型,text表示文本类型,blob表示二进制类型

接下来就实践一下:

新建MyDatabaseHelper继承于SQLiteOpenHelper

java 复制代码
public class MyDatabaseHelper extends SQLiteOpenHelper {
    public static final String CREATE_BOOK = "create table book ("
            + "id integer primary key autoincrement, "
            + "author text, "
            + "price reeal, "
            + "pages integer, "
            + "name text )";

    private Context mcontext;
    public MyDatabaseHelper (Context context, String name,
                        SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        mcontext = context;
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_BOOK);
        Toast.makeText(mcontext, "succeeded succeeded", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

    }
}
  1. 我们先使用SQL语句创建了一个名为book的表
  • 在表中创建id的时候我们使用primary key,该字段是表的主键。主键是一个或多个字段的组合,用于唯一标识表中的每条记录。在数据库中,主键的值必须唯一,不能为null(除非特别指定)
  • 我们还加入了autoincrement ,这是一个特定于某些数据库系统(如SQLite)的属性,表示每当表中插入一条新记录时,该字段的值会自动递增。这通常用于生成一个唯一的序列号,使得每条记录都有一个唯一的标识符。在SQLite中,autoincrement属性仅适用于整型(integer)字段。
  1. 构造出MyDatabaseHelper,传入4个参数:
  • context:应用程序的上下文,用于获取数据库文件的路径
  • name:数据库文件的名称
  • factory:游标工厂,用于创建游标对象。游标(Cursor)是一个重要的概念,它允许应用程序从数据库中检索和操作数据。游标工厂(Cursor Factory)是一个接口,用于创建游标对象
  • version:数据库的版本号
  1. 重写了MyDatabaseHelperonCreate()方法,这样在我们创建这个数据库的时候就会跟着执行onCreate()方法,从而同时完成表的创建。
  2. onUpgrade()方法:这是SQLiteOpenHelper的另一个重写方法,当数据库需要升级时调用

这样数据库的创建代码就完成了,接下来我们就在活动当中加上一个按钮,使按下按钮数据库就自动进行创建:

java 复制代码
public class MainActivity extends AppCompatActivity {

    //按钮用来触发数据库的创建
    private Button buttonCreateData;
    //新建数据库
    MyDatabaseHelper dphelper;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });
		//创建了一个MyDatabaseHelper对象
        dphelper = new MyDatabaseHelper(this, "BookStore.db", null, 1);
        //为按钮注册点击事件
        buttonCreateData = (Button) findViewById(R.id.create_database);
        buttonCreateData.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                dphelper.getWritableDatabase();
            }
        });
    }
}

我们先创建了一个MyDatabaseHelper对象,当你第一次点击按钮此时程序当中并没有这个数据库,因此会调用MyDatabaseHelper里的onCreate()方法进行创建,此时就会弹出Toast提示。当我们再次按的时候,由于已经创建过了,就会不会再执行了,也就没有了Toast消息提示。

升级数据库

在上面数据库类里面就有一个空的方法onUpgrade(),我们只简单提了一下是在数据库升级时调用,接下来就重点学习吧。可是很重要的!!

我们在其中加入一个表用于书的分类,我们再在onCreate()方法里面加上将这个表创建的语句:

java 复制代码
public static final String CREATE_CATEGORY = "create table category ("
    + "id integer primary key autoincrement, "
    + "category_name text, "
    + "category_code integer)";
java 复制代码
public void onCreate(SQLiteDatabase db) {
    db.execSQL(CREATE_BOOK);
    db.execSQL(CREATE_CATEGORY);
    Toast.makeText(mcontext, "succeeded succeeded", Toast.LENGTH_SHORT).show();
}

此时运行程序,并没有Toast语句弹出,即没有执行onCreate()方法,这是因为数据库已经在之前建好了,之后无论怎样都不会执行,因此我们就要用到onUpgrade()方法,对这个方法进行修改:

java 复制代码
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    db.execSQL("drop table if exists book");
    db.execSQL("drop table if exists category");
    onCreate(db);
}

这两个语句为当在数据库当中发现存在这两张表的时候就将表删除,之后再执行onCreate()方法,接下来就是让这个方法在创建的时候执行,上面提到构造的时候传入个参数还记得吗,最后一个就是版本号,此时我们能将版本号改为2,再次运行程序,此时就弹出了添加成功的信息。

添加数据

SQLiteDatabse 中提供了一个insert()方法,这个方法就是专门用于添加数据的:

  • 第一个参数是表名,我们希望向哪张表里添加数据,这里就传入该表的名字
  • 第二个参数用于在未指定添加数据的情况下给某些可为空的列自动赋值NULL,一般我们用不到这个功能,直接传入null即可
  • 第三个参数是一个ContentValues对象,它提供了一系列的put()方法重载,用于向ContentValues中添加数据,只需要将表中的每个列名以及相应的待添加数据传入即可。
java 复制代码
Button buttonadd = (Button) findViewById(R.id.add_data);
buttonadd.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        SQLiteDatabase db = dphelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        values.put("name", "Tombook");
        values.put("author", "Tom");
        values.put("price", 23.23);
        values.put("pages", 56);
        db.insert("book", null, values);
        values.clear();
        values.put("name", "Ailabook");
        values.put("author", "Aila");
        values.put("price", 56.23);
        values.put("pages", 99);
        db.insert("book", null, values);
        Toast.makeText(MainActivity.this, "hhhh", Toast.LENGTH_SHORT).show();
    }
});

更新数据

SQLite-Database中也提供了一个非常好用的 update()方法,用于对数据进行更新

  • 第一个参数和insert()方法一样,也是表名,在这里指定去更新哪张表里的数据
  • 第二个参数是ContentValues对象,要把更新数据在这里组装进去
  • 第三、第四个参数用于约束更新某一行或某几行中的数据,不指定的话默认就是更新所有行
java 复制代码
Button buttonupdata = (Button) findViewById(R.id.updata_data);
buttonupdata.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        SQLiteDatabase db = dphelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        values.put("price", 12.12);
        db.update("book", values, "name = ?",new String[] {"Tombook"});
    }
});

第三个参数对应的是SQL语句的 where部分,表示更新所有name等于?的行,而?是一个占位符,可以通过第四个参数提供的一个字符串数组为第三个参数中的每个占位符指定相应的内容。因此上述代码想表达的意图是将名字是Tombook的这本书的价格改成10.99。

删除数据

SQLite-Database中也提供了一个非常好用的 delete()方法,用于对数据进行删除

  • 第一个参数仍然为表名
  • 第二三个参数用于约束更新某一行或某几行中的数据,不指定的话默认就是删除所有行
java 复制代码
Button buttondelete = (Button) findViewById(R.id.dalete_data);
buttondelete.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        SQLiteDatabase db = dphelper.getWritableDatabase();
        db.delete("book", "pages > ?", new String[] {"500"});
    }
});

查询数据

SQLite-Database中专门提供了一个非常好用的 query()方法,用于对数据进行查找,需要传入七个参数

  • 表名,即我们希望从哪张表中查询数据
  • 用于指定查询那几列,不指定则默认查所有列
  • 第三四个参数用于约束查询某一行或者某几行的数据,不指定则默认查询所有行的数据
  • 用于指定需要去group by的列,不指定则表示不对查询结果进行group by的操作(即进行分组)
  • 用于对进行group by之后的数据进行进一步的过滤,不指定则表示不进行过滤
  • 用于指定查询结果的排序方式,不指定则表示默认的排序方式
java 复制代码
Button buttonquery = (Button) findViewById(R.id.query_data);
buttonquery.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        SQLiteDatabase db = dphelper.getWritableDatabase();
        Cursor cursor = db.query("book", null, null, null, null, null, null);
        if (cursor.moveToFirst()) {
            while (cursor.moveToNext()){
                @SuppressLint("Range") String name = cursor.getString(cursor.getColumnIndex("name"));
                @SuppressLint("Range") int pages = cursor.getInt(cursor.getColumnIndex("pages"));
                @SuppressLint("Range") double price = cursor.getDouble(cursor.getColumnIndex("price"));  
            }
        }
        cursor.close();
    }
});

文章到这里就结束了!

相关推荐
云和数据.ChenGuang20 分钟前
Django 应用安装脚本 – 如何将应用添加到 INSTALLED_APPS 设置中 原创
数据库·django·sqlite
woshilys1 小时前
sql server 查询对象的修改时间
运维·数据库·sqlserver
Hacker_LaoYi1 小时前
SQL注入的那些面试题总结
数据库·sql
2401_857439692 小时前
SSM 架构下 Vue 电脑测评系统:为电脑性能评估赋能
开发语言·php
建投数据2 小时前
建投数据与腾讯云数据库TDSQL完成产品兼容性互认证
数据库·腾讯云
SoraLuna2 小时前
「Mac畅玩鸿蒙与硬件47」UI互动应用篇24 - 虚拟音乐控制台
开发语言·macos·ui·华为·harmonyos
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
Hacker_LaoYi3 小时前
【渗透技术总结】SQL手工注入总结
数据库·sql
岁月变迁呀3 小时前
Redis梳理
数据库·redis·缓存
独行soc3 小时前
#渗透测试#漏洞挖掘#红蓝攻防#护网#sql注入介绍06-基于子查询的SQL注入(Subquery-Based SQL Injection)
数据库·sql·安全·web安全·漏洞挖掘·hw