pgmemcache installation

本文涉及的产品
云原生数据库 PolarDB PostgreSQL 版,标准版 2核4GB 50GB
云原生数据库 PolarDB MySQL 版,通用型 2核4GB 50GB
简介:
pgmemcache是一系列的PostgreSQL函数, 用于memcache的读写操作. 
通过pgmemcache以及PostgreSQL的触发器可以方便的对数据库中的数据进行缓存. 
当然缓存的操作也可以挪至应用程序自己来处理. pgmemcache只是一种选择.
本文涉及的环境如下 :
CentOS 5.7 x64
PostgreSQL 9.2.0
libevent 2.0.20-stable
memcached 1.4.15
libmemcache 0.48
pgmemcache 2.0.6


首先介绍一下pgmemcache的安装 : 
pgmemcache 依赖 libmemcache和PostgreSQL
libmemcache依赖libevent和memcache
memcache依赖libevent.

所以首先要安装的是libevent : 
wget https://github.com/downloads/libevent/libevent/libevent-2.0.20-stable.tar.gz
tar -zxvf libevent-2.0.20-stable.tar.gz
cd libevent-2.0.20-stable
./configure
make
make install

安装完后可以看到安装好后的libevent动态库文件.
updatedb
locate libevent-2.0.so
/usr/local/lib/libevent-2.0.so.5
/usr/local/lib/libevent-2.0.so.5.1.8

将这个目录加入动态库配置文件 : 
vi /etc/ld.so.conf
/usr/local/lib
ldconfig

确认已经生效 : 
ldconfig -p|grep libevent
        libevent_pthreads-2.0.so.5 (libc6,x86-64) => /usr/local/lib/libevent_pthreads-2.0.so.5
        libevent_openssl-2.0.so.5 (libc6,x86-64) => /usr/local/lib/libevent_openssl-2.0.so.5
        libevent_extra-2.0.so.5 (libc6,x86-64) => /usr/local/lib/libevent_extra-2.0.so.5
        libevent_core-2.0.so.5 (libc6,x86-64) => /usr/local/lib/libevent_core-2.0.so.5
        libevent-2.0.so.5 (libc6,x86-64) => /usr/local/lib/libevent-2.0.so.5


接下来要安装memcached : 
wget http://memcached.googlecode.com/files/memcached-1.4.15.tar.gz
tar -zxvf memcached-1.4.15.tar.gz
cd memcached-1.4.15
./configure --help
./configure --prefix=/opt/memcached-1.4.15 --enable-sasl --enable-64bit
make
make install
cd /opt/memcached-1.4.15/share/man/man1
man ./memcached.1


接下来安装libmemcached (找一个合适的版本, 例如CentOS 5.7 用到的是 libmemcached-0.48 ) : 
wget http://download.tangent.org/libmemcached-0.48.tar.gz
tar -zxvf libmemcached-0.48.tar.gz
cd libmemcached-0.48
./configure --prefix=/opt/libmemcached-0.48 --with-memcached=/opt/memcached-1.4.15/bin/memcached
make
make install


修改动态库配置文件, 并使之生效 : 
vi /etc/ld.so.conf
/opt/libmemcached-0.48/lib
ldconfig

