【sentry 到 ranger 系列】一、Sentry 的 Hive 鉴权插件

这篇具有很好参考价值的文章主要介绍了【sentry 到 ranger 系列】一、Sentry 的 Hive 鉴权插件。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

一、前景引入

  在本系列的第一篇文章里【sentry 到 ranger 系列】sentry 的开篇 ,已经对 Sentry 所处的一个整体的位置有了了解,如下图所示 【sentry 到 ranger 系列】一、Sentry 的 Hive 鉴权插件,# 从 sentry 到 ranger 不得不知道的事儿,sentry,hive,hadoop,大数据,数据仓库
  接下来,从 Hive 的鉴权开始看一下 Sentry 究竟怎么实现的权限管理和提供的鉴权能力。

二、Sentry 对 Hive 【授权】的接管

2.1、权限数据的产生

  在了解权限的接管细节前,可以先了解下 Hive 的权限数据是由什么场景产生的。
  首先,Hive 本身是有一套权限管理的,甚至 Hive 本身的权限管理从权限粒度上来看比 Sentry 的粒度更细更好用。而使用 Sentry 的一个原因就是当 Hive 和 HDFS 都使用了 Sentry 之后,能自动完成 Hive 的权限到 HDFS 路径的映射,不用再在 HDFS 上考虑怎么维护权限信息甚至干脆不做权限验证(后续会做 HDFS 的这部分逻辑分析)。
  Sentry 接管 Hive 后,一共只有三种权限可配置:ALL、SELECT、INSERT。后面两个很好理解,而 ALL 权限,包含了查询和写入这两种使用以外的所有权限,比如 ALTER、CREATE 等等。这里简单做一下 Hive 和 Sentry 的权限粒度对比。