查看新增的动态库 是否生效  : 
ldconfig -p|grep libmemcache
        libmemcachedutil.so.1 (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcachedutil.so.1
        libmemcachedutil.so (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcachedutil.so
        libmemcachedprotocol.so.0 (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcachedprotocol.so.0
        libmemcachedprotocol.so (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcachedprotocol.so
        libmemcached.so.6 (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcached.so.6
        libmemcached.so (libc6,x86-64) => /opt/libmemcached-0.48/lib/libmemcached.so
        libhashkit.so.0 (libc6,x86-64) => /opt/libmemcached-0.48/lib/libhashkit.so.0
        libhashkit.so (libc6,x86-64) => /opt/libmemcached-0.48/lib/libhashkit.so


最后要安装pgmemcache_2.0.6.tar.bz2
wget http://pgfoundry.org/frs/download.php/3018/pgmemcache_2.0.6.tar.bz2
tar -jxvf pgmemcache_2.0.6.tar.bz2
cd pgmemcache

需要用到pg_config, 所以需要加入到PATH中.
. /home/pg9.2.0/.bash_profile


pgmemcache的头文件中包含了libmemcached的一些头, 如下, 所以需要将这些头文件拷贝到pgmemcache的目录中来.
less pgmemcache.h
#include <libmemcached/sasl.h>
#include <libmemcached/memcached.h>
#include <libmemcached/server.h>
#include <sasl/sasl.h>


拷贝这些头文件到本地目录中, 
cp -r /opt/libmemcached-0.48/include/libhashkit ./
cp -r /opt/libmemcached-0.48/include/libmemcached ./


同时编译时需要用到libmemcached.so, 如下Makefile :
less Makefile
SHLIB_LINK = -lmemcached -lsasl2


但是没有指定库目录, 所以需要修改一下
vi Makefile
SHLIB_LINK = -L/opt/libmemcached-0.48/lib -lmemcached -lsasl2


接下来编译安装就可以了.
gmake
gmake install


安装好pgmemcache后, 需要做的是修改PostgreSQL的配置文件以及重启数据库, 在需要用到pgmemcache函数的数据库中执行pgmemcache.sql.
这里假设172.16.3.150上已经启动了memcached.
su - pg9.2.0
cd $PGDATA
vi postgresql.conf
shared_preload_libraries = 'pgmemcache'
pgmemcache.default_servers = '172.16.3.150:11211'  #多个memcached可以用逗号隔开配置.
pgmemcache.default_behavior = 'BINARY_PROTOCOL:1'  #多个配置可以用逗号隔开配置. 


重启数据库 : 
pg_ctl stop -m fast
pg_ctl start

在加载pgmemcache.sql前, 需要对这个脚本修改一下, 否则会报语法错误.
cd $PGHOME/share/contrib
vi pgmemcache.sql
:%s/LANGUAGE\ 'C'/LANGUAGE\ C/g
:x!


在需要的库中执行脚本 : 
psql -h 127.0.0.1 -U postgres digoal -f ./pgmemcache.sql


好了简单的测试一下pgmemcache的函数 : 
digoal=> select memcache_set('key1', '1');
 memcache_set 
--------------
 t
(1 row)

digoal=> select memcache_get('key1');
 memcache_get 
--------------
 1
(1 row)

digoal=> select memcache_incr('key1',99);
 memcache_incr 
---------------
           100
(1 row)

digoal=> select memcache_incr('key1',99);
 memcache_incr 
---------------
           199
(1 row)

digoal=> select memcache_stats();
        memcache_stats        
------------------------------
                             +
 Server: 172.16.3.150 (11211)+
 pid: 1918                   +
 uptime: 13140               +
 time: 1353222576            +
 version: 1.4.15             +
 pointer_size: 64            +
 rusage_user: 0.999          +
 rusage_system: 0.1999       +
 curr_items: 1               +
 total_items: 3              +
 bytes: 72                   +
 curr_connections: 6         +
 total_connections: 10       +
 connection_structures: 7    +
 cmd_get: 1                  +
 cmd_set: 1                  +
 get_hits: 1                 +
 get_misses: 0               +
 evictions: 0                +
 bytes_read: 207             +
 bytes_written: 3196         +
 limit_maxbytes: 67108864    +
 threads: 4                  +


digoal=> select memcache_flush_all();
 memcache_flush_all 
--------------------
 t

digoal=> select memcache_get('key1');
 memcache_get 
--------------
 
(1 row)

digoal=> select memcache_stats();
        memcache_stats        
------------------------------
                             +
 Server: 172.16.3.150 (11211)+
 pid: 1918                   +
 uptime: 13165               +
 time: 1353222601            +
 version: 1.4.15             +
 pointer_size: 64            +
 rusage_user: 0.999          +
 rusage_system: 0.1999       +
 curr_items: 0               +
 total_items: 3              +
 bytes: 0                    +
 curr_connections: 6         +
 total_connections: 11       +
 connection_structures: 7    +
 cmd_get: 2                  +
 cmd_set: 1                  +
 get_hits: 1                 +
 get_misses: 1               +
 evictions: 0                +
 bytes_read: 276             +
 bytes_written: 4278         +
 limit_maxbytes: 67108864    +
 threads: 4                  +

【cache应用场景举例】
1. 测试表
digoal=> create table tbl_user_info (userid int8 primary key, pwd text);
NOTICE:  CREATE TABLE / PRIMARY KEY will create implicit index "tbl_user_info_pkey" for table "tbl_user_info"
CREATE TABLE

2. 测试数据

digoal=> insert into tbl_user_info select generate_series(1,10000000), md5(clock_timestamp()::text);
INSERT 0 10000000

3. 更新触发器(不安全)
CREATE OR REPLACE FUNCTION tbl_user_info_upd() RETURNS TRIGGER AS $$
        BEGIN
        IF OLD.pwd != NEW.pwd THEN
                PERFORM memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd);
        END IF;
        RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
CREATE TRIGGER tbl_user_info_upd AFTER UPDATE ON tbl_user_info FOR EACH ROW EXECUTE PROCEDURE tbl_user_info_upd();

3. 插入触发器(不安全)
CREATE OR REPLACE FUNCTION tbl_user_info_ins() RETURNS TRIGGER AS $$
        BEGIN
        PERFORM memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd);
        RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
CREATE TRIGGER tbl_user_info_ins AFTER INSERT ON tbl_user_info FOR EACH ROW EXECUTE PROCEDURE tbl_user_info_ins();

4. 删除触发器(安全, 因为无法命中cache是安全的, 但是cache数据和table数据不一致是不安全的)
CREATE OR REPLACE FUNCTION tbl_user_info_del() RETURNS TRIGGER AS $$
BEGIN
        PERFORM memcache_delete('tbl_user_info_' || NEW.userid || '_pwd');
        RETURN OLD;
END;
$$ LANGUAGE 'plpgsql';
CREATE TRIGGER tbl_user_info_del AFTER DELETE ON tbl_user_info FOR EACH ROW EXECUTE PROCEDURE tbl_user_info_del();

5. 密码匹配函数 : 
CREATE OR REPLACE FUNCTION auth (i_userid int8, i_pwd text) returns boolean as $$
declare
  v_input_pwd_md5 text;
  v_user_pwd_md5 text;
begin
  v_input_pwd_md5 := md5(i_pwd);
  select memcache_get('tbl_user_info_' || i_userid || '_pwd') into v_user_pwd_md5;
  if (v_user_pwd_md5 <> '' ) then
    raise notice 'hit in memcache.';
    if (v_input_pwd_md5 = v_user_pwd_md5) then
      return true;
    else
      return false;
    end if;
  else
    select pwd into v_user_pwd_md5 from tbl_user_info where userid=i_userid;
    if found then
      raise notice 'hit in table.';
      if (v_input_pwd_md5 = v_user_pwd_md5) then
        return true;
      else
        return false;
      end if;
    else
      return false;
    end if;
  end if;
exception
when others then
  return false;
end;
$$ language plpgsql;


6. 再次插入测试数据, 并且手动退出请求. 这样将导致PG回滚这条SQL, 但是memcache已经被操作了. 
digoal=> insert into tbl_user_info select generate_series(10000001,11000001), md5(clock_timestamp()::text);
Cancel request sent
ERROR:  canceling statement due to user request
CONTEXT:  SQL statement "SELECT memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd)"
PL/pgSQL function tbl_user_info_ins() line 3 at PERFORM


不要以为memcache也回滚了, 因为memcache启动时没有启用-M, 超出后将 removing items  , 所以我们查询这些KEY都不存在.
-M            return error on memory exhausted (rather than removing items)
digoal=> select count(*) from tbl_user_info ;
  count   
----------
 10000001
(1 row)

这些KEY查不到数据, 不是因为memcache的操作回滚了, 而是发生了 removing items  的情况, 如上所述.
digoal=> select memcache_get('tbl_user_info_10000001_pwd');
 memcache_get 
--------------
 
(1 row)
digoal=> select memcache_get('tbl_user_info_10000002_pwd');
 memcache_get 
--------------
 
(1 row)

重启memcache, 并限制8MB的内存和开启-M.
[root@db-172-16-3-150 ~]# ps -ewf|grep memcache
pg9.2.0   1918     1  0 Nov18 ?        00:01:06 ./memcached -d -u pg9.2.0
root     20612 20581  0 08:43 pts/7    00:00:00 grep memcache
[root@db-172-16-3-150 ~]# kill 1918

[root@db-172-16-3-150 bin]# ./memcached -d -u pg9.2.0 -M -m 8

再次插入时, 内存使用完就开始报warning了, 但是SQL语句照常执行, 并没有回滚掉. 所以说是不安全的.
digoal=> insert into tbl_user_info select generate_series(10000001,11000001), md5(clock_timestamp()::text);
WARNING:  MEMORY ALLOCATION FAILURE
CONTEXT:  SQL statement "SELECT memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd)"
PL/pgSQL function tbl_user_info_ins() line 3 at PERFORM

手工的CACNEL SQL请求, 这时PG将发生回滚, 但是这里注意memcache的数据已经写入了.
ERROR:  canceling statement due to user request
CONTEXT:  SQL statement "SELECT memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd)"
PL/pgSQL function tbl_user_info_ins() line 3 at PERFORM

从这里看, memcache的数据已经写入了.
digoal=> select memcache_get('tbl_user_info_10000001_pwd');
           memcache_get           
----------------------------------
 07e975fb5fc9a308760cb7711681635b
(1 row)

【小结】
1. memcache可以选择是否编译64位版本.
   --enable-64bit          build 64bit version

    64bit 对应pointer_size : 64, 所以将占用更多的空间. 如果没有超过20亿的key, 使用32位就够了.
2. 事务安全性
    memcache_get是安全的.
    但是memcache_set, replace在事务中是不安全的. 因为事务回滚时, 这些函数不会回滚. 也就是说对memcache的更改无法回滚.
    在trigger function写exception就可以解决了么?
    例如:
CREATE OR REPLACE FUNCTION tbl_user_info_ins() RETURNS TRIGGER AS $$
        BEGIN
        PERFORM memcache_set('tbl_user_info_' || NEW.userid || '_pwd', NEW.pwd);
        RETURN NEW;
EXCEPTION when others then
  PERFORM memcache_delete('tbl_user_info_' || NEW.userid || '_pwd');
END;
$$ LANGUAGE 'plpgsql';

    经过测试只对第一条tuple有效.
3. 除了pgmemcache以外, pgredis也是类似的项目, 只不过是redis的一些封装好的API.

【参考】
8.  http://libevent.org/
9. pgmemcache functions :
-- $1 = server hostname - TEXT
CREATE OR REPLACE FUNCTION memcache_server_add(TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_server_add' LANGUAGE C STRICT;

-- $1 = key - TEXT
-- $2 = val - TEXT (opt)
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
-- $4 = flags - INT4 (opt)
CREATE OR REPLACE FUNCTION memcache_add(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_add' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_add(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_add_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_add(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_add' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_get(TEXT) RETURNS TEXT
AS '$libdir/pgmemcache', 'memcache_get' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_get(BYTEA) RETURNS TEXT
AS '$libdir/pgmemcache', 'memcache_get' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_get_multi(IN TEXT[], OUT key TEXT, OUT value TEXT) RETURNS SETOF record
AS '$libdir/pgmemcache', 'memcache_get_multi' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_get_multi(IN BYTEA[], OUT key TEXT, OUT value TEXT) RETURNS SETOF record
AS '$libdir/pgmemcache', 'memcache_get_multi' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = decrement - INT4, default 1 (opt)
CREATE OR REPLACE FUNCTION memcache_decr(TEXT, INT4) RETURNS INT4
AS '$libdir/pgmemcache', 'memcache_decr' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_decr(TEXT) RETURNS INT4
AS '$libdir/pgmemcache', 'memcache_decr' LANGUAGE C;


-- $1 = key - TEXT
-- $2 = increment - INT4, default 1 (opt)
CREATE OR REPLACE FUNCTION memcache_incr(TEXT, INT4) RETURNS INT4
AS '$libdir/pgmemcache', 'memcache_incr' LANGUAGE C STRICT;

CREATE OR REPLACE FUNCTION memcache_incr(TEXT) RETURNS INT4
AS '$libdir/pgmemcache', 'memcache_incr' LANGUAGE C STRICT;


-- $1 = key - TEXT
-- $2 = hold timer - INTERVAL (opt)
CREATE OR REPLACE FUNCTION memcache_delete(TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_delete' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_delete(TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_delete' LANGUAGE C;

-- Flush all servers in the cluster
CREATE OR REPLACE FUNCTION memcache_flush_all() RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_flush_all0' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_replace(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_replace' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_replace(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_replace_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_replace(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_replace' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_set(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_set' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_set(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_set_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_set(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_set' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_set(BYTEA, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_set' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_set(TEXT, BYTEA) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_set' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_prepend' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_prepend_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT) RETURNS BOOLAS '$libdir/pgmemcache', 'memcache_prepend' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_prepend' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_prepend_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_prepend(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_prepend' LANGUAGE C;

-- $1 = key - TEXT
-- $2 = val - TEXT
-- $3 = expire - INTERVAL or TIMESTAMPTZ (opt)
CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT, INTERVAL) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT, TIMESTAMPTZ) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append_absexpire' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_append(TEXT, TEXT) RETURNS BOOL
AS '$libdir/pgmemcache', 'memcache_append' LANGUAGE C;

CREATE OR REPLACE FUNCTION memcache_stats() RETURNS TEXT
AS '$libdir/pgmemcache', 'memcache_stats' LANGUAGE C;


相关实践学习
使用PolarDB和ECS搭建门户网站
本场景主要介绍基于PolarDB和ECS实现搭建门户网站。
阿里云数据库产品家族及特性
阿里云智能数据库产品团队一直致力于不断健全产品体系,提升产品性能,打磨产品功能,从而帮助客户实现更加极致的弹性能力、具备更强的扩展能力、并利用云设施进一步降低企业成本。以云原生+分布式为核心技术抓手,打造以自研的在线事务型(OLTP)数据库Polar DB和在线分析型(OLAP)数据库Analytic DB为代表的新一代企业级云原生数据库产品体系, 结合NoSQL数据库、数据库生态工具、云原生智能化数据库管控平台,为阿里巴巴经济体以及各个行业的企业客户和开发者提供从公共云到混合云再到私有云的完整解决方案,提供基于云基础设施进行数据从处理、到存储、再到计算与分析的一体化解决方案。本节课带你了解阿里云数据库产品家族及特性。
目录
相关文章
|
3月前
|
Unix iOS开发 Ruby
解决CocoaPods installed but it is not working
解决CocoaPods installed but it is not working
99 5
kali2022.1The following packages were automatically installed and are no longer required
kali2022.1The following packages were automatically installed and are no longer required
87 1
|
开发工具