Hive Sentry
ALL ALL
SELECT SELECT
UPDATE INSERT
ALTER 无单独管控能力,集成在ALL权限
CREATE 无单独管控能力,集成在ALL权限
DROP 无单独管控能力,集成在ALL权限
INDEX 无单独管控能力,集成在ALL权限
LOCK 无单独管控能力,集成在ALL权限
SHOW_DATABASE 无单独管控能力,集成在ALL权限

  当 Sentry 接管了 Hive 之后,原先 Hive 的权限语法就不能用了,不过大部分两者用法都差不多,具体的使用可以直接参考官方的文档🦋 cloudera sentry 权限语法🦋
  除了以上原生的赋权操作,还有一部分操作是 Sentry 需要维护的,那就是库表的名称信息,试想一下,当 tom 有对表 testselect 权限,当执行 drop table 操作的时候,Sentry 应该怎么维护原先的权限关系呢?我们直接看一下 Sentry 源码中的一段描述,位置在 class SentryMetastorePostEventListener

  /**
   * Drop the privileges on the database. Note that child tables will be
   * dropped individually by client, so we just need to handle the removing
   * the db privileges. The table drop should cleanup the table privileges.
   */
  @Override
  public void onDropDatabase(DropDatabaseEvent dbEvent) throws MetaException {
  ...

  也就是说 Sentry 的做法是要解除原先表 test 的所有权限关系。同理可验证,当执行 alter table rename 操作的时候,Sentry 会更新 tom 的权限为更新后的表的权限。

2.2、插件源码跟踪

  从前面可以看到,Sentry 权限的更新包含两部分,一部分是授权语句,另一部分来自于库表的部分 DDL 操作。
  在前文【sentry 到 ranger 系列】sentry 的开篇中讲到的SentryHiveAuthorizationTaskFactoryImply就是同步授权语句的,而对 DDL 的权限处理就交给的是SentryMetastorePostEventListener。因此现在应该就能区分开,为什么这里有两个类都在做权限同步操作了。前文对SentryMetastorePostEventListener的细节聊得比较少,本篇重点聊聊SentryMetastorePostEventListener的实现,关键走通之后,SentryHiveAuthorizationTaskFactoryImply的细节也是同理的。
  下面我们看一下SentryMetastorePostEventListener做了哪些事情,这能从其实现方法中见微知著:

onConfigChange(ConfigChangeEvent): void
onCreateTable(CreateTableEvent): void
onDropTable(DropTableEvent): void
onAlterTable(AlterTableEvent): void
onAddPartition(AddPartitionEvent): void
onDropPartition(DropPartitionEvent): void
onAlterPartition(AlterPartitionEvent): void
onCreateDatabase(CreateDatabaseEvent): void
onDropDatabase(DropDatabaseEvent): void
onLoadPartitionDone(LoadPartitionDoneEvent): void
onAddIndex(AddIndexEvent): void
onDroplndex(DroplndexEvent): voidi
onAlterindex(AlterindexEvent): void
onCreateFunction(CreateFunctionEvent): void.
onDropFunction(DropFunctionEvent): void.
onlnsert(InsertEvent): void

  这也符合我们对其定位的推测,其方法都是对 DDL 的处理。以onDropTable为例,方法代码如下:


  @Override
  public void onDropTable(DropTableEvent tableEvent) throws MetaException {

    // don't sync paths/privileges if the operation has failed
    if (!tableEvent.getStatus()) {
      LOGGER.debug("Skip syncing paths/privileges with Sentry server for onDropTable event," +
        " since the operation failed. \n");
      return;
    }

    if (tableEvent.getTable().getSd().getLocation() != null) {
      String authzObj = tableEvent.getTable().getDbName() + "."
          + tableEvent.getTable().getTableName();
      for (SentryMetastoreListenerPlugin plugin : sentryPlugins) {
        plugin.removeAllPaths(authzObj, null);
      }
    }
    // drop the privileges on the given table
    if (!syncWithPolicyStore(AuthzConfVars.AUTHZ_SYNC_DROP_WITH_POLICY_STORE)) {
      return;
    }

    if (!tableEvent.getStatus()) {
      return;
    }

    dropSentryTablePrivilege(tableEvent.getTable().getDbName(),
        tableEvent.getTable().getTableName());
  }

  正常情况下就会走到方法dropSentryTablePrivilege


  private void dropSentryTablePrivilege(String dbName, String tabName)
      throws MetaException {
    List<Authorizable> authorizableTable = new ArrayList<Authorizable>();
    authorizableTable.add(server);
    authorizableTable.add(new Database(dbName));
    authorizableTable.add(new Table(tabName));

    try {
      dropSentryPrivileges(authorizableTable);
    } catch (SentryUserException e) {
      throw new MetaException(
          "Failed to remove Sentry policies for drop table " + dbName + "."
              + tabName + " Error: " + e.getMessage());
    } catch (IOException e) {
      throw new MetaException("Failed to find local user " + e.getMessage());
    }

  }
  private void dropSentryPrivileges(
      List<? extends Authorizable> authorizableTable)
      throws SentryUserException, IOException, MetaException {
    String requestorUserName = UserGroupInformation.getCurrentUser()
        .getShortUserName();
    try (SentryPolicyServiceClient sentryClient = SentryServiceClientFactory.create(authzConf)) {
      sentryClient.dropPrivileges(requestorUserName, authorizableTable);
    } catch (Exception e) {
      throw new MetaException("Failed to connect to Sentry service "
              + e.getMessage());
    }
  }

  dropPrivileges是抽象方法,实现在SentryPolicyServiceClientDefaultImpl:

  @Override
  public void dropPrivileges(String requestorUserName,
                             List<? extends Authorizable> authorizableObjects)
    throws SentryUserException {
    TSentryAuthorizable tSentryAuthorizable = setupSentryAuthorizable(authorizableObjects);

    TDropPrivilegesRequest request = new TDropPrivilegesRequest(
      ThriftConstants.TSENTRY_SERVICE_VERSION_CURRENT, requestorUserName,
      tSentryAuthorizable);
    try {
      TDropPrivilegesResponse response = client.drop_sentry_privilege(request);
      Status.throwIfNotOk(response.getStatus());
    } catch (TException e) {
      throw new SentryUserException(THRIFT_EXCEPTION_MESSAGE, e);
    }
  }

  这里的 Client 就是 Thfit 协议的客户端了,就能发送给 Sentry Server 的 Thrift 服务端。Thrift 的代码是挺好追踪的,我们就来看看怎么发送给 Thrift 服务端的。

2.3、Thrift 接口跟踪

  点进 client.drop_sentry_privilege(request) 方法里,就进入到了 Thrift 的接口定义,SentryPolicyService的内部类Clientdrop_sentry_privilege方法:

    public TDropPrivilegesResponse drop_sentry_privilege(TDropPrivilegesRequest request) throws org.apache.thrift.TException
    {
      send_drop_sentry_privilege(request);
      return recv_drop_sentry_privilege();
    }

  这个地方不用着急点进去,因为再点进去就是 Thrift 内部实现了,重点关注SentryPolicyService,看看它的签名:

@Generated(value = "Autogenerated by Thrift Compiler (0.9.3)", date = "2017-04-26")
public class SentryPolicyService {...}

  可以看到代码是被生成出来的,在使用 Thrift 的时候,会先对协议编写一个协议文件,以.thrift结尾,再由 Thrift 通过代码生成技术来生成各类 java 文件,这里像 SentryPolicyService 就是一个 Sentry 和 Hive 的协议定义的 java 文件体现,里面包含了对客户端的定义和服务端的定义以及接口方法处理的定义。其他还会生成接口消息的实体类等等。

@SuppressWarnings({"cast", "rawtypes", "serial", "unchecked"})
@Generated(value = "Autogenerated by Thrift Compiler (0.9.3)", date = "2017-04-26")
public class SentryPolicyService {
  public interface Iface {...}
  public interface AsyncIface {...}
  public static class Client extends org.apache.thrift.TServiceClient implements Iface {...}
  public static class AsyncClient extends org.apache.thrift.async.TAsyncClient implements AsyncIface {...}
  public static class Processor<I extends Iface> extends org.apache.thrift.TBaseProcessor<I> implements org.apache.thrift.TProcessor {...}
  public static class AsyncProcessor<I extends AsyncIface> extends org.apache.thrift.TBaseAsyncProcessor<I> {...}
  public static class create_sentry_role_args implements org.apache.thrift.TBase<create_sentry_role_args, create_sentry_role_args._Fields>, java.io.Serializable, Cloneable, Comparable<create_sentry_role_args> {...}
}

  对每一个客户端Client的方法,如上面的drop_sentry_privilege,都能在名为 Iface 的接口里面找到服务侧的同名方法:

    public TDropPrivilegesResponse drop_sentry_privilege(TDropPrivilegesRequest request) throws org.apache.thrift.TException;

  然后很方便的跳转到其实现方法,这里即服务端的SentryPolicyStoreProcessordrop_sentry_privilege方法:

  @Override
  public TDropPrivilegesResponse drop_sentry_privilege(
      TDropPrivilegesRequest request) throws TException {
    final Timer.Context timerContext = sentryMetrics.dropPrivilegeTimer.time();
    TDropPrivilegesResponse response = new TDropPrivilegesResponse();
    try {
      validateClientVersion(request.getProtocol_version());
      authorize(request.getRequestorUserName(), adminGroups);

      // TODO: now only has SentryPlugin. Once add more SentryPolicyStorePlugins,
      // TODO: need to differentiate the updates for different Plugins.
      Preconditions.checkState(sentryPlugins.size() <= 1);
      Update update = null;
      for (SentryPolicyStorePlugin plugin : sentryPlugins) {
        update = plugin.onDropSentryPrivilege(request);
      }
      if (update != null) {
        sentryStore.dropPrivilege(request.getAuthorizable(), update);
      } else {
        sentryStore.dropPrivilege(request.getAuthorizable());
      }
      response.setStatus(Status.OK());
    } catch (SentryAccessDeniedException e) {
      LOGGER.error(e.getMessage(), e);
      response.setStatus(Status.AccessDenied(e.getMessage(), e));
    } catch (SentryThriftAPIMismatchException e) {
      LOGGER.error(e.getMessage(), e);
      response.setStatus(Status.THRIFT_VERSION_MISMATCH(e.getMessage(), e));
    } catch (Exception e) {
      String msg = "Unknown error for request: " + request + ", message: "
          + e.getMessage();
      LOGGER.error(msg, e);
      response.setStatus(Status.RuntimeError(msg, e));
    } finally {
      timerContext.stop();
    }
    return response;
  }

  这样就进入到服务端的逻辑了,如此一来,对于 Thrift 的接口,就能将客户端和服务端的处理逻辑串联起来,对于我们理解业务逻辑或者debug,就够用了。
  至于服务端的架构和逻辑,我们在后面再探索。

三、Sentry 对 Hive 【鉴权】的接管

  通过前面的跟踪,在忽略 Sentry Server 怎么维护数据的情况下,现在已经对 Sentry 怎么收集 Hive 的权限信息有了足够的认知。接下来,就是这些数据怎么被使用了,也就是鉴权的部分。

3.1、鉴权在 Hive 处理数据中的生命周期

  当用户尝试访问 Hive 表或执行 Hive 操作时,HiveServer2 会与 HiveMetastore 进行通信,验证用户的权限是否允许其进行所请求的操作,当权限校验通过,才会执行数据处理作业。还是以删表为例,来看一下 HiveMetastore(HMS) 会怎么处理请求。
  HMS 也是一个 Thrift 的服务端实现,我们已经对 Thrift 不陌生了。HMS 继承自ThriftHiveMetastore,在Iface中能找到drop_table方法,在 HMS 中实际实现如下:


    private boolean drop_table_core(final RawStore ms, final String dbname, final String name,
        final boolean deleteData, final EnvironmentContext envContext,
        final String indexName) throws NoSuchObjectException,
        MetaException, IOException, InvalidObjectException, InvalidInputException {
      boolean success = false;
      boolean isExternal = false;
      Path tblPath = null;
      List<Path> partPaths = null;
      Table tbl = null;
      boolean ifPurge = false;
      Map<String, String> transactionalListenerResponses = Collections.emptyMap();
      try {
        ms.openTransaction();
        // drop any partitions
        tbl = get_table_core(dbname, name);
		...
        firePreEvent(new PreDropTableEvent(tbl, deleteData, this));
		...
        // Drop the partitions and get a list of locations which need to be deleted
        partPaths = dropPartitionsAndGetLocations(ms, dbname, name, tblPath,
            tbl.getPartitionKeys(), deleteData && !isExternal);
        if (!ms.dropTable(dbname, name)) {
          String tableName = dbname + "." + name;
          throw new MetaException(indexName == null ? "Unable to drop table " + tableName:
              "Unable to drop index table " + tableName + " for index " + indexName);
        } else {
          if (!transactionalListeners.isEmpty()) {
            transactionalListenerResponses =
                MetaStoreListenerNotifier.notifyEvent(transactionalListeners,
                                                      EventType.DROP_TABLE,
                                                      new DropTableEvent(tbl, true, deleteData, this),
                                                      envContext);
          }
          success = ms.commitTransaction();
        }
      } finally {
        if (!success) {
          ms.rollbackTransaction();
        } else if (deleteData && !isExternal) {
          // Data needs deletion. Check if trash may be skipped.
          // Delete the data in the partitions which have other locations
          deletePartitionData(partPaths, ifPurge);
          // Delete the data in the table
          deleteTableData(tblPath, ifPurge);
          // ok even if the data is not deleted
        }

        if (!listeners.isEmpty()) {
          MetaStoreListenerNotifier.notifyEvent(listeners,
                                                EventType.DROP_TABLE,
                                                new DropTableEvent(tbl, success, deleteData, this),
                                                envContext,
                                                transactionalListenerResponses, ms);
        }
      }
      return success;
    }

  其中firePreEvent实现如下:

    private void firePreEvent(PreEventContext event) throws MetaException {
      for (MetaStorePreEventListener listener : preListeners) {
        try {
          listener.onEvent(event);
        } catch (NoSuchObjectException e) {
          throw new MetaException(e.getMessage());
        } catch (InvalidOperationException e) {
          throw new MetaException(e.getMessage());
        }
      }
    }

  MetaStoreListenerNotifier.notifyEvent实现如下:

  /**
   * Notify a list of listeners about a specific metastore event. Each listener notified might update
   * the (ListenerEvent) event by setting a parameter key/value pair. These updated parameters will
   * be returned to the caller.
   *
   * @param listeners List of MetaStoreEventListener listeners.
   * @param eventType Type of the notification event.
   * @param event The ListenerEvent with information about the event.
   * @return A list of key/value pair parameters that the listeners set. The returned object will return an empty
   *         map if no parameters were updated or if no listeners were notified.
   * @throws MetaException If an error occurred while calling the listeners.
   */
  public static Map<String, String> notifyEvent(List<MetaStoreEventListener> listeners,
                                                EventType eventType,
                                                ListenerEvent event) throws MetaException {

    Preconditions.checkNotNull(listeners, "Listeners must not be null.");
    Preconditions.checkNotNull(event, "The event must not be null.");

    for (MetaStoreEventListener listener : listeners) {
      notificationEvents.get(eventType).notify(listener, event);
    }

    // Each listener called above might set a different parameter on the event.
    // This write permission is allowed on the listener side to avoid breaking compatibility if we change the API
    // method calls.
    return event.getParameters();
  }

  也就是说,一次删表行为,在 HMS 中经历了大致如下的生命周期:

1、MetaStorePreEventListener 处理 -> 2、HMS 处理元数据(transactionalListeners) -> 3、MetaStoreEventListener 处理

  和前面的授权的接管联系起来可以看到,SentryMetastorePostEventListener是处于第3个阶段;那么用于鉴权的操作推测应该就是处于第1个阶段,应该是继承了MetaStorePreEventListener

3.2、MetastoreAuthzBinding

  如前面所言,MetastoreAuthzBinding 就是Sentry 插件继承了MetaStorePreEventListener 的实现,前面的firePreEvent方法中看到是直接调的 listener 的onEvent方法,所以直接看MetastoreAuthzBindingonEvent方法:

  /**
   * Main listener callback which is the entry point for Sentry
   */
  @Override
  public void onEvent(PreEventContext context) throws MetaException,
      NoSuchObjectException, InvalidOperationException {

    if (!needsAuthorization(getUserName())) {
      return;
    }
    switch (context.getEventType()) {
    case CREATE_TABLE:
      authorizeCreateTable((PreCreateTableEvent) context);
      break;
    case DROP_TABLE:
      authorizeDropTable((PreDropTableEvent) context);
      break;
    case ALTER_TABLE:
      authorizeAlterTable((PreAlterTableEvent) context);
      break;
    case ADD_PARTITION:
      authorizeAddPartition((PreAddPartitionEvent) context);
      break;
    case DROP_PARTITION:
      authorizeDropPartition((PreDropPartitionEvent) context);
      break;
    case ALTER_PARTITION:
      authorizeAlterPartition((PreAlterPartitionEvent) context);
      break;
    case CREATE_DATABASE:
      authorizeCreateDatabase((PreCreateDatabaseEvent) context);
      break;
    case DROP_DATABASE:
      authorizeDropDatabase((PreDropDatabaseEvent) context);
      break;
    case LOAD_PARTITION_DONE:
      // noop for now
      break;
    default:
      break;
    }
  }

  还是以删表为例,发现所有操作其实都被封装成了校验对象,走到MetastoreAuthzBindingauthorize方法,这里用到了 Sentry 封装的一个校验模块:sentry-provider,其整个校验被抽象为三部分组件:
AuthorizationProviderPolicyEngineProviderBackend,调用关系大致如下
【sentry 到 ranger 系列】一、Sentry 的 Hive 鉴权插件,# 从 sentry 到 ranger 不得不知道的事儿,sentry,hive,hadoop,大数据,数据仓库
  通过这种方式,将校验的通用部分抽离,差异部分实现不同实现类,主要分为 DB、搜索引擎、Kafka等类型。最后的ProviderBackend集成了 Sentry 的 Thrift Client,根据要校验的信息,从 Sentry Server 中拉取数据实现鉴权,如果校验不通过,则通过抛异常的方式被捕获处理后返回给用户端。至此整个鉴权的逻辑完成闭环√

四、收尾

  本篇从 Sentry 收集 Hive 权限信息,到 Sentry 的鉴权插件怎么在 Hive 和 Sentry 之间实现鉴权,进行了梳理,对关键代码进行了解析,能从感性和理性的角度都对 Sentry 接管 Hive 权限有更深入的认识。
  如果对在读的你有帮助,希望能给个一键三连(。•̀ᴗ-)✧文章来源地址https://www.toymoban.com/news/detail-795585.html

到了这里,关于【sentry 到 ranger 系列】一、Sentry 的 Hive 鉴权插件的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 安装sentry-cli问题

    安装sentry-cli问题 1 使用brew install sentry-cli 安装的时候 有时候会报 Error: Xcode alone is not sufficient on Monterey. 2 使用 curl -sL https://sentry.io/get-cli/ | sh 安装成功 Installed sentry-cli 2.20.5 Done! 查看

    2024年02月11日
    浏览(28)
  • sentry-cli上传dSYM

    一、安装sentry-cli 第一种方法 第二种方法 二、 上传dSYM 1、需要先配置自己的SENTRY_URL: export SENTRY_URL=xxxx //xxxx替换自己的sentry地址 2、执行sentry-cli上传 AUTH_TOKEN 去settings 》 account 〉API 》 Auth tokens获取 sentry-org 去settings 》 sentry 〉general 中获取 projectName 就是项目中对应的名字

    2024年02月11日
    浏览(38)
  • 前端异常监控平台Sentry安装配置使用及问题

    前言:Sentry是一款开源的异常监控平台,支持各种语言的SDK,通过对应SDK可以收集错误信息和性能数据,并可以再后台web页面中查看相关信息。 官方地址: 安装说明:https://develop.sentry.dev/self-hosted/ 后台使用说明:https://docs.sentry.io/product/releases/ SDK使用说明(根据需要选择平台

    2024年02月04日
    浏览(37)
  • 不得不了解的linux网络配置

    1.1.1ifconfig命令—查看网络接口地址 1.1.1.1查看所有网络接口信息 [root@localhost ~]# ifconfig 1.1.1.3查看指定的网络接口信息(不论该网络接口是否处于激活状态) mtu:代表最大传输单元,它的单位是字节。在我们常用的以太网中,MTU一般是1500,而无线路由器默认一般是 1492。 本地MTU值

    2023年04月21日
    浏览(49)
  • CDH数仓项目(三) —— Kerberos安全认证和Sentry权限管理

    本文基于《CDH数仓项目(一) —— CDH安装部署搭建详细流程》和《CDH数仓项目(二) —— 用户行为数仓和业务数仓搭建》和搭建CDH数仓。本章节主要介绍基于CDH数仓的Kerberos认证和Sentry权限管理 Kerberos是一种计算机网络授权协议,用来在非安全网络中,对个人通信以安全的手段进

    2023年04月22日
    浏览(53)
  • 程序员不得不知道的 API 接口常识

    1、初识 API 接口 记得在我初学 web 开发的时候,后端框架相关的教程基本都会教学生写渲染模版(不分语言),也就是说后端返回的是整个网页的数据,浏览器只负责渲染。 一般这类模版在后端都会对应一个路由,比如前端想登入一个看用户信息的页面,在 url 中输入的访问

    2024年02月01日
    浏览(50)
  • Anaconda你不得不知道的若干知识点

    多半是h5py的版本不对 重新安装适配对应的tensorflow 办法一: 卸载新版本 pip uninstall xlrd 安装老版本:pip install xlrd=1.2.0 (或者更早版本) 方法二: 将xlrd用到的excel版本格式修改为xls(保险起见,另存为xls格式) 建议:个人更推荐使用第二种方法 方法三: 利用openpyxl代替xlrd打开

    2024年02月01日
    浏览(41)
  • 【C++代码之美】你不得不知道的经典代码

    斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardo Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、…… 如下代码是15阶的斐波那契数列: 代码如下: 输出结果: 打印

    2024年02月01日
    浏览(49)
  • sentry收集错误[Failed to fetch dynamically imported module]解决

    vue3 + vite创建的项目在引入sentry后,邮箱会经常收到错误:[Failed to fetch dynamically imported module] 分析:错误出现的时间点大致在项目每一次重新部署之后。 原因:每次打包,会生成新文件名称不同的文件。浏览器当下的 script 中会引用之前打包的文件,文件不存在就会报以上错

    2024年02月13日
    浏览(46)
  • 程序员不得不了解的计算机进制转换

    最近在备考软考的软件设计师考试,学到了关于计算机的数据表示,由于我是半路出家学的Java,导致计算机基础知识很差,在这里记录一下学习感受 早期计算机的存储介质是晶体管,晶体管根据电压不同,只能表示2种状态,也就是0和1 计算机使用二进制运算更加方便 更详细

    2024年02月05日
    浏览(38)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